diff --git a/android/settings.gradle b/android/settings.gradle index 82e2b5a..9ba153b 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -7,7 +7,7 @@ pluginManagement { return flutterSdkPath } settings.ext.flutterSdkPath = flutterSdkPath() - +0 includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") repositories { diff --git a/lib/api/holidays/getHolidaysCache.dart b/lib/api/holidays/getHolidaysCache.dart index 49e04e1..66e3147 100644 --- a/lib/api/holidays/getHolidaysCache.dart +++ b/lib/api/holidays/getHolidaysCache.dart @@ -5,7 +5,7 @@ import 'getHolidays.dart'; import 'getHolidaysResponse.dart'; class GetHolidaysCache extends RequestCache { - GetHolidaysCache({onUpdate, renew}) : super(RequestCache.cacheDay, onUpdate, renew: renew) { + GetHolidaysCache({void Function(GetHolidaysResponse)? onUpdate, bool? renew}) : super(RequestCache.cacheDay, onUpdate, renew: renew) { start('state-holidays'); } diff --git a/lib/api/marianumcloud/talk/chat/getChatCache.dart b/lib/api/marianumcloud/talk/chat/getChatCache.dart index 3792365..60de7c1 100644 --- a/lib/api/marianumcloud/talk/chat/getChatCache.dart +++ b/lib/api/marianumcloud/talk/chat/getChatCache.dart @@ -8,7 +8,7 @@ import 'getChatResponse.dart'; class GetChatCache extends RequestCache { String chatToken; - GetChatCache({required onUpdate, required this.chatToken}) : super(RequestCache.cacheNothing, onUpdate) { + GetChatCache({required void Function(GetChatResponse) onUpdate, required this.chatToken}) : super(RequestCache.cacheNothing, onUpdate) { start('nc-chat-$chatToken'); } diff --git a/lib/api/marianumcloud/talk/chat/getChatResponse.dart b/lib/api/marianumcloud/talk/chat/getChatResponse.dart index 840df36..2c1db07 100644 --- a/lib/api/marianumcloud/talk/chat/getChatResponse.dart +++ b/lib/api/marianumcloud/talk/chat/getChatResponse.dart @@ -86,7 +86,7 @@ class GetChatResponseObject { } -Map? _fromJson(json) { +Map? _fromJson(dynamic json) { if(json is Map) { var data = {}; for (var element in json.keys) { diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart b/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart index 55df84d..a3fddc1 100644 --- a/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart +++ b/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart @@ -7,7 +7,7 @@ import 'getParticipantsResponse.dart'; class GetParticipantsCache extends RequestCache { String chatToken; - GetParticipantsCache({required onUpdate, required this.chatToken}) : super(RequestCache.cacheNothing, onUpdate) { + GetParticipantsCache({required void Function(GetParticipantsResponse) onUpdate, required this.chatToken}) : super(RequestCache.cacheNothing, onUpdate) { start('nc-chat-participants-$chatToken'); } diff --git a/lib/api/marianumcloud/talk/room/getRoomCache.dart b/lib/api/marianumcloud/talk/room/getRoomCache.dart index a4ea708..54f8578 100644 --- a/lib/api/marianumcloud/talk/room/getRoomCache.dart +++ b/lib/api/marianumcloud/talk/room/getRoomCache.dart @@ -7,7 +7,7 @@ import 'getRoomParams.dart'; import 'getRoomResponse.dart'; class GetRoomCache extends RequestCache { - GetRoomCache({onUpdate, renew}) : super(RequestCache.cacheMinute, onUpdate, renew: renew) { + GetRoomCache({void Function(GetRoomResponse)? onUpdate, bool? renew}) : super(RequestCache.cacheMinute, onUpdate, renew: renew) { start('nc-rooms'); } diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart b/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart index 845dc47..2a3e2fc 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart +++ b/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart @@ -9,7 +9,7 @@ import 'listFilesResponse.dart'; class ListFilesCache extends RequestCache { String path; - ListFilesCache({required onUpdate, required this.path}) : super(RequestCache.cacheNothing, onUpdate) { + ListFilesCache({required void Function(ListFilesResponse) onUpdate, required this.path}) : super(RequestCache.cacheNothing, onUpdate) { var bytes = utf8.encode('MarianumMobile-$path'); var cacheName = md5.convert(bytes).toString(); start('wd-folder-$cacheName'); diff --git a/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart b/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart index 9f6ef34..9f7e24d 100644 --- a/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart +++ b/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart @@ -5,7 +5,7 @@ import 'getBreakersResponse.dart'; class GetBreakersCache extends RequestCache { - GetBreakersCache({onUpdate, renew}) : super(RequestCache.cacheMinute, onUpdate, renew: renew) { + GetBreakersCache({void Function(GetBreakersResponse)? onUpdate, bool? renew}) : super(RequestCache.cacheMinute, onUpdate, renew: renew) { start('breakers'); } diff --git a/lib/api/requestCache.dart b/lib/api/requestCache.dart index 0420415..cbbf15b 100644 --- a/lib/api/requestCache.dart +++ b/lib/api/requestCache.dart @@ -13,8 +13,8 @@ abstract class RequestCache { static String collection = 'MarianumMobile'; int maxCacheTime; - Function(T) onUpdate; - Function(Exception) onError; + void Function(T)? onUpdate; + void Function(Exception) onError; bool? renew; RequestCache(this.maxCacheTime, this.onUpdate, {this.onError = ignore, this.renew = false}); @@ -22,24 +22,23 @@ abstract class RequestCache { static void ignore(Exception e) {} Future start(String document) async { - var tableData = await Localstore.instance.collection(collection).doc(document).get(); - if(tableData != null) { - onUpdate(onLocalData(tableData['json'])); + final tableData = await Localstore.instance.collection(collection).doc(document).get(); + if (tableData != null) { + onUpdate?.call(onLocalData(tableData['json'])); } - if(DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < (tableData?['lastupdate'] ?? 0)) { - if(renew == null || !renew!) return; + if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < (tableData?['lastupdate'] ?? 0)) { + if (renew == null || !renew!) return; } try { - var newValue = await onLoad(); - onUpdate(newValue); - + final newValue = await onLoad(); + onUpdate?.call(newValue); Localstore.instance.collection(collection).doc(document).set({ 'json': jsonEncode(newValue), - 'lastupdate': DateTime.now().millisecondsSinceEpoch + 'lastupdate': DateTime.now().millisecondsSinceEpoch, }); - } on Exception catch(e) { + } on Exception catch (e) { onError(e); } } diff --git a/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart b/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart index a91decd..c4e4627 100644 --- a/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart +++ b/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart @@ -5,7 +5,7 @@ import 'getHolidays.dart'; import 'getHolidaysResponse.dart'; class GetHolidaysCache extends RequestCache { - GetHolidaysCache({onUpdate}) : super(RequestCache.cacheDay, onUpdate) { + GetHolidaysCache({void Function(GetHolidaysResponse)? onUpdate}) : super(RequestCache.cacheDay, onUpdate) { start('wu-holidays'); } diff --git a/lib/api/webuntis/queries/getRooms/getRoomsCache.dart b/lib/api/webuntis/queries/getRooms/getRoomsCache.dart index e33589b..33d00ee 100644 --- a/lib/api/webuntis/queries/getRooms/getRoomsCache.dart +++ b/lib/api/webuntis/queries/getRooms/getRoomsCache.dart @@ -5,7 +5,7 @@ import 'getRooms.dart'; import 'getRoomsResponse.dart'; class GetRoomsCache extends RequestCache { - GetRoomsCache({onUpdate}) : super(RequestCache.cacheHour, onUpdate) { + GetRoomsCache({void Function(GetRoomsResponse)? onUpdate}) : super(RequestCache.cacheHour, onUpdate) { start('wu-rooms'); } diff --git a/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart b/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart index 07a5ede..bec137b 100644 --- a/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart +++ b/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart @@ -5,7 +5,7 @@ import 'getSubjects.dart'; import 'getSubjectsResponse.dart'; class GetSubjectsCache extends RequestCache { - GetSubjectsCache({onUpdate}) : super(RequestCache.cacheHour, onUpdate) { + GetSubjectsCache({void Function(GetSubjectsResponse)? onUpdate}) : super(RequestCache.cacheHour, onUpdate) { start('wu-subjects'); } diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart b/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart index 3b6d87a..0872b70 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart +++ b/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart @@ -10,7 +10,12 @@ class GetTimetableCache extends RequestCache { int startdate; int enddate; - GetTimetableCache({required onUpdate, onError, required this.startdate, required this.enddate}) : super(RequestCache.cacheMinute, onUpdate, onError: onError) { + GetTimetableCache({ + required void Function(GetTimetableResponse) onUpdate, + void Function(Exception)? onError, + required this.startdate, + required this.enddate, + }) : super(RequestCache.cacheMinute, onUpdate, onError: onError ?? RequestCache.ignore) { start('wu-timetable-$startdate-$enddate'); } diff --git a/lib/app.dart b/lib/app.dart index 0b4a03b..86b2650 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,26 +1,24 @@ - import 'dart:async'; import 'dart:developer'; import 'package:easy_debounce/easy_throttle.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; -import 'state/app/modules/app_modules.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:provider/provider.dart'; import 'api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; import 'api/mhsl/server/userIndex/update/updateUserindex.dart'; import 'main.dart'; -import 'model/breakers/Breaker.dart'; -import 'model/breakers/BreakerProps.dart'; -import 'model/chatList/chatListProps.dart'; +import 'widget/breaker/breaker.dart'; import 'model/dataCleaner.dart'; -import 'model/timetable/timetableProps.dart'; import 'notification/notificationController.dart'; import 'notification/notificationTasks.dart'; import 'notification/notifyUpdater.dart'; -import 'storage/base/settingsProvider.dart'; +import 'state/app/modules/app_modules.dart'; +import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; +import 'state/app/modules/chatList/bloc/chat_list_bloc.dart'; +import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'view/pages/overhang.dart'; class App extends StatefulWidget { @@ -31,101 +29,106 @@ class App extends StatefulWidget { } class _AppState extends State with WidgetsBindingObserver { - - late Timer refetchChats; - late Timer updateTimings; + late Timer _refetchChats; + late Timer _updateTimings; @override void didChangeAppLifecycleState(AppLifecycleState state) { - log('AppLifecycle: ${state.toString()}'); - - if(state == AppLifecycleState.resumed) { - EasyThrottle.throttle( - 'appLifecycleState', - const Duration(seconds: 10), - () { - log('Refreshing due to LifecycleChange'); - NotificationTasks.updateProviders(context); - Provider.of(context, listen: false).run(); - } - ); + log('AppLifecycle: $state'); + if (state == AppLifecycleState.resumed) { + EasyThrottle.throttle('appLifecycleState', const Duration(seconds: 10), () { + if (!mounted) return; + log('Refreshing due to LifecycleChange'); + NotificationTasks.updateProviders(context); + }); } } - @override void initState() { + super.initState(); Main.bottomNavigator = PersistentTabController(initialIndex: 0); WidgetsBinding.instance.addObserver(this); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - Provider.of(context, listen: false).run(); - Provider.of(context, listen: false).run(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().refresh(); + context.read().refresh(); }); - updateTimings = Timer.periodic(const Duration(seconds: 30), (Timer t) => setState((){})); + _updateTimings = Timer.periodic(const Duration(seconds: 30), (_) { + if (mounted) setState(() {}); + }); - refetchChats = Timer.periodic(const Duration(seconds: 60), (timer) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - Provider.of(context, listen: false).run(); + _refetchChats = Timer.periodic(const Duration(seconds: 60), (_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().refresh(); }); }); - // User index UpdateUserIndex.index(); - // User Notifications - if(Provider.of(context, listen: false).val().notificationSettings.enabled) { - update() => NotifyUpdater.registerToServer(); - FirebaseMessaging.instance.onTokenRefresh.listen((event) => update()); + if (context.read().val().notificationSettings.enabled) { + void update() => NotifyUpdater.registerToServer(); + FirebaseMessaging.instance.onTokenRefresh.listen((_) => update()); update(); } - - FirebaseMessaging.onMessage.listen((message) => NotificationController.onForegroundMessageHandler(message, context)); + + FirebaseMessaging.onMessage.listen((message) { + if (!mounted) return; + NotificationController.onForegroundMessageHandler(message, context); + }); FirebaseMessaging.onBackgroundMessage(NotificationController.onBackgroundMessageHandler); - FirebaseMessaging.onMessageOpenedApp.listen((message) => NotificationController.onAppOpenedByNotification(message, context)); - FirebaseMessaging.instance.getInitialMessage().then((message) => message == null ? null : NotificationController.onAppOpenedByNotification(message, context)); + FirebaseMessaging.onMessageOpenedApp.listen((message) { + if (!mounted) return; + NotificationController.onAppOpenedByNotification(message, context); + }); + FirebaseMessaging.instance.getInitialMessage().then((message) { + if (message == null || !mounted) return; + NotificationController.onAppOpenedByNotification(message, context); + }); DataCleaner.cleanOldCache(); - - super.initState(); } - @override - Widget build(BuildContext context) => Consumer(builder: (context, settings, child) => PersistentTabView( - controller: Main.bottomNavigator, - navBarOverlap: const NavBarOverlap.none(), - backgroundColor: Theme.of(context).colorScheme.primary, - handleAndroidBackButtonPress: false, - - screenTransitionAnimation: const ScreenTransitionAnimation(curve: Curves.easeOutQuad, duration: Duration(milliseconds: 200)), - tabs: [ - ...AppModule.getBottomBarModules(context).map((e) => e.toBottomTab(context)), - - PersistentTabConfig( - screen: const Breaker(breaker: BreakerArea.more, child: Overhang()), - item: ItemConfig( - activeForegroundColor: Theme.of(context).primaryColor, - inactiveForegroundColor: Theme.of(context).colorScheme.secondary, - icon: const Icon(Icons.apps), - title: 'Mehr' - ), - ), - ], - navBarBuilder: (config) => Style6BottomNavBar( - navBarConfig: config, - navBarDecoration: NavBarDecoration( - border: const Border(top: BorderSide(width: 1, color: Colors.grey)), - color: Theme.of(context).colorScheme.surface, - ), - ), - )); - @override void dispose() { - refetchChats.cancel(); - updateTimings.cancel(); + _refetchChats.cancel(); + _updateTimings.cancel(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } + + @override + Widget build(BuildContext context) => PersistentTabView( + controller: Main.bottomNavigator, + navBarOverlap: const NavBarOverlap.none(), + backgroundColor: Theme.of(context).colorScheme.primary, + handleAndroidBackButtonPress: false, + screenTransitionAnimation: const ScreenTransitionAnimation( + curve: Curves.easeOutQuad, + duration: Duration(milliseconds: 200), + ), + tabs: [ + ...AppModule.getBottomBarModules(context).map((e) => e.toBottomTab(context)), + PersistentTabConfig( + screen: const Breaker(breaker: BreakerArea.more, child: Overhang()), + item: ItemConfig( + activeForegroundColor: Theme.of(context).primaryColor, + inactiveForegroundColor: Theme.of(context).colorScheme.secondary, + icon: const Icon(Icons.apps), + title: 'Mehr', + ), + ), + ], + navBarBuilder: (config) => Style6BottomNavBar( + navBarConfig: config, + navBarDecoration: NavBarDecoration( + border: const Border(top: BorderSide(width: 1, color: Colors.grey)), + color: Theme.of(context).colorScheme.surface, + ), + ), + ); } diff --git a/lib/main.dart b/lib/main.dart index fb1415e..5c3d278 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,33 +1,34 @@ import 'dart:async'; import 'dart:developer'; import 'dart:io'; +import 'dart:ui'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:jiffy/jiffy.dart'; import 'package:loader_overlay/loader_overlay.dart'; import 'package:path_provider/path_provider.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:provider/provider.dart'; import 'api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; import 'app.dart'; import 'firebase_options.dart'; import 'model/accountData.dart'; -import 'model/accountModel.dart'; -import 'model/breakers/Breaker.dart'; -import 'model/breakers/BreakerProps.dart'; -import 'model/chatList/chatListProps.dart'; -import 'model/chatList/chatProps.dart'; -import 'model/files/filesProps.dart'; -import 'model/holidays/holidaysProps.dart'; -import 'model/timetable/timetableProps.dart'; -import 'storage/base/settingsProvider.dart'; +import 'widget/breaker/breaker.dart'; +import 'state/app/modules/account/bloc/account_bloc.dart'; +import 'state/app/modules/account/bloc/account_state.dart'; +import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; +import 'state/app/modules/chat/bloc/chat_bloc.dart'; +import 'state/app/modules/chatList/bloc/chat_list_bloc.dart'; +import 'state/app/modules/settings/bloc/settings_cubit.dart'; +import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; +import 'storage/base/settings.dart'; import 'theming/darkAppTheme.dart'; import 'theming/lightAppTheme.dart'; import 'view/login/login.dart'; @@ -37,133 +38,123 @@ Future main() async { log('MarianumMobile started'); WidgetsFlutterBinding.ensureInitialized(); - addCertificateAsTrusted(ByteData certificate) => SecurityContext.defaultContext.setTrustedCertificatesBytes(certificate.buffer.asUint8List()); + void addCertificateAsTrusted(ByteData certificate) => + SecurityContext.defaultContext.setTrustedCertificatesBytes(certificate.buffer.asUint8List()); - var initialisationTasks = [ + final initialisationTasks = [ Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform) - .then((value) async => log("Firebase token: ${await FirebaseMessaging.instance.getToken() ?? "Error: no Firebase token!"}")) - .onError((error, stackTrace) => log('Error initializing Firebase: $error')), - + .then((_) async => log('Firebase token: ${await FirebaseMessaging.instance.getToken() ?? "Error: no Firebase token!"}')) + .onError((error, _) => log('Error initializing Firebase: $error')), PlatformAssetBundle().load('assets/ca/lets-encrypt-r3.pem').then(addCertificateAsTrusted), PlatformAssetBundle().load('assets/ca/lets-encrypt-r10.pem').then(addCertificateAsTrusted), PlatformAssetBundle().load('assets/ca/lets-encrypt-r13.pem').then(addCertificateAsTrusted), - Future(() async { - await HydratedStorage.build( - storageDirectory: HydratedStorageDirectory((await getTemporaryDirectory()).path) - ).then((storage) => HydratedBloc.storage = storage); - }) + final storage = await HydratedStorage.build( + storageDirectory: HydratedStorageDirectory((await getTemporaryDirectory()).path), + ); + HydratedBloc.storage = storage; + }), ]; log('starting app initialisation...'); await Future.wait(initialisationTasks); log('app initialisation done!'); - if(kReleaseMode) { + if (kReleaseMode) { ErrorWidget.builder = (error) => PlaceholderView( - icon: Icons.phonelink_erase_rounded, - text: error.toStringShort(), - ); + icon: Icons.phonelink_erase_rounded, + text: error.toStringShort(), + ); } + // Capture uncaught Flutter and platform errors so they show up in logs + // instead of being silently swallowed. + FlutterError.onError = (details) { + log('Uncaught Flutter error: ${details.exception}', stackTrace: details.stack); + FlutterError.presentError(details); + }; + PlatformDispatcher.instance.onError = (error, stack) { + log('Uncaught platform error: $error', stackTrace: stack); + return true; + }; + log('running app...'); runApp( - MultiProvider( - providers: [ - ChangeNotifierProvider(create: (context) => BreakerProps()), - - ChangeNotifierProvider(create: (context) => SettingsProvider()), - ChangeNotifierProvider(create: (context) => AccountModel()), - - ChangeNotifierProvider(create: (context) => TimetableProps()), - ChangeNotifierProvider(create: (context) => ChatListProps()), - ChangeNotifierProvider(create: (context) => ChatProps()), - ChangeNotifierProvider(create: (context) => FilesProps()), - - ChangeNotifierProvider(create: (context) => HolidaysProps()), - ], - child: const Main(), - ) + MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => SettingsCubit()), + BlocProvider(create: (_) => AccountBloc()), + BlocProvider(create: (_) => BreakerBloc()), + BlocProvider(create: (_) => ChatListBloc()), + BlocProvider(create: (_) => ChatBloc()), + BlocProvider(create: (_) => TimetableBloc()), + ], + child: const Main(), + ), ); } class Main extends StatefulWidget { const Main({super.key}); - static PersistentTabController bottomNavigator = PersistentTabController(initialIndex: 0); + static PersistentTabController bottomNavigator = PersistentTabController(initialIndex: 0); @override State
createState() => _MainState(); } class _MainState extends State
{ - late Timer refetchProps; - @override void initState() { + super.initState(); Jiffy.setLocale('de'); AccountData().waitForPopulation().then((value) { - Provider.of(context, listen: false) - .setState(value ? AccountModelState.loggedIn : AccountModelState.loggedOut); + if (!mounted) return; + context.read().setStatus(value ? AccountStatus.loggedIn : AccountStatus.loggedOut); }); - - refetchProps = Timer.periodic(const Duration(seconds: 60), (timer) { - Provider.of(context, listen: false).run(); - }); - - super.initState(); } @override Widget build(BuildContext context) => Directionality( - textDirection: TextDirection.ltr, - child: Consumer( - builder: (context, settings, child) { - var devToolsSettings = settings.val().devToolsSettings; - return MaterialApp( - showPerformanceOverlay: devToolsSettings.showPerformanceOverlay, - checkerboardOffscreenLayers: devToolsSettings.checkerboardOffscreenLayers, - checkerboardRasterCacheImages: devToolsSettings.checkerboardRasterCacheImages, - - debugShowCheckedModeBanner: false, - localizationsDelegates: const [ - ...GlobalMaterialLocalizations.delegates, - GlobalWidgetsLocalizations.delegate, - ], - supportedLocales: const [ - Locale('de'), - Locale('en'), - ], - locale: const Locale('de'), - - title: 'Marianum Fulda', - - themeMode: settings.val().appTheme, - theme: LightAppTheme.theme, - darkTheme: DarkAppTheme.theme, - home: LoaderOverlay( - child: Breaker( + textDirection: TextDirection.ltr, + child: BlocBuilder( + builder: (context, settings) { + final devToolsSettings = settings.devToolsSettings; + return MaterialApp( + showPerformanceOverlay: devToolsSettings.showPerformanceOverlay, + checkerboardOffscreenLayers: devToolsSettings.checkerboardOffscreenLayers, + checkerboardRasterCacheImages: devToolsSettings.checkerboardRasterCacheImages, + debugShowCheckedModeBanner: false, + localizationsDelegates: const [ + ...GlobalMaterialLocalizations.delegates, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: const [Locale('de'), Locale('en')], + locale: const Locale('de'), + title: 'Marianum Fulda', + themeMode: settings.appTheme, + theme: LightAppTheme.theme, + darkTheme: DarkAppTheme.theme, + home: LoaderOverlay( + child: Breaker( breaker: BreakerArea.global, - child: Consumer( - builder: (context, accountModel, child) { - switch(accountModel.state) { - case AccountModelState.loggedIn: return const App(); - case AccountModelState.loggedOut: return const Login(); - case AccountModelState.undefined: return const PlaceholderView(icon: Icons.timer, text: 'Daten werden geladen'); + child: BlocBuilder( + builder: (context, accountState) { + switch (accountState.status) { + case AccountStatus.loggedIn: + return const App(); + case AccountStatus.loggedOut: + return const Login(); + case AccountStatus.undefined: + return const PlaceholderView(icon: Icons.timer, text: 'Daten werden geladen'); } }, - ) + ), + ), ), - ), - ); - }, - ), - ); - - @override - void dispose() { - refetchProps.cancel(); - super.dispose(); - } + ); + }, + ), + ); } diff --git a/lib/model/accountData.dart b/lib/model/accountData.dart index 419146a..03e2f53 100644 --- a/lib/model/accountData.dart +++ b/lib/model/accountData.dart @@ -4,68 +4,91 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/cupertino.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'accountModel.dart'; +import '../state/app/modules/account/bloc/account_bloc.dart'; +import '../state/app/modules/account/bloc/account_state.dart'; class AccountData { static const _usernameField = 'username'; static const _passwordField = 'password'; - + + static const FlutterSecureStorage _secureStorage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ); + static final AccountData _instance = AccountData._construct(); - final Future _storage = SharedPreferences.getInstance(); Completer _populated = Completer(); factory AccountData() => _instance; AccountData._construct() { - _updateFromStorage(); + _migrateAndLoad(); } String? _username; String? _password; String getUsername() { - if(_username == null) throw Exception('Username not initialized'); + if (_username == null) throw Exception('Username not initialized'); return _username!; } String getPassword() { - if(_password == null) throw Exception('Password not initialized'); + if (_password == null) throw Exception('Password not initialized'); return _password!; } - String getUserSecret() => sha512.convert(utf8.encode('${AccountData().getUsername()}:${AccountData().getPassword()}')).toString(); + String getUserSecret() => + sha512.convert(utf8.encode('${getUsername()}:${getPassword()}')).toString(); - Future getDeviceId() async => sha512.convert(utf8.encode('${getUserSecret()}@${await FirebaseMessaging.instance.getToken()}')).toString(); + Future getDeviceId() async => sha512 + .convert(utf8.encode('${getUserSecret()}@${await FirebaseMessaging.instance.getToken()}')) + .toString(); Future setData(String username, String password) async { - var storage = await _storage; - - storage.setString(_usernameField, username); - storage.setString(_passwordField, password); - await _updateFromStorage(); + await _secureStorage.write(key: _usernameField, value: username); + await _secureStorage.write(key: _passwordField, value: password); + _username = username; + _password = password; + if (!_populated.isCompleted) _populated.complete(); } Future removeData({BuildContext? context}) async { _populated = Completer(); - - if(context != null) Provider.of(context, listen: false).setState(AccountModelState.loggedOut); - - var storage = await _storage; - await storage.remove(_usernameField); - await storage.remove(_passwordField); + if (context != null) { + context.read().setStatus(AccountStatus.loggedOut); + } + _username = null; + _password = null; + await _secureStorage.delete(key: _usernameField); + await _secureStorage.delete(key: _passwordField); } - Future _updateFromStorage() async { - var storage = await _storage; - //await storage.reload(); // This line was the cause of the first rejected google play upload :( - if(storage.containsKey(_usernameField) && storage.containsKey(_passwordField)) { - _username = storage.getString(_usernameField); - _password = storage.getString(_passwordField); + Future _migrateAndLoad() async { + await _migrateFromLegacyStorage(); + _username = await _secureStorage.read(key: _usernameField); + _password = await _secureStorage.read(key: _passwordField); + if (!_populated.isCompleted) _populated.complete(); + } + + // Move credentials from the old SharedPreferences plain-text storage into the + // platform's secure keystore. Run once per install and clear the legacy keys. + Future _migrateFromLegacyStorage() async { + final prefs = await SharedPreferences.getInstance(); + final legacyUsername = prefs.getString(_usernameField); + final legacyPassword = prefs.getString(_passwordField); + if (legacyUsername == null || legacyPassword == null) return; + + final hasSecure = (await _secureStorage.read(key: _usernameField)) != null; + if (!hasSecure) { + await _secureStorage.write(key: _usernameField, value: legacyUsername); + await _secureStorage.write(key: _passwordField, value: legacyPassword); } - if(!_populated.isCompleted) _populated.complete(); + await prefs.remove(_usernameField); + await prefs.remove(_passwordField); } Future waitForPopulation() async { @@ -76,7 +99,7 @@ class AccountData { bool isPopulated() => _username != null && _password != null; String buildHttpAuthString() { - if(!isPopulated()) throw Exception('AccountData (e.g. username or password) is not initialized!'); + if (!isPopulated()) throw Exception('AccountData (e.g. username or password) is not initialized!'); return '$_username:$_password'; } } diff --git a/lib/model/accountModel.dart b/lib/model/accountModel.dart deleted file mode 100644 index 26abcfd..0000000 --- a/lib/model/accountModel.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/cupertino.dart'; - -class AccountModel extends ChangeNotifier { - AccountModelState _accountState = AccountModelState.undefined; - AccountModelState get state => _accountState; - - void setState(AccountModelState state) { - _accountState = state; - notifyListeners(); - } -} - -enum AccountModelState { - undefined, - loggedIn, - loggedOut, -} diff --git a/lib/model/breakers/Breaker.dart b/lib/model/breakers/Breaker.dart deleted file mode 100644 index d9ad93e..0000000 --- a/lib/model/breakers/Breaker.dart +++ /dev/null @@ -1,36 +0,0 @@ - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; -import '../../widget/placeholderView.dart'; -import 'BreakerProps.dart'; - - -class Breaker extends StatefulWidget { - final BreakerArea breaker; - final Widget child; - - const Breaker({required this.breaker, required this.child, super.key}); - - @override - State createState() => _BreakerState(); -} - -class _BreakerState extends State { - @override - Widget build(BuildContext context) => Consumer( - builder: (context, value, child) { - var blocked = value.isBlocked(widget.breaker); - if(blocked != null) { - return PlaceholderView( - icon: Icons.app_blocking_outlined, - text: 'Die App / Dieser Bereich ist zurzeit nicht verfügbar!\n\n' - "${blocked.isEmpty ? "Es wurde vom Server kein Grund übermittelt.\nAktualisiere die App und versuche es später erneut" : blocked}" - ); - } - - return widget.child; - }, - ); -} diff --git a/lib/model/breakers/BreakerProps.dart b/lib/model/breakers/BreakerProps.dart deleted file mode 100644 index 4386c2f..0000000 --- a/lib/model/breakers/BreakerProps.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:package_info_plus/package_info_plus.dart'; - -import '../../api/apiResponse.dart'; -import '../../api/mhsl/breaker/getBreakers/getBreakersCache.dart'; -import '../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; -import '../dataHolder.dart'; - -class BreakerProps extends DataHolder { - GetBreakersResponse? _getBreakersResponse; - GetBreakersResponse get getBreakersResponse => _getBreakersResponse!; - - PackageInfo? packageInfo; - - String? isBlocked(BreakerArea? type) { - if(kDebugMode) return null; - - if(packageInfo == null) { - PackageInfo.fromPlatform().then((value) => packageInfo = value); - return null; - } - - if(primaryLoading()) return null; - var breakers = _getBreakersResponse!; - - if(breakers.global.areas.contains(type)) return breakers.global.message; - - var selfVersion = int.parse(packageInfo!.buildNumber); - for(var key in breakers.regional.keys) { - var value = breakers.regional[key]!; - - if(int.parse(key.split('b')[1]) >= selfVersion) { - if(value.areas.contains(type)) return value.message; - } - } - - return null; - } - - @override - List properties() => [_getBreakersResponse]; - - @override - void run() { - GetBreakersCache( - onUpdate: (GetBreakersResponse getBreakersResponse) { - _getBreakersResponse = getBreakersResponse; - notifyListeners(); - } - ); - } -} diff --git a/lib/model/chatList/chatListProps.dart b/lib/model/chatList/chatListProps.dart deleted file mode 100644 index f1b7005..0000000 --- a/lib/model/chatList/chatListProps.dart +++ /dev/null @@ -1,28 +0,0 @@ - -import 'package:flutter_app_badge/flutter_app_badge.dart'; - -import '../../api/apiResponse.dart'; -import '../../api/marianumcloud/talk/room/getRoomCache.dart'; -import '../../api/marianumcloud/talk/room/getRoomResponse.dart'; -import '../dataHolder.dart'; - -class ChatListProps extends DataHolder { - GetRoomResponse? _getRoomResponse; - GetRoomResponse get getRoomsResponse => _getRoomResponse!; - - @override - List properties() => [_getRoomResponse]; - - @override - void run({renew}) { - GetRoomCache( - renew: renew, - onUpdate: (GetRoomResponse data) => { - _getRoomResponse = data, - notifyListeners(), - FlutterAppBadge.count(data.data.map((e) => e.unreadMessages).reduce((a, b) => a+b)) - } - ); - } - -} diff --git a/lib/model/chatList/chatProps.dart b/lib/model/chatList/chatProps.dart deleted file mode 100644 index 80b1038..0000000 --- a/lib/model/chatList/chatProps.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:provider/provider.dart'; - -import '../../api/apiResponse.dart'; -import '../../api/marianumcloud/talk/chat/getChatCache.dart'; -import '../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../storage/base/settingsProvider.dart'; -import '../dataHolder.dart'; - -class ChatProps extends DataHolder { - String _queryToken = ''; - DateTime _lastTokenSet = DateTime.now(); - int? _referenceMessageId; - - GetChatResponse? _getChatResponse; - GetChatResponse get getChatResponse => _getChatResponse!; - - int? get getReferenceMessageId => _referenceMessageId; - set unsafeInternalSetReferenceMessageId(int? reference) => _referenceMessageId = reference; - - @override - List properties() => [_getChatResponse]; - - @override - void run() { - notifyListeners(); - if(_queryToken.isEmpty) return; - var requestStart = DateTime.now(); - - GetChatCache( - chatToken: _queryToken, - onUpdate: (GetChatResponse data) { - if(_lastTokenSet.isAfter(requestStart)) return; // Another request was faster - - _getChatResponse = data; - notifyListeners(); - } - ); - } - - void setReferenceMessageId(int? messageId, BuildContext context, String sendToToken) { - Future.microtask(() { - _referenceMessageId = messageId; - notifyListeners(); - - var settings = Provider.of(context, listen: false); - if(messageId != null) { - settings.val(write: true).talkSettings.draftReplies[sendToToken] = messageId; - } else { - settings.val(write: true).talkSettings.draftReplies.removeWhere((key, value) => key == sendToToken); - } - }); - } - - void setQueryToken(String token) { - _queryToken = token; - _getChatResponse = null; - _lastTokenSet = DateTime.now(); - run(); - } - - String currentToken() => _queryToken; -} diff --git a/lib/model/dataHolder.dart b/lib/model/dataHolder.dart deleted file mode 100644 index 84b3dbd..0000000 --- a/lib/model/dataHolder.dart +++ /dev/null @@ -1,22 +0,0 @@ - -import 'package:flutter/cupertino.dart'; -import 'package:localstore/localstore.dart'; - -import '../api/apiResponse.dart'; - -abstract class DataHolder extends ChangeNotifier { - - CollectionRef storage(String path) => Localstore.instance.collection(path); - - void run(); - List properties(); - - bool primaryLoading() { - // log("${toString()} ${properties().map((e) => e != null ? "1" : "0").join(", ")}"); - for(var element in properties()) { - if(element == null) return true; - } - return false; - //return properties().where((element) => element != null).isEmpty; - } -} diff --git a/lib/model/files/filesProps.dart b/lib/model/files/filesProps.dart deleted file mode 100644 index 979c708..0000000 --- a/lib/model/files/filesProps.dart +++ /dev/null @@ -1,52 +0,0 @@ - -import '../../api/apiResponse.dart'; -import '../../api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart'; -import '../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart'; -import '../dataHolder.dart'; - -extension ExtendedList on List { - T indexOrNull(int index) => index +1 <= length ? this[index] : null; - T firstOrNull() => isEmpty ? null : first; - T lastOrNull() => isEmpty ? null : last; -} - -class FilesProps extends DataHolder { - List folderPath = List.empty(growable: true); - String currentFolderName = 'Home'; - - ListFilesResponse? _listFilesResponse; - ListFilesResponse get listFilesResponse => _listFilesResponse!; - - void runPath(List path) { - folderPath = path; - run(); - } - - @override - List properties() => [_listFilesResponse]; - - @override - void run() { - _listFilesResponse = null; - notifyListeners(); - ListFilesCache( - path: folderPath.isEmpty ? '/' : folderPath.join('/'), - onUpdate: (ListFilesResponse data) => { - _listFilesResponse = data, - notifyListeners(), - } - ); - } - - void enterFolder(String name) { - folderPath.add(name); - currentFolderName = name; - run(); - } - - void popFolder() { - folderPath.removeLast(); - if(folderPath.isEmpty) currentFolderName = 'Home'; - run(); - } -} diff --git a/lib/model/holidays/holidaysProps.dart b/lib/model/holidays/holidaysProps.dart deleted file mode 100644 index a3e153f..0000000 --- a/lib/model/holidays/holidaysProps.dart +++ /dev/null @@ -1,24 +0,0 @@ - -import '../../api/apiResponse.dart'; -import '../../api/holidays/getHolidaysCache.dart'; -import '../../api/holidays/getHolidaysResponse.dart'; -import '../dataHolder.dart'; - - -class HolidaysProps extends DataHolder { - GetHolidaysResponse? _getHolidaysResponse; - GetHolidaysResponse get getHolidaysResponse => _getHolidaysResponse!; - - @override - List properties() => [_getHolidaysResponse]; - - @override - void run() { - GetHolidaysCache( - onUpdate: (GetHolidaysResponse data) => { - _getHolidaysResponse = data, - notifyListeners(), - }, - ); - } -} diff --git a/lib/model/timetable/timetableProps.dart b/lib/model/timetable/timetableProps.dart deleted file mode 100644 index 2d7be91..0000000 --- a/lib/model/timetable/timetableProps.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:intl/intl.dart'; - -import '../../api/apiResponse.dart'; -import '../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart'; -import '../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.dart'; -import '../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart'; -import '../../api/webuntis/queries/getHolidays/getHolidaysCache.dart'; -import '../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; -import '../../api/webuntis/queries/getRooms/getRoomsCache.dart'; -import '../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; -import '../../api/webuntis/queries/getSubjects/getSubjectsCache.dart'; -import '../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; -import '../../api/webuntis/queries/getTimetable/getTimetableCache.dart'; -import '../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; -import '../../api/webuntis/webuntisError.dart'; -import '../accountData.dart'; -import '../dataHolder.dart'; - -class TimetableProps extends DataHolder { - final _queryWeek = DateTime.now().add(const Duration(days: 2)); - - late DateTime startDate = getDate(_queryWeek.subtract(Duration(days: _queryWeek.weekday - 1))); - late DateTime endDate = getDate(_queryWeek.add(Duration(days: DateTime.daysPerWeek - _queryWeek.weekday - 2))); - - GetTimetableResponse? _getTimetableResponse; - GetTimetableResponse get getTimetableResponse => _getTimetableResponse!; - - GetRoomsResponse? _getRoomsResponse; - GetRoomsResponse get getRoomsResponse => _getRoomsResponse!; - - GetSubjectsResponse? _getSubjectsResponse; - GetSubjectsResponse get getSubjectsResponse => _getSubjectsResponse!; - - GetHolidaysResponse? _getHolidaysResponse; - GetHolidaysResponse get getHolidaysResponse => _getHolidaysResponse!; - - GetCustomTimetableEventResponse? _getCustomTimetableEventResponse; - GetCustomTimetableEventResponse get getCustomTimetableEventResponse => _getCustomTimetableEventResponse!; - - WebuntisError? error; - WebuntisError? get getError => error; - bool get hasError => error != null; - - @override - List properties() => [_getTimetableResponse, _getRoomsResponse, _getSubjectsResponse, _getHolidaysResponse, _getCustomTimetableEventResponse]; - - @override - void run({renew}) { - GetTimetableCache( - startdate: int.parse(DateFormat('yyyyMMdd').format(startDate)), - enddate: int.parse(DateFormat('yyyyMMdd').format(endDate)), - onUpdate: (GetTimetableResponse data) => { - _getTimetableResponse = data, - notifyListeners(), - }, - onError: (Exception e) => { - error = e as WebuntisError?, - notifyListeners(), - } - ); - - GetRoomsCache( - onUpdate: (GetRoomsResponse data) => { - _getRoomsResponse = data, - notifyListeners(), - } - ); - - GetSubjectsCache( - onUpdate: (GetSubjectsResponse data) => { - _getSubjectsResponse = data, - notifyListeners(), - } - ); - - GetHolidaysCache( // This broke in the past. Below here is backup simulation for an empty holiday block - onUpdate: (GetHolidaysResponse data) => { - _getHolidaysResponse = data, - notifyListeners(), - } - ); - // _getHolidaysResponse = GetHolidaysResponse.fromJson(jsonDecode(""" - // {"jsonrpc":"2.0","id":"ID","result":[]} - // """)); - - GetCustomTimetableEventCache( - renew: renew, - GetCustomTimetableEventParams( - AccountData().getUserSecret() - ), - onUpdate: (GetCustomTimetableEventResponse data) => { - _getCustomTimetableEventResponse = data, - notifyListeners(), - } - ); - - notifyListeners(); - } - - DateTime getDate(DateTime d) => DateTime(d.year, d.month, d.day); - - bool isWeekend(DateTime queryDate) => queryDate.weekday == DateTime.saturday || queryDate.weekday == DateTime.sunday; - - void updateWeek(DateTime start, DateTime end) { - properties().forEach((element) => element = null); - error = null; - notifyListeners(); - startDate = start; - endDate = end; - try { - run(); - } on WebuntisError catch(e) { - error = e; - notifyListeners(); - } - } - - void resetWeek() { - error = null; - notifyListeners(); - - var queryWeek = DateTime.now().add(const Duration(days: 2)); - - startDate = getDate(queryWeek.subtract(Duration(days: queryWeek.weekday - 1))); - endDate = getDate(queryWeek.add(Duration(days: DateTime.daysPerWeek - queryWeek.weekday))); - - run(); - notifyListeners(); - } -} diff --git a/lib/notification/notificationTasks.dart b/lib/notification/notificationTasks.dart index 7fb59df..e858f08 100644 --- a/lib/notification/notificationTasks.dart +++ b/lib/notification/notificationTasks.dart @@ -1,26 +1,26 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../main.dart'; -import '../model/chatList/chatListProps.dart'; -import '../model/chatList/chatProps.dart'; import '../state/app/modules/app_modules.dart'; +import '../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../state/app/modules/chatList/bloc/chat_list_bloc.dart'; class NotificationTasks { static void updateBadgeCount(RemoteMessage notification) { - FlutterAppBadge.count(int.parse(notification.data['unreadCount'] ?? 0)); + FlutterAppBadge.count(int.parse(notification.data['unreadCount'] ?? '0')); } static void updateProviders(BuildContext context) { - Provider.of(context, listen: false).run(renew: true); - Provider.of(context, listen: false).run(); + context.read().refresh(); + context.read().refresh(); } static void navigateToTalk(BuildContext context) { - var talkTab = AppModule.getBottomBarModules(context).map((e) => e.module).toList().indexOf(Modules.talk); - if(talkTab == -1) return; + final talkTab = AppModule.getBottomBarModules(context).map((e) => e.module).toList().indexOf(Modules.talk); + if (talkTab == -1) return; Main.bottomNavigator.jumpToTab(talkTab); } } diff --git a/lib/notification/notifyUpdater.dart b/lib/notification/notifyUpdater.dart index dc3dff0..dd9527a 100644 --- a/lib/notification/notifyUpdater.dart +++ b/lib/notification/notifyUpdater.dart @@ -1,34 +1,33 @@ -import 'package:flutter/material.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/material.dart'; import '../api/mhsl/notify/register/notifyRegister.dart'; import '../api/mhsl/notify/register/notifyRegisterParams.dart'; import '../model/accountData.dart'; -import '../storage/base/settingsProvider.dart'; +import '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../widget/confirmDialog.dart'; class NotifyUpdater { - static ConfirmDialog enableAfterDisclaimer(SettingsProvider settings) => ConfirmDialog( - title: 'Warnung', - icon: Icons.warning_amber, - content: '' - 'Die Push-Benachrichtigungen werden durch mhsl.eu versendet.\n\n' - 'Durch das aktivieren dieser Funktion wird dein Nutzername, dein Password und eine Geräte-ID von mhsl dauerhaft gespeichert und verarbeitet.\n\n' - 'Für mehr Informationen drücke lange auf die Einstellungsoption!', - confirmButton: 'Aktivieren', - onConfirm: () { - FirebaseMessaging.instance.requestPermission( - provisional: false - ); - settings.val(write: true).notificationSettings.enabled = true; - NotifyUpdater.registerToServer(); - }, - ); + static ConfirmDialog enableAfterDisclaimer(SettingsCubit settings) => ConfirmDialog( + title: 'Warnung', + icon: Icons.warning_amber, + content: '' + 'Die Push-Benachrichtigungen werden durch mhsl.eu versendet.\n\n' + 'Durch das aktivieren dieser Funktion wird dein Nutzername, dein Password und eine Geräte-ID von mhsl dauerhaft gespeichert und verarbeitet.\n\n' + 'Für mehr Informationen drücke lange auf die Einstellungsoption!', + confirmButton: 'Aktivieren', + onConfirm: () { + FirebaseMessaging.instance.requestPermission(provisional: false); + settings.val(write: true).notificationSettings.enabled = true; + NotifyUpdater.registerToServer(); + }, + ); static Future registerToServer() async { - var fcmToken = await FirebaseMessaging.instance.getToken(); - - if(fcmToken == null) throw Exception('Failed to register push notification because there is no FBC token!'); + final fcmToken = await FirebaseMessaging.instance.getToken(); + if (fcmToken == null) { + throw Exception('Failed to register push notification because there is no FBC token!'); + } NotifyRegister( NotifyRegisterParams( diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart b/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart index e1613ba..4b5b557 100644 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart +++ b/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart @@ -25,8 +25,9 @@ class LoadableStateConsumer().state; - if(!loadableState.isLoading && onLoad != null) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) => onLoad!(loadableState.data!)); + final loadedData = loadableState.data; + if(!loadableState.isLoading && onLoad != null && loadedData is TState) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) => onLoad!(loadedData)); } var childWidget = ConditionalWrapper( @@ -47,8 +48,8 @@ class LoadableStateConsumer, TState> extends St @override Widget build(BuildContext context) => BlocProvider( create: (context) { - var bloc = create(context); - this.onInitialisation != null ? this.onInitialisation!(context, bloc) : null; + final bloc = create(context); + onInitialisation?.call(context, bloc); return bloc; }, child: Builder( diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart index f384924..67cf540 100644 --- a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart +++ b/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart @@ -103,7 +103,8 @@ abstract class LoadableHydratedBloc< Map? toJson(LoadableState state) { Map? data; try { - data = state.data == null ? null : toStorage(state.data!); + final stateData = state.data; + data = stateData is TState ? toStorage(stateData) : null; } catch(e) { log('Failed to save state ${TState.toString()}: ${e.toString()}'); } diff --git a/lib/state/app/modules/account/bloc/account_bloc.dart b/lib/state/app/modules/account/bloc/account_bloc.dart new file mode 100644 index 0000000..59514dd --- /dev/null +++ b/lib/state/app/modules/account/bloc/account_bloc.dart @@ -0,0 +1,12 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'account_event.dart'; +import 'account_state.dart'; + +class AccountBloc extends Bloc { + AccountBloc() : super(const AccountState()) { + on((event, emit) => emit(state.copyWith(status: event.status))); + } + + void setStatus(AccountStatus status) => add(AccountStatusChanged(status)); +} diff --git a/lib/state/app/modules/account/bloc/account_event.dart b/lib/state/app/modules/account/bloc/account_event.dart new file mode 100644 index 0000000..a3a0f8a --- /dev/null +++ b/lib/state/app/modules/account/bloc/account_event.dart @@ -0,0 +1,10 @@ +import 'account_state.dart'; + +sealed class AccountEvent { + const AccountEvent(); +} + +class AccountStatusChanged extends AccountEvent { + final AccountStatus status; + const AccountStatusChanged(this.status); +} diff --git a/lib/state/app/modules/account/bloc/account_state.dart b/lib/state/app/modules/account/bloc/account_state.dart new file mode 100644 index 0000000..d0ab496 --- /dev/null +++ b/lib/state/app/modules/account/bloc/account_state.dart @@ -0,0 +1,8 @@ +enum AccountStatus { undefined, loggedIn, loggedOut } + +class AccountState { + final AccountStatus status; + const AccountState({this.status = AccountStatus.undefined}); + + AccountState copyWith({AccountStatus? status}) => AccountState(status: status ?? this.status); +} diff --git a/lib/state/app/modules/app_modules.dart b/lib/state/app/modules/app_modules.dart index 744feef..10b6b36 100644 --- a/lib/state/app/modules/app_modules.dart +++ b/lib/state/app/modules/app_modules.dart @@ -1,16 +1,18 @@ import 'package:flutter/material.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; -import '../../../model/breakers/Breaker.dart'; -import '../../../model/chatList/chatListProps.dart'; -import '../../../storage/base/settingsProvider.dart'; +import '../../../widget/breaker/breaker.dart'; import '../../../view/pages/files/files.dart'; import '../../../view/pages/more/roomplan/roomplan.dart'; import '../../../view/pages/talk/chatList.dart'; import '../../../view/pages/timetable/timetable.dart'; import '../../../widget/centeredLeading.dart'; +import 'chatList/bloc/chat_list_bloc.dart'; +import 'chatList/bloc/chat_list_state.dart'; +import 'settings/bloc/settings_cubit.dart'; +import '../infrastructure/loadableState/loadable_state.dart'; import 'gradeAverages/view/grade_averages_view.dart'; import 'holidays/view/holidays_view.dart'; import 'marianumMessage/view/marianum_message_list_view.dart'; @@ -27,7 +29,7 @@ class AppModule { AppModule(this.module, {required this.name, required this.icon, this.breakerArea = BreakerArea.global, required this.create}); static Map modules(BuildContext context, { showFiltered = false }) { - var settings = Provider.of(context, listen: false); + var settings = context.read(); var available = { Modules.timetable: AppModule( Modules.timetable, @@ -39,10 +41,11 @@ class AppModule { Modules.talk: AppModule( Modules.talk, name: 'Talk', - icon: () => Consumer( - builder: (context, value, child) { - if(value.primaryLoading()) return Icon(Icons.chat); - var messages = value.getRoomsResponse.data.map((e) => e.unreadMessages).reduce((a, b) => a+b); + icon: () => BlocBuilder>( + builder: (context, state) { + final rooms = state.data?.rooms; + if (rooms == null || rooms.data.isEmpty) return const Icon(Icons.chat); + final messages = rooms.data.map((e) => e.unreadMessages).reduce((a, b) => a + b); return badges.Badge( showBadge: messages > 0, position: badges.BadgePosition.topEnd(top: -3, end: -3), @@ -53,7 +56,7 @@ class AppModule { elevation: 1, ), badgeContent: Text('$messages', style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)), - child: Icon(Icons.chat), + child: const Icon(Icons.chat), ); }, ), diff --git a/lib/state/app/modules/breaker/bloc/breaker_bloc.dart b/lib/state/app/modules/breaker/bloc/breaker_bloc.dart new file mode 100644 index 0000000..0381513 --- /dev/null +++ b/lib/state/app/modules/breaker/bloc/breaker_bloc.dart @@ -0,0 +1,51 @@ +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../../../../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../repository/breaker_repository.dart'; +import 'breaker_event.dart'; +import 'breaker_state.dart'; + +class BreakerBloc extends LoadableHydratedBloc { + PackageInfo? _packageInfo; + + @override + BreakerRepository repository() => BreakerRepository(); + + @override + BreakerState fromNothing() => const BreakerState(); + + @override + BreakerState fromStorage(Map json) => BreakerState.fromJson(json); + + @override + Map? toStorage(BreakerState state) => state.toJson(); + + @override + Future gatherData() async { + _packageInfo ??= await PackageInfo.fromPlatform(); + final response = await repo.data.getBreakers(); + add(DataGathered((s) => s.copyWith(response: response))); + } + + void refresh() => fetch(); + + String? isBlocked(BreakerArea? type) { + if (kDebugMode) return null; + final response = innerState?.response; + if (response == null || _packageInfo == null) return null; + + if (response.global.areas.contains(type)) return response.global.message; + + final selfBuild = int.parse(_packageInfo!.buildNumber); + for (final entry in response.regional.entries) { + final affectedBuild = int.parse(entry.key.split('b')[1]); + if (affectedBuild >= selfBuild && entry.value.areas.contains(type)) { + return entry.value.message; + } + } + return null; + } +} diff --git a/lib/state/app/modules/breaker/bloc/breaker_event.dart b/lib/state/app/modules/breaker/bloc/breaker_event.dart new file mode 100644 index 0000000..5c9ed7e --- /dev/null +++ b/lib/state/app/modules/breaker/bloc/breaker_event.dart @@ -0,0 +1,4 @@ +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import 'breaker_state.dart'; + +sealed class BreakerEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/breaker/bloc/breaker_state.dart b/lib/state/app/modules/breaker/bloc/breaker_state.dart new file mode 100644 index 0000000..140cc45 --- /dev/null +++ b/lib/state/app/modules/breaker/bloc/breaker_state.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; + +part 'breaker_state.freezed.dart'; +part 'breaker_state.g.dart'; + +@freezed +abstract class BreakerState with _$BreakerState { + const factory BreakerState({ + GetBreakersResponse? response, + }) = _BreakerState; + + factory BreakerState.fromJson(Map json) => _$BreakerStateFromJson(json); +} diff --git a/lib/state/app/modules/breaker/bloc/breaker_state.freezed.dart b/lib/state/app/modules/breaker/bloc/breaker_state.freezed.dart new file mode 100644 index 0000000..7af9941 --- /dev/null +++ b/lib/state/app/modules/breaker/bloc/breaker_state.freezed.dart @@ -0,0 +1,277 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'breaker_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$BreakerState { + + GetBreakersResponse? get response; +/// Create a copy of BreakerState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BreakerStateCopyWith get copyWith => _$BreakerStateCopyWithImpl(this as BreakerState, _$identity); + + /// Serializes this BreakerState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BreakerState&&(identical(other.response, response) || other.response == response)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,response); + +@override +String toString() { + return 'BreakerState(response: $response)'; +} + + +} + +/// @nodoc +abstract mixin class $BreakerStateCopyWith<$Res> { + factory $BreakerStateCopyWith(BreakerState value, $Res Function(BreakerState) _then) = _$BreakerStateCopyWithImpl; +@useResult +$Res call({ + GetBreakersResponse? response +}); + + + + +} +/// @nodoc +class _$BreakerStateCopyWithImpl<$Res> + implements $BreakerStateCopyWith<$Res> { + _$BreakerStateCopyWithImpl(this._self, this._then); + + final BreakerState _self; + final $Res Function(BreakerState) _then; + +/// Create a copy of BreakerState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? response = freezed,}) { + return _then(_self.copyWith( +response: freezed == response ? _self.response : response // ignore: cast_nullable_to_non_nullable +as GetBreakersResponse?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [BreakerState]. +extension BreakerStatePatterns on BreakerState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _BreakerState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _BreakerState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _BreakerState value) $default,){ +final _that = this; +switch (_that) { +case _BreakerState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _BreakerState value)? $default,){ +final _that = this; +switch (_that) { +case _BreakerState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( GetBreakersResponse? response)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _BreakerState() when $default != null: +return $default(_that.response);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( GetBreakersResponse? response) $default,) {final _that = this; +switch (_that) { +case _BreakerState(): +return $default(_that.response);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( GetBreakersResponse? response)? $default,) {final _that = this; +switch (_that) { +case _BreakerState() when $default != null: +return $default(_that.response);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _BreakerState implements BreakerState { + const _BreakerState({this.response}); + factory _BreakerState.fromJson(Map json) => _$BreakerStateFromJson(json); + +@override final GetBreakersResponse? response; + +/// Create a copy of BreakerState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$BreakerStateCopyWith<_BreakerState> get copyWith => __$BreakerStateCopyWithImpl<_BreakerState>(this, _$identity); + +@override +Map toJson() { + return _$BreakerStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _BreakerState&&(identical(other.response, response) || other.response == response)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,response); + +@override +String toString() { + return 'BreakerState(response: $response)'; +} + + +} + +/// @nodoc +abstract mixin class _$BreakerStateCopyWith<$Res> implements $BreakerStateCopyWith<$Res> { + factory _$BreakerStateCopyWith(_BreakerState value, $Res Function(_BreakerState) _then) = __$BreakerStateCopyWithImpl; +@override @useResult +$Res call({ + GetBreakersResponse? response +}); + + + + +} +/// @nodoc +class __$BreakerStateCopyWithImpl<$Res> + implements _$BreakerStateCopyWith<$Res> { + __$BreakerStateCopyWithImpl(this._self, this._then); + + final _BreakerState _self; + final $Res Function(_BreakerState) _then; + +/// Create a copy of BreakerState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? response = freezed,}) { + return _then(_BreakerState( +response: freezed == response ? _self.response : response // ignore: cast_nullable_to_non_nullable +as GetBreakersResponse?, + )); +} + + +} + +// dart format on diff --git a/lib/state/app/modules/breaker/bloc/breaker_state.g.dart b/lib/state/app/modules/breaker/bloc/breaker_state.g.dart new file mode 100644 index 0000000..d47f3e3 --- /dev/null +++ b/lib/state/app/modules/breaker/bloc/breaker_state.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'breaker_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_BreakerState _$BreakerStateFromJson(Map json) => + _BreakerState( + response: json['response'] == null + ? null + : GetBreakersResponse.fromJson( + json['response'] as Map, + ), + ); + +Map _$BreakerStateToJson(_BreakerState instance) => + {'response': instance.response}; diff --git a/lib/state/app/modules/breaker/dataProvider/breaker_data_provider.dart b/lib/state/app/modules/breaker/dataProvider/breaker_data_provider.dart new file mode 100644 index 0000000..5623e71 --- /dev/null +++ b/lib/state/app/modules/breaker/dataProvider/breaker_data_provider.dart @@ -0,0 +1,14 @@ +import 'dart:async'; + +import '../../../../../api/mhsl/breaker/getBreakers/getBreakersCache.dart'; +import '../../../../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; + +class BreakerDataProvider { + Future getBreakers() { + final completer = Completer(); + GetBreakersCache(onUpdate: (data) { + if (!completer.isCompleted) completer.complete(data); + }); + return completer.future; + } +} diff --git a/lib/state/app/modules/breaker/repository/breaker_repository.dart b/lib/state/app/modules/breaker/repository/breaker_repository.dart new file mode 100644 index 0000000..42bb070 --- /dev/null +++ b/lib/state/app/modules/breaker/repository/breaker_repository.dart @@ -0,0 +1,11 @@ +import '../../../infrastructure/repository/repository.dart'; +import '../bloc/breaker_state.dart'; +import '../dataProvider/breaker_data_provider.dart'; + +class BreakerRepository extends Repository { + final BreakerDataProvider _provider; + + BreakerRepository([BreakerDataProvider? provider]) : _provider = provider ?? BreakerDataProvider(); + + BreakerDataProvider get data => _provider; +} diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart new file mode 100644 index 0000000..e89d7a9 --- /dev/null +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -0,0 +1,59 @@ +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../repository/chat_repository.dart'; +import 'chat_event.dart'; +import 'chat_state.dart'; + +class ChatBloc extends LoadableHydratedBloc { + DateTime _lastTokenSet = DateTime.fromMillisecondsSinceEpoch(0); + + @override + ChatRepository repository() => ChatRepository(); + + @override + ChatState fromNothing() => const ChatState(); + + @override + ChatState fromStorage(Map json) => ChatState.fromJson(json); + + @override + Map? toStorage(ChatState state) => state.toJson(); + + @override + Future gatherData() async { + final token = innerState?.currentToken ?? ''; + if (token.isEmpty) return; + _loadChat(token); + } + + void setToken(String token) { + if (token == (innerState?.currentToken ?? '')) { + refresh(); + return; + } + add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null))); + _loadChat(token); + } + + void setReferenceMessageId(int? messageId) { + add(Emit((s) => s.copyWith(referenceMessageId: messageId))); + } + + void refresh() { + final token = innerState?.currentToken ?? ''; + if (token.isNotEmpty) _loadChat(token); + } + + void _loadChat(String token) { + final requestStart = DateTime.now(); + _lastTokenSet = requestStart; + repo.data.getChat( + token: token, + onUpdate: (data) { + if (_lastTokenSet.isAfter(requestStart)) return; + if ((innerState?.currentToken ?? '') != token) return; + add(DataGathered((s) => s.copyWith(chatResponse: data))); + }, + ); + } +} diff --git a/lib/state/app/modules/chat/bloc/chat_event.dart b/lib/state/app/modules/chat/bloc/chat_event.dart new file mode 100644 index 0000000..460817d --- /dev/null +++ b/lib/state/app/modules/chat/bloc/chat_event.dart @@ -0,0 +1,4 @@ +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import 'chat_state.dart'; + +sealed class ChatEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/chat/bloc/chat_state.dart b/lib/state/app/modules/chat/bloc/chat_state.dart new file mode 100644 index 0000000..221b84d --- /dev/null +++ b/lib/state/app/modules/chat/bloc/chat_state.dart @@ -0,0 +1,17 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; + +part 'chat_state.freezed.dart'; +part 'chat_state.g.dart'; + +@freezed +abstract class ChatState with _$ChatState { + const factory ChatState({ + @Default('') String currentToken, + GetChatResponse? chatResponse, + int? referenceMessageId, + }) = _ChatState; + + factory ChatState.fromJson(Map json) => _$ChatStateFromJson(json); +} diff --git a/lib/state/app/modules/chat/bloc/chat_state.freezed.dart b/lib/state/app/modules/chat/bloc/chat_state.freezed.dart new file mode 100644 index 0000000..0467823 --- /dev/null +++ b/lib/state/app/modules/chat/bloc/chat_state.freezed.dart @@ -0,0 +1,283 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'chat_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$ChatState { + + String get currentToken; GetChatResponse? get chatResponse; int? get referenceMessageId; +/// Create a copy of ChatState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ChatStateCopyWith get copyWith => _$ChatStateCopyWithImpl(this as ChatState, _$identity); + + /// Serializes this ChatState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId); + +@override +String toString() { + return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId)'; +} + + +} + +/// @nodoc +abstract mixin class $ChatStateCopyWith<$Res> { + factory $ChatStateCopyWith(ChatState value, $Res Function(ChatState) _then) = _$ChatStateCopyWithImpl; +@useResult +$Res call({ + String currentToken, GetChatResponse? chatResponse, int? referenceMessageId +}); + + + + +} +/// @nodoc +class _$ChatStateCopyWithImpl<$Res> + implements $ChatStateCopyWith<$Res> { + _$ChatStateCopyWithImpl(this._self, this._then); + + final ChatState _self; + final $Res Function(ChatState) _then; + +/// Create a copy of ChatState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,}) { + return _then(_self.copyWith( +currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable +as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable +as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ChatState]. +extension ChatStatePatterns on ChatState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ChatState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ChatState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ChatState value) $default,){ +final _that = this; +switch (_that) { +case _ChatState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ChatState value)? $default,){ +final _that = this; +switch (_that) { +case _ChatState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ChatState() when $default != null: +return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId) $default,) {final _that = this; +switch (_that) { +case _ChatState(): +return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId)? $default,) {final _that = this; +switch (_that) { +case _ChatState() when $default != null: +return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ChatState implements ChatState { + const _ChatState({this.currentToken = '', this.chatResponse, this.referenceMessageId}); + factory _ChatState.fromJson(Map json) => _$ChatStateFromJson(json); + +@override@JsonKey() final String currentToken; +@override final GetChatResponse? chatResponse; +@override final int? referenceMessageId; + +/// Create a copy of ChatState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ChatStateCopyWith<_ChatState> get copyWith => __$ChatStateCopyWithImpl<_ChatState>(this, _$identity); + +@override +Map toJson() { + return _$ChatStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId); + +@override +String toString() { + return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId)'; +} + + +} + +/// @nodoc +abstract mixin class _$ChatStateCopyWith<$Res> implements $ChatStateCopyWith<$Res> { + factory _$ChatStateCopyWith(_ChatState value, $Res Function(_ChatState) _then) = __$ChatStateCopyWithImpl; +@override @useResult +$Res call({ + String currentToken, GetChatResponse? chatResponse, int? referenceMessageId +}); + + + + +} +/// @nodoc +class __$ChatStateCopyWithImpl<$Res> + implements _$ChatStateCopyWith<$Res> { + __$ChatStateCopyWithImpl(this._self, this._then); + + final _ChatState _self; + final $Res Function(_ChatState) _then; + +/// Create a copy of ChatState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,}) { + return _then(_ChatState( +currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable +as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable +as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + + +} + +// dart format on diff --git a/lib/state/app/modules/chat/bloc/chat_state.g.dart b/lib/state/app/modules/chat/bloc/chat_state.g.dart new file mode 100644 index 0000000..b685b00 --- /dev/null +++ b/lib/state/app/modules/chat/bloc/chat_state.g.dart @@ -0,0 +1,22 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_ChatState _$ChatStateFromJson(Map json) => _ChatState( + currentToken: json['currentToken'] as String? ?? '', + chatResponse: json['chatResponse'] == null + ? null + : GetChatResponse.fromJson(json['chatResponse'] as Map), + referenceMessageId: (json['referenceMessageId'] as num?)?.toInt(), +); + +Map _$ChatStateToJson(_ChatState instance) => + { + 'currentToken': instance.currentToken, + 'chatResponse': instance.chatResponse, + 'referenceMessageId': instance.referenceMessageId, + }; diff --git a/lib/state/app/modules/chat/dataProvider/chat_data_provider.dart b/lib/state/app/modules/chat/dataProvider/chat_data_provider.dart new file mode 100644 index 0000000..dab8899 --- /dev/null +++ b/lib/state/app/modules/chat/dataProvider/chat_data_provider.dart @@ -0,0 +1,11 @@ +import '../../../../../api/marianumcloud/talk/chat/getChatCache.dart'; +import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; + +class ChatDataProvider { + void getChat({ + required String token, + required void Function(GetChatResponse data) onUpdate, + }) { + GetChatCache(chatToken: token, onUpdate: onUpdate); + } +} diff --git a/lib/state/app/modules/chat/repository/chat_repository.dart b/lib/state/app/modules/chat/repository/chat_repository.dart new file mode 100644 index 0000000..54e4356 --- /dev/null +++ b/lib/state/app/modules/chat/repository/chat_repository.dart @@ -0,0 +1,11 @@ +import '../../../infrastructure/repository/repository.dart'; +import '../bloc/chat_state.dart'; +import '../dataProvider/chat_data_provider.dart'; + +class ChatRepository extends Repository { + final ChatDataProvider _provider; + + ChatRepository([ChatDataProvider? provider]) : _provider = provider ?? ChatDataProvider(); + + ChatDataProvider get data => _provider; +} diff --git a/lib/state/app/modules/chatList/bloc/chat_list_bloc.dart b/lib/state/app/modules/chatList/bloc/chat_list_bloc.dart new file mode 100644 index 0000000..2080fae --- /dev/null +++ b/lib/state/app/modules/chatList/bloc/chat_list_bloc.dart @@ -0,0 +1,46 @@ +import 'package:flutter_app_badge/flutter_app_badge.dart'; + +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../repository/chat_list_repository.dart'; +import 'chat_list_event.dart'; +import 'chat_list_state.dart'; + +class ChatListBloc extends LoadableHydratedBloc { + @override + ChatListRepository repository() => ChatListRepository(); + + @override + ChatListState fromNothing() => const ChatListState(); + + @override + ChatListState fromStorage(Map json) => ChatListState.fromJson(json); + + @override + Map? toStorage(ChatListState state) => state.toJson(); + + @override + Future gatherData() async { + final rooms = await repo.data.getRooms(); + add(DataGathered((s) => s.copyWith(rooms: rooms))); + _updateAppBadge(rooms); + } + + Future refresh({bool renew = true}) async { + final rooms = await repo.data.getRooms(renew: renew); + add(DataGathered((s) => s.copyWith(rooms: rooms))); + _updateAppBadge(rooms); + } + + Future createDirectChat(String invite) async { + await repo.data.createDirectRoom(invite); + await refresh(); + } + + void _updateAppBadge(dynamic rooms) { + try { + final unread = rooms.data.map((e) => e.unreadMessages).fold(0, (a, b) => a + b as int); + FlutterAppBadge.count(unread); + } catch (_) {} + } +} diff --git a/lib/state/app/modules/chatList/bloc/chat_list_event.dart b/lib/state/app/modules/chatList/bloc/chat_list_event.dart new file mode 100644 index 0000000..614898d --- /dev/null +++ b/lib/state/app/modules/chatList/bloc/chat_list_event.dart @@ -0,0 +1,4 @@ +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import 'chat_list_state.dart'; + +sealed class ChatListEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/chatList/bloc/chat_list_state.dart b/lib/state/app/modules/chatList/bloc/chat_list_state.dart new file mode 100644 index 0000000..12ad303 --- /dev/null +++ b/lib/state/app/modules/chatList/bloc/chat_list_state.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; + +part 'chat_list_state.freezed.dart'; +part 'chat_list_state.g.dart'; + +@freezed +abstract class ChatListState with _$ChatListState { + const factory ChatListState({ + GetRoomResponse? rooms, + }) = _ChatListState; + + factory ChatListState.fromJson(Map json) => _$ChatListStateFromJson(json); +} diff --git a/lib/state/app/modules/chatList/bloc/chat_list_state.freezed.dart b/lib/state/app/modules/chatList/bloc/chat_list_state.freezed.dart new file mode 100644 index 0000000..ff2714b --- /dev/null +++ b/lib/state/app/modules/chatList/bloc/chat_list_state.freezed.dart @@ -0,0 +1,277 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'chat_list_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$ChatListState { + + GetRoomResponse? get rooms; +/// Create a copy of ChatListState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ChatListStateCopyWith get copyWith => _$ChatListStateCopyWithImpl(this as ChatListState, _$identity); + + /// Serializes this ChatListState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatListState&&(identical(other.rooms, rooms) || other.rooms == rooms)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,rooms); + +@override +String toString() { + return 'ChatListState(rooms: $rooms)'; +} + + +} + +/// @nodoc +abstract mixin class $ChatListStateCopyWith<$Res> { + factory $ChatListStateCopyWith(ChatListState value, $Res Function(ChatListState) _then) = _$ChatListStateCopyWithImpl; +@useResult +$Res call({ + GetRoomResponse? rooms +}); + + + + +} +/// @nodoc +class _$ChatListStateCopyWithImpl<$Res> + implements $ChatListStateCopyWith<$Res> { + _$ChatListStateCopyWithImpl(this._self, this._then); + + final ChatListState _self; + final $Res Function(ChatListState) _then; + +/// Create a copy of ChatListState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? rooms = freezed,}) { + return _then(_self.copyWith( +rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable +as GetRoomResponse?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ChatListState]. +extension ChatListStatePatterns on ChatListState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ChatListState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ChatListState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ChatListState value) $default,){ +final _that = this; +switch (_that) { +case _ChatListState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ChatListState value)? $default,){ +final _that = this; +switch (_that) { +case _ChatListState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( GetRoomResponse? rooms)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ChatListState() when $default != null: +return $default(_that.rooms);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( GetRoomResponse? rooms) $default,) {final _that = this; +switch (_that) { +case _ChatListState(): +return $default(_that.rooms);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( GetRoomResponse? rooms)? $default,) {final _that = this; +switch (_that) { +case _ChatListState() when $default != null: +return $default(_that.rooms);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ChatListState implements ChatListState { + const _ChatListState({this.rooms}); + factory _ChatListState.fromJson(Map json) => _$ChatListStateFromJson(json); + +@override final GetRoomResponse? rooms; + +/// Create a copy of ChatListState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ChatListStateCopyWith<_ChatListState> get copyWith => __$ChatListStateCopyWithImpl<_ChatListState>(this, _$identity); + +@override +Map toJson() { + return _$ChatListStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatListState&&(identical(other.rooms, rooms) || other.rooms == rooms)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,rooms); + +@override +String toString() { + return 'ChatListState(rooms: $rooms)'; +} + + +} + +/// @nodoc +abstract mixin class _$ChatListStateCopyWith<$Res> implements $ChatListStateCopyWith<$Res> { + factory _$ChatListStateCopyWith(_ChatListState value, $Res Function(_ChatListState) _then) = __$ChatListStateCopyWithImpl; +@override @useResult +$Res call({ + GetRoomResponse? rooms +}); + + + + +} +/// @nodoc +class __$ChatListStateCopyWithImpl<$Res> + implements _$ChatListStateCopyWith<$Res> { + __$ChatListStateCopyWithImpl(this._self, this._then); + + final _ChatListState _self; + final $Res Function(_ChatListState) _then; + +/// Create a copy of ChatListState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? rooms = freezed,}) { + return _then(_ChatListState( +rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable +as GetRoomResponse?, + )); +} + + +} + +// dart format on diff --git a/lib/state/app/modules/chatList/bloc/chat_list_state.g.dart b/lib/state/app/modules/chatList/bloc/chat_list_state.g.dart new file mode 100644 index 0000000..1a28f8c --- /dev/null +++ b/lib/state/app/modules/chatList/bloc/chat_list_state.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_list_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_ChatListState _$ChatListStateFromJson(Map json) => + _ChatListState( + rooms: json['rooms'] == null + ? null + : GetRoomResponse.fromJson(json['rooms'] as Map), + ); + +Map _$ChatListStateToJson(_ChatListState instance) => + {'rooms': instance.rooms}; diff --git a/lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart b/lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart new file mode 100644 index 0000000..d7bf80e --- /dev/null +++ b/lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart @@ -0,0 +1,22 @@ +import 'dart:async'; + +import '../../../../../api/marianumcloud/talk/room/getRoomCache.dart'; +import '../../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../../../api/marianumcloud/talk/createRoom/createRoom.dart'; +import '../../../../../api/marianumcloud/talk/createRoom/createRoomParams.dart'; + +class ChatListDataProvider { + Future getRooms({bool renew = false}) { + final completer = Completer(); + GetRoomCache( + renew: renew, + onUpdate: (data) { + if (!completer.isCompleted) completer.complete(data); + }, + ); + return completer.future; + } + + Future createDirectRoom(String invite) => + CreateRoom(CreateRoomParams(roomType: 1, invite: invite)).run(); +} diff --git a/lib/state/app/modules/chatList/repository/chat_list_repository.dart b/lib/state/app/modules/chatList/repository/chat_list_repository.dart new file mode 100644 index 0000000..5a10ce6 --- /dev/null +++ b/lib/state/app/modules/chatList/repository/chat_list_repository.dart @@ -0,0 +1,11 @@ +import '../../../infrastructure/repository/repository.dart'; +import '../bloc/chat_list_state.dart'; +import '../dataProvider/chat_list_data_provider.dart'; + +class ChatListRepository extends Repository { + final ChatListDataProvider _provider; + + ChatListRepository([ChatListDataProvider? provider]) : _provider = provider ?? ChatListDataProvider(); + + ChatListDataProvider get data => _provider; +} diff --git a/lib/state/app/modules/files/bloc/files_bloc.dart b/lib/state/app/modules/files/bloc/files_bloc.dart new file mode 100644 index 0000000..98eff6e --- /dev/null +++ b/lib/state/app/modules/files/bloc/files_bloc.dart @@ -0,0 +1,52 @@ +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../repository/files_repository.dart'; +import 'files_event.dart'; +import 'files_state.dart'; + +class FilesBloc extends LoadableHydratedBloc { + final List initialPath; + + FilesBloc({this.initialPath = const []}); + + @override + FilesRepository repository() => FilesRepository(); + + @override + FilesState fromNothing() => FilesState(currentPath: initialPath); + + @override + FilesState fromStorage(Map json) => FilesState.fromJson(json); + + @override + Map? toStorage(FilesState state) => null; + + @override + Future gatherData() async { + final path = innerState?.currentPath ?? initialPath; + await _query(path); + } + + Future refresh() async { + final path = innerState?.currentPath ?? initialPath; + await _query(path); + } + + Future setPath(List path) async { + add(Emit((s) => s.copyWith(currentPath: path, listing: null))); + await _query(path); + } + + Future createFolder(String name) async { + final path = innerState?.currentPath ?? initialPath; + await repo.data.createFolder('${path.join('/')}/$name'); + await refresh(); + } + + Future _query(List path) async { + final pathString = path.isEmpty ? '/' : path.join('/'); + final listing = await repo.data.listFiles(pathString); + listing.files.removeWhere((file) => file.name.isEmpty || file.name == path.lastOrNull); + add(DataGathered((s) => s.copyWith(listing: listing))); + } +} diff --git a/lib/state/app/modules/files/bloc/files_event.dart b/lib/state/app/modules/files/bloc/files_event.dart new file mode 100644 index 0000000..5b6a3a1 --- /dev/null +++ b/lib/state/app/modules/files/bloc/files_event.dart @@ -0,0 +1,4 @@ +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import 'files_state.dart'; + +sealed class FilesEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/files/bloc/files_state.dart b/lib/state/app/modules/files/bloc/files_state.dart new file mode 100644 index 0000000..d13b079 --- /dev/null +++ b/lib/state/app/modules/files/bloc/files_state.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart'; + +part 'files_state.freezed.dart'; +part 'files_state.g.dart'; + +@freezed +abstract class FilesState with _$FilesState { + const factory FilesState({ + @Default([]) List currentPath, + ListFilesResponse? listing, + }) = _FilesState; + + factory FilesState.fromJson(Map json) => _$FilesStateFromJson(json); +} diff --git a/lib/state/app/modules/files/bloc/files_state.freezed.dart b/lib/state/app/modules/files/bloc/files_state.freezed.dart new file mode 100644 index 0000000..03e46f9 --- /dev/null +++ b/lib/state/app/modules/files/bloc/files_state.freezed.dart @@ -0,0 +1,286 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'files_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$FilesState { + + List get currentPath; ListFilesResponse? get listing; +/// Create a copy of FilesState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$FilesStateCopyWith get copyWith => _$FilesStateCopyWithImpl(this as FilesState, _$identity); + + /// Serializes this FilesState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FilesState&&const DeepCollectionEquality().equals(other.currentPath, currentPath)&&(identical(other.listing, listing) || other.listing == listing)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(currentPath),listing); + +@override +String toString() { + return 'FilesState(currentPath: $currentPath, listing: $listing)'; +} + + +} + +/// @nodoc +abstract mixin class $FilesStateCopyWith<$Res> { + factory $FilesStateCopyWith(FilesState value, $Res Function(FilesState) _then) = _$FilesStateCopyWithImpl; +@useResult +$Res call({ + List currentPath, ListFilesResponse? listing +}); + + + + +} +/// @nodoc +class _$FilesStateCopyWithImpl<$Res> + implements $FilesStateCopyWith<$Res> { + _$FilesStateCopyWithImpl(this._self, this._then); + + final FilesState _self; + final $Res Function(FilesState) _then; + +/// Create a copy of FilesState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? currentPath = null,Object? listing = freezed,}) { + return _then(_self.copyWith( +currentPath: null == currentPath ? _self.currentPath : currentPath // ignore: cast_nullable_to_non_nullable +as List,listing: freezed == listing ? _self.listing : listing // ignore: cast_nullable_to_non_nullable +as ListFilesResponse?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [FilesState]. +extension FilesStatePatterns on FilesState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _FilesState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _FilesState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _FilesState value) $default,){ +final _that = this; +switch (_that) { +case _FilesState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _FilesState value)? $default,){ +final _that = this; +switch (_that) { +case _FilesState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List currentPath, ListFilesResponse? listing)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _FilesState() when $default != null: +return $default(_that.currentPath,_that.listing);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List currentPath, ListFilesResponse? listing) $default,) {final _that = this; +switch (_that) { +case _FilesState(): +return $default(_that.currentPath,_that.listing);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List currentPath, ListFilesResponse? listing)? $default,) {final _that = this; +switch (_that) { +case _FilesState() when $default != null: +return $default(_that.currentPath,_that.listing);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _FilesState implements FilesState { + const _FilesState({final List currentPath = const [], this.listing}): _currentPath = currentPath; + factory _FilesState.fromJson(Map json) => _$FilesStateFromJson(json); + + final List _currentPath; +@override@JsonKey() List get currentPath { + if (_currentPath is EqualUnmodifiableListView) return _currentPath; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_currentPath); +} + +@override final ListFilesResponse? listing; + +/// Create a copy of FilesState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$FilesStateCopyWith<_FilesState> get copyWith => __$FilesStateCopyWithImpl<_FilesState>(this, _$identity); + +@override +Map toJson() { + return _$FilesStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _FilesState&&const DeepCollectionEquality().equals(other._currentPath, _currentPath)&&(identical(other.listing, listing) || other.listing == listing)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_currentPath),listing); + +@override +String toString() { + return 'FilesState(currentPath: $currentPath, listing: $listing)'; +} + + +} + +/// @nodoc +abstract mixin class _$FilesStateCopyWith<$Res> implements $FilesStateCopyWith<$Res> { + factory _$FilesStateCopyWith(_FilesState value, $Res Function(_FilesState) _then) = __$FilesStateCopyWithImpl; +@override @useResult +$Res call({ + List currentPath, ListFilesResponse? listing +}); + + + + +} +/// @nodoc +class __$FilesStateCopyWithImpl<$Res> + implements _$FilesStateCopyWith<$Res> { + __$FilesStateCopyWithImpl(this._self, this._then); + + final _FilesState _self; + final $Res Function(_FilesState) _then; + +/// Create a copy of FilesState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? currentPath = null,Object? listing = freezed,}) { + return _then(_FilesState( +currentPath: null == currentPath ? _self._currentPath : currentPath // ignore: cast_nullable_to_non_nullable +as List,listing: freezed == listing ? _self.listing : listing // ignore: cast_nullable_to_non_nullable +as ListFilesResponse?, + )); +} + + +} + +// dart format on diff --git a/lib/state/app/modules/files/bloc/files_state.g.dart b/lib/state/app/modules/files/bloc/files_state.g.dart new file mode 100644 index 0000000..7d5d345 --- /dev/null +++ b/lib/state/app/modules/files/bloc/files_state.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'files_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_FilesState _$FilesStateFromJson(Map json) => _FilesState( + currentPath: + (json['currentPath'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + listing: json['listing'] == null + ? null + : ListFilesResponse.fromJson(json['listing'] as Map), +); + +Map _$FilesStateToJson(_FilesState instance) => + { + 'currentPath': instance.currentPath, + 'listing': instance.listing, + }; diff --git a/lib/state/app/modules/files/dataProvider/files_data_provider.dart b/lib/state/app/modules/files/dataProvider/files_data_provider.dart new file mode 100644 index 0000000..dda4716 --- /dev/null +++ b/lib/state/app/modules/files/dataProvider/files_data_provider.dart @@ -0,0 +1,25 @@ +import 'dart:async'; + +import 'package:nextcloud/nextcloud.dart'; + +import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart'; +import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart'; +import '../../../../../api/marianumcloud/webdav/webdavApi.dart'; + +class FilesDataProvider { + Future listFiles(String path) { + final completer = Completer(); + ListFilesCache( + path: path, + onUpdate: (data) { + if (!completer.isCompleted) completer.complete(data); + }, + ); + return completer.future; + } + + Future createFolder(String fullPath) async { + final webdav = await WebdavApi.webdav; + await webdav.mkcol(PathUri.parse(fullPath)); + } +} diff --git a/lib/state/app/modules/files/repository/files_repository.dart b/lib/state/app/modules/files/repository/files_repository.dart new file mode 100644 index 0000000..341734e --- /dev/null +++ b/lib/state/app/modules/files/repository/files_repository.dart @@ -0,0 +1,11 @@ +import '../../../infrastructure/repository/repository.dart'; +import '../bloc/files_state.dart'; +import '../dataProvider/files_data_provider.dart'; + +class FilesRepository extends Repository { + final FilesDataProvider _provider; + + FilesRepository([FilesDataProvider? provider]) : _provider = provider ?? FilesDataProvider(); + + FilesDataProvider get data => _provider; +} diff --git a/lib/state/app/modules/settings/bloc/settings_cubit.dart b/lib/state/app/modules/settings/bloc/settings_cubit.dart new file mode 100644 index 0000000..19cbc2d --- /dev/null +++ b/lib/state/app/modules/settings/bloc/settings_cubit.dart @@ -0,0 +1,65 @@ +import 'dart:developer'; + +import 'package:easy_debounce/easy_debounce.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +import '../../../../../storage/base/settings.dart'; +import '../../../../../view/settings/defaultSettings.dart'; + +class SettingsCubit extends HydratedCubit { + static const _debounceTag = 'settings_persist'; + + SettingsCubit() : super(DefaultSettings.get()); + + Settings val({bool write = false}) { + if (write) { + // Notify listeners immediately so the UI reflects the mutation right away; + // debounce the actual persistence to disk to avoid hammering on rapid edits. + _emitFreshInstance(); + EasyDebounce.debounce(_debounceTag, const Duration(milliseconds: 500), _emitFreshInstance); + } + return state; + } + + void _emitFreshInstance() { + try { + emit(Settings.fromJson(state.toJson())); + } catch (e) { + log('Failed to refresh settings state: $e'); + } + } + + Future reset() async { + emit(DefaultSettings.get()); + } + + @override + Settings fromJson(Map json) { + try { + return Settings.fromJson(json); + } catch (_) { + try { + return Settings.fromJson(_mergeSettings(json, DefaultSettings.get().toJson())); + } catch (_) { + return DefaultSettings.get(); + } + } + } + + @override + Map? toJson(Settings state) => state.toJson(); + + Map _mergeSettings(Map oldMap, Map newMap) { + final merged = Map.from(newMap); + oldMap.forEach((key, value) { + if (merged.containsKey(key)) { + if (value is Map && merged[key] is Map) { + merged[key] = _mergeSettings(value, merged[key]); + } else { + merged[key] = value; + } + } + }); + return merged; + } +} diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart new file mode 100644 index 0000000..5055e08 --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -0,0 +1,138 @@ +import 'package:intl/intl.dart'; + +import '../../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../repository/timetable_repository.dart'; +import 'timetable_event.dart'; +import 'timetable_state.dart'; + +class TimetableBloc extends LoadableHydratedBloc { + static const Duration _weekSpan = Duration(days: 7); + static final DateFormat _weekKeyFormat = DateFormat('yyyyMMdd'); + + DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0); + + @override + TimetableRepository repository() => TimetableRepository(); + + @override + TimetableState fromNothing() { + final reference = DateTime.now().add(const Duration(days: 2)); + return TimetableState( + startDate: _startOfWeek(reference), + endDate: _endOfWeek(reference), + ); + } + + @override + TimetableState fromStorage(Map json) => TimetableState.fromJson(json); + + @override + Map? toStorage(TimetableState state) => state.toJson(); + + @override + Future gatherData() async { + final initial = innerState ?? fromNothing(); + await Future.wait([ + _loadCurrentWeek(initial.startDate, initial.endDate), + _loadStaticReferenceData(), + _loadCustomEvents(), + ]); + _prefetchAdjacentWeeks(initial.startDate, initial.endDate); + } + + void changeWeek(DateTime startDate, DateTime endDate) { + final current = innerState ?? fromNothing(); + if (current.startDate == startDate && current.endDate == endDate) return; + add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate))); + _loadCurrentWeek(startDate, endDate); + _prefetchAdjacentWeeks(startDate, endDate); + } + + void resetWeek() { + final reference = DateTime.now().add(const Duration(days: 2)); + changeWeek(_startOfWeek(reference), _endOfWeek(reference)); + } + + void refresh() => fetch(); + + Future addCustomEvent(CustomTimetableEvent event) async { + await repo.data.addCustomEvent(event); + await _refreshCustomEvents(); + } + + Future updateCustomEvent(String id, CustomTimetableEvent event) async { + await repo.data.updateCustomEvent(id, event); + await _refreshCustomEvents(); + } + + Future removeCustomEvent(String id) async { + await repo.data.removeCustomEvent(id); + await _refreshCustomEvents(); + } + + Future _loadCurrentWeek(DateTime startDate, DateTime endDate) async { + final requestStart = DateTime.now(); + _lastWeekRequestStart = requestStart; + try { + final week = await repo.data.getWeek(startDate, endDate); + if (_lastWeekRequestStart.isAfter(requestStart)) return; + _writeWeekToCache(startDate, week); + } catch (_) { + // Errors are surfaced via LoadableHydratedBloc.fetch's catchError. + rethrow; + } + } + + Future _loadStaticReferenceData() async { + final (rooms, subjects, schoolHolidays) = await ( + repo.data.getRooms(), + repo.data.getSubjects(), + repo.data.getSchoolHolidays(), + ).wait; + + add(DataGathered((s) => s.copyWith( + rooms: rooms, + subjects: subjects, + schoolHolidays: schoolHolidays, + dataVersion: s.dataVersion + 1, + ))); + } + + Future _loadCustomEvents({bool renew = false}) async { + final events = await repo.data.getCustomEvents(renew: renew); + add(DataGathered((s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1))); + } + + Future _refreshCustomEvents() => _loadCustomEvents(renew: true); + + void _prefetchAdjacentWeeks(DateTime start, DateTime end) { + _prefetchWeek(start.subtract(_weekSpan), end.subtract(_weekSpan)); + _prefetchWeek(start.add(_weekSpan), end.add(_weekSpan)); + } + + void _prefetchWeek(DateTime start, DateTime end) { + repo.data.getWeek(start, end).then((week) => _writeWeekToCache(start, week)).catchError((_) {}); + } + + void _writeWeekToCache(DateTime weekStart, GetTimetableResponse week) { + final key = _weekKeyFormat.format(weekStart); + add(DataGathered((s) { + final updated = Map.of(s.weekCache); + updated[key] = week; + return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1); + })); + } + + static DateTime _startOfWeek(DateTime reference) { + final monday = reference.subtract(Duration(days: reference.weekday - 1)); + return DateTime(monday.year, monday.month, monday.day); + } + + static DateTime _endOfWeek(DateTime reference) { + final friday = reference.add(Duration(days: DateTime.daysPerWeek - reference.weekday - 2)); + return DateTime(friday.year, friday.month, friday.day); + } +} diff --git a/lib/state/app/modules/timetable/bloc/timetable_event.dart b/lib/state/app/modules/timetable/bloc/timetable_event.dart new file mode 100644 index 0000000..de90c8e --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/timetable_event.dart @@ -0,0 +1,4 @@ +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import 'timetable_state.dart'; + +sealed class TimetableEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.dart b/lib/state/app/modules/timetable/bloc/timetable_state.dart new file mode 100644 index 0000000..cc88b26 --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/timetable_state.dart @@ -0,0 +1,33 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart'; +import '../../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; +import '../../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; +import '../../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; +import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; + +part 'timetable_state.freezed.dart'; +part 'timetable_state.g.dart'; + +@freezed +abstract class TimetableState with _$TimetableState { + const TimetableState._(); + + const factory TimetableState({ + @Default({}) Map weekCache, + GetRoomsResponse? rooms, + GetSubjectsResponse? subjects, + GetHolidaysResponse? schoolHolidays, + GetCustomTimetableEventResponse? customEvents, + required DateTime startDate, + required DateTime endDate, + @Default(0) int dataVersion, + }) = _TimetableState; + + factory TimetableState.fromJson(Map json) => _$TimetableStateFromJson(json); + + Iterable getAllKnownLessons() => + weekCache.values.expand((response) => response.result); + + bool get hasReferenceData => rooms != null && subjects != null && schoolHolidays != null && customEvents != null; +} diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart b/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart new file mode 100644 index 0000000..1ad6cd1 --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart @@ -0,0 +1,304 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'timetable_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$TimetableState { + + Map get weekCache; GetRoomsResponse? get rooms; GetSubjectsResponse? get subjects; GetHolidaysResponse? get schoolHolidays; GetCustomTimetableEventResponse? get customEvents; DateTime get startDate; DateTime get endDate; int get dataVersion; +/// Create a copy of TimetableState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TimetableStateCopyWith get copyWith => _$TimetableStateCopyWithImpl(this as TimetableState, _$identity); + + /// Serializes this TimetableState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TimetableState&&const DeepCollectionEquality().equals(other.weekCache, weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(weekCache),rooms,subjects,schoolHolidays,customEvents,startDate,endDate,dataVersion); + +@override +String toString() { + return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)'; +} + + +} + +/// @nodoc +abstract mixin class $TimetableStateCopyWith<$Res> { + factory $TimetableStateCopyWith(TimetableState value, $Res Function(TimetableState) _then) = _$TimetableStateCopyWithImpl; +@useResult +$Res call({ + Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion +}); + + + + +} +/// @nodoc +class _$TimetableStateCopyWithImpl<$Res> + implements $TimetableStateCopyWith<$Res> { + _$TimetableStateCopyWithImpl(this._self, this._then); + + final TimetableState _self; + final $Res Function(TimetableState) _then; + +/// Create a copy of TimetableState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) { + return _then(_self.copyWith( +weekCache: null == weekCache ? _self.weekCache : weekCache // ignore: cast_nullable_to_non_nullable +as Map,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable +as GetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable +as GetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable +as GetHolidaysResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable +as GetCustomTimetableEventResponse?,startDate: null == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable +as DateTime,endDate: null == endDate ? _self.endDate : endDate // ignore: cast_nullable_to_non_nullable +as DateTime,dataVersion: null == dataVersion ? _self.dataVersion : dataVersion // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TimetableState]. +extension TimetableStatePatterns on TimetableState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TimetableState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TimetableState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TimetableState value) $default,){ +final _that = this; +switch (_that) { +case _TimetableState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TimetableState value)? $default,){ +final _that = this; +switch (_that) { +case _TimetableState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TimetableState() when $default != null: +return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion) $default,) {final _that = this; +switch (_that) { +case _TimetableState(): +return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,) {final _that = this; +switch (_that) { +case _TimetableState() when $default != null: +return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TimetableState extends TimetableState { + const _TimetableState({final Map weekCache = const {}, this.rooms, this.subjects, this.schoolHolidays, this.customEvents, required this.startDate, required this.endDate, this.dataVersion = 0}): _weekCache = weekCache,super._(); + factory _TimetableState.fromJson(Map json) => _$TimetableStateFromJson(json); + + final Map _weekCache; +@override@JsonKey() Map get weekCache { + if (_weekCache is EqualUnmodifiableMapView) return _weekCache; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_weekCache); +} + +@override final GetRoomsResponse? rooms; +@override final GetSubjectsResponse? subjects; +@override final GetHolidaysResponse? schoolHolidays; +@override final GetCustomTimetableEventResponse? customEvents; +@override final DateTime startDate; +@override final DateTime endDate; +@override@JsonKey() final int dataVersion; + +/// Create a copy of TimetableState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TimetableStateCopyWith<_TimetableState> get copyWith => __$TimetableStateCopyWithImpl<_TimetableState>(this, _$identity); + +@override +Map toJson() { + return _$TimetableStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TimetableState&&const DeepCollectionEquality().equals(other._weekCache, _weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_weekCache),rooms,subjects,schoolHolidays,customEvents,startDate,endDate,dataVersion); + +@override +String toString() { + return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)'; +} + + +} + +/// @nodoc +abstract mixin class _$TimetableStateCopyWith<$Res> implements $TimetableStateCopyWith<$Res> { + factory _$TimetableStateCopyWith(_TimetableState value, $Res Function(_TimetableState) _then) = __$TimetableStateCopyWithImpl; +@override @useResult +$Res call({ + Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion +}); + + + + +} +/// @nodoc +class __$TimetableStateCopyWithImpl<$Res> + implements _$TimetableStateCopyWith<$Res> { + __$TimetableStateCopyWithImpl(this._self, this._then); + + final _TimetableState _self; + final $Res Function(_TimetableState) _then; + +/// Create a copy of TimetableState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) { + return _then(_TimetableState( +weekCache: null == weekCache ? _self._weekCache : weekCache // ignore: cast_nullable_to_non_nullable +as Map,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable +as GetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable +as GetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable +as GetHolidaysResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable +as GetCustomTimetableEventResponse?,startDate: null == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable +as DateTime,endDate: null == endDate ? _self.endDate : endDate // ignore: cast_nullable_to_non_nullable +as DateTime,dataVersion: null == dataVersion ? _self.dataVersion : dataVersion // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +// dart format on diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.g.dart b/lib/state/app/modules/timetable/bloc/timetable_state.g.dart new file mode 100644 index 0000000..07960f5 --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/timetable_state.g.dart @@ -0,0 +1,52 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'timetable_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_TimetableState _$TimetableStateFromJson(Map json) => + _TimetableState( + weekCache: + (json['weekCache'] as Map?)?.map( + (k, e) => MapEntry( + k, + GetTimetableResponse.fromJson(e as Map), + ), + ) ?? + const {}, + rooms: json['rooms'] == null + ? null + : GetRoomsResponse.fromJson(json['rooms'] as Map), + subjects: json['subjects'] == null + ? null + : GetSubjectsResponse.fromJson( + json['subjects'] as Map, + ), + schoolHolidays: json['schoolHolidays'] == null + ? null + : GetHolidaysResponse.fromJson( + json['schoolHolidays'] as Map, + ), + customEvents: json['customEvents'] == null + ? null + : GetCustomTimetableEventResponse.fromJson( + json['customEvents'] as Map, + ), + startDate: DateTime.parse(json['startDate'] as String), + endDate: DateTime.parse(json['endDate'] as String), + dataVersion: (json['dataVersion'] as num?)?.toInt() ?? 0, + ); + +Map _$TimetableStateToJson(_TimetableState instance) => + { + 'weekCache': instance.weekCache, + 'rooms': instance.rooms, + 'subjects': instance.subjects, + 'schoolHolidays': instance.schoolHolidays, + 'customEvents': instance.customEvents, + 'startDate': instance.startDate.toIso8601String(), + 'endDate': instance.endDate.toIso8601String(), + 'dataVersion': instance.dataVersion, + }; diff --git a/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart b/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart new file mode 100644 index 0000000..261bb0d --- /dev/null +++ b/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:intl/intl.dart'; + +import '../../../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart'; +import '../../../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.dart'; +import '../../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart'; +import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.dart'; +import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart'; +import '../../../../../api/mhsl/customTimetableEvent/remove/removeCustomTimetableEvent.dart'; +import '../../../../../api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.dart'; +import '../../../../../api/mhsl/customTimetableEvent/update/updateCustomTimetableEvent.dart'; +import '../../../../../api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.dart'; +import '../../../../../api/webuntis/queries/getHolidays/getHolidaysCache.dart'; +import '../../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; +import '../../../../../api/webuntis/queries/getRooms/getRoomsCache.dart'; +import '../../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; +import '../../../../../api/webuntis/queries/getSubjects/getSubjectsCache.dart'; +import '../../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; +import '../../../../../api/webuntis/queries/getTimetable/getTimetableCache.dart'; +import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../../model/accountData.dart'; + +class TimetableDataProvider { + static final DateFormat _dateFormat = DateFormat('yyyyMMdd'); + + Future getWeek(DateTime startDate, DateTime endDate) { + final completer = Completer(); + GetTimetableCache( + startdate: int.parse(_dateFormat.format(startDate)), + enddate: int.parse(_dateFormat.format(endDate)), + onUpdate: (data) { + if (!completer.isCompleted) completer.complete(data); + }, + onError: (e) { + if (!completer.isCompleted) completer.completeError(e); + }, + ); + return completer.future; + } + + Future getRooms() { + final completer = Completer(); + GetRoomsCache(onUpdate: (data) { + if (!completer.isCompleted) completer.complete(data); + }); + return completer.future; + } + + Future getSubjects() { + final completer = Completer(); + GetSubjectsCache(onUpdate: (data) { + if (!completer.isCompleted) completer.complete(data); + }); + return completer.future; + } + + Future getSchoolHolidays() { + final completer = Completer(); + GetHolidaysCache(onUpdate: (data) { + if (!completer.isCompleted) completer.complete(data); + }); + return completer.future; + } + + Future getCustomEvents({bool renew = false}) { + final completer = Completer(); + GetCustomTimetableEventCache( + GetCustomTimetableEventParams(AccountData().getUserSecret()), + renew: renew, + onUpdate: (data) { + if (!completer.isCompleted) completer.complete(data); + }, + ); + return completer.future; + } + + Future addCustomEvent(CustomTimetableEvent event) => + AddCustomTimetableEvent(AddCustomTimetableEventParams(AccountData().getUserSecret(), event)).run(); + + Future updateCustomEvent(String id, CustomTimetableEvent event) => + UpdateCustomTimetableEvent(UpdateCustomTimetableEventParams(id, event)).run(); + + Future removeCustomEvent(String id) => + RemoveCustomTimetableEvent(RemoveCustomTimetableEventParams(id)).run(); +} diff --git a/lib/state/app/modules/timetable/repository/timetable_repository.dart b/lib/state/app/modules/timetable/repository/timetable_repository.dart new file mode 100644 index 0000000..43ac3a7 --- /dev/null +++ b/lib/state/app/modules/timetable/repository/timetable_repository.dart @@ -0,0 +1,11 @@ +import '../../../infrastructure/repository/repository.dart'; +import '../bloc/timetable_state.dart'; +import '../dataProvider/timetable_data_provider.dart'; + +class TimetableRepository extends Repository { + final TimetableDataProvider _provider; + + TimetableRepository([TimetableDataProvider? provider]) : _provider = provider ?? TimetableDataProvider(); + + TimetableDataProvider get data => _provider; +} diff --git a/lib/storage/base/settingsProvider.dart b/lib/storage/base/settingsProvider.dart deleted file mode 100644 index b1fb43f..0000000 --- a/lib/storage/base/settingsProvider.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'dart:convert'; -import 'dart:developer'; -import 'package:easy_debounce/easy_debounce.dart'; -import 'package:flutter/material.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../../view/settings/defaultSettings.dart'; -import 'settings.dart'; - -class SettingsProvider extends ChangeNotifier { - static const String _fieldName = 'settings'; - - late SharedPreferences _storage; - late Settings _settings = DefaultSettings.get(); - - Settings val({bool write = false}) { - if(write) { - notifyListeners(); - EasyDebounce.debounce( - _fieldName, - const Duration(milliseconds: 500), - update - ); - } - return _settings; - } - - SettingsProvider() { - _readFromStorage(); - } - - Future reset() async { - _storage = await SharedPreferences.getInstance(); - _storage.remove(_fieldName); - _settings = DefaultSettings.get(); - await update(); - - notifyListeners(); - } - - Future _readFromStorage() async { - _storage = await SharedPreferences.getInstance(); - - try { - _settings = Settings.fromJson(jsonDecode(_storage.getString(_fieldName)!)); - } catch(exception) { - try { - log('Settings were changed, trying to recover from old Settings: ${exception.toString()}'); - _settings = Settings.fromJson(_mergeSettings(jsonDecode(_storage.getString(_fieldName)!), DefaultSettings.get().toJson())); - log('Settings recovered successfully: ${_settings.toJson().toString()}'); - } catch(exception) { - log('Settings are defective and not recoverable, using defaults: ${exception.toString()}'); - _settings = DefaultSettings.get(); - log('Settings were reset to defaults!'); - } - } - - notifyListeners(); - } - - Future update() async { - await _storage.setString(_fieldName, jsonEncode(_settings.toJson())); - } - - Map _mergeSettings(Map oldMap, Map newMap) { - var mergedMap = Map.from(newMap); - - oldMap.forEach((key, value) { - if (mergedMap.containsKey(key)) { - if (value is Map && mergedMap[key] is Map) { - mergedMap[key] = _mergeSettings(value, mergedMap[key]); - } else { - mergedMap[key] = value; - } - } - }); - - return mergedMap; - } -} diff --git a/lib/storage/timetable/timetableSettings.dart b/lib/storage/timetable/timetableSettings.dart index 26c9d73..72d6a93 100644 --- a/lib/storage/timetable/timetableSettings.dart +++ b/lib/storage/timetable/timetableSettings.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../view/pages/timetable/timetableNameMode.dart'; +import 'timetable_name_mode.dart'; part 'timetableSettings.g.dart'; diff --git a/lib/view/pages/timetable/timetableNameMode.dart b/lib/storage/timetable/timetable_name_mode.dart similarity index 70% rename from lib/view/pages/timetable/timetableNameMode.dart rename to lib/storage/timetable/timetable_name_mode.dart index 6e4f0dd..d6aeeb2 100644 --- a/lib/view/pages/timetable/timetableNameMode.dart +++ b/lib/storage/timetable/timetable_name_mode.dart @@ -1,25 +1,18 @@ import 'package:flutter/material.dart'; -import '../../../widget/dropdownDisplay.dart'; +import '../../widget/dropdownDisplay.dart'; -enum TimetableNameMode { - name, - longName, - alternateName -} +enum TimetableNameMode { name, longName, alternateName } class TimetableNameModes { - static DropdownDisplay getDisplayOptions(TimetableNameMode theme) { - switch(theme) { + static DropdownDisplay getDisplayOptions(TimetableNameMode mode) { + switch (mode) { case TimetableNameMode.name: return DropdownDisplay(icon: Icons.device_unknown_outlined, displayName: 'Name'); - case TimetableNameMode.longName: return DropdownDisplay(icon: Icons.perm_device_info_outlined, displayName: 'Langname'); - case TimetableNameMode.alternateName: return DropdownDisplay(icon: Icons.on_device_training_outlined, displayName: 'Kurzform'); } } } - diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index 6f7d0a5..167b06f 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -2,13 +2,14 @@ import 'dart:developer'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_login/flutter_login.dart'; -import 'package:provider/provider.dart'; import '../../api/marianumcloud/talk/room/getRoom.dart'; import '../../api/marianumcloud/talk/room/getRoomParams.dart'; import '../../model/accountData.dart'; -import '../../model/accountModel.dart'; +import '../../state/app/modules/account/bloc/account_bloc.dart'; +import '../../state/app/modules/account/bloc/account_state.dart'; class Login extends StatefulWidget { const Login({super.key}); @@ -20,7 +21,7 @@ class Login extends StatefulWidget { class _LoginState extends State { bool displayDisclaimerText = true; - String? _checkInput(value)=> (value ?? '').length == 0 ? 'Eingabe erforderlich' : null; + String? _checkInput(String? value) => (value ?? '').isEmpty ? 'Eingabe erforderlich' : null; Future _login(LoginData data) async { await AccountData().removeData(); @@ -55,7 +56,7 @@ class _LoginState extends State { userValidator: _checkInput, passwordValidator: _checkInput, - onSubmitAnimationCompleted: () => Provider.of(context, listen: false).setState(AccountModelState.loggedIn), + onSubmitAnimationCompleted: () => context.read().setStatus(AccountStatus.loggedIn), onLogin: _login, onSignup: null, diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index 86ab4f4..83420bc 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -1,32 +1,22 @@ - import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:loader_overlay/loader_overlay.dart'; -import 'package:nextcloud/nextcloud.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:provider/provider.dart'; import '../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart'; -import '../../../api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart'; -import '../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart'; -import '../../../api/marianumcloud/webdav/webdavApi.dart'; -import '../../../model/files/filesProps.dart'; -import '../../../storage/base/settingsProvider.dart'; -import '../../../widget/loadingSpinner.dart'; -import '../../../widget/placeholderView.dart'; +import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; +import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; +import '../../../state/app/modules/files/bloc/files_bloc.dart'; +import '../../../state/app/modules/files/bloc/files_state.dart'; +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../widget/filePick.dart'; +import '../../../widget/placeholderView.dart'; import 'fileElement.dart'; import 'filesUploadDialog.dart'; -class Files extends StatefulWidget { - final List path; - Files({List? path, super.key}) : path = path ?? []; - - @override - State createState() => _FilesState(); -} - class BetterSortOption { String displayName; int Function(CacheableFile, CacheableFile) compare; @@ -35,111 +25,107 @@ class BetterSortOption { BetterSortOption({required this.displayName, required this.icon, required this.compare}); } -enum SortOption { - name, - date, - size -} +enum SortOption { name, date, size } class SortOptions { static Map options = { SortOption.name: BetterSortOption( displayName: 'Name', icon: Icons.sort_by_alpha_outlined, - compare: (CacheableFile a, CacheableFile b) => a.name.compareTo(b.name) + compare: (a, b) => a.name.compareTo(b.name), ), SortOption.date: BetterSortOption( displayName: 'Datum', icon: Icons.history_outlined, - compare: (CacheableFile a, CacheableFile b) => a.modifiedAt!.compareTo(b.modifiedAt!) + compare: (a, b) => a.modifiedAt!.compareTo(b.modifiedAt!), ), SortOption.size: BetterSortOption( displayName: 'Größe', icon: Icons.sd_card_outlined, - compare: (CacheableFile a, CacheableFile b) { - if(a.isDirectory || b.isDirectory) return a.isDirectory ? 1 : 0; - if(a.size == null) return 0; - if(b.size == null) return 1; + compare: (a, b) { + if (a.isDirectory || b.isDirectory) return a.isDirectory ? 1 : 0; + if (a.size == null) return 0; + if (b.size == null) return 1; return a.size!.compareTo(b.size!); - } - ) + }, + ), }; static BetterSortOption getOption(SortOption option) => options[option]!; } -class _FilesState extends State { - FilesProps props = FilesProps(); - ListFilesResponse? data; +class Files extends StatelessWidget { + final List path; - late SettingsProvider settings = Provider.of(context, listen: false); + Files({List? path, super.key}) : path = path ?? []; - SortOption currentSort = SortOption.name; - bool currentSortDirection = true; + @override + Widget build(BuildContext context) => BlocModule>( + create: (_) => FilesBloc(initialPath: path), + child: (context, _, _) => _FilesView(path: path), + ); +} + +class _FilesView extends StatefulWidget { + final List path; + const _FilesView({required this.path}); + + @override + State<_FilesView> createState() => _FilesViewState(); +} + +class _FilesViewState extends State<_FilesView> { + late final SettingsCubit settings; + late SortOption currentSort; + late bool currentSortDirection; @override void initState() { super.initState(); + settings = context.read(); currentSort = settings.val().fileSettings.sortBy; currentSortDirection = settings.val().fileSettings.ascending; - _query(); - } - - void _query() { - ListFilesCache( - path: widget.path.isEmpty ? '/' : widget.path.join('/'), - onUpdate: (ListFilesResponse d) { - d.files.removeWhere((element) => element.name.isEmpty || element.name == widget.path.lastOrNull()); - setState(() { - data = d; - }); - } - ); } Future mediaUpload(List? paths) async { - if(paths == null) return; - + if (paths == null) return; + final bloc = context.read(); pushScreen( context, withNavBar: false, - screen: FilesUploadDialog(filePaths: paths, remotePath: widget.path.join('/'), onUploadFinished: (uploadedFilePaths) => _query()), + screen: FilesUploadDialog( + filePaths: paths, + remotePath: widget.path.join('/'), + onUploadFinished: (_) => bloc.refresh(), + ), ); - - return; } @override Widget build(BuildContext context) { - var files = data?.sortBy( - sortOption: currentSort, - foldersToTop: Provider.of(context).val().fileSettings.sortFoldersToTop, - reversed: currentSortDirection - ) ?? List.empty(); - + final bloc = context.read(); return Scaffold( appBar: AppBar( title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'), actions: [ - // IconButton( - // icon: const Icon(Icons.search), - // onPressed: () => { - // // TODO implement search - // }, - // ), PopupMenuButton( icon: Icon(currentSortDirection ? Icons.text_rotate_up : Icons.text_rotation_down), - itemBuilder: (context) => [true, false].map((e) => PopupMenuItem( - value: e, - enabled: e != currentSortDirection, - child: Row( - children: [ - Icon(e ? Icons.text_rotate_up : Icons.text_rotation_down, color: Theme.of(context).colorScheme.onSurface), - const SizedBox(width: 15), - Text(e ? 'Aufsteigend' : 'Absteigend') - ], - ) - )).toList(), + itemBuilder: (context) => [true, false] + .map((e) => PopupMenuItem( + value: e, + enabled: e != currentSortDirection, + child: Row( + children: [ + Icon( + e ? Icons.text_rotate_up : Icons.text_rotation_down, + color: Theme.of(context).colorScheme.onSurface, + ), + const SizedBox(width: 15), + Text(e ? 'Aufsteigend' : 'Absteigend'), + ], + ), + )) + .toList(), onSelected: (e) { setState(() { currentSortDirection = e; @@ -149,17 +135,19 @@ class _FilesState extends State { ), PopupMenuButton( icon: const Icon(Icons.sort), - itemBuilder: (context) => SortOptions.options.keys.map((key) => PopupMenuItem( - value: key, - enabled: key != currentSort, - child: Row( - children: [ - Icon(SortOptions.getOption(key).icon, color: Theme.of(context).colorScheme.onSurface), - const SizedBox(width: 15), - Text(SortOptions.getOption(key).displayName), - ], - ) - )).toList(), + itemBuilder: (context) => SortOptions.options.keys + .map((key) => PopupMenuItem( + value: key, + enabled: key != currentSort, + child: Row( + children: [ + Icon(SortOptions.getOption(key).icon, color: Theme.of(context).colorScheme.onSurface), + const SizedBox(width: 15), + Text(SortOptions.getOption(key).displayName), + ], + ), + )) + .toList(), onSelected: (e) { setState(() { currentSort = e; @@ -172,81 +160,91 @@ class _FilesState extends State { floatingActionButton: FloatingActionButton( heroTag: 'uploadFile', backgroundColor: Theme.of(context).primaryColor, - onPressed: () { - showDialog(context: context, builder: (context) => SimpleDialog( - children: [ - ListTile( - leading: const Icon(Icons.create_new_folder_outlined), - title: const Text('Ordner erstellen'), - onTap: () { - Navigator.of(context).pop(); - showDialog(context: context, builder: (context) { - var inputController = TextEditingController(); - return AlertDialog( - title: const Text('Neuer Ordner'), - content: TextField( - controller: inputController, - decoration: const InputDecoration( - labelText: 'Name', - ), - ), - actions: [ - TextButton(onPressed: () { - Navigator.of(context).pop(); - }, child: const Text('Abbrechen')), - TextButton(onPressed: () { - WebdavApi.webdav.then((webdav) { - webdav.mkcol(PathUri.parse("${widget.path.join("/")}/${inputController.text}")).then((value) => _query()); - }); - Navigator.of(context).pop(); - }, child: const Text('Ordner erstellen')), - ], - ); - }); - }, - ), - ListTile( - leading: const Icon(Icons.upload_file), - title: const Text('Aus Dateien hochladen'), - onTap: () { - FilePick.documentPick().then(mediaUpload); - Navigator.of(context).pop(); - }, - ), - Visibility( - visible: !Platform.isIOS, - child: ListTile( - leading: const Icon(Icons.add_a_photo_outlined), - title: const Text('Aus Gallerie hochladen'), - onTap: () { - FilePick.multipleGalleryPick().then((value) { - if(value != null) mediaUpload(value.map((e) => e.path).toList()); - }); - Navigator.of(context).pop(); - }, - ), - ), - ], - )); - }, + onPressed: () => _showAddDialog(context, bloc), child: const Icon(Icons.add), ), - body: data == null ? const LoadingSpinner() : data!.files.isEmpty ? const PlaceholderView(icon: Icons.folder_off_rounded, text: 'Der Ordner ist leer') : LoaderOverlay( - child: RefreshIndicator( - onRefresh: () { - _query(); - return Future.delayed(const Duration(seconds: 3)); + body: LoadableStateConsumer( + child: (state, _) { + final listing = state.listing; + if (listing == null) return const SizedBox.shrink(); + if (listing.files.isEmpty) { + return const PlaceholderView(icon: Icons.folder_off_rounded, text: 'Der Ordner ist leer'); + } + final files = listing.sortBy( + sortOption: currentSort, + foldersToTop: context.watch().val().fileSettings.sortFoldersToTop, + reversed: currentSortDirection, + ); + return LoaderOverlay( + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: files.length, + itemBuilder: (context, index) => FileElement(files[index], widget.path, bloc.refresh), + ), + ); + }, + ), + ); + } + + void _showAddDialog(BuildContext context, FilesBloc bloc) { + showDialog( + context: context, + builder: (dialogCtx) => SimpleDialog(children: [ + ListTile( + leading: const Icon(Icons.create_new_folder_outlined), + title: const Text('Ordner erstellen'), + onTap: () { + Navigator.of(dialogCtx).pop(); + _showCreateFolderDialog(context, bloc); }, - child: ListView.builder( - padding: EdgeInsets.zero, - itemCount: files.length, - itemBuilder: (context, index) { - var file = files.toList()[index]; - return FileElement(file, widget.path, _query); + ), + ListTile( + leading: const Icon(Icons.upload_file), + title: const Text('Aus Dateien hochladen'), + onTap: () { + FilePick.documentPick().then(mediaUpload); + Navigator.of(dialogCtx).pop(); + }, + ), + Visibility( + visible: !Platform.isIOS, + child: ListTile( + leading: const Icon(Icons.add_a_photo_outlined), + title: const Text('Aus Gallerie hochladen'), + onTap: () { + FilePick.multipleGalleryPick().then((value) { + if (value != null) mediaUpload(value.map((e) => e.path).toList()); + }); + Navigator.of(dialogCtx).pop(); }, ), - ) - ) + ), + ]), + ); + } + + void _showCreateFolderDialog(BuildContext context, FilesBloc bloc) { + final inputController = TextEditingController(); + showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: const Text('Neuer Ordner'), + content: TextField( + controller: inputController, + decoration: const InputDecoration(labelText: 'Name'), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Abbrechen')), + TextButton( + onPressed: () { + bloc.createFolder(inputController.text); + Navigator.of(dialogCtx).pop(); + }, + child: const Text('Ordner erstellen'), + ), + ], + ), ); } } diff --git a/lib/view/pages/files/filesUploadDialog.dart b/lib/view/pages/files/filesUploadDialog.dart index 72803c2..bc0112f 100644 --- a/lib/view/pages/files/filesUploadDialog.dart +++ b/lib/view/pages/files/filesUploadDialog.dart @@ -65,6 +65,28 @@ class _FilesUploadDialogState extends State { ); } + void _showUploadError(String message) { + setState(() { + _isUploading = false; + _overallProgressValue = 0.0; + _infoText = ''; + }); + showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('Upload fehlgeschlagen'), + contentPadding: const EdgeInsets.all(10), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Schließen', textAlign: TextAlign.center), + ), + ], + ), + ); + } + Future uploadFiles({bool override = false}) async { setState(() { _isUploading = true; @@ -74,16 +96,24 @@ class _FilesUploadDialogState extends State { } }); - var webdavClient = await WebdavApi.webdav; + final webdavClient = await WebdavApi.webdav; if (!override) { - var result = (await webdavClient.propfind(PathUri.parse(widget.remotePath))).responses; + List result; + try { + result = (await webdavClient.propfind(PathUri.parse(widget.remotePath))).responses; + } catch (e) { + if (!mounted) return; + _showUploadError('Verbindung fehlgeschlagen: $e'); + return; + } var conflictingFiles = _uploadableFiles.where((file) { var fileName = file.fileName; return result.any((element) => Uri.decodeComponent(element.href!).endsWith('/$fileName')); }).toList(); if(conflictingFiles.isNotEmpty) { + if (!mounted) return; bool replaceFiles = await showDialog( context: context, barrierDismissible: false, @@ -157,17 +187,24 @@ class _FilesUploadDialogState extends State { _infoText = '${_uploadableFiles.indexOf(file) + 1}/${_uploadableFiles.length}'; }); - var uploadTask = await webdavClient.putFile( - File(filePath), - FileStat.statSync(filePath), - PathUri.parse(fullRemotePath), - onProgress: (progress) { - setState(() { - file._uploadProgress = progress; - _overallProgressValue = ((progress + _uploadableFiles.indexOf(file)) / _uploadableFiles.length).toDouble(); - }); - }, - ); + final dynamic uploadTask; + try { + uploadTask = await webdavClient.putFile( + File(filePath), + FileStat.statSync(filePath), + PathUri.parse(fullRemotePath), + onProgress: (progress) { + setState(() { + file._uploadProgress = progress; + _overallProgressValue = ((progress + _uploadableFiles.indexOf(file)) / _uploadableFiles.length).toDouble(); + }); + }, + ); + } catch (e) { + if (!mounted) return; + _showUploadError('Upload fehlgeschlagen für "$fileName": $e'); + return; + } if(uploadTask.statusCode < 200 || uploadTask.statusCode > 299) { setState(() { @@ -175,6 +212,7 @@ class _FilesUploadDialogState extends State { _overallProgressValue = 0.0; _infoText = ''; }); + if (!mounted) return; Navigator.of(context).pop(); showHttpErrorCode(uploadTask.statusCode); } else { @@ -187,6 +225,7 @@ class _FilesUploadDialogState extends State { _overallProgressValue = 0.0; _infoText = ''; }); + if (!mounted) return; Navigator.of(context).pop(); widget.onUploadFinished(uploadetFilePaths); } diff --git a/lib/view/pages/more/feedback/feedbackDialog.dart b/lib/view/pages/more/feedback/feedbackDialog.dart index 80d7daf..1b532f2 100644 --- a/lib/view/pages/more/feedback/feedbackDialog.dart +++ b/lib/view/pages/more/feedback/feedbackDialog.dart @@ -6,13 +6,13 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:loader_overlay/loader_overlay.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:badges/badges.dart' as badges; import '../../../../api/mhsl/server/feedback/addFeedback.dart'; import '../../../../api/mhsl/server/feedback/addFeedbackParams.dart'; import '../../../../model/accountData.dart'; -import '../../../../storage/base/settingsProvider.dart'; +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/filePick.dart'; import '../../../../widget/focusBehaviour.dart'; import '../../../../widget/infoDialog.dart'; @@ -113,7 +113,7 @@ class _FeedbackDialogState extends State { child: Visibility( visible: _error != null, child: Visibility( - visible: Provider.of(context, listen: false).val().devToolsEnabled, + visible: context.read().val().devToolsEnabled, replacement: const Text('Senden fehlgeschlagen, bitte überprüfe die Internetverbindung.', textAlign: TextAlign.center, style: TextStyle(color: Colors.red)), child: Text('Senden fehlgeschlagen: \n $_error', textAlign: TextAlign.center, style: const TextStyle(color: Colors.red)), ), @@ -156,13 +156,16 @@ class _FeedbackDialogState extends State { appVersion: int.parse((await PackageInfo.fromPlatform()).buildNumber), ) ).run().then((value) { + if (!context.mounted) return; Navigator.of(context).pop(); InfoDialog.show(context, 'Danke für dein Feedback!'); context.loaderOverlay.hide(); }).catchError((error, trace) { + if (!mounted) return; setState(() { _error = error.toString(); }); + if (!context.mounted) return; context.loaderOverlay.hide(); }); }, diff --git a/lib/view/pages/more/roomplan/roomplan.dart b/lib/view/pages/more/roomplan/roomplan.dart index edabc5e..efbc774 100644 --- a/lib/view/pages/more/roomplan/roomplan.dart +++ b/lib/view/pages/more/roomplan/roomplan.dart @@ -13,7 +13,7 @@ class Roomplan extends StatelessWidget { imageProvider: Image.asset('assets/img/raumplan.jpg').image, minScale: 0.5, maxScale: 2.0, - backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.background), + backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), ), ); } diff --git a/lib/view/pages/more/share/appSharePlatformView.dart b/lib/view/pages/more/share/appSharePlatformView.dart index b25d7c1..b793d4e 100644 --- a/lib/view/pages/more/share/appSharePlatformView.dart +++ b/lib/view/pages/more/share/appSharePlatformView.dart @@ -8,7 +8,7 @@ class AppSharePlatformView extends StatelessWidget { @override Widget build(BuildContext context) { - var foregroundColor = Theme.of(context).colorScheme.onBackground; + var foregroundColor = Theme.of(context).colorScheme.onSurface; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, diff --git a/lib/view/pages/more/share/selectShareTypeDialog.dart b/lib/view/pages/more/share/selectShareTypeDialog.dart index 0b768bc..001c86b 100644 --- a/lib/view/pages/more/share/selectShareTypeDialog.dart +++ b/lib/view/pages/more/share/selectShareTypeDialog.dart @@ -23,14 +23,14 @@ class SelectShareTypeDialog extends StatelessWidget { title: const Text('Per Link teilen'), trailing: const Icon(Icons.arrow_right), onTap: () { - Share.share( - sharePositionOrigin: SharePositionOrigin.get(context), - subject: 'App Teilen', - 'Hol dir die für das Marianum maßgeschneiderte App:' - '\n\nAndroid: https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client ' - '\niOS: https://apps.apple.com/us/app/marianum-fulda/id6458789560 ' - '\n\nViel Spaß!' - ); + SharePlus.instance.share(ShareParams( + sharePositionOrigin: SharePositionOrigin.get(context), + subject: 'App Teilen', + text: 'Hol dir die für das Marianum maßgeschneiderte App:' + '\n\nAndroid: https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client ' + '\niOS: https://apps.apple.com/us/app/marianum-fulda/id6458789560 ' + '\n\nViel Spaß!', + )); }, ) ], diff --git a/lib/view/pages/overhang.dart b/lib/view/pages/overhang.dart index 7b3db61..074e3e9 100644 --- a/lib/view/pages/overhang.dart +++ b/lib/view/pages/overhang.dart @@ -3,12 +3,13 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:in_app_review/in_app_review.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../extensions/renderNotNull.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import '../../state/app/modules/app_modules.dart'; -import '../../storage/base/settingsProvider.dart'; +import '../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../storage/base/settings.dart' as model; import '../../widget/centeredLeading.dart'; import '../../widget/infoDialog.dart'; import '../settings/defaultSettings.dart'; @@ -27,7 +28,9 @@ class _OverhangState extends State { bool editMode = false; @override - Widget build(BuildContext context) => Consumer(builder: (context, settings, child) => Scaffold( + Widget build(BuildContext context) => BlocBuilder(builder: (context, _) { + final settings = context.read(); + return Scaffold( appBar: AppBar( title: const Text('Mehr'), actions: [ @@ -42,9 +45,11 @@ class _OverhangState extends State { ], ), body: editMode ? _sorting() : _overhang(), - )); + ); + }); - Widget _sorting() => Consumer(builder: (context, settings, child) { + Widget _sorting() => BlocBuilder(builder: (context, _) { + final settings = context.read(); void changeVisibility(Modules module) { var hidden = settings.val(write: true).modulesSettings.hiddenModules; hidden.contains(module) ? hidden.remove(module) : (hidden.length < 3 ? hidden.add(module) : null); @@ -107,8 +112,14 @@ class _OverhangState extends State { trailing: const Icon(Icons.arrow_right), onTap: () { InAppReview.instance.openStoreListing(appStoreId: '6458789560').then( - (value) => InfoDialog.show(context, 'Vielen Dank!'), - onError: (error) => InfoDialog.show(context, error.toString()) + (value) { + if (!context.mounted) return; + InfoDialog.show(context, 'Vielen Dank!'); + }, + onError: (error) { + if (!context.mounted) return; + InfoDialog.show(context, error.toString()); + }, ); }, ); diff --git a/lib/view/pages/talk/chatList.dart b/lib/view/pages/talk/chatList.dart index 905a11d..df0f4bc 100644 --- a/lib/view/pages/talk/chatList.dart +++ b/lib/view/pages/talk/chatList.dart @@ -1,79 +1,85 @@ - -import 'dart:async'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_split_view/flutter_split_view.dart'; -import 'package:provider/provider.dart'; -import '../../../api/marianumcloud/talk/createRoom/createRoom.dart'; -import '../../../api/marianumcloud/talk/createRoom/createRoomParams.dart'; -import '../../../model/chatList/chatListProps.dart'; +import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; +import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; +import '../../../state/app/modules/chatList/bloc/chat_list_bloc.dart'; +import '../../../state/app/modules/chatList/bloc/chat_list_state.dart'; import '../../../notification/notifyUpdater.dart'; -import '../../../storage/base/settingsProvider.dart'; +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../widget/confirmDialog.dart'; -import '../../../widget/loadingSpinner.dart'; import 'components/chatTile.dart'; import 'components/splitViewPlaceholder.dart'; import 'joinChat.dart'; import 'searchChat.dart'; -class ChatList extends StatefulWidget { +class ChatList extends StatelessWidget { const ChatList({super.key}); @override - State createState() => _ChatListState(); + Widget build(BuildContext context) => BlocModule>( + create: (_) => ChatListBloc(), + child: (context, bloc, _) => const _ChatListView(), + ); } -class _ChatListState extends State { - late SettingsProvider settings; +class _ChatListView extends StatefulWidget { + const _ChatListView(); + + @override + State<_ChatListView> createState() => _ChatListViewState(); +} + +class _ChatListViewState extends State<_ChatListView> { + late final SettingsCubit _settings; @override void initState() { super.initState(); - settings = Provider.of(context, listen: false); + _settings = context.read(); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _query(); - - if(!settings.val().notificationSettings.enabled && !settings.val().notificationSettings.askUsageDismissed) { - settings.val(write: true).notificationSettings.askUsageDismissed = true; - - ConfirmDialog( - icon: Icons.notifications_active_outlined, - title: 'Benachrichtigungen aktivieren', - content: 'Auf wunsch kannst du Push-Benachrichtigungen aktivieren. Deine Einstellungen kannst du jederzeit ändern.', - confirmButton: 'Weiter', - onConfirm: () { - FirebaseMessaging.instance.requestPermission( - provisional: false - ).then((value) { - switch (value.authorizationStatus) { - case AuthorizationStatus.authorized: - NotifyUpdater.enableAfterDisclaimer(settings).asDialog(context); - break; - case AuthorizationStatus.denied: - showDialog(context: context, builder: (context) => const AlertDialog( - content: Text('Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.'), - )); - break; - default: - break; - } - }); - }, - ).asDialog(context); - } - }); + WidgetsBinding.instance.addPostFrameCallback((_) => _maybeAskForNotificationPermission()); } - void _query({bool renew = false}) { - Provider.of(context, listen: false).run(renew: renew); + void _maybeAskForNotificationPermission() { + final notificationSettings = _settings.val().notificationSettings; + if (notificationSettings.enabled || notificationSettings.askUsageDismissed) return; + + _settings.val(write: true).notificationSettings.askUsageDismissed = true; + ConfirmDialog( + icon: Icons.notifications_active_outlined, + title: 'Benachrichtigungen aktivieren', + content: 'Auf wunsch kannst du Push-Benachrichtigungen aktivieren. Deine Einstellungen kannst du jederzeit ändern.', + confirmButton: 'Weiter', + onConfirm: () { + FirebaseMessaging.instance.requestPermission(provisional: false).then((value) { + if (!mounted) return; + switch (value.authorizationStatus) { + case AuthorizationStatus.authorized: + NotifyUpdater.enableAfterDisclaimer(_settings).asDialog(context); + break; + case AuthorizationStatus.denied: + showDialog( + context: context, + builder: (_) => const AlertDialog( + content: Text('Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.'), + ), + ); + break; + default: + break; + } + }); + }, + ).asDialog(context); } @override Widget build(BuildContext context) { - ChatListProps? latestData; - + final bloc = context.read(); return SplitView.material( placeholder: const SplitViewPlaceholder(), breakpoint: 1000, @@ -83,63 +89,50 @@ class _ChatListState extends State { actions: [ IconButton( icon: const Icon(Icons.search), - onPressed: () async { - if(latestData == null) return; - showSearch(context: context, delegate: SearchChat(latestData!.getRoomsResponse.data.toList())); + onPressed: () { + final rooms = bloc.state.data?.rooms; + if (rooms == null) return; + showSearch(context: context, delegate: SearchChat(rooms.data.toList())); }, - ) + ), ], ), floatingActionButton: FloatingActionButton( heroTag: 'createChat', backgroundColor: Theme.of(context).primaryColor, - onPressed: () async { + onPressed: () { showSearch(context: context, delegate: JoinChat()).then((username) { - if(username == null) return; - + if (username == null || !context.mounted) return; ConfirmDialog( title: 'Chat starten', content: "Möchtest du einen Chat mit Nutzer '$username' starten?", confirmButton: 'Chat starten', onConfirm: () { - CreateRoom(CreateRoomParams( - roomType: 1, - invite: username, - )).run().then((value) { - _query(renew: true); - }); + bloc.createDirectChat(username); }, ).asDialog(context); }); }, child: const Icon(Icons.add_comment_outlined), ), - body: Consumer( - builder: (context, data, child) { + body: LoadableStateConsumer( + child: (state, _) { + final rooms = state.rooms; + if (rooms == null) return const SizedBox.shrink(); - if(data.primaryLoading()) return const LoadingSpinner(); - latestData = data; - var chats = []; - for (var chatRoom in data.getRoomsResponse.sortBy( + final talkSettings = context.watch().val().talkSettings; + final sorted = rooms.sortBy( lastActivity: true, - favoritesToTop: Provider.of(context).val().talkSettings.sortFavoritesToTop, - unreadToTop: Provider.of(context).val().talkSettings.sortUnreadToTop, - ) - ) { - var hasDraft = settings.val().talkSettings.drafts.containsKey(chatRoom.token); - chats.add(ChatTile(data: chatRoom, query: _query, hasDraft: hasDraft)); - } + favoritesToTop: talkSettings.sortFavoritesToTop, + unreadToTop: talkSettings.sortUnreadToTop, + ); - return RefreshIndicator( - color: Theme.of(context).primaryColor, - onRefresh: () { - _query(renew: true); - return Future.delayed(const Duration(seconds: 3)); - }, - child: ListView( - padding: EdgeInsets.zero, - children: chats - ), + return ListView( + padding: EdgeInsets.zero, + children: sorted.map((room) { + final hasDraft = _settings.val().talkSettings.drafts.containsKey(room.token); + return ChatTile(data: room, hasDraft: hasDraft); + }).toList(), ); }, ), diff --git a/lib/view/pages/talk/chatView.dart b/lib/view/pages/talk/chatView.dart index 4d6ca48..63bf7cc 100644 --- a/lib/view/pages/talk/chatView.dart +++ b/lib/view/pages/talk/chatView.dart @@ -1,12 +1,12 @@ - import 'package:flutter/material.dart'; -import '../../../extensions/dateTime.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../api/marianumcloud/talk/chat/getChatResponse.dart'; import '../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../extensions/dateTime.dart'; +import '../../../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../../../state/app/modules/chat/bloc/chat_state.dart'; import '../../../theming/appTheme.dart'; -import '../../../model/chatList/chatProps.dart'; import '../../../widget/clickableAppBar.dart'; import '../../../widget/loadingSpinner.dart'; import '../../../widget/userAvatar.dart'; @@ -27,66 +27,63 @@ class ChatView extends StatefulWidget { } class _ChatViewState extends State { - final ScrollController _listController = ScrollController(); - @override - void initState() { - super.initState(); - } - - void _query({bool renew = false}) { - Provider.of(context, listen: false).setQueryToken(widget.room.token); + void _refresh() { + context.read().setToken(widget.room.token); } @override - Widget build(BuildContext context) => Consumer( - builder: (context, data, child) { - var messages = List.empty(growable: true); + Widget build(BuildContext context) => BlocBuilder( + builder: (context, _) { + final state = context.watch().state.data ?? const ChatState(); + final response = state.chatResponse; + final isLoading = response == null; - if(!data.primaryLoading()) { + final messages = []; + if (response != null) { var lastDate = DateTime.now(); - data.getChatResponse.sortByTimestamp().forEach((element) { - var elementDate = DateTime.fromMillisecondsSinceEpoch(element.timestamp * 1000); + for (final element in response.sortByTimestamp()) { + final elementDate = DateTime.fromMillisecondsSinceEpoch(element.timestamp * 1000); - if(element.systemMessage.contains('reaction')) return; - if(element.systemMessage.contains('poll_voted')) return; - var commonRead = int.parse(data.getChatResponse.headers?['x-chat-last-common-read'] ?? '0'); + if (element.systemMessage.contains('reaction')) continue; + if (element.systemMessage.contains('poll_voted')) continue; + final commonRead = int.parse(response.headers?['x-chat-last-common-read'] ?? '0'); - if(!elementDate.isSameDay(lastDate)) { + if (!elementDate.isSameDay(lastDate)) { lastDate = elementDate; messages.add(ChatBubble( context: context, isSender: false, bubbleData: GetChatResponseObject.getDateDummy(element.timestamp), chatData: widget.room, - refetch: _query, + refetch: ({bool renew = false}) => _refresh(), )); } - messages.add( - ChatBubble( - context: context, - isSender: element.actorId == widget.selfId && element.messageType == GetRoomResponseObjectMessageType.comment, - bubbleData: element, - chatData: widget.room, - refetch: _query, - isRead: element.id <= commonRead, - selfId: widget.selfId, - ) - ); - }); - if(data.getChatResponse.data.length >= 200) { + messages.add(ChatBubble( + context: context, + isSender: element.actorId == widget.selfId && + element.messageType == GetRoomResponseObjectMessageType.comment, + bubbleData: element, + chatData: widget.room, + refetch: ({bool renew = false}) => _refresh(), + isRead: element.id <= commonRead, + selfId: widget.selfId, + )); + } + + if (response.data.length >= 200) { messages.insert(0, ChatBubble( context: context, isSender: false, bubbleData: GetChatResponseObject.getTextDummy( - 'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. ' - 'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de' + 'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. ' + 'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de', ), chatData: widget.room, - refetch: _query, + refetch: ({bool renew = false}) => _refresh(), )); } } @@ -94,9 +91,7 @@ class _ChatViewState extends State { return Scaffold( backgroundColor: const Color(0xffefeae2), appBar: ClickableAppBar( - onTap: () { - TalkNavigator.pushSplitView(context, ChatInfo(widget.room)); - }, + onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)), appBar: AppBar( title: Row( children: [ @@ -104,7 +99,7 @@ class _ChatViewState extends State { const SizedBox(width: 10), Expanded( child: Text(widget.room.displayName, overflow: TextOverflow.ellipsis, maxLines: 1), - ) + ), ], ), ), @@ -117,26 +112,27 @@ class _ChatViewState extends State { opacity: 1, repeat: ImageRepeat.repeat, invertColors: AppTheme.isDarkMode(context), - ) + ), ), - child: data.primaryLoading() ? const LoadingSpinner() : Column( - children: [ - Expanded( - child: ListView( - reverse: true, - controller: _listController, - children: messages.reversed.toList(), + child: isLoading + ? const LoadingSpinner() + : Column( + children: [ + Expanded( + child: ListView( + reverse: true, + controller: _listController, + children: messages.reversed.toList(), + ), + ), + Container( + color: Theme.of(context).colorScheme.surface, + child: TalkNavigator.isSecondaryVisible(context) + ? ChatTextfield(widget.room.token, selfId: widget.selfId) + : SafeArea(child: ChatTextfield(widget.room.token, selfId: widget.selfId)), + ), + ], ), - ), - Container( - color: Theme.of(context).colorScheme.surface, - child: TalkNavigator.isSecondaryVisible(context) - ? ChatTextfield(widget.room.token, selfId: widget.selfId) - : SafeArea(child: ChatTextfield(widget.room.token, selfId: widget.selfId) - ), - ) - ], - ), ), ); }, diff --git a/lib/view/pages/talk/components/answerReference.dart b/lib/view/pages/talk/components/answerReference.dart index ce02745..6a39b09 100644 --- a/lib/view/pages/talk/components/answerReference.dart +++ b/lib/view/pages/talk/components/answerReference.dart @@ -16,8 +16,8 @@ class AnswerReference extends StatelessWidget { return DecoratedBox( decoration: BoxDecoration( color: referenceMessage.actorId == selfId - ? style.getSelfStyle(false).color!.withGreen(200).withOpacity(0.2) - : style.getRemoteStyle(false).color!.withWhite(200).withOpacity(0.2), + ? style.getSelfStyle(false).color!.withGreen(200).withValues(alpha: 0.2) + : style.getRemoteStyle(false).color!.withWhite(200).withValues(alpha: 0.2), borderRadius: const BorderRadius.all(Radius.circular(5)), border: Border(left: BorderSide( color: referenceMessage.actorId == selfId diff --git a/lib/view/pages/talk/components/chatBubble.dart b/lib/view/pages/talk/components/chatBubble.dart index 141c47e..c5f216e 100644 --- a/lib/view/pages/talk/components/chatBubble.dart +++ b/lib/view/pages/talk/components/chatBubble.dart @@ -8,7 +8,7 @@ import 'package:jiffy/jiffy.dart'; import 'package:open_filex/open_filex.dart'; import '../../../../api/marianumcloud/talk/getPoll/getPollState.dart'; import '../../../../extensions/text.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; import '../../../../api/marianumcloud/talk/deleteMessage/deleteMessage.dart'; @@ -17,7 +17,7 @@ import '../../../../api/marianumcloud/talk/deleteReactMessage/deleteReactMessage import '../../../../api/marianumcloud/talk/reactMessage/reactMessage.dart'; import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart'; import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; -import '../../../../model/chatList/chatProps.dart'; +import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../widget/debug/debugTile.dart'; import '../../../../widget/loadingSpinner.dart'; import '../../files/fileElement.dart'; @@ -189,9 +189,9 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM child: ListTile( leading: const Icon(Icons.reply_outlined), title: const Text('Antworten'), - onTap: () => { - Provider.of(context, listen: false).setReferenceMessageId(widget.bubbleData.id, context, widget.chatData.token), - Navigator.of(context).pop(), + onTap: () { + context.read().setReferenceMessageId(widget.bubbleData.id); + Navigator.of(context).pop(); }, ), ), @@ -236,7 +236,8 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM title: const Text('Nachricht löschen'), onTap: () { DeleteMessage(widget.chatData.token, widget.bubbleData.id).run().then((value) { - Provider.of(context, listen: false).run(); + if (!context.mounted) return; + context.read().refresh(); Navigator.of(context).pop(); }); }, @@ -294,7 +295,7 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM _position = const Offset(0, 0); }); if(widget.bubbleData.isReplyable && isAction) { - Provider.of(context, listen: false).setReferenceMessageId(widget.bubbleData.id, context, widget.chatData.token); + context.read().setReferenceMessageId(widget.bubbleData.id); } }, onLongPress: showOptionsDialog, @@ -341,6 +342,7 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM TextButton(onPressed: () { downloadCore?.then((value) { if(!value.isCancelled) value.cancel(); + if (!context.mounted) return; Navigator.of(context).pop(); }); setState(() { diff --git a/lib/view/pages/talk/components/chatBubbleStyles.dart b/lib/view/pages/talk/components/chatBubbleStyles.dart index 5cf527a..7451dd1 100644 --- a/lib/view/pages/talk/components/chatBubbleStyles.dart +++ b/lib/view/pages/talk/components/chatBubbleStyles.dart @@ -5,14 +5,16 @@ import '../../../../theming/appTheme.dart'; extension ColorExtensions on Color { Color invert() { - final r = 255 - red; - final g = 255 - green; - final b = 255 - blue; - - return Color.fromARGB((opacity * 255).round(), r, g, b); + final invertedR = 1.0 - r; + final invertedG = 1.0 - g; + final invertedB = 1.0 - b; + return Color.from(alpha: a, red: invertedR, green: invertedG, blue: invertedB); } - Color withWhite(int whiteValue) => Color.fromARGB(alpha, whiteValue, whiteValue, whiteValue); + Color withWhite(int whiteValue) { + final value = whiteValue / 255.0; + return Color.from(alpha: a, red: value, green: value, blue: value); + } } class ChatBubbleStyles { diff --git a/lib/view/pages/talk/components/chatTextfield.dart b/lib/view/pages/talk/components/chatTextfield.dart index 2da2f9a..a056984 100644 --- a/lib/view/pages/talk/components/chatTextfield.dart +++ b/lib/view/pages/talk/components/chatTextfield.dart @@ -1,17 +1,17 @@ - import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nextcloud/nextcloud.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:provider/provider.dart'; import '../../../../api/marianumcloud/files-sharing/fileSharingApi.dart'; import '../../../../api/marianumcloud/files-sharing/fileSharingApiParams.dart'; import '../../../../api/marianumcloud/talk/sendMessage/sendMessage.dart'; import '../../../../api/marianumcloud/talk/sendMessage/sendMessageParams.dart'; import '../../../../api/marianumcloud/webdav/webdavApi.dart'; -import '../../../../model/chatList/chatProps.dart'; -import '../../../../storage/base/settingsProvider.dart'; +import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/filePick.dart'; import '../../../../widget/focusBehaviour.dart'; import '../../files/filesUploadDialog.dart'; @@ -20,6 +20,7 @@ import 'answerReference.dart'; class ChatTextfield extends StatefulWidget { final String sendToToken; final String? selfId; + const ChatTextfield(this.sendToToken, {this.selfId, super.key}); @override @@ -27,207 +28,214 @@ class ChatTextfield extends StatefulWidget { } class _ChatTextfieldState extends State { - late SettingsProvider settings; + late SettingsCubit settings; final TextEditingController _textBoxController = TextEditingController(); bool isLoading = false; - void _query() { - Provider.of(context, listen: false).run(); - } - void share(String shareFolder, List filePaths) { - for (var element in filePaths) { - var fileName = element.split(Platform.pathSeparator).last; + for (final element in filePaths) { + final fileName = element.split(Platform.pathSeparator).last; FileSharingApi().share(FileSharingApiParams( - shareType: 10, - shareWith: widget.sendToToken, - path: '$shareFolder/$fileName', - )).then((value) => _query()); + shareType: 10, + shareWith: widget.sendToToken, + path: '$shareFolder/$fileName', + )).then((_) { + if (mounted) context.read().refresh(); + }); } } Future mediaUpload(List? paths) async { if (paths == null) return; - var shareFolder = 'MarianumMobile'; - WebdavApi.webdav.then((webdav) { - webdav.mkcol(PathUri.parse('/$shareFolder')); - }); + const shareFolder = 'MarianumMobile'; + WebdavApi.webdav.then((webdav) => webdav.mkcol(PathUri.parse('/$shareFolder'))); + if (!mounted) return; pushScreen( context, withNavBar: false, screen: FilesUploadDialog( filePaths: paths, remotePath: shareFolder, - onUploadFinished: (uploadedFilePaths) { - share(shareFolder, uploadedFilePaths); - }, + onUploadFinished: (uploaded) => share(shareFolder, uploaded), uniqueNames: true, ), ); } - void setDraft(String text) { - if(text.isNotEmpty) { - settings.val(write: true).talkSettings.drafts[widget.sendToToken] = text; + void _setDraft(String text) { + final talkSettings = settings.val(write: true).talkSettings; + if (text.isNotEmpty) { + talkSettings.drafts[widget.sendToToken] = text; } else { - settings.val(write: true).talkSettings.drafts.removeWhere((key, value) => key == widget.sendToToken); + talkSettings.drafts.removeWhere((key, _) => key == widget.sendToToken); + } + } + + void _setDraftReply(int? messageId) { + final talkSettings = settings.val(write: true).talkSettings; + if (messageId != null) { + talkSettings.draftReplies[widget.sendToToken] = messageId; + } else { + talkSettings.draftReplies.removeWhere((key, _) => key == widget.sendToToken); } } @override void initState() { super.initState(); - settings = Provider.of(context, listen: false); - Provider.of(context, listen: false).unsafeInternalSetReferenceMessageId = - settings.val().talkSettings.draftReplies[widget.sendToToken]; + settings = context.read(); + final draftReply = settings.val().talkSettings.draftReplies[widget.sendToToken]; + if (draftReply != null) { + context.read().setReferenceMessageId(draftReply); + } } @override Widget build(BuildContext context) { _textBoxController.text = settings.val().talkSettings.drafts[widget.sendToToken] ?? ''; + final chatBloc = context.watch(); + final chatState = chatBloc.state.data; - return Stack( - children: [ - Align( - alignment: Alignment.bottomLeft, - child: Container( - padding: const EdgeInsets.only(left: 10, bottom: 3, top: 3, right: 10), - width: double.infinity, - child: Column( - children: [ - Consumer( - builder: (context, data, child) { - if(data.getReferenceMessageId != null) { - var referenceMessage = data.getChatResponse.sortByTimestamp().where((element) => element.id == data.getReferenceMessageId).last; - return Row( - children: [ - Expanded( - child: AnswerReference( - context: context, - referenceMessage: referenceMessage, - selfId: widget.selfId, - ), - ), - IconButton( - onPressed: () => data.setReferenceMessageId(null, context, widget.sendToToken), - icon: const Icon(Icons.close_outlined), - padding: const EdgeInsets.only(left: 0), - ), - ], - ); - } else { - return const SizedBox.shrink(); - } - }, - ), - Row( - children: [ - GestureDetector( - onTap: (){ - showDialog(context: context, builder: (context) => SimpleDialog( - children: [ - ListTile( - leading: const Icon(Icons.file_open), - title: const Text('Aus Dateien auswählen'), - onTap: () { - FilePick.documentPick().then(mediaUpload); - Navigator.of(context).pop(); - }, - ), - Visibility( - visible: !Platform.isIOS, - child: ListTile( - leading: const Icon(Icons.image), - title: const Text('Aus Gallerie auswählen'), - onTap: () { - FilePick.multipleGalleryPick().then((value) { - if(value != null) mediaUpload(value.map((e) => e.path).toList()); - }); - Navigator.of(context).pop(); - }, - ), - ), - ], - )); - }, - child: Material( - elevation: 5, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(30), - ), - child: Container( - height: 30, - width: 30, - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - borderRadius: BorderRadius.circular(30), - ), - child: const Icon(Icons.attach_file_outlined, color: Colors.white, size: 20, ), - ), - ) - ), - const SizedBox(width: 15), - Expanded( - child: TextField( - autocorrect: true, - textCapitalization: TextCapitalization.sentences, - controller: _textBoxController, - maxLines: 7, - minLines: 1, - decoration: const InputDecoration( - hintText: 'Nachricht schreiben...', - border: InputBorder.none, - ), - onChanged: (String text) { - if(text.trim().toLowerCase() == 'marbot marbot marbot') { - var newText = 'Roboter sind cool und so, aber Marbots sind besser!'; - _textBoxController.text = newText; - text = newText; - } - setDraft(text); - }, - onTapOutside: (PointerDownEvent event) => FocusBehaviour.textFieldTapOutside(context), - ), - ), - const SizedBox(width: 15), - FloatingActionButton( - mini: true, - onPressed: () { - if(_textBoxController.text.isEmpty) return; - if(isLoading) return; - - setState(() { - isLoading = true; - }); - SendMessage(widget.sendToToken, SendMessageParams( - _textBoxController.text, - replyTo: Provider.of(context, listen: false).getReferenceMessageId.toString() - )).run().then((value) { - _query(); - setState(() { - isLoading = false; - }); - _textBoxController.text = ''; - setDraft(''); - Provider.of(context, listen: false).setReferenceMessageId(null, context, widget.sendToToken); - }); - }, - backgroundColor: Theme.of(context).primaryColor, - elevation: 5, - child: isLoading - ? Container(padding: const EdgeInsets.all(10), child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) - : const Icon(Icons.send, color: Colors.white, size: 18), - ), - ], - ), - ], + Widget replyBanner = const SizedBox.shrink(); + if (chatState != null && chatState.referenceMessageId != null && chatState.chatResponse != null) { + try { + final referenceMessage = chatState.chatResponse!.sortByTimestamp().firstWhere( + (e) => e.id == chatState.referenceMessageId, + ); + replyBanner = Row( + children: [ + Expanded( + child: AnswerReference( + context: context, + referenceMessage: referenceMessage, + selfId: widget.selfId, + ), ), + IconButton( + onPressed: () { + chatBloc.setReferenceMessageId(null); + _setDraftReply(null); + }, + icon: const Icon(Icons.close_outlined), + padding: const EdgeInsets.only(left: 0), + ), + ], + ); + } catch (_) {/* reference no longer in current chat data */} + } + return Stack(children: [ + Align( + alignment: Alignment.bottomLeft, + child: Container( + padding: const EdgeInsets.only(left: 10, bottom: 3, top: 3, right: 10), + width: double.infinity, + child: Column( + children: [ + replyBanner, + Row(children: [ + GestureDetector( + onTap: () { + showDialog(context: context, builder: (dialogCtx) => SimpleDialog(children: [ + ListTile( + leading: const Icon(Icons.file_open), + title: const Text('Aus Dateien auswählen'), + onTap: () { + FilePick.documentPick().then(mediaUpload); + Navigator.of(dialogCtx).pop(); + }, + ), + Visibility( + visible: !Platform.isIOS, + child: ListTile( + leading: const Icon(Icons.image), + title: const Text('Aus Gallerie auswählen'), + onTap: () { + FilePick.multipleGalleryPick().then((value) { + if (value != null) mediaUpload(value.map((e) => e.path).toList()); + }); + Navigator.of(dialogCtx).pop(); + }, + ), + ), + ])); + }, + child: Material( + elevation: 5, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)), + child: Container( + height: 30, + width: 30, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(30), + ), + child: const Icon(Icons.attach_file_outlined, color: Colors.white, size: 20), + ), + ), + ), + const SizedBox(width: 15), + Expanded( + child: TextField( + autocorrect: true, + textCapitalization: TextCapitalization.sentences, + controller: _textBoxController, + maxLines: 7, + minLines: 1, + decoration: const InputDecoration( + hintText: 'Nachricht schreiben...', + border: InputBorder.none, + ), + onChanged: (text) { + if (text.trim().toLowerCase() == 'marbot marbot marbot') { + const newText = 'Roboter sind cool und so, aber Marbots sind besser!'; + _textBoxController.text = newText; + text = newText; + } + _setDraft(text); + }, + onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context), + ), + ), + const SizedBox(width: 15), + FloatingActionButton( + mini: true, + onPressed: () { + if (_textBoxController.text.isEmpty || isLoading) return; + + setState(() => isLoading = true); + SendMessage( + widget.sendToToken, + SendMessageParams( + _textBoxController.text, + replyTo: chatBloc.state.data?.referenceMessageId?.toString(), + ), + ).run().then((_) { + if (!mounted) return; + chatBloc.refresh(); + setState(() => isLoading = false); + _textBoxController.text = ''; + _setDraft(''); + chatBloc.setReferenceMessageId(null); + _setDraftReply(null); + }); + }, + backgroundColor: Theme.of(context).primaryColor, + elevation: 5, + child: isLoading + ? Container( + padding: const EdgeInsets.all(10), + child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ) + : const Icon(Icons.send, color: Colors.white, size: 18), + ), + ]), + ], ), ), - ], - ); + ), + ]); } } diff --git a/lib/view/pages/talk/components/chatTile.dart b/lib/view/pages/talk/components/chatTile.dart index 7c313dc..53d20b4 100644 --- a/lib/view/pages/talk/components/chatTile.dart +++ b/lib/view/pages/talk/components/chatTile.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:provider/provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import '../../../../api/marianumcloud/talk/chat/richObjectStringProcessor.dart'; import '../../../../api/marianumcloud/talk/leaveRoom/leaveRoom.dart'; @@ -10,7 +9,9 @@ import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; import '../../../../api/marianumcloud/talk/setFavorite/setFavorite.dart'; import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarker.dart'; import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dart'; -import '../../../../model/chatList/chatProps.dart'; +import '../../../../model/accountData.dart'; +import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../../../../state/app/modules/chatList/bloc/chat_list_bloc.dart'; import '../../../../widget/confirmDialog.dart'; import '../../../../widget/debug/debugTile.dart'; import '../../../../widget/userAvatar.dart'; @@ -19,167 +20,177 @@ import '../talkNavigator.dart'; class ChatTile extends StatefulWidget { final GetRoomResponseObject data; - final void Function({bool renew}) query; final bool disableContextActions; final bool hasDraft; - const ChatTile({super.key, required this.data, required this.query, this.disableContextActions = false, this.hasDraft = false}); + const ChatTile({super.key, required this.data, this.disableContextActions = false, this.hasDraft = false}); @override State createState() => _ChatTileState(); } class _ChatTileState extends State { - late String selfUsername; + String? selfUsername; @override void initState() { super.initState(); - SharedPreferences.getInstance().then((value) => { - selfUsername = value.getString('username')! + AccountData().waitForPopulation().then((_) { + if (!mounted) return; + setState(() => selfUsername = AccountData().isPopulated() ? AccountData().getUsername() : null); }); } + void _refreshList() => context.read().refresh(); + void setCurrentAsRead() { SetReadMarker( - widget.data.token, - true, - setReadMarkerParams: SetReadMarkerParams( - lastReadMessage: widget.data.lastMessage.id - ) - ).run().then((value) => widget.query(renew: true)); + widget.data.token, + true, + setReadMarkerParams: SetReadMarkerParams(lastReadMessage: widget.data.lastMessage.id), + ).run().then((_) { + if (!mounted) return; + _refreshList(); + }); } @override - Widget build(BuildContext context) => Consumer(builder: (context, chatData, child) { - var isGroup = widget.data.type != GetRoomResponseObjectConversationType.oneToOne; - var circleAvatar = UserAvatar(id: isGroup ? widget.data.token : widget.data.name, isGroup: isGroup); + Widget build(BuildContext context) { + final chatBloc = context.watch(); + final isGroup = widget.data.type != GetRoomResponseObjectConversationType.oneToOne; + final circleAvatar = UserAvatar(id: isGroup ? widget.data.token : widget.data.name, isGroup: isGroup); + return ListTile( - style: ListTileStyle.list, - tileColor: chatData.currentToken() == widget.data.token && TalkNavigator.isSecondaryVisible(context) - ? Theme.of(context).primaryColor.withAlpha(100) - : null, - leading: Stack( + style: ListTileStyle.list, + tileColor: chatBloc.state.data?.currentToken == widget.data.token && TalkNavigator.isSecondaryVisible(context) + ? Theme.of(context).primaryColor.withAlpha(100) + : null, + leading: Stack( + children: [ + circleAvatar, + Visibility( + visible: widget.data.isFavorite, + child: Positioned( + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.all(1), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withAlpha(200), + borderRadius: BorderRadius.circular(90.0), + ), + child: const Icon(Icons.star, color: Colors.amberAccent, size: 15), + ), + ), + ) + ], + ), + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: Text(widget.data.displayName, overflow: TextOverflow.ellipsis)), + if (widget.hasDraft) ...[ + const SizedBox(width: 5), + const Icon(Icons.edit_outlined, size: 15), + ], + ], + ), + subtitle: Text( + '${Jiffy.parseFromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).fromNow()}: ' + '${RichObjectStringProcessor.parseToString(widget.data.lastMessage.message.replaceAll("\n", " "), widget.data.lastMessage.messageParameters)}', + overflow: TextOverflow.ellipsis, + ), + trailing: widget.data.unreadMessages <= 0 + ? null + : Container( + padding: const EdgeInsets.all(1), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(30), + ), + constraints: const BoxConstraints(minWidth: 20, minHeight: 20), + child: Text( + '${widget.data.unreadMessages}', + style: const TextStyle(color: Colors.white, fontSize: 15), + textAlign: TextAlign.center, + ), + ), + onTap: () { + if (selfUsername == null) return; + setCurrentAsRead(); + final view = ChatView(room: widget.data, selfId: selfUsername!, avatar: circleAvatar); + TalkNavigator.pushSplitView(context, view, overrideToSingleSubScreen: true); + context.read().setToken(widget.data.token); + }, + onLongPress: () { + if (widget.disableContextActions) return; + showDialog(context: context, builder: (dialogCtx) => SimpleDialog( children: [ - circleAvatar, Visibility( - visible: widget.data.isFavorite, - child: Positioned( - right: 0, - bottom: 0, - child: Container( - padding: const EdgeInsets.all(1), - decoration: BoxDecoration( - color: Theme.of(context).primaryColor.withAlpha(200), - borderRadius: BorderRadius.circular(90.0), - ), - child: const Icon(Icons.star, color: Colors.amberAccent, size: 15), - ), - ), - ) - ], - ), - title: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text(widget.data.displayName, overflow: TextOverflow.ellipsis), - ), - if(widget.hasDraft) ...[ - const SizedBox(width: 5), - const Icon(Icons.edit_outlined, size: 15), - ], - ], - ), - subtitle: Text("${Jiffy.parseFromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).fromNow()}: ${RichObjectStringProcessor.parseToString(widget.data.lastMessage.message.replaceAll("\n", " "), widget.data.lastMessage.messageParameters)}", overflow: TextOverflow.ellipsis), - trailing: widget.data.unreadMessages <= 0 - ? null - : Container( - padding: const EdgeInsets.all(1), - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - borderRadius: BorderRadius.circular(30), - ), - constraints: const BoxConstraints( - minWidth: 20, - minHeight: 20, - ), - child: Text( - '${widget.data.unreadMessages}', - style: const TextStyle( - color: Colors.white, - fontSize: 15, - ), - textAlign: TextAlign.center, - ), - ), - onTap: () async { - setCurrentAsRead(); - var view = ChatView(room: widget.data, selfId: selfUsername, avatar: circleAvatar); - TalkNavigator.pushSplitView(context, view, overrideToSingleSubScreen: true); - Provider.of(context, listen: false).setQueryToken(widget.data.token); - }, - onLongPress: () { - if(widget.disableContextActions) return; - showDialog(context: context, builder: (context) => SimpleDialog( - children: [ - Visibility( - visible: widget.data.unreadMessages > 0, - replacement: ListTile( - leading: const Icon(Icons.mark_chat_unread_outlined), - title: const Text('Als ungelesen markieren'), - onTap: () { - SetReadMarker(widget.data.token, false).run().then((value) => widget.query(renew: true)); - Navigator.of(context).pop(); - }, - ), - child: ListTile( - leading: const Icon(Icons.mark_chat_read_outlined), - title: const Text('Als gelesen markieren'), - onTap: () { - setCurrentAsRead(); - Navigator.of(context).pop(); - }, - ), - ), - Visibility( - visible: widget.data.isFavorite, - replacement: ListTile( - leading: const Icon(Icons.star_outline), - title: const Text('Zu Favoriten hinzufügen'), - onTap: () { - SetFavorite(widget.data.token, true).run().then((value) => widget.query(renew: true)); - Navigator.of(context).pop(); - }, - ), - child: ListTile( - leading: const Icon(Icons.stars_outlined), - title: const Text('Von Favoriten entfernen'), - onTap: () { - SetFavorite(widget.data.token, false).run().then((value) => widget.query(renew: true)); - Navigator.of(context).pop(); - }, - ), - ), - ListTile( - leading: const Icon(Icons.delete_outline), - title: const Text('Konversation verlassen'), + visible: widget.data.unreadMessages > 0, + replacement: ListTile( + leading: const Icon(Icons.mark_chat_unread_outlined), + title: const Text('Als ungelesen markieren'), onTap: () { - ConfirmDialog( - title: 'Chat verlassen', - content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.', - confirmButton: 'Löschen', - onConfirm: () { - LeaveRoom(widget.data.token).run().then((value) => widget.query(renew: true)); - Navigator.of(context).pop(); - }, - ).asDialog(context); + SetReadMarker(widget.data.token, false).run().then((_) { + if (mounted) _refreshList(); + }); + Navigator.of(dialogCtx).pop(); }, ), - DebugTile(context).jsonData(widget.data.toJson()), - ], - )); - }, - ); - }); + child: ListTile( + leading: const Icon(Icons.mark_chat_read_outlined), + title: const Text('Als gelesen markieren'), + onTap: () { + setCurrentAsRead(); + Navigator.of(dialogCtx).pop(); + }, + ), + ), + Visibility( + visible: widget.data.isFavorite, + replacement: ListTile( + leading: const Icon(Icons.star_outline), + title: const Text('Zu Favoriten hinzufügen'), + onTap: () { + SetFavorite(widget.data.token, true).run().then((_) { + if (mounted) _refreshList(); + }); + Navigator.of(dialogCtx).pop(); + }, + ), + child: ListTile( + leading: const Icon(Icons.stars_outlined), + title: const Text('Von Favoriten entfernen'), + onTap: () { + SetFavorite(widget.data.token, false).run().then((_) { + if (mounted) _refreshList(); + }); + Navigator.of(dialogCtx).pop(); + }, + ), + ), + ListTile( + leading: const Icon(Icons.delete_outline), + title: const Text('Konversation verlassen'), + onTap: () { + ConfirmDialog( + title: 'Chat verlassen', + content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.', + confirmButton: 'Löschen', + onConfirm: () { + LeaveRoom(widget.data.token).run().then((_) { + if (mounted) _refreshList(); + }); + Navigator.of(dialogCtx).pop(); + }, + ).asDialog(dialogCtx); + }, + ), + DebugTile(dialogCtx).jsonData(widget.data.toJson()), + ], + )); + }, + ); + } } diff --git a/lib/view/pages/talk/components/pollOptionsList.dart b/lib/view/pages/talk/components/pollOptionsList.dart index 30b3ab3..c7eb9ea 100644 --- a/lib/view/pages/talk/components/pollOptionsList.dart +++ b/lib/view/pages/talk/components/pollOptionsList.dart @@ -26,7 +26,7 @@ class _PollOptionsListState extends State { ? (widget.pollData.votes['option-$optionId'] as num?) ?? 0 : 0; var numVoters = widget.pollData.numVoters ?? 0; - double portion = numVoters == 0 ? 0 : (votes / numVoters); + final portion = numVoters == 0 ? 0.0 : (votes / numVoters); return ListTile( // enabled: false, diff --git a/lib/view/pages/talk/searchChat.dart b/lib/view/pages/talk/searchChat.dart index 3f39738..ebca04d 100644 --- a/lib/view/pages/talk/searchChat.dart +++ b/lib/view/pages/talk/searchChat.dart @@ -25,7 +25,7 @@ class SearchChat extends SearchDelegate { itemCount: items.length, itemBuilder: (context, index) { var item = items.elementAt(index); - return ChatTile(data: item, disableContextActions: true, query: ({bool renew = true}) {}); + return ChatTile(data: item, disableContextActions: true); }, ); } diff --git a/lib/view/pages/timetable/appointmenetComponent.dart b/lib/view/pages/timetable/appointmenetComponent.dart deleted file mode 100644 index 0f2d3e4..0000000 --- a/lib/view/pages/timetable/appointmenetComponent.dart +++ /dev/null @@ -1,92 +0,0 @@ - -import 'package:flutter/material.dart'; -import 'package:syncfusion_flutter_calendar/calendar.dart'; - -import 'CrossPainter.dart'; - -class AppointmentComponent extends StatefulWidget { - final CalendarAppointmentDetails details; - final bool crossedOut; - const AppointmentComponent({super.key, required this.details, this.crossedOut = false}); - - @override - State createState() => _AppointmentComponentState(); -} - -class _AppointmentComponentState extends State { - @override - Widget build(BuildContext context) { - final Appointment meeting = widget.details.appointments.first; - final appointmentHeight = widget.details.bounds.height; - - return Stack( - children: [ - Column( - children: [ - Container( - padding: const EdgeInsets.all(3), - height: appointmentHeight, - alignment: Alignment.topLeft, - decoration: BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: const BorderRadius.all(Radius.circular(5)), - color: meeting.color.withAlpha(meeting.endTime.isBefore(DateTime.now()) ? 100 : 255), - ), - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FittedBox( - fit: BoxFit.fitWidth, - child: Text( - meeting.subject, - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - softWrap: false, - ), - ), - FittedBox( - fit: BoxFit.fitWidth, - child: Text( - (meeting.location == null || meeting.location!.isEmpty ? ' ' : meeting.location!), - maxLines: 3, - overflow: TextOverflow.ellipsis, - softWrap: true, - style: const TextStyle( - color: Colors.white, - fontSize: 10, - ), - ), - ) - ], - ), - ), - ), - ], - ), - Visibility( - visible: widget.crossedOut, - child: Positioned.fill( - child: Container( - decoration: BoxDecoration( - border: Border.all( - width: 2, - color: Colors.red.withAlpha(200), - ), - borderRadius: const BorderRadius.all(Radius.circular(5)), - ), - child: CustomPaint( - painter: CrossPainter(), - ), - ) - ), - ), - ], - ); - } -} diff --git a/lib/view/pages/timetable/appointmentDetails.dart b/lib/view/pages/timetable/appointmentDetails.dart deleted file mode 100644 index 9adb671..0000000 --- a/lib/view/pages/timetable/appointmentDetails.dart +++ /dev/null @@ -1,226 +0,0 @@ - -import 'dart:async'; - -import 'package:bottom_sheet/bottom_sheet.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; -import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:provider/provider.dart'; -import 'package:rrule/rrule.dart'; -import 'package:syncfusion_flutter_calendar/calendar.dart'; - -import '../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../api/mhsl/customTimetableEvent/remove/removeCustomTimetableEvent.dart'; -import '../../../api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.dart'; -import '../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; -import '../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; -import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; -import '../../../model/timetable/timetableProps.dart'; -import '../../../widget/centeredLeading.dart'; -import '../../../widget/confirmDialog.dart'; -import '../../../widget/debug/debugTile.dart'; -import '../../../widget/unimplementedDialog.dart'; -import '../more/roomplan/roomplan.dart'; -import 'arbitraryAppointment.dart'; -import 'customTimetableEventEditDialog.dart'; - -class AppointmentDetails { - static String _getEventPrefix(String? code) { - if(code == 'cancelled') return 'Entfällt: '; - if(code == 'irregular') return 'Änderung: '; - return code ?? ''; - } - - static void show(BuildContext context, TimetableProps webuntisData, Appointment appointment) { - (appointment.id! as ArbitraryAppointment).handlers( - (webuntis) => _webuntis(context, webuntisData, appointment, webuntis), - (customData) => _custom(context, webuntisData, customData) - ); - } - - static void _bottomSheet( - BuildContext context, - Widget Function(BuildContext context) header, - SliverChildListDelegate Function(BuildContext context) body - ) { - showStickyFlexibleBottomSheet( - minHeight: 0, - initHeight: 0.4, - maxHeight: 0.7, - anchors: [0, 0.4, 0.7], - isSafeArea: true, - maxHeaderHeight: 100, - - context: context, - headerBuilder: (context, bottomSheetOffset) => header(context), - bodyBuilder: (context, bottomSheetOffset) => body(context) - ); - } - - static void _webuntis(BuildContext context, TimetableProps webuntisData, Appointment appointment, GetTimetableResponseObject timetableData) { - GetSubjectsResponseObject subject; - GetRoomsResponseObject room; - - try { - subject = webuntisData.getSubjectsResponse.result.firstWhere((subject) => subject.id == timetableData.su[0].id); - } catch(e) { - subject = GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true); - } - - try { - room = webuntisData.getRoomsResponse.result.firstWhere((room) => room.id == timetableData.ro[0].id); - } catch(e) { - room = GetRoomsResponseObject(0, '?', 'Unbekannt', true, '?'); - } - - _bottomSheet( - context, - (context) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text('${_getEventPrefix(timetableData.code)}${subject.alternateName}', textAlign: TextAlign.center, style: const TextStyle(fontSize: 25), overflow: TextOverflow.ellipsis), - Text(subject.longName), - Text("${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: "HH:mm")} - ${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: "HH:mm")}", style: const TextStyle(fontSize: 15)), - ], - ), - ), - - (context) => SliverChildListDelegate( - [ - const Divider(), - ListTile( - leading: const Icon(Icons.notifications_active), - title: Text("Status: ${timetableData.code != null ? "Geändert" : "Regulär"}"), - ), - ListTile( - leading: const Icon(Icons.room), - title: Text('Raum: ${room.name} (${room.longName})'), - trailing: IconButton( - icon: const Icon(Icons.house_outlined), - onPressed: () { - pushScreen(context, withNavBar: false, screen: const Roomplan()); - }, - ), - ), - ListTile( - leading: const Icon(Icons.person), - title: timetableData.te.isNotEmpty - ? Text("Lehrkraft: ${timetableData.te[0].name} ${timetableData.te[0].longname.isNotEmpty ? "(${timetableData.te[0].longname})" : ""}") - : const Text('?'), - trailing: Visibility( - visible: !kReleaseMode, - child: IconButton( - icon: const Icon(Icons.textsms_outlined), - onPressed: () { - UnimplementedDialog.show(context); - }, - ), - ), - ), - ListTile( - leading: const Icon(Icons.abc), - title: Text('Typ: ${timetableData.activityType}'), - ), - ListTile( - leading: const Icon(Icons.people), - title: Text("Klasse(n): ${timetableData.kl.map((e) => e.name).join(", ")}"), - ), - DebugTile(context).jsonData(timetableData.toJson()), - ], - ) - ); - } - - static Completer deleteCustomEvent(BuildContext context, CustomTimetableEvent appointment) { - var future = Completer(); - ConfirmDialog( - title: 'Termin löschen', - content: "Der ${appointment.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.", - confirmButton: 'Löschen', - onConfirm: () { - RemoveCustomTimetableEvent( - RemoveCustomTimetableEventParams( - appointment.id - ) - ).run().then((value) { - Provider.of(context, listen: false).run(renew: true); - future.complete(); - }); - }, - ).asDialog(context); - return future; - } - - static void _custom(BuildContext context, TimetableProps webuntisData, CustomTimetableEvent appointment) { - _bottomSheet( - context, - (context) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(appointment.title, style: const TextStyle(fontSize: 25, overflow: TextOverflow.ellipsis)), - Text("${Jiffy.parseFromDateTime(appointment.startDate).format(pattern: "HH:mm")} - ${Jiffy.parseFromDateTime(appointment.endDate).format(pattern: "HH:mm")}", style: const TextStyle(fontSize: 15)), - ], - ), - ), - (context) => SliverChildListDelegate( - [ - const Divider(), - Center( - child: Wrap( - children: [ - TextButton.icon( - onPressed: () { - Navigator.of(context).pop(); - showDialog( - context: context, - builder: (context) => CustomTimetableEventEditDialog(existingEvent: appointment), - ); - }, - label: const Text('Bearbeiten'), - icon: const Icon(Icons.edit_outlined), - ), - TextButton.icon( - onPressed: () { - deleteCustomEvent(context, appointment).future.then((value) => Navigator.of(context).pop()); - }, - label: const Text('Löschen'), - icon: const Icon(Icons.delete_outline), - ), - ], - ), - ), - const Divider(), - ListTile( - leading: const Icon(Icons.info_outline), - title: Text(appointment.description.isEmpty ? 'Keine Beschreibung' : appointment.description), - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.repeat_outlined)), - title: Text("Serie: ${appointment.rrule.isNotEmpty ? "Wiederholend" : "Einmailg"}"), - subtitle: FutureBuilder( - future: RruleL10nEn.create(), - builder: (context, snapshot) { - if(appointment.rrule.isEmpty) return const Text('Keine weiteren vorkomnisse'); - if(snapshot.data == null) return const Text('...'); - var rrule = RecurrenceRule.fromString(appointment.rrule); - if(!rrule.canFullyConvertToText) return const Text('Keine genauere Angabe möglich.'); - return Text(rrule.toText(l10n: snapshot.data!)); - }, - ) - ), - DebugTile(context).child( - ListTile( - leading: const CenteredLeading(Icon(Icons.rule)), - title: const Text('RRule'), - subtitle: Text(appointment.rrule.isEmpty ? 'Keine' : appointment.rrule), - ) - ), - DebugTile(context).jsonData(appointment.toJson()), - ] - ) - ); - } -} diff --git a/lib/view/pages/timetable/arbitraryAppointment.dart b/lib/view/pages/timetable/arbitraryAppointment.dart deleted file mode 100644 index 46c9b1b..0000000 --- a/lib/view/pages/timetable/arbitraryAppointment.dart +++ /dev/null @@ -1,19 +0,0 @@ - -import '../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; - -class ArbitraryAppointment { - GetTimetableResponseObject? webuntis; - CustomTimetableEvent? custom; - - ArbitraryAppointment({this.webuntis, this.custom}); - - bool hasWebuntis() => webuntis != null; - - bool hasCustom() => custom != null; - - void handlers(void Function(GetTimetableResponseObject webuntisData) webuntis, void Function(CustomTimetableEvent customData) custom) { - if(hasWebuntis()) webuntis(this.webuntis!); - if(hasCustom()) custom(this.custom!); - } -} diff --git a/lib/view/pages/timetable/customTimetableEventEditDialog.dart b/lib/view/pages/timetable/customTimetableEventEditDialog.dart deleted file mode 100644 index ba21525..0000000 --- a/lib/view/pages/timetable/customTimetableEventEditDialog.dart +++ /dev/null @@ -1,233 +0,0 @@ - -import 'dart:developer'; - -import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; -import '../../../extensions/dateTime.dart'; -import 'package:provider/provider.dart'; -import 'package:rrule_generator/rrule_generator.dart'; -import 'package:time_range_picker/time_range_picker.dart'; - -import '../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart'; -import '../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.dart'; -import '../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../api/mhsl/customTimetableEvent/update/updateCustomTimetableEvent.dart'; -import '../../../api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.dart'; -import '../../../model/accountData.dart'; -import '../../../model/timetable/timetableProps.dart'; -import '../../../widget/focusBehaviour.dart'; -import '../../../widget/infoDialog.dart'; -import 'customTimetableColors.dart'; - -class CustomTimetableEventEditDialog extends StatefulWidget { - final CustomTimetableEvent? existingEvent; - const CustomTimetableEventEditDialog({this.existingEvent, super.key}); - - @override - State createState() => _AddCustomTimetableEventDialogState(); -} - -class _AddCustomTimetableEventDialogState extends State { - late DateTime _date = widget.existingEvent?.startDate ?? DateTime.now(); - late TimeOfDay _startTime = widget.existingEvent?.startDate.toTimeOfDay() ?? const TimeOfDay(hour: 08, minute: 00); - late TimeOfDay _endTime = widget.existingEvent?.endDate.toTimeOfDay() ?? const TimeOfDay(hour: 09, minute: 30); - late final TextEditingController _eventName = TextEditingController(text: widget.existingEvent?.title); - late final TextEditingController _eventDescription = TextEditingController(text: widget.existingEvent?.description); - late String _recurringRule = widget.existingEvent?.rrule ?? ''; - late CustomTimetableColors _customTimetableColor = CustomTimetableColors.values.firstWhere( - (element) => element.name == widget.existingEvent?.color, - orElse: () => TimetableColors.defaultColor - ); - - late bool isEditingExisting = widget.existingEvent != null; - - bool validate() { - if(_eventName.text.isEmpty) return false; - return true; - } - - void fetchTimetable() { - Provider.of(context, listen: false).run(renew: true); - } - - @override - Widget build(BuildContext context) => AlertDialog( - insetPadding: const EdgeInsets.all(20), - contentPadding: const EdgeInsets.all(10), - title: const Text('Termin hinzufügen'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: TextField( - controller: _eventName, - autofocus: true, - decoration: const InputDecoration( - labelText: 'Terminname', - border: OutlineInputBorder() - ), - onTapOutside: (PointerDownEvent event) => FocusBehaviour.textFieldTapOutside(context), - ), - ), - ListTile( - title: TextField( - controller: _eventDescription, - maxLines: 2, - minLines: 2, - decoration: const InputDecoration( - labelText: 'Beschreibung', - border: OutlineInputBorder() - ), - onTapOutside: (PointerDownEvent event) => FocusBehaviour.textFieldTapOutside(context), - ), - ), - const Divider(), - ListTile( - leading: const Icon(Icons.date_range_outlined), - title: Text(Jiffy.parseFromDateTime(_date).yMMMd), - subtitle: const Text('Datum'), - onTap: () async { - final pickedDate = await showDatePicker( - context: context, - initialDate: _date, - firstDate: DateTime.now().subtract(const Duration(days: 30)), - lastDate: DateTime.now().add(const Duration(days: 30)), - ); - if (pickedDate != null && pickedDate != _date) { - setState(() { - _date = pickedDate; - }); - } - }, - ), - ListTile( - leading: const Icon(Icons.access_time_outlined), - title: Text('${_startTime.format(context).toString()} - ${_endTime.format(context).toString()}'), - subtitle: const Text('Zeitraum'), - onTap: () async { - TimeRange timeRange = await showTimeRangePicker( - context: context, - start: _startTime, - end: _endTime, - disabledTime: TimeRange(startTime: const TimeOfDay(hour: 16, minute: 30), endTime: const TimeOfDay(hour: 08, minute: 00)), - disabledColor: Colors.grey, - paintingStyle: PaintingStyle.fill, - interval: const Duration(minutes: 5), - fromText: 'Beginnend', - toText: 'Endend', - strokeColor: Theme.of(context).colorScheme.secondary, - minDuration: const Duration(minutes: 15), - selectedColor: Theme.of(context).primaryColor, - ticks: 24, - ); - - setState(() { - _startTime = timeRange.startTime; - _endTime = timeRange.endTime; - }); - }, - ), - const Divider(), - ListTile( - leading: const Icon(Icons.color_lens_outlined), - title: const Text('Farbgebung'), - trailing: DropdownButton( - value: _customTimetableColor, - icon: const Icon(Icons.arrow_drop_down), - items: CustomTimetableColors.values.map((e) => DropdownMenuItem( - value: e, - enabled: e != _customTimetableColor, - child: Row( - children: [ - Icon(Icons.circle, color: TimetableColors.getDisplayOptions(e).color), - const SizedBox(width: 10), - Text(TimetableColors.getDisplayOptions(e).displayName), - ], - ), - )).toList(), - onChanged: (e) { - setState(() { - _customTimetableColor = e!; - }); - }, - ), - ), - const Divider(), - RRuleGenerator( - config: RRuleGeneratorConfig( - headerEnabled: true, - weekdayBackgroundColor: Theme.of(context).colorScheme.secondary, - weekdaySelectedBackgroundColor: Theme.of(context).primaryColor, - weekdayColor: Colors.black, - ), - initialRRule: _recurringRule, - textDelegate: const GermanRRuleTextDelegate(), - onChange: (String newValue) { - log('Rule: $newValue'); - setState(() { - _recurringRule = newValue; - }); - }, - ) - ], - ), - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('Abbrechen'), - ), - TextButton( - onPressed: () { - if(!validate()) return; - - var editedEvent = CustomTimetableEvent( - id: '', - title: _eventName.text, - description: _eventDescription.text, - startDate: _date.withTime(_startTime), - endDate: _date.withTime(_endTime), - color: _customTimetableColor.name, - rrule: _recurringRule, - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - ); - - if(!isEditingExisting) { - AddCustomTimetableEvent( - AddCustomTimetableEventParams( - AccountData().getUserSecret(), - editedEvent - ) - ).run().then((value) { - Navigator.of(context).pop(); - fetchTimetable(); - }) - .catchError((error, stack) { - InfoDialog.show(context, error.toString()); - }); - } else { - UpdateCustomTimetableEvent( - UpdateCustomTimetableEventParams( - widget.existingEvent?.id ?? '', - editedEvent - ) - ).run().then((value) { - Navigator.of(context).pop(); - fetchTimetable(); - }) - .catchError((error, stack) { - InfoDialog.show(context, error.toString()); - }); - } - - - }, - child: Text(isEditingExisting ? 'Speichern' : 'Erstellen'), - ), - ], - ); -} diff --git a/lib/view/pages/timetable/customTimetableColors.dart b/lib/view/pages/timetable/custom_events/custom_event_colors.dart similarity index 72% rename from lib/view/pages/timetable/customTimetableColors.dart rename to lib/view/pages/timetable/custom_events/custom_event_colors.dart index 1b65838..a4540fe 100644 --- a/lib/view/pages/timetable/customTimetableColors.dart +++ b/lib/view/pages/timetable/custom_events/custom_event_colors.dart @@ -1,35 +1,30 @@ import 'package:flutter/material.dart'; -import '../../../theming/darkAppTheme.dart'; +import '../../../../theming/darkAppTheme.dart'; -enum CustomTimetableColors { - orange, - red, - green, - blue -} +enum CustomTimetableColors { orange, red, green, blue } class TimetableColors { static const CustomTimetableColors defaultColor = CustomTimetableColors.orange; static ColorModeDisplay getDisplayOptions(CustomTimetableColors color) { - switch(color) { + switch (color) { case CustomTimetableColors.green: return ColorModeDisplay(color: Colors.green, displayName: 'Grün'); - case CustomTimetableColors.blue: return ColorModeDisplay(color: Colors.blue, displayName: 'Blau'); - case CustomTimetableColors.orange: return ColorModeDisplay(color: Colors.orange.shade800, displayName: 'Orange'); - case CustomTimetableColors.red: return ColorModeDisplay(color: DarkAppTheme.marianumRed, displayName: 'Rot'); - } } - static Color getColorFromString(String color) => getDisplayOptions(CustomTimetableColors.values.firstWhere((element) => element.name == color, orElse: () => TimetableColors.defaultColor)).color; + static Color getColorFromString(String color) => + getDisplayOptions(CustomTimetableColors.values.firstWhere( + (e) => e.name == color, + orElse: () => defaultColor, + )).color; } class ColorModeDisplay { diff --git a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart new file mode 100644 index 0000000..b383134 --- /dev/null +++ b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart @@ -0,0 +1,190 @@ +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:rrule_generator/rrule_generator.dart'; +import 'package:time_range_picker/time_range_picker.dart'; + +import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../extensions/dateTime.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../../widget/focusBehaviour.dart'; +import '../../../../widget/infoDialog.dart'; +import 'custom_event_colors.dart'; + +class CustomEventEditDialog extends StatefulWidget { + final CustomTimetableEvent? existingEvent; + + const CustomEventEditDialog({this.existingEvent, super.key}); + + @override + State createState() => _CustomEventEditDialogState(); +} + +class _CustomEventEditDialogState extends State { + late DateTime _date = widget.existingEvent?.startDate ?? DateTime.now(); + late TimeOfDay _startTime = widget.existingEvent?.startDate.toTimeOfDay() ?? const TimeOfDay(hour: 8, minute: 0); + late TimeOfDay _endTime = widget.existingEvent?.endDate.toTimeOfDay() ?? const TimeOfDay(hour: 9, minute: 30); + late final TextEditingController _name = TextEditingController(text: widget.existingEvent?.title); + late final TextEditingController _description = TextEditingController(text: widget.existingEvent?.description); + late String _rrule = widget.existingEvent?.rrule ?? ''; + late CustomTimetableColors _color = CustomTimetableColors.values.firstWhere( + (e) => e.name == widget.existingEvent?.color, + orElse: () => TimetableColors.defaultColor, + ); + + bool get _isEditing => widget.existingEvent != null; + + bool _validate() => _name.text.isNotEmpty; + + void _save() { + if (!_validate()) return; + + final edited = CustomTimetableEvent( + id: widget.existingEvent?.id ?? '', + title: _name.text, + description: _description.text, + startDate: _date.withTime(_startTime), + endDate: _date.withTime(_endTime), + color: _color.name, + rrule: _rrule, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + final bloc = context.read(); + final future = _isEditing + ? bloc.updateCustomEvent(widget.existingEvent!.id, edited) + : bloc.addCustomEvent(edited); + + future.then((_) { + if (!mounted) return; + Navigator.of(context).pop(); + }).catchError((Object error) { + if (!mounted) return; + InfoDialog.show(context, error.toString()); + }); + } + + Future _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _date, + firstDate: DateTime.now().subtract(const Duration(days: 30)), + lastDate: DateTime.now().add(const Duration(days: 30)), + ); + if (picked != null && picked != _date) setState(() => _date = picked); + } + + Future _pickTimeRange() async { + final range = await showTimeRangePicker( + context: context, + start: _startTime, + end: _endTime, + disabledTime: TimeRange( + startTime: const TimeOfDay(hour: 16, minute: 30), + endTime: const TimeOfDay(hour: 8, minute: 0), + ), + disabledColor: Colors.grey, + paintingStyle: PaintingStyle.fill, + interval: const Duration(minutes: 5), + fromText: 'Beginnend', + toText: 'Endend', + strokeColor: Theme.of(context).colorScheme.secondary, + minDuration: const Duration(minutes: 15), + selectedColor: Theme.of(context).primaryColor, + ticks: 24, + ); + setState(() { + _startTime = range.startTime; + _endTime = range.endTime; + }); + } + + @override + Widget build(BuildContext context) => AlertDialog( + insetPadding: const EdgeInsets.all(20), + contentPadding: const EdgeInsets.all(10), + title: Text(_isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: TextField( + controller: _name, + autofocus: true, + decoration: const InputDecoration(labelText: 'Terminname', border: OutlineInputBorder()), + onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context), + ), + ), + ListTile( + title: TextField( + controller: _description, + maxLines: 2, + minLines: 2, + decoration: const InputDecoration(labelText: 'Beschreibung', border: OutlineInputBorder()), + onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context), + ), + ), + const Divider(), + ListTile( + leading: const Icon(Icons.date_range_outlined), + title: Text(Jiffy.parseFromDateTime(_date).yMMMd), + subtitle: const Text('Datum'), + onTap: _pickDate, + ), + ListTile( + leading: const Icon(Icons.access_time_outlined), + title: Text('${_startTime.format(context)} - ${_endTime.format(context)}'), + subtitle: const Text('Zeitraum'), + onTap: _pickTimeRange, + ), + const Divider(), + ListTile( + leading: const Icon(Icons.color_lens_outlined), + title: const Text('Farbgebung'), + trailing: DropdownButton( + value: _color, + icon: const Icon(Icons.arrow_drop_down), + items: CustomTimetableColors.values + .map((e) => DropdownMenuItem( + value: e, + enabled: e != _color, + child: Row( + children: [ + Icon(Icons.circle, color: TimetableColors.getDisplayOptions(e).color), + const SizedBox(width: 10), + Text(TimetableColors.getDisplayOptions(e).displayName), + ], + ), + )) + .toList(), + onChanged: (e) => setState(() => _color = e!), + ), + ), + const Divider(), + RRuleGenerator( + config: RRuleGeneratorConfig( + headerEnabled: true, + weekdayBackgroundColor: Theme.of(context).colorScheme.secondary, + weekdaySelectedBackgroundColor: Theme.of(context).primaryColor, + weekdayColor: Colors.black, + ), + initialRRule: _rrule, + textDelegate: const GermanRRuleTextDelegate(), + onChange: (newValue) { + log('Rule: $newValue'); + setState(() => _rrule = newValue); + }, + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')), + TextButton(onPressed: _save, child: Text(_isEditing ? 'Speichern' : 'Erstellen')), + ], + ); +} diff --git a/lib/view/pages/timetable/custom_events/custom_events_view.dart b/lib/view/pages/timetable/custom_events/custom_events_view.dart new file mode 100644 index 0000000..34af737 --- /dev/null +++ b/lib/view/pages/timetable/custom_events/custom_events_view.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; + +import '../../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../../../../widget/centeredLeading.dart'; +import '../../../../widget/placeholderView.dart'; +import '../details/delete_custom_event.dart'; +import 'custom_event_edit_dialog.dart'; + +class CustomEventsView extends StatelessWidget { + const CustomEventsView({super.key}); + + void _openCreateDialog(BuildContext context) { + showDialog( + context: context, + builder: (_) => const CustomEventEditDialog(), + barrierDismissible: false, + ); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Eigene Termine'), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _openCreateDialog(context), + ), + ], + ), + body: LoadableStateConsumer( + child: (state, _) { + final events = state.customEvents?.events ?? const []; + + if (events.isEmpty) { + return PlaceholderView( + icon: Icons.calendar_today_outlined, + text: 'Keine Einträge vorhanden', + button: TextButton( + onPressed: () => _openCreateDialog(context), + child: const Text('Termin erstellen'), + ), + ); + } + + return ListView( + children: events.map((e) => ListTile( + title: Text(e.title), + subtitle: Text( + '${e.rrule.isNotEmpty ? "wiederholend, " : ""}' + 'beginnend ${Jiffy.parseFromDateTime(e.startDate).fromNow()}', + ), + leading: CenteredLeading(Icon(e.rrule.isEmpty ? Icons.event_outlined : Icons.event_repeat_outlined)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () => showDialog( + context: context, + builder: (_) => CustomEventEditDialog(existingEvent: e), + ), + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => showDeleteCustomEventDialog(context, e), + ), + ], + ), + )).toList(), + ); + }, + ), + ); +} diff --git a/lib/view/pages/timetable/data/arbitrary_appointment.dart b/lib/view/pages/timetable/data/arbitrary_appointment.dart new file mode 100644 index 0000000..d9bb91a --- /dev/null +++ b/lib/view/pages/timetable/data/arbitrary_appointment.dart @@ -0,0 +1,24 @@ +import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; + +sealed class ArbitraryAppointment { + const ArbitraryAppointment(); + + T when({ + required T Function(GetTimetableResponseObject lesson) webuntis, + required T Function(CustomTimetableEvent event) custom, + }) => switch (this) { + WebuntisAppointment(:final lesson) => webuntis(lesson), + CustomAppointment(:final event) => custom(event), + }; +} + +class WebuntisAppointment extends ArbitraryAppointment { + final GetTimetableResponseObject lesson; + const WebuntisAppointment(this.lesson); +} + +class CustomAppointment extends ArbitraryAppointment { + final CustomTimetableEvent event; + const CustomAppointment(this.event); +} diff --git a/lib/view/pages/timetable/data/lesson_color.dart b/lib/view/pages/timetable/data/lesson_color.dart new file mode 100644 index 0000000..18477a4 --- /dev/null +++ b/lib/view/pages/timetable/data/lesson_color.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'lesson_status.dart'; + +class LessonColor { + static const Color cancelled = Color(0xff000000); + static const Color irregular = Color(0xff8F19B3); + static const Color teacherChanged = Color(0xFF29639B); + static const Color parseFallback = Color(0xff404040); + + static Color forStatus(LessonStatus status, ColorScheme scheme) { + switch (status) { + case LessonStatus.cancelled: + return cancelled; + case LessonStatus.irregular: + return irregular; + case LessonStatus.teacherChanged: + return teacherChanged; + case LessonStatus.past: + case LessonStatus.regular: + return scheme.primary; + case LessonStatus.ongoing: + return Color.from( + alpha: scheme.primary.a, + red: 200 / 255, + green: scheme.primary.g, + blue: scheme.primary.b, + ); + } + } +} diff --git a/lib/view/pages/timetable/data/lesson_status.dart b/lib/view/pages/timetable/data/lesson_status.dart new file mode 100644 index 0000000..933f3cb --- /dev/null +++ b/lib/view/pages/timetable/data/lesson_status.dart @@ -0,0 +1,21 @@ +import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; + +enum LessonStatus { + cancelled, + irregular, + teacherChanged, + past, + ongoing, + regular, +} + +class LessonStatusClassifier { + static LessonStatus classify(GetTimetableResponseObject lesson, DateTime startTime, DateTime endTime, DateTime now) { + if (lesson.code == 'cancelled') return LessonStatus.cancelled; + if (lesson.code == 'irregular' || (lesson.te.isNotEmpty && lesson.te.first.id == 0)) return LessonStatus.irregular; + if (lesson.te.any((t) => t.orgname != null)) return LessonStatus.teacherChanged; + if (endTime.isBefore(now)) return LessonStatus.past; + if (startTime.isBefore(now) && endTime.isAfter(now)) return LessonStatus.ongoing; + return LessonStatus.regular; + } +} diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart new file mode 100644 index 0000000..8360c8c --- /dev/null +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -0,0 +1,136 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; +import '../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; +import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../storage/timetable/timetableSettings.dart'; +import '../../../../storage/timetable/timetable_name_mode.dart'; +import '../custom_events/custom_event_colors.dart'; +import 'arbitrary_appointment.dart'; +import 'lesson_color.dart'; +import 'lesson_status.dart'; +import 'webuntis_time.dart'; + +class TimetableAppointmentFactory { + final List lessons; + final List customEvents; + final GetRoomsResponse rooms; + final GetSubjectsResponse subjects; + final TimetableSettings settings; + final ColorScheme colorScheme; + final DateTime now; + + TimetableAppointmentFactory({ + required this.lessons, + required this.customEvents, + required this.rooms, + required this.subjects, + required this.settings, + required this.colorScheme, + required this.now, + }); + + List build() { + final source = settings.connectDoubleLessons ? _mergeAdjacentLessons(lessons) : lessons; + return [ + ...source.map(_lessonToAppointment), + ...customEvents.map(_customEventToAppointment), + ]; + } + + Appointment _lessonToAppointment(GetTimetableResponseObject lesson) { + try { + final startTime = WebuntisTime.parse(lesson.date, lesson.startTime); + final endTime = WebuntisTime.parse(lesson.date, lesson.endTime); + final status = LessonStatusClassifier.classify(lesson, startTime, endTime, now); + + return Appointment( + id: WebuntisAppointment(lesson), + startTime: startTime, + endTime: endTime, + subject: _subjectName(lesson), + location: _locationLabel(lesson), + notes: lesson.activityType, + color: LessonColor.forStatus(status, colorScheme), + ); + } catch (_) { + return Appointment( + id: WebuntisAppointment(lesson), + startTime: WebuntisTime.parse(lesson.date, lesson.startTime), + endTime: WebuntisTime.parse(lesson.date, lesson.endTime), + subject: 'Änderung', + notes: lesson.info, + location: 'Unbekannt', + color: LessonColor.parseFallback, + startTimeZone: '', + endTimeZone: '', + ); + } + } + + Appointment _customEventToAppointment(CustomTimetableEvent event) => Appointment( + id: CustomAppointment(event), + startTime: event.startDate, + endTime: event.endDate, + location: event.description, + subject: event.title, + recurrenceRule: event.rrule, + color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name), + startTimeZone: '', + endTimeZone: '', + ); + + String _subjectName(GetTimetableResponseObject lesson) { + final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id); + if (subject == null) return 'Unbekannt'; + return switch (settings.timetableNameMode) { + TimetableNameMode.name => subject.name, + TimetableNameMode.longName => subject.longName, + TimetableNameMode.alternateName => subject.alternateName, + }; + } + + String _locationLabel(GetTimetableResponseObject lesson) { + final roomName = rooms.result.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)?.name ?? 'Unbekannt'; + final teacherName = lesson.te.firstOrNull?.longname ?? 'Unbekannt'; + return '$roomName\n$teacherName'; + } + + // Pure: returns a new list, does not mutate input. + static List _mergeAdjacentLessons( + List input, { + Duration maxGap = const Duration(minutes: 5), + }) { + if (input.isEmpty) return const []; + + final sorted = [...input]..sort((a, b) => + WebuntisTime.parse(a.date, a.startTime).compareTo(WebuntisTime.parse(b.date, b.startTime))); + + final merged = [sorted.first]; + for (var i = 1; i < sorted.length; i++) { + final previous = merged.last; + final current = sorted[i]; + if (_canMerge(previous, current, maxGap)) { + previous.endTime = current.endTime; + } else { + merged.add(current); + } + } + return merged; + } + + static bool _canMerge(GetTimetableResponseObject a, GetTimetableResponseObject b, Duration maxGap) { + final aSubject = a.su.firstOrNull?.id; + final bSubject = b.su.firstOrNull?.id; + if (aSubject == null || bSubject == null || aSubject != bSubject) return false; + if (a.ro.firstOrNull?.id != b.ro.firstOrNull?.id) return false; + if (a.te.firstOrNull?.id != b.te.firstOrNull?.id) return false; + if (a.code != b.code) return false; + + final gap = WebuntisTime.parse(b.date, b.startTime).difference(WebuntisTime.parse(a.date, a.endTime)); + return gap <= maxGap; + } +} diff --git a/lib/view/pages/timetable/data/webuntis_time.dart b/lib/view/pages/timetable/data/webuntis_time.dart new file mode 100644 index 0000000..bd9b6fa --- /dev/null +++ b/lib/view/pages/timetable/data/webuntis_time.dart @@ -0,0 +1,14 @@ +import 'package:intl/intl.dart'; + +class WebuntisTime { + static final DateFormat _dateFormat = DateFormat('yyyyMMdd'); + + static DateTime parse(int date, int time) { + final timeString = time.toString().padLeft(4, '0'); + return DateTime.parse('$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}'); + } + + static int formatDate(DateTime date) => int.parse(_dateFormat.format(date)); + + static String dateKey(DateTime date) => _dateFormat.format(date); +} diff --git a/lib/view/pages/timetable/details/_bottom_sheet.dart b/lib/view/pages/timetable/details/_bottom_sheet.dart new file mode 100644 index 0000000..c50f3f0 --- /dev/null +++ b/lib/view/pages/timetable/details/_bottom_sheet.dart @@ -0,0 +1,20 @@ +import 'package:bottom_sheet/bottom_sheet.dart'; +import 'package:flutter/material.dart'; + +void showAppointmentBottomSheet( + BuildContext context, { + required Widget Function(BuildContext context) header, + required SliverChildListDelegate Function(BuildContext context) body, +}) { + showStickyFlexibleBottomSheet( + minHeight: 0, + initHeight: 0.4, + maxHeight: 0.7, + anchors: [0, 0.4, 0.7], + isSafeArea: true, + maxHeaderHeight: 100, + context: context, + headerBuilder: (context, _) => header(context), + bodyBuilder: (context, _) => body(context), + ); +} diff --git a/lib/view/pages/timetable/details/appointment_details_dispatcher.dart b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart new file mode 100644 index 0000000..4a6ae76 --- /dev/null +++ b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../data/arbitrary_appointment.dart'; +import 'custom_event_sheet.dart'; +import 'webuntis_lesson_sheet.dart'; + +class AppointmentDetailsDispatcher { + static void show(BuildContext context, TimetableBloc bloc, Appointment appointment) { + final id = appointment.id; + if (id is! ArbitraryAppointment) return; + + id.when( + webuntis: (lesson) => WebuntisLessonSheet.show(context, bloc, appointment, lesson), + custom: (event) => CustomEventSheet.show(context, event), + ); + } +} diff --git a/lib/view/pages/timetable/details/custom_event_sheet.dart b/lib/view/pages/timetable/details/custom_event_sheet.dart new file mode 100644 index 0000000..9f11099 --- /dev/null +++ b/lib/view/pages/timetable/details/custom_event_sheet.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:rrule/rrule.dart'; + +import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../widget/centeredLeading.dart'; +import '../../../../widget/debug/debugTile.dart'; +import '../custom_events/custom_event_edit_dialog.dart'; +import '_bottom_sheet.dart'; +import 'delete_custom_event.dart'; + +class CustomEventSheet { + static void show(BuildContext context, CustomTimetableEvent event) { + showAppointmentBottomSheet( + context, + header: (_) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(event.title, style: const TextStyle(fontSize: 25, overflow: TextOverflow.ellipsis)), + Text( + '${Jiffy.parseFromDateTime(event.startDate).format(pattern: 'HH:mm')} - ' + '${Jiffy.parseFromDateTime(event.endDate).format(pattern: 'HH:mm')}', + style: const TextStyle(fontSize: 15), + ), + ], + ), + ), + body: (sheetCtx) => SliverChildListDelegate([ + const Divider(), + Center( + child: Wrap( + children: [ + TextButton.icon( + onPressed: () { + Navigator.of(sheetCtx).pop(); + showDialog( + context: context, + builder: (_) => CustomEventEditDialog(existingEvent: event), + ); + }, + label: const Text('Bearbeiten'), + icon: const Icon(Icons.edit_outlined), + ), + TextButton.icon( + onPressed: () { + showDeleteCustomEventDialog(context, event).future.then((_) { + if (!sheetCtx.mounted) return; + Navigator.of(sheetCtx).pop(); + }); + }, + label: const Text('Löschen'), + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ), + const Divider(), + ListTile( + leading: const Icon(Icons.info_outline), + title: Text(event.description.isEmpty ? 'Keine Beschreibung' : event.description), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.repeat_outlined)), + title: Text('Serie: ${event.rrule.isNotEmpty ? "Wiederholend" : "Einmalig"}'), + subtitle: FutureBuilder( + future: RruleL10nEn.create(), + builder: (_, snapshot) { + if (event.rrule.isEmpty) return const Text('Keine weiteren Vorkommnisse'); + if (snapshot.data == null) return const Text('...'); + final rrule = RecurrenceRule.fromString(event.rrule); + if (!rrule.canFullyConvertToText) return const Text('Keine genauere Angabe möglich.'); + return Text(rrule.toText(l10n: snapshot.data!)); + }, + ), + ), + DebugTile(sheetCtx).child( + ListTile( + leading: const CenteredLeading(Icon(Icons.rule)), + title: const Text('RRule'), + subtitle: Text(event.rrule.isEmpty ? 'Keine' : event.rrule), + ), + ), + DebugTile(sheetCtx).jsonData(event.toJson()), + ]), + ); + } +} diff --git a/lib/view/pages/timetable/details/delete_custom_event.dart b/lib/view/pages/timetable/details/delete_custom_event.dart new file mode 100644 index 0000000..ad362d5 --- /dev/null +++ b/lib/view/pages/timetable/details/delete_custom_event.dart @@ -0,0 +1,24 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../../widget/confirmDialog.dart'; + +Completer showDeleteCustomEventDialog(BuildContext context, CustomTimetableEvent event) { + final completer = Completer(); + final bloc = context.read(); + ConfirmDialog( + title: 'Termin löschen', + content: 'Der ${event.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.', + confirmButton: 'Löschen', + onConfirm: () { + bloc.removeCustomEvent(event.id).then(completer.complete).onError((Object error, StackTrace stack) { + completer.completeError(error, stack); + }); + }, + ).asDialog(context); + return completer; +} diff --git a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart new file mode 100644 index 0000000..1fb0bd7 --- /dev/null +++ b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart @@ -0,0 +1,110 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; +import '../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; +import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../../../../widget/debug/debugTile.dart'; +import '../../../../widget/unimplementedDialog.dart'; +import '../../more/roomplan/roomplan.dart'; +import '_bottom_sheet.dart'; + +class WebuntisLessonSheet { + static void show(BuildContext context, TimetableBloc bloc, Appointment appointment, GetTimetableResponseObject lesson) { + final state = bloc.state.data; + if (state == null) return; + + final subject = _resolveSubject(state, lesson); + final room = _resolveRoom(state, lesson); + + showAppointmentBottomSheet( + context, + header: (_) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${_codePrefix(lesson.code)}${subject.alternateName}', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 25), + overflow: TextOverflow.ellipsis, + ), + Text(subject.longName), + Text( + '${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - ' + '${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}', + style: const TextStyle(fontSize: 15), + ), + ], + ), + ), + body: (_) => SliverChildListDelegate([ + const Divider(), + ListTile( + leading: const Icon(Icons.notifications_active), + title: Text('Status: ${lesson.code != null ? "Geändert" : "Regulär"}'), + ), + ListTile( + leading: const Icon(Icons.room), + title: Text('Raum: ${room.name} (${room.longName})'), + trailing: IconButton( + icon: const Icon(Icons.house_outlined), + onPressed: () => pushScreen(context, withNavBar: false, screen: const Roomplan()), + ), + ), + ListTile( + leading: const Icon(Icons.person), + title: lesson.te.isNotEmpty + ? Text( + 'Lehrkraft: ${lesson.te[0].name}' + '${lesson.te[0].longname.isNotEmpty ? " (${lesson.te[0].longname})" : ""}', + ) + : const Text('?'), + trailing: Visibility( + visible: !kReleaseMode, + child: IconButton( + icon: const Icon(Icons.textsms_outlined), + onPressed: () => UnimplementedDialog.show(context), + ), + ), + ), + ListTile( + leading: const Icon(Icons.abc), + title: Text('Typ: ${lesson.activityType}'), + ), + ListTile( + leading: const Icon(Icons.people), + title: Text('Klasse(n): ${lesson.kl.map((e) => e.name).join(", ")}'), + ), + DebugTile(context).jsonData(lesson.toJson()), + ]), + ); + } + + static String _codePrefix(String? code) { + if (code == 'cancelled') return 'Entfällt: '; + if (code == 'irregular') return 'Änderung: '; + return code ?? ''; + } + + static GetSubjectsResponseObject _resolveSubject(TimetableState state, GetTimetableResponseObject lesson) { + try { + return state.subjects!.result.firstWhere((s) => s.id == lesson.su[0].id); + } catch (_) { + return GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true); + } + } + + static GetRoomsResponseObject _resolveRoom(TimetableState state, GetTimetableResponseObject lesson) { + try { + return state.rooms!.result.firstWhere((r) => r.id == lesson.ro[0].id); + } catch (_) { + return GetRoomsResponseObject(0, '?', 'Unbekannt', true, '?'); + } + } +} diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 208551c..cfcac4a 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -1,26 +1,25 @@ import 'dart:async'; -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import '../../../extensions/dateTime.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -import '../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; -import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; -import '../../../model/timetable/timetableProps.dart'; -import '../../../storage/base/settingsProvider.dart'; -import '../../../widget/loadingSpinner.dart'; -import '../../../widget/placeholderView.dart'; -import 'appointmenetComponent.dart'; -import 'appointmentDetails.dart'; -import 'arbitraryAppointment.dart'; -import 'customTimetableColors.dart'; -import 'customTimetableEventEditDialog.dart'; -import 'timeRegionComponent.dart'; -import 'timetableEvents.dart'; -import 'timetableNameMode.dart'; -import 'viewCustomTimetableEvents.dart'; +import '../../../extensions/dateTime.dart'; +import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import 'custom_events/custom_event_edit_dialog.dart'; +import 'custom_events/custom_events_view.dart'; +import 'data/arbitrary_appointment.dart'; +import 'data/timetable_appointment_factory.dart'; +import 'details/appointment_details_dispatcher.dart'; +import 'widgets/appointment_tile.dart'; +import 'widgets/lesson_appointment_source.dart'; +import 'widgets/special_regions_builder.dart'; +import 'widgets/time_region_tile.dart'; + +enum _CalendarAction { addEvent, viewEvents } class Timetable extends StatefulWidget { const Timetable({super.key}); @@ -29,362 +28,152 @@ class Timetable extends StatefulWidget { State createState() => _TimetableState(); } -enum CalendarActions { addEvent, viewEvents } - class _TimetableState extends State { - CalendarController controller = CalendarController(); - late Timer updateTimings; - late final SettingsProvider settings; + final CalendarController _controller = CalendarController(); + late Timer _highlightTicker; + + LessonAppointmentSource? _cachedSource; + int? _lastDataVersion; @override void initState() { - settings = Provider.of(context, listen: false); - - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - Provider.of(context, listen: false).run(); - }); - - controller.displayDate = DateTime.now().add(const Duration(days: 2)); - - updateTimings = Timer.periodic(const Duration(seconds: 30), (Timer t) => setState((){})); - super.initState(); + _controller.displayDate = _initialDisplayDate(); + + _highlightTicker = Timer.periodic(const Duration(seconds: 30), (_) { + if (mounted) setState(() => _cachedSource = null); + }); } - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Stunden & Vertretungsplan'), - actions: [ - IconButton( - icon: const Icon(Icons.home_outlined), - onPressed: () { - controller.displayDate = DateTime.now().add(const Duration(days: 2)); - } - ), - PopupMenuButton( - icon: const Icon(Icons.edit_calendar_outlined), - itemBuilder: (context) => CalendarActions.values.map( - (e) { - String title; - Icon icon; - switch(e) { - case CalendarActions.addEvent: - title = 'Kalendereintrag hinzufügen'; - icon = const Icon(Icons.add); - case CalendarActions.viewEvents: - title = 'Kalendereinträge anzeigen'; - icon = const Icon(Icons.perm_contact_calendar_outlined); - } - return PopupMenuItem( - value: e, - child: ListTile( - title: Text(title), - leading: icon, - ) - ); - } - ).toList(), - onSelected: (value) { - switch(value) { - case CalendarActions.addEvent: - showDialog( - context: context, - builder: (context) => const CustomTimetableEventEditDialog(), - barrierDismissible: false, - ); - case CalendarActions.viewEvents: - Navigator.of(context).push(MaterialPageRoute(builder: (context) => const ViewCustomTimetableEvents())); - } - }, - ) - ], - ), - body: Consumer( - builder: (context, value, child) { - - if(value.hasError) { - return PlaceholderView( - icon: Icons.calendar_month, - text: 'Webuntis error: ${value.error.toString()}', - button: TextButton( - child: const Text('Neu laden'), - onPressed: () { - controller.displayDate = DateTime.now().add(const Duration(days: 2)); - Provider.of(context, listen: false).resetWeek(); - }, - ), - ); - } - - if(value.primaryLoading()) return const LoadingSpinner(); - - var holidays = value.getHolidaysResponse; - - return RefreshIndicator( - child: SfCalendar( - timeZone: 'W. Europe Standard Time', - view: CalendarView.workWeek, - dataSource: _buildTableEvents(value), - - maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday), - minDate: DateTime.now().subtract(const Duration (days: 14)).nextWeekday(DateTime.sunday), - - controller: controller, - - onViewChanged: (ViewChangedDetails details) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - Provider.of(context, listen: false).updateWeek(details.visibleDates.first, details.visibleDates.last); - }); - }, - - onTap: (calendarTapDetails) { - if(calendarTapDetails.appointments == null) return; - Appointment tapped = calendarTapDetails.appointments!.first; - AppointmentDetails.show(context, value, tapped); - }, - - firstDayOfWeek: DateTime.monday, - specialRegions: _buildSpecialTimeRegions(holidays), - timeSlotViewSettings: const TimeSlotViewSettings( - startHour: 07.5, - endHour: 16.5, - timeInterval: Duration(minutes: 30), - timeFormat: 'HH:mm', - dayFormat: 'EE', - timeIntervalHeight: 40, - ), - - timeRegionBuilder: (BuildContext context, TimeRegionDetails timeRegionDetails) => TimeRegionComponent(details: timeRegionDetails), - appointmentBuilder: (BuildContext context, CalendarAppointmentDetails details) => AppointmentComponent( - details: details, - crossedOut: _isCrossedOut(details) - ), - - headerHeight: 0, - selectionDecoration: const BoxDecoration(), - - allowAppointmentResize: false, - allowDragAndDrop: false, - allowViewNavigation: false, - ), - onRefresh: () async { - Provider.of(context, listen: false).run(renew: true); - return Future.delayed(const Duration(seconds: 3)); - } - ); - }, - ), - ); - @override void dispose() { - updateTimings.cancel(); + _highlightTicker.cancel(); super.dispose(); } - List _buildSpecialTimeRegions(GetHolidaysResponse holidays) { - var lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday); - var firstBreak = lastMonday.copyWith(hour: 10, minute: 15); - var secondBreak = lastMonday.copyWith(hour: 13, minute: 50); + DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2)); - var holidayList = holidays.result.map((holiday) { - var startDay = _parseWebuntisTimestamp(holiday.startDate, 0); - var dayCount = _parseWebuntisTimestamp(holiday.endDate, 0) - .difference(startDay) - .inDays; - var days = List.generate(dayCount, (index) => startDay.add(Duration(days: index))); - - return days.map((holidayDay) => TimeRegion( - startTime: holidayDay.copyWith(hour: 07, minute: 55), - endTime: holidayDay.copyWith(hour: 16, minute: 30), - text: 'holiday:${holiday.name}', - color: Theme - .of(context) - .disabledColor - .withAlpha(50), - iconData: Icons.holiday_village_outlined - )); - }).expand((e) => e); - - bool isInHoliday(DateTime time) => holidayList.any((element) => element.startTime.isSameDay(time)); - - return [ - ...holidayList, - - if(!isInHoliday(firstBreak)) - TimeRegion( - startTime: firstBreak, - endTime: firstBreak.add(const Duration(minutes: 20)), - recurrenceRule: 'FREQ=DAILY;INTERVAL=1', - text: 'centerIcon', - color: Theme.of(context).primaryColor.withAlpha(50), - iconData: Icons.restaurant - ), - - if(!isInHoliday(secondBreak)) - TimeRegion( - startTime: secondBreak, - endTime: secondBreak.add(const Duration(minutes: 15)), - recurrenceRule: 'FREQ=DAILY;INTERVAL=1', - text: 'centerIcon', - color: Theme.of(context).primaryColor.withAlpha(50), - iconData: Icons.restaurant - ), - ]; + void _jumpToToday() { + _controller.displayDate = _initialDisplayDate(); } - List _removeDuplicates(TimetableProps data, Duration maxTimeBetweenDouble) { - - var timetableList = data.getTimetableResponse.result.toList(); - - if(timetableList.isEmpty) return timetableList; - - timetableList.sort((a, b) => _parseWebuntisTimestamp(a.date, a.startTime).compareTo(_parseWebuntisTimestamp(b.date, b.startTime))); - - var previousElement = timetableList.first; - for(var i = 1; i < timetableList.length; i++) { - var currentElement = timetableList.elementAt(i); - - bool isSameLesson() { - var currentSubjectId = currentElement.su.firstOrNull?.id; - var previousSubjectId = previousElement.su.firstOrNull?.id; - - if(currentSubjectId == null || previousSubjectId == null || currentSubjectId != previousSubjectId) return false; - - var currentRoomId = currentElement.ro.firstOrNull?.id; - var previousRoomId = previousElement.ro.firstOrNull?.id; - - if(currentRoomId != previousRoomId) return false; - - var currentTeacherId = currentElement.te.firstOrNull?.id; - var previousTeacherId = previousElement.te.firstOrNull?.id; - - if(currentTeacherId != previousTeacherId) return false; - - var currentStatusCode = currentElement.code; - var previousStatusCode = previousElement.code; - - if(currentStatusCode != previousStatusCode) return false; - - return true; - } - - bool isNotSeparated() => _parseWebuntisTimestamp(previousElement.date, previousElement.endTime).add(maxTimeBetweenDouble) - .isSameOrAfter(_parseWebuntisTimestamp(currentElement.date, currentElement.startTime)); - - if(isSameLesson() && isNotSeparated()) { - previousElement.endTime = currentElement.endTime; - timetableList.remove(currentElement); - i--; - } else { - previousElement = currentElement; - } - } - - return timetableList; - } - - TimetableEvents _buildTableEvents(TimetableProps data) { - - var timetableList = data.getTimetableResponse.result.toList(); - - if(settings.val().timetableSettings.connectDoubleLessons) { - timetableList = _removeDuplicates(data, const Duration(minutes: 5)); - } - - var appointments = timetableList.map((element) { - - var rooms = data.getRoomsResponse; - var subjects = data.getSubjectsResponse; - - try { - var startTime = _parseWebuntisTimestamp(element.date, element.startTime); - var endTime = _parseWebuntisTimestamp(element.date, element.endTime); - - var subject = subjects.result.firstWhereOrNull((subject) => subject.id == element.su.firstOrNull?.id); - var subjectName = 'Unbekannt'; - if(subject != null) { - subjectName = { - TimetableNameMode.name: subject.name, - TimetableNameMode.longName: subject.longName, - TimetableNameMode.alternateName: subject.alternateName, - }[settings.val().timetableSettings.timetableNameMode]!; - } - - return Appointment( - id: ArbitraryAppointment(webuntis: element), - startTime: startTime, - endTime: endTime, - subject: subjectName, - location: '' - '${rooms.result.firstWhereOrNull((room) => room.id == element.ro.firstOrNull?.id)?.name ?? 'Unbekannt'}' - '\n' - '${element.te.firstOrNull?.longname ?? 'Unbekannt'}', - notes: element.activityType, - color: _getEventColor(element, startTime, endTime), + void _onAction(_CalendarAction action) { + switch (action) { + case _CalendarAction.addEvent: + showDialog( + context: context, + builder: (_) => const CustomEventEditDialog(), + barrierDismissible: false, ); - } catch(e) { - var endTime = _parseWebuntisTimestamp(element.date, element.endTime); - return Appointment( - id: ArbitraryAppointment(webuntis: element), - startTime: _parseWebuntisTimestamp(element.date, element.startTime), - endTime: endTime, - subject: 'Änderung', - notes: element.info, - location: 'Unbekannt', - color: const Color(0xff404040), - startTimeZone: '', - endTimeZone: '', - ); - } - }).toList(); - - appointments.addAll(data.getCustomTimetableEventResponse.events.map((customEvent) => Appointment( - id: ArbitraryAppointment(custom: customEvent), - startTime: customEvent.startDate, - endTime: customEvent.endDate, - location: customEvent.description, - subject: customEvent.title, - recurrenceRule: customEvent.rrule, - color: TimetableColors.getColorFromString(customEvent.color ?? TimetableColors.defaultColor.name), - startTimeZone: '', - endTimeZone: '', - ))); - - return TimetableEvents(appointments); - } - - DateTime _parseWebuntisTimestamp(int date, int time) { - var timeString = time.toString().padLeft(4, '0'); - return DateTime.parse('$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}'); - } - - Color _getEventColor(GetTimetableResponseObject webuntisElement, DateTime startTime, DateTime endTime) { - // Cancelled - if(webuntisElement.code == 'cancelled') return const Color(0xff000000); - - // Any changes or no teacher at this element - if(webuntisElement.code == 'irregular' || webuntisElement.te.first.id == 0) return const Color(0xff8F19B3); - - // Teacher has changed - if(webuntisElement.te.any((element) => element.orgname != null)) return const Color(0xFF29639B); - - // Event was in the past - if(endTime.isBefore(DateTime.now())) return Theme.of(context).primaryColor; - - // Event takes currently place - if(endTime.isAfter(DateTime.now()) && startTime.isBefore(DateTime.now())) return Theme.of(context).primaryColor.withRed(200); - - // Fallback - return Theme.of(context).primaryColor; - } - - bool _isCrossedOut(CalendarAppointmentDetails calendarEntry) { - var appointment = calendarEntry.appointments.first.id as ArbitraryAppointment; - if(appointment.hasWebuntis()) { - return appointment.webuntis!.code == 'cancelled'; + case _CalendarAction.viewEvents: + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const CustomEventsView())); } + } + + LessonAppointmentSource _appointmentSource(TimetableState state) { + if (_cachedSource != null && _lastDataVersion == state.dataVersion) { + return _cachedSource!; + } + _lastDataVersion = state.dataVersion; + + final settings = context.read(); + final appointments = TimetableAppointmentFactory( + lessons: state.getAllKnownLessons().toList(), + customEvents: state.customEvents?.events ?? const [], + rooms: state.rooms!, + subjects: state.subjects!, + settings: settings.val().timetableSettings, + colorScheme: Theme.of(context).colorScheme, + now: DateTime.now(), + ).build(); + + return _cachedSource = LessonAppointmentSource(appointments); + } + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + return Scaffold( + appBar: AppBar( + title: const Text('Stunden & Vertretungsplan'), + actions: [ + IconButton(icon: const Icon(Icons.home_outlined), onPressed: _jumpToToday), + PopupMenuButton<_CalendarAction>( + icon: const Icon(Icons.edit_calendar_outlined), + onSelected: _onAction, + itemBuilder: (_) => const [ + PopupMenuItem( + value: _CalendarAction.addEvent, + child: ListTile(title: Text('Kalendereintrag hinzufügen'), leading: Icon(Icons.add)), + ), + PopupMenuItem( + value: _CalendarAction.viewEvents, + child: ListTile( + title: Text('Kalendereinträge anzeigen'), + leading: Icon(Icons.perm_contact_calendar_outlined), + ), + ), + ], + ), + ], + ), + body: LoadableStateConsumer( + child: (state, _) => _calendar(state, bloc), + ), + ); + } + + Widget _calendar(TimetableState state, TimetableBloc bloc) { + if (!state.hasReferenceData) return const SizedBox.shrink(); + + return SfCalendar( + timeZone: 'W. Europe Standard Time', + view: CalendarView.workWeek, + dataSource: _appointmentSource(state), + maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday), + minDate: DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.sunday), + controller: _controller, + onViewChanged: (details) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + bloc.changeWeek(details.visibleDates.first, details.visibleDates.last); + }); + }, + onTap: (tap) { + if (tap.appointments == null || tap.appointments!.isEmpty) return; + AppointmentDetailsDispatcher.show(context, bloc, tap.appointments!.first); + }, + firstDayOfWeek: DateTime.monday, + specialRegions: SpecialRegionsBuilder( + holidays: state.schoolHolidays!, + colorScheme: Theme.of(context).colorScheme, + disabledColor: Theme.of(context).disabledColor, + ).build(), + timeSlotViewSettings: const TimeSlotViewSettings( + startHour: 7.5, + endHour: 16.5, + timeInterval: Duration(minutes: 30), + timeFormat: 'HH:mm', + dayFormat: 'EE', + timeIntervalHeight: 40, + ), + timeRegionBuilder: (_, details) => TimeRegionTile(details: details), + appointmentBuilder: (_, details) => AppointmentTile( + details: details, + crossedOut: _isCrossedOut(details), + ), + headerHeight: 0, + selectionDecoration: const BoxDecoration(), + allowAppointmentResize: false, + allowDragAndDrop: false, + allowViewNavigation: false, + ); + } + + bool _isCrossedOut(CalendarAppointmentDetails details) { + final appointment = details.appointments.first; + final id = appointment.id; + if (id is WebuntisAppointment) return id.lesson.code == 'cancelled'; return false; } } diff --git a/lib/view/pages/timetable/timetableEvents.dart b/lib/view/pages/timetable/timetableEvents.dart deleted file mode 100644 index 8450df7..0000000 --- a/lib/view/pages/timetable/timetableEvents.dart +++ /dev/null @@ -1,8 +0,0 @@ - -import 'package:syncfusion_flutter_calendar/calendar.dart'; - -class TimetableEvents extends CalendarDataSource { - TimetableEvents(List source) { - appointments = source; - } -} diff --git a/lib/view/pages/timetable/viewCustomTimetableEvents.dart b/lib/view/pages/timetable/viewCustomTimetableEvents.dart deleted file mode 100644 index f089184..0000000 --- a/lib/view/pages/timetable/viewCustomTimetableEvents.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; -import 'package:provider/provider.dart'; - -import '../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart'; -import '../../../model/timetable/timetableProps.dart'; -import '../../../widget/centeredLeading.dart'; -import '../../../widget/loadingSpinner.dart'; -import '../../../widget/placeholderView.dart'; -import 'appointmentDetails.dart'; -import 'customTimetableEventEditDialog.dart'; - -class ViewCustomTimetableEvents extends StatefulWidget { - const ViewCustomTimetableEvents({super.key}); - - @override - State createState() => _ViewCustomTimetableEventsState(); -} - -class _ViewCustomTimetableEventsState extends State { - late Future events; - - @override - void initState() { - super.initState(); - } - - _openCreateDialog() { - showDialog( - context: context, - builder: (context) => const CustomTimetableEventEditDialog(), - barrierDismissible: false, - ); - } - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Eigene Termine'), - actions: [ - IconButton( - icon: const Icon(Icons.add), - onPressed: _openCreateDialog, - ) - ], - ), - body: Consumer(builder: (context, value, child) { - if(value.primaryLoading()) return const LoadingSpinner(); - - var listView = ListView( - children: value.getCustomTimetableEventResponse.events.map((e) => ListTile( - title: Text(e.title), - subtitle: Text("${e.rrule.isNotEmpty ? "wiederholdend, " : ""}beginnend ${Jiffy.parseFromDateTime(e.startDate).fromNow()}"), - leading: CenteredLeading(Icon(e.rrule.isEmpty ? Icons.event_outlined : Icons.event_repeat_outlined)), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit_outlined), - onPressed: () { - showDialog(context: context, builder: (context) => CustomTimetableEventEditDialog(existingEvent: e)); - }, - ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () { - AppointmentDetails.deleteCustomEvent(context, e); - }, - ) - ], - ), - )).toList(), - ); - - var placeholder = PlaceholderView( - icon: Icons.calendar_today_outlined, - text: 'Keine Einträge vorhanden', - button: TextButton( - onPressed: _openCreateDialog, - child: const Text('Termin erstellen'), - ), - ); - - return RefreshIndicator( - onRefresh: () { - Provider.of(context, listen: false).run(renew: true); - return Future.delayed(const Duration(seconds: 3)); - }, - child: value.getCustomTimetableEventResponse.events.isEmpty - ? placeholder - : listView - ); - }), - ); -} diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart new file mode 100644 index 0000000..15fcea1 --- /dev/null +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import 'cross_painter.dart'; + +class AppointmentTile extends StatelessWidget { + final CalendarAppointmentDetails details; + final bool crossedOut; + + const AppointmentTile({super.key, required this.details, this.crossedOut = false}); + + @override + Widget build(BuildContext context) { + final Appointment meeting = details.appointments.first; + final isPast = meeting.endTime.isBefore(DateTime.now()); + final color = meeting.color.withAlpha(isPast ? 100 : 255); + + return Stack( + children: [ + Container( + padding: const EdgeInsets.all(3), + height: details.bounds.height, + alignment: Alignment.topLeft, + decoration: BoxDecoration( + shape: BoxShape.rectangle, + borderRadius: const BorderRadius.all(Radius.circular(5)), + color: color, + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FittedBox( + fit: BoxFit.fitWidth, + child: Text( + meeting.subject, + style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500), + maxLines: 1, + softWrap: false, + ), + ), + FittedBox( + fit: BoxFit.fitWidth, + child: Text( + meeting.location?.isNotEmpty == true ? meeting.location! : ' ', + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 10), + ), + ), + ], + ), + ), + ), + if (crossedOut) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + border: Border.all(width: 2, color: Colors.red.withAlpha(200)), + borderRadius: const BorderRadius.all(Radius.circular(5)), + ), + child: CustomPaint(painter: CrossPainter()), + ), + ), + ], + ); + } +} diff --git a/lib/view/pages/timetable/CrossPainter.dart b/lib/view/pages/timetable/widgets/cross_painter.dart similarity index 100% rename from lib/view/pages/timetable/CrossPainter.dart rename to lib/view/pages/timetable/widgets/cross_painter.dart diff --git a/lib/view/pages/timetable/widgets/lesson_appointment_source.dart b/lib/view/pages/timetable/widgets/lesson_appointment_source.dart new file mode 100644 index 0000000..9269184 --- /dev/null +++ b/lib/view/pages/timetable/widgets/lesson_appointment_source.dart @@ -0,0 +1,7 @@ +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +class LessonAppointmentSource extends CalendarDataSource { + LessonAppointmentSource(List source) { + appointments = source; + } +} diff --git a/lib/view/pages/timetable/widgets/special_regions_builder.dart b/lib/view/pages/timetable/widgets/special_regions_builder.dart new file mode 100644 index 0000000..0ab0115 --- /dev/null +++ b/lib/view/pages/timetable/widgets/special_regions_builder.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; +import '../../../../extensions/dateTime.dart'; +import '../data/webuntis_time.dart'; +import 'time_region_tile.dart'; + +class SpecialRegionsBuilder { + final GetHolidaysResponse holidays; + final ColorScheme colorScheme; + final Color disabledColor; + + SpecialRegionsBuilder({ + required this.holidays, + required this.colorScheme, + required this.disabledColor, + }); + + List build() { + final lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday); + final firstBreak = lastMonday.copyWith(hour: 10, minute: 15); + final secondBreak = lastMonday.copyWith(hour: 13, minute: 50); + + final holidayRegions = _buildHolidayRegions().toList(); + bool isInHoliday(DateTime time) => holidayRegions.any((region) => region.startTime.isSameDay(time)); + + return [ + ...holidayRegions, + if (!isInHoliday(firstBreak)) _breakRegion(firstBreak, const Duration(minutes: 20)), + if (!isInHoliday(secondBreak)) _breakRegion(secondBreak, const Duration(minutes: 15)), + ]; + } + + Iterable _buildHolidayRegions() => holidays.result.expand((holiday) { + final startDay = WebuntisTime.parse(holiday.startDate, 0); + final dayCount = WebuntisTime.parse(holiday.endDate, 0).difference(startDay).inDays; + final days = List.generate(dayCount, (i) => startDay.add(Duration(days: i))); + return days.map((day) => TimeRegion( + startTime: day.copyWith(hour: 7, minute: 55), + endTime: day.copyWith(hour: 16, minute: 30), + text: '$kTimeRegionHolidayPrefix${holiday.name}', + color: disabledColor.withAlpha(50), + iconData: Icons.holiday_village_outlined, + )); + }); + + TimeRegion _breakRegion(DateTime start, Duration duration) => TimeRegion( + startTime: start, + endTime: start.add(duration), + recurrenceRule: 'FREQ=DAILY;INTERVAL=1', + text: kTimeRegionCenterIcon, + color: colorScheme.primary.withAlpha(50), + iconData: Icons.restaurant, + ); +} diff --git a/lib/view/pages/timetable/timeRegionComponent.dart b/lib/view/pages/timetable/widgets/time_region_tile.dart similarity index 62% rename from lib/view/pages/timetable/timeRegionComponent.dart rename to lib/view/pages/timetable/widgets/time_region_tile.dart index 01d3d7e..10c074b 100644 --- a/lib/view/pages/timetable/timeRegionComponent.dart +++ b/lib/view/pages/timetable/widgets/time_region_tile.dart @@ -1,31 +1,28 @@ import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -class TimeRegionComponent extends StatefulWidget { +const String kTimeRegionCenterIcon = 'centerIcon'; +const String kTimeRegionHolidayPrefix = 'holiday:'; + +class TimeRegionTile extends StatelessWidget { final TimeRegionDetails details; - const TimeRegionComponent({super.key, required this.details}); - @override - State createState() => _TimeRegionComponentState(); -} + const TimeRegionTile({super.key, required this.details}); -class _TimeRegionComponentState extends State { @override Widget build(BuildContext context) { - var text = widget.details.region.text!; - var color = widget.details.region.color; + final text = details.region.text ?? ''; + final color = details.region.color; - if (text == 'centerIcon') { + if (text == kTimeRegionCenterIcon) { return Container( color: color, alignment: Alignment.center, - child: Icon( - widget.details.region.iconData, - size: 17, - color: Theme.of(context).primaryColor, - ), + child: Icon(details.region.iconData, size: 17, color: Theme.of(context).primaryColor), ); - } else if(text.startsWith('holiday')) { + } + + if (text.startsWith(kTimeRegionHolidayPrefix)) { return Container( color: color, alignment: Alignment.center, @@ -38,7 +35,7 @@ class _TimeRegionComponentState extends State { RotatedBox( quarterTurns: 1, child: Text( - text.split(':').last, + text.substring(kTimeRegionHolidayPrefix.length), maxLines: 1, style: const TextStyle( fontWeight: FontWeight.bold, diff --git a/lib/view/settings/defaultSettings.dart b/lib/view/settings/defaultSettings.dart index 7b99b25..9348a6d 100644 --- a/lib/view/settings/defaultSettings.dart +++ b/lib/view/settings/defaultSettings.dart @@ -13,7 +13,7 @@ import '../../storage/notification/notificationSettings.dart'; import '../../storage/talk/talkSettings.dart'; import '../../storage/timetable/timetableSettings.dart'; import '../pages/files/files.dart'; -import '../pages/timetable/timetableNameMode.dart'; +import '../../storage/timetable/timetable_name_mode.dart'; class DefaultSettings { static Settings get() => Settings( diff --git a/lib/view/settings/devToolsSettings.dart b/lib/view/settings/devToolsSettings.dart index 4dc8877..147861f 100644 --- a/lib/view/settings/devToolsSettings.dart +++ b/lib/view/settings/devToolsSettings.dart @@ -2,16 +2,16 @@ import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../storage/base/settingsProvider.dart'; +import '../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../widget/centeredLeading.dart'; import '../../widget/confirmDialog.dart'; import '../../widget/debug/cacheView.dart'; import '../../widget/debug/jsonViewer.dart'; class DevToolsSettings extends StatefulWidget { - final SettingsProvider settings; + final SettingsCubit settings; const DevToolsSettings({required this.settings, super.key}); @override @@ -83,7 +83,7 @@ class _DevToolsSettingsState extends State { content: 'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten sind nicht betroffen.', confirmButton: 'Unwiederruflich Löschen', onConfirm: () { - Provider.of(context, listen: false).reset(); + context.read().reset(); }, ).asDialog(context); }, diff --git a/lib/view/settings/settings.dart b/lib/view/settings/settings.dart index 0d3f58d..85cfbc5 100644 --- a/lib/view/settings/settings.dart +++ b/lib/view/settings/settings.dart @@ -3,18 +3,19 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../model/accountData.dart'; -import '../../model/timetable/timetableProps.dart'; import '../../notification/notifyUpdater.dart'; -import '../../storage/base/settingsProvider.dart'; +import '../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../storage/base/settings.dart' as model; import '../../theming/appTheme.dart'; import '../../widget/centeredLeading.dart'; import '../../widget/confirmDialog.dart'; import '../../widget/debug/cacheView.dart'; -import '../pages/timetable/timetableNameMode.dart'; +import '../../storage/timetable/timetable_name_mode.dart'; import 'defaultSettings.dart'; import 'devToolsSettings.dart'; import 'privacyInfo.dart'; @@ -36,7 +37,9 @@ class _SettingsState extends State { bool developerMode = false; @override - Widget build(BuildContext context) => Consumer(builder: (context, settings, child) => Scaffold( + Widget build(BuildContext context) => BlocBuilder(builder: (context, _) { + final settings = context.read(); + return Scaffold( appBar: AppBar( title: const Text('Einstellungen'), ), @@ -58,7 +61,8 @@ class _SettingsState extends State { value.clear(), }).then((value) async { PaintingBinding.instance.imageCache.clear(); - Provider.of(context, listen: false).reset(); + if (!context.mounted) return; + context.read().reset(); const CacheView().clear(); AccountData().removeData(context: context); Navigator.popUntil(context, (route) => !Navigator.canPop(context)); @@ -115,7 +119,7 @@ class _SettingsState extends State { )).toList(), onChanged: (value) { settings.val(write: true).timetableSettings.timetableNameMode = value!; - Provider.of(context, listen: false).run(renew: false); + context.read().refresh(); }, ) ), @@ -126,7 +130,7 @@ class _SettingsState extends State { value: settings.val().timetableSettings.connectDoubleLessons, onChanged: (e) { settings.val(write: true).timetableSettings.connectDoubleLessons = e!; - Provider.of(context, listen: false).run(renew: false); + context.read().refresh(); }, ), ), @@ -215,6 +219,7 @@ class _SettingsState extends State { title: const Text('Informationen und Lizenzen'), onTap: () { PackageInfo.fromPlatform().then((appInfo) { + if (!context.mounted) return; showAboutDialog( context: context, applicationIcon: const Icon(Icons.apps), @@ -308,5 +313,6 @@ class _SettingsState extends State { ), ], ), - )); + ); + }); } diff --git a/lib/widget/breaker/breaker.dart b/lib/widget/breaker/breaker.dart new file mode 100644 index 0000000..9c66de2 --- /dev/null +++ b/lib/widget/breaker/breaker.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; +import '../../state/app/modules/breaker/bloc/breaker_bloc.dart'; +import '../../widget/placeholderView.dart'; + +class Breaker extends StatelessWidget { + final BreakerArea breaker; + final Widget child; + + const Breaker({required this.breaker, required this.child, super.key}); + + @override + Widget build(BuildContext context) { + final bloc = context.watch(); + final blocked = bloc.isBlocked(breaker); + if (blocked != null) { + return PlaceholderView( + icon: Icons.app_blocking_outlined, + text: 'Die App / Dieser Bereich ist zurzeit nicht verfügbar!\n\n' + '${blocked.isEmpty ? "Es wurde vom Server kein Grund übermittelt.\nAktualisiere die App und versuche es später erneut" : blocked}', + ); + } + return child; + } +} diff --git a/lib/widget/debug/debugTile.dart b/lib/widget/debug/debugTile.dart index bc5a8c4..7a29fcc 100644 --- a/lib/widget/debug/debugTile.dart +++ b/lib/widget/debug/debugTile.dart @@ -1,8 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../storage/base/settingsProvider.dart'; +import '../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../centeredLeading.dart'; import 'jsonViewer.dart'; @@ -11,29 +11,27 @@ class DebugTile { bool onlyInDebug; DebugTile(this.context, {this.onlyInDebug = false}); - bool devConditionFulfilled() => Provider.of(context, listen: false).val().devToolsEnabled && (onlyInDebug ? kDebugMode : true); + bool devConditionFulfilled() => + context.read().val().devToolsEnabled && (onlyInDebug ? kDebugMode : true); Widget jsonData(Map data, {bool ignoreConfig = false}) => callback( - title: 'JSON daten anzeigen', - onTab: () => JsonViewer.asDialog(context, data) - ); + title: 'JSON daten anzeigen', + onTab: () => JsonViewer.asDialog(context, data), + ); Widget callback({String title = 'Debugaktion', required void Function() onTab}) => child( - ListTile( - leading: const CenteredLeading(Icon(Icons.developer_mode_outlined)), - title: Text(title), - subtitle: const Text('Entwicklermodus aktiviert'), - onTap: onTab, - ) - ); + ListTile( + leading: const CenteredLeading(Icon(Icons.developer_mode_outlined)), + title: Text(title), + subtitle: const Text('Entwicklermodus aktiviert'), + onTap: onTab, + ), + ); - Widget child(Widget child) => Visibility( - visible: devConditionFulfilled(), - child: child, - ); + Widget child(Widget child) => Visibility(visible: devConditionFulfilled(), child: child); void run(void Function() callback) { - if(!devConditionFulfilled()) return; + if (!devConditionFulfilled()) return; callback(); } } diff --git a/lib/widget/debug/jsonViewer.dart b/lib/widget/debug/jsonViewer.dart index 465a98b..389ddc5 100644 --- a/lib/widget/debug/jsonViewer.dart +++ b/lib/widget/debug/jsonViewer.dart @@ -29,11 +29,13 @@ class JsonViewer extends StatelessWidget { actions: [ TextButton(onPressed: () { Clipboard.setData(ClipboardData(text: JsonViewer.format(dataMap))).then((value) { + if (!context.mounted) return; showDialog(context: context, builder: (context) => const AlertDialog(content: Text('Formatiertes JSON wurde erfolgreich in deiner Zwischenlage abgelegt.'))); }); }, child: const Text('Kopieren')), TextButton(onPressed: () { Clipboard.setData(ClipboardData(text: dataMap.toString())).then((value) { + if (!context.mounted) return; showDialog(context: context, builder: (context) => const AlertDialog(content: Text('Unformatiertes JSON wurde erfolgreich in deiner Zwischenablage abgelegt.'))); }); }, child: const Text('Inline Kopieren')), diff --git a/lib/widget/fileViewer.dart b/lib/widget/fileViewer.dart index 54f6557..8ab5c58 100644 --- a/lib/widget/fileViewer.dart +++ b/lib/widget/fileViewer.dart @@ -4,11 +4,11 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:open_filex/open_filex.dart'; import 'package:photo_view/photo_view.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:share_plus/share_plus.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; -import '../storage/base/settingsProvider.dart'; +import '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../utils/FileSaver.dart'; import 'infoDialog.dart'; import 'placeholderView.dart'; @@ -32,7 +32,7 @@ enum FileViewingActions { class _FileViewerState extends State { PhotoViewController photoViewController = PhotoViewController(); - late SettingsProvider settings = Provider.of(context, listen: false); + late SettingsCubit settings = context.read(); late bool openExternal; @override @@ -137,6 +137,7 @@ class _FileViewerState extends State { default: OpenFilex.open(widget.path).then((result) { + if (!context.mounted) return; Navigator.of(context).pop(); if(result.type != ResultType.done) { showDialog(context: context, builder: (context) => AlertDialog( diff --git a/lib/widget/infoDialog.dart b/lib/widget/infoDialog.dart index e30a61e..001b2ed 100644 --- a/lib/widget/infoDialog.dart +++ b/lib/widget/infoDialog.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; class InfoDialog { - static show(BuildContext context, String info) { + static void show(BuildContext context, String info) { showDialog(context: context, builder: (context) => AlertDialog( content: Text(info), contentPadding: const EdgeInsets.all(20), diff --git a/lib/widget/placeholderView.dart b/lib/widget/placeholderView.dart index e890332..ce14d8d 100644 --- a/lib/widget/placeholderView.dart +++ b/lib/widget/placeholderView.dart @@ -23,7 +23,7 @@ class PlaceholderView extends StatelessWidget { textAlign: TextAlign.center, ), const SizedBox(height: 30), - if(button != null) button!, + ?button, ], ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 9563857..aa90db4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,8 @@ dependencies: url: https://github.com/Harsh223/flowder.git flutter_app_badge: ^2.0.2 flutter_bloc: ^9.0.0 + flutter_secure_storage: ^9.2.4 + intl: ^0.20.2 flutter_linkify: ^6.0.0 flutter_local_notifications: ^21.0.0 flutter_login: ^6.0.0