diff --git a/assets/img/raumplan.jpg b/assets/img/raumplan.jpg deleted file mode 100644 index 4bdf0e0..0000000 Binary files a/assets/img/raumplan.jpg and /dev/null differ diff --git a/assets/img/raumplan.png b/assets/img/raumplan.png new file mode 100644 index 0000000..4993888 Binary files /dev/null and b/assets/img/raumplan.png differ diff --git a/lib/app.dart b/lib/app.dart index 5ac8709..9f549c9 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -17,6 +17,7 @@ import 'state/app/modules/app_modules.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; +import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; import 'utils/debouncer.dart'; import 'view/pages/overhang.dart'; import 'widget/breaker/breaker.dart'; @@ -54,6 +55,10 @@ class _AppState extends State with WidgetsBindingObserver { if (!mounted) return; context.read().refresh(); context.read().refresh(); + // App is freshly mounted on every login (BlocConsumer in main.dart + // swaps it in for Login), so this also covers the post-logout case + // where the bloc was reset to an empty state and needs a fresh fetch. + context.read().refresh(); }); _updateTimings = Timer.periodic(const Duration(seconds: 30), (_) { diff --git a/lib/main.dart b/lib/main.dart index a8f88c7..3ce1dad 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ 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:shared_preferences/shared_preferences.dart'; import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart'; import 'app.dart'; @@ -33,6 +34,7 @@ import 'theming/light_app_theme.dart'; import 'view/login/login.dart'; import 'widget/app_progress_indicator.dart'; import 'widget/breaker/breaker.dart'; +import 'widget/debug/cache_view.dart'; Future main() async { log('MarianumMobile started'); @@ -160,7 +162,40 @@ class _MainState extends State
{ home: LoaderOverlay( child: Breaker( breaker: BreakerArea.global, - child: BlocBuilder( + child: BlocConsumer( + listenWhen: (previous, current) => previous.status != current.status, + listener: (context, accountState) { + if (accountState.status != AccountStatus.loggedOut) return; + // Routes pushed via AppRoutes (e.g. Settings) live on the + // root navigator and survive the home swap below, so they + // would still cover the Login screen after logout. Pop + // them here so the user immediately sees Login. + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.popUntil((route) => route.isFirst); + } + // Capture bloc references before the post-frame callback + // — by the time it runs the dialog/Settings context is + // gone but this listener context is still valid. + final settingsCubit = context.read(); + final timetableBloc = context.read(); + final chatListBloc = context.read(); + final chatBloc = context.read(); + final breakerBloc = context.read(); + // Defer the actual wipe until after this frame so the + // App tree (TimetableBloc/ChatListBloc watchers etc.) + // is already torn down. Resetting blocs while App is + // still in front caused a black-frame race. + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_wipeUserState( + settingsCubit: settingsCubit, + timetableBloc: timetableBloc, + chatListBloc: chatListBloc, + chatBloc: chatBloc, + breakerBloc: breakerBloc, + )); + }); + }, builder: (context, accountState) { switch (accountState.status) { case AccountStatus.loggedIn: @@ -190,3 +225,28 @@ class _MainState extends State
{ ), ); } + +Future _wipeUserState({ + required SettingsCubit settingsCubit, + required TimetableBloc timetableBloc, + required ChatListBloc chatListBloc, + required ChatBloc chatBloc, + required BreakerBloc breakerBloc, +}) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); + PaintingBinding.instance.imageCache.clear(); + await settingsCubit.reset(); + await Future.wait([ + timetableBloc.reset(), + chatListBloc.reset(), + chatBloc.reset(), + breakerBloc.reset(), + ]); + await HydratedBloc.storage.clear(); + await const CacheView().clear(); + } catch (e, s) { + log('User state wipe failed: $e', stackTrace: s); + } +} diff --git a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart index 625e1dd..5d3bd43 100644 --- a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart +++ b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart @@ -8,10 +8,16 @@ import 'package:jiffy/jiffy.dart'; import 'loadable_state_event.dart'; import 'loadable_state_state.dart'; -class LoadableStateBloc extends Bloc { +class LoadableStateBloc extends Bloc + with WidgetsBindingObserver { late StreamSubscription> _updateStream; void Function()? reFetch; + /// Last time [reFetch] was triggered by an [AppLifecycleState.resumed] + /// event. Used to coalesce rapid foreground/background flips so we don't + /// spam the network when the user briefly checks notifications. + DateTime _lastResumeRefetch = DateTime.fromMillisecondsSinceEpoch(0); + LoadableStateBloc() : super(const LoadableStateState(connections: null)) { on((event, emit) { emit(event.state); @@ -25,6 +31,23 @@ class LoadableStateBloc extends Bloc { Connectivity().checkConnectivity().then(emitConnectivity); _updateStream = Connectivity().onConnectivityChanged.listen(emitConnectivity); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) return; + final now = DateTime.now(); + if (now.difference(_lastResumeRefetch) < const Duration(seconds: 10)) return; + _lastResumeRefetch = now; + // Re-check connectivity. The resulting [ConnectivityChanged] event takes + // it from there: its handler updates the offline/online indicator and + // triggers [reFetch] when the device is connected, so a stale + // "Verbindung fehlgeschlagen" bar from a suspend-time fetch clears as + // soon as the network is reachable again. + unawaited(Connectivity().checkConnectivity().then( + (result) => add(ConnectivityChanged(LoadableStateState(connections: result))), + )); } bool connectivityStatusKnown() => state.connections != null; @@ -55,6 +78,7 @@ class LoadableStateBloc extends Bloc { @override Future close() { + WidgetsBinding.instance.removeObserver(this); _updateStream.cancel(); return super.close(); } diff --git a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart index f241431..74e9f00 100644 --- a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart @@ -60,10 +60,27 @@ abstract class LoadableHydratedBloc< error: event.error ))); + on>((event, emit) => emit(const LoadableState( + isLoading: false, + data: null, + lastFetch: null, + reFetch: null, + error: null, + ))); + _repository = repository(); fetch(); } + /// Wipes this bloc's persisted state and resets the in-memory state to an + /// empty, non-loading shell. Intended for logout: callers must trigger a + /// fresh [fetch] (e.g. via [retry] or page-specific refresh) once the user + /// is authenticated again, otherwise the UI would stay blank. + Future reset() async { + await clear(); + add(Reset()); + } + TState? get innerState => state.data; TRepository get repo => _repository; diff --git a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart index 1485c60..c0b9934 100644 --- a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart @@ -14,3 +14,4 @@ class Error extends LoadableHydratedBlocEvent { Error(this.error); } class RefetchStarted extends LoadableHydratedBlocEvent {} +class Reset extends LoadableHydratedBlocEvent {} diff --git a/lib/view/pages/more/roomplan/roomplan.dart b/lib/view/pages/more/roomplan/roomplan.dart index efbc774..0b15a20 100644 --- a/lib/view/pages/more/roomplan/roomplan.dart +++ b/lib/view/pages/more/roomplan/roomplan.dart @@ -10,7 +10,7 @@ class Roomplan extends StatelessWidget { title: const Text('Raumplan'), ), body: PhotoView( - imageProvider: Image.asset('assets/img/raumplan.jpg').image, + imageProvider: Image.asset('assets/img/raumplan.png').image, minScale: 0.5, maxScale: 2.0, backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index 7067c22..d4b8128 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -1,12 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import '../../../../model/account_data.dart'; -import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; -import '../../../../widget/debug/cache_view.dart'; class AccountSection extends StatelessWidget { const AccountSection({super.key}); @@ -26,16 +22,13 @@ class AccountSection extends StatelessWidget { title: 'Abmelden?', content: 'Möchtest du dich wirklich abmelden?', confirmButton: 'Abmelden', - onConfirmAsync: () async { - final prefs = await SharedPreferences.getInstance(); - await prefs.clear(); - PaintingBinding.instance.imageCache.clear(); - if (!context.mounted) return; - await context.read().reset(); - await const CacheView().clear(); - if (!context.mounted) return; - await AccountData().removeData(context: context); - }, + // Cleanup of caches, hydrated bloc storage and bloc in-memory state is + // handled by the AccountBloc listener in main.dart on the loggedOut + // transition. Doing the cleanup *before* setting loggedOut caused + // rebuilds in the still-mounted App tree (TimetableBloc/ChatListBloc + // emitting empty states) which raced with the home-route swap and + // produced a black screen. + onConfirmAsync: () => AccountData().removeData(context: context), ), ); } diff --git a/lib/view/pages/talk/data/chat_bubble_styles.dart b/lib/view/pages/talk/data/chat_bubble_styles.dart index 33db5e7..6c7627a 100644 --- a/lib/view/pages/talk/data/chat_bubble_styles.dart +++ b/lib/view/pages/talk/data/chat_bubble_styles.dart @@ -24,7 +24,6 @@ class ChatBubbleStyles { BubbleStyle getSystemStyle() => BubbleStyle( color: AppTheme.isDarkMode(context) ? const Color(0xff182229) : Colors.white, - borderWidth: 1, elevation: 2, margin: const BubbleEdges.only(bottom: 20, top: 10), alignment: Alignment.center, @@ -35,7 +34,6 @@ class ChatBubbleStyles { return BubbleStyle( nip: BubbleNip.leftTop, color: seamless ? Colors.transparent : color, - borderWidth: seamless ? 0 : 1, elevation: seamless ? 0 : 1, margin: const BubbleEdges.only(bottom: 10, left: 10, right: 50), alignment: Alignment.topLeft, @@ -47,7 +45,6 @@ class ChatBubbleStyles { return BubbleStyle( nip: BubbleNip.rightBottom, color: seamless ? Colors.transparent : color, - borderWidth: seamless ? 0 : 1, elevation: seamless ? 0 : 1, margin: const BubbleEdges.only(bottom: 10, right: 10, left: 50), alignment: Alignment.topRight, diff --git a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart index 13885c9..7b88c05 100644 --- a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart +++ b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; @@ -18,8 +19,9 @@ class WebuntisLessonSheet { final state = bloc.state.data; if (state == null) return; - final subject = _resolveSubject(state, lesson); - final room = _resolveRoom(state, lesson); + final headerSubject = _resolveSubject(state, lesson.su.firstOrNull?.id); + final headerTitle = _firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']); + final headerLongName = headerSubject.longName.isNotEmpty && headerSubject.longName != headerTitle ? headerSubject.longName : ''; showAppointmentBottomSheet( context, @@ -28,12 +30,12 @@ class WebuntisLessonSheet { mainAxisSize: MainAxisSize.min, children: [ Text( - '${_codePrefix(lesson.code)}${subject.alternateName}', + '${_codePrefix(lesson.code)}$headerTitle', textAlign: TextAlign.center, style: const TextStyle(fontSize: 25), overflow: TextOverflow.ellipsis, ), - Text(subject.longName), + if (headerLongName.isNotEmpty) Text(headerLongName), Text( '${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - ' '${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}', @@ -42,68 +44,210 @@ class WebuntisLessonSheet { ], ), ), - body: (_) => SliverChildListDelegate([ + body: (_) => SliverChildListDelegate([ const Divider(), ListTile( leading: const Icon(Icons.notifications_active), - title: Text('Status: ${lesson.code != null ? "Geändert" : "Regulär"}'), + title: Text('Status: ${_statusLabel(lesson.code)}'), ), - ListTile( - leading: const Icon(Icons.room), - title: Text('Raum: ${room.name} (${room.longName})'), - trailing: IconButton( - icon: const Icon(Icons.house_outlined), - onPressed: () => AppRoutes.openRoomplan(context), + if (lesson.su.length > 1) + _listTile( + icon: Icons.book_outlined, + label: 'Fächer', + entries: lesson.su.map((s) { + final resolved = _resolveSubject(state, s.id); + return _formatLine( + _firstNonEmpty([resolved.name, s.name, '?']), + longname: _firstNonEmpty([resolved.longName, s.longname, '']), + ); + }).toList(), ), - ), - 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), - ), + _roomTile(context, state, lesson), + _teacherTile(context, lesson), + if ((lesson.activityType ?? '').trim().isNotEmpty) + ListTile( + leading: const Icon(Icons.abc), + title: Text('Typ: ${lesson.activityType}'), ), - ), - 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(", ")}'), - ), + if (lesson.kl.isNotEmpty) + _listTile( + icon: Icons.people, + label: lesson.kl.length == 1 ? 'Klasse' : 'Klassen', + entries: lesson.kl + .map((k) => _formatLine( + k.name.isNotEmpty ? k.name : '?', + longname: k.longname, + )) + .toList(), + ), + ..._optionalTextTiles(lesson), DebugTile(context).jsonData(lesson.toJson()), ]), ); } + static Widget _roomTile(BuildContext context, TimetableState state, GetTimetableResponseObject lesson) { + final trailing = IconButton( + icon: const Icon(Icons.house_outlined), + onPressed: () => AppRoutes.openRoomplan(context), + ); + + if (lesson.ro.isEmpty) { + return ListTile( + leading: const Icon(Icons.room), + title: const Text('Raum: ?'), + trailing: trailing, + ); + } + + final entries = lesson.ro.map((r) { + final resolved = _resolveRoom(state, r.id); + final name = _firstNonEmpty([resolved.name, r.name, '?']); + final longname = _firstNonEmpty([resolved.longName, r.longname, '']); + final building = resolved.building.trim(); + return _formatLine( + name, + longname: longname, + extra: (building.isNotEmpty && building != '?') ? building : null, + ); + }).toList(); + + return _listTile( + icon: Icons.room, + label: lesson.ro.length == 1 ? 'Raum' : 'Räume', + entries: entries, + trailing: trailing, + ); + } + + static Widget _teacherTile(BuildContext context, GetTimetableResponseObject lesson) { + final trailing = Visibility( + visible: !kReleaseMode, + child: IconButton( + icon: const Icon(Icons.textsms_outlined), + onPressed: () => UnimplementedDialog.show(context), + ), + ); + + if (lesson.te.isEmpty) { + return ListTile( + leading: const Icon(Icons.person), + title: const Text('Lehrkraft: ?'), + trailing: trailing, + ); + } + + final entries = lesson.te.map((t) { + final base = _formatLine( + t.name.isNotEmpty ? t.name : '?', + longname: t.longname, + ); + final orgname = (t.orgname ?? '').trim(); + return orgname.isEmpty ? base : '$base · ehemals $orgname'; + }).toList(); + + return _listTile( + icon: Icons.person, + label: lesson.te.length == 1 ? 'Lehrkraft' : 'Lehrkräfte', + entries: entries, + trailing: trailing, + ); + } + + static Widget _listTile({ + required IconData icon, + required String label, + required List entries, + Widget? trailing, + }) { + if (entries.length == 1) { + return ListTile( + leading: Icon(icon), + title: Text('$label: ${entries.first}'), + trailing: trailing, + ); + } + return ListTile( + leading: Icon(icon), + title: Text(label), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: entries.map(Text.new).toList(), + ), + trailing: trailing, + ); + } + + static List _optionalTextTiles(GetTimetableResponseObject lesson) { + return [ + _textTile(Icons.info_outline, 'Info', lesson.info), + _textTile(Icons.swap_horiz, 'Vertretungstext', lesson.substText), + _textTile(Icons.subject, 'Stundentext', lesson.lstext), + _textTile(Icons.category_outlined, 'Stundentyp', lesson.lstype), + _textTile(Icons.flag_outlined, 'Statusmerkmale', lesson.statflags), + _textTile(Icons.school_outlined, 'Lerngruppe', lesson.sg), + _textTile(Icons.bookmark_outline, 'Buchungshinweis', lesson.bkRemark), + _textTile(Icons.notes, 'Buchungstext', lesson.bkText), + ].whereType().toList(); + } + + static Widget? _textTile(IconData icon, String label, String? value) { + final text = (value ?? '').trim(); + if (text.isEmpty) return null; + return ListTile( + leading: Icon(icon), + title: Text(label), + subtitle: Text(text), + ); + } + + static String _formatLine(String name, {String? longname, String? extra}) { + final parts = [ + if (name.isNotEmpty) name else '?', + ]; + final ln = (longname ?? '').trim(); + if (ln.isNotEmpty && ln != name) parts.add('($ln)'); + final ex = (extra ?? '').trim(); + if (ex.isNotEmpty) parts.add('· $ex'); + return parts.join(' '); + } + + static String _firstNonEmpty(List values) { + for (final v in values) { + if (v.trim().isNotEmpty) return v; + } + return ''; + } + + static String _statusLabel(String? code) { + switch (code) { + case null: + case '': + return 'Regulär'; + case 'cancelled': + return 'Entfällt'; + case 'irregular': + return 'Geändert'; + default: + return code; + } + } + 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 GetSubjectsResponseObject _resolveSubject(TimetableState state, int? id) { + final fallback = GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true); + if (id == null) return fallback; + return state.subjects?.result.firstWhereOrNull((s) => s.id == id) ?? fallback; } - 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, '?'); - } + static GetRoomsResponseObject _resolveRoom(TimetableState state, int? id) { + final fallback = GetRoomsResponseObject(0, '?', 'Unbekannt', true, ''); + if (id == null) return fallback; + return state.rooms?.result.firstWhereOrNull((r) => r.id == id) ?? fallback; } }