refactored lesson details, centralized logout logic, and added resume re-fetch

This commit is contained in:
2026-05-06 16:27:45 +02:00
parent 71506aab2d
commit 50d2941e52
11 changed files with 309 additions and 68 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+5
View File
@@ -17,6 +17,7 @@ import 'state/app/modules/app_modules.dart';
import 'state/app/modules/breaker/bloc/breaker_bloc.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/chat_list/bloc/chat_list_bloc.dart';
import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart';
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
import 'utils/debouncer.dart'; import 'utils/debouncer.dart';
import 'view/pages/overhang.dart'; import 'view/pages/overhang.dart';
import 'widget/breaker/breaker.dart'; import 'widget/breaker/breaker.dart';
@@ -54,6 +55,10 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (!mounted) return; if (!mounted) return;
context.read<BreakerBloc>().refresh(); context.read<BreakerBloc>().refresh();
context.read<ChatListBloc>().refresh(); context.read<ChatListBloc>().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<TimetableBloc>().refresh();
}); });
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) { _updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
+61 -1
View File
@@ -15,6 +15,7 @@ import 'package:jiffy/jiffy.dart';
import 'package:loader_overlay/loader_overlay.dart'; import 'package:loader_overlay/loader_overlay.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.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 'api/mhsl/breaker/get_breakers/get_breakers_response.dart';
import 'app.dart'; import 'app.dart';
@@ -33,6 +34,7 @@ import 'theming/light_app_theme.dart';
import 'view/login/login.dart'; import 'view/login/login.dart';
import 'widget/app_progress_indicator.dart'; import 'widget/app_progress_indicator.dart';
import 'widget/breaker/breaker.dart'; import 'widget/breaker/breaker.dart';
import 'widget/debug/cache_view.dart';
Future<void> main() async { Future<void> main() async {
log('MarianumMobile started'); log('MarianumMobile started');
@@ -160,7 +162,40 @@ class _MainState extends State<Main> {
home: LoaderOverlay( home: LoaderOverlay(
child: Breaker( child: Breaker(
breaker: BreakerArea.global, breaker: BreakerArea.global,
child: BlocBuilder<AccountBloc, AccountState>( child: BlocConsumer<AccountBloc, AccountState>(
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<SettingsCubit>();
final timetableBloc = context.read<TimetableBloc>();
final chatListBloc = context.read<ChatListBloc>();
final chatBloc = context.read<ChatBloc>();
final breakerBloc = context.read<BreakerBloc>();
// 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) { builder: (context, accountState) {
switch (accountState.status) { switch (accountState.status) {
case AccountStatus.loggedIn: case AccountStatus.loggedIn:
@@ -190,3 +225,28 @@ class _MainState extends State<Main> {
), ),
); );
} }
Future<void> _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);
}
}
@@ -8,10 +8,16 @@ import 'package:jiffy/jiffy.dart';
import 'loadable_state_event.dart'; import 'loadable_state_event.dart';
import 'loadable_state_state.dart'; import 'loadable_state_state.dart';
class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState> { class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState>
with WidgetsBindingObserver {
late StreamSubscription<List<ConnectivityResult>> _updateStream; late StreamSubscription<List<ConnectivityResult>> _updateStream;
void Function()? reFetch; 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)) { LoadableStateBloc() : super(const LoadableStateState(connections: null)) {
on<ConnectivityChanged>((event, emit) { on<ConnectivityChanged>((event, emit) {
emit(event.state); emit(event.state);
@@ -25,6 +31,23 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState> {
Connectivity().checkConnectivity().then(emitConnectivity); Connectivity().checkConnectivity().then(emitConnectivity);
_updateStream = Connectivity().onConnectivityChanged.listen(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; bool connectivityStatusKnown() => state.connections != null;
@@ -55,6 +78,7 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState> {
@override @override
Future<void> close() { Future<void> close() {
WidgetsBinding.instance.removeObserver(this);
_updateStream.cancel(); _updateStream.cancel();
return super.close(); return super.close();
} }
@@ -60,10 +60,27 @@ abstract class LoadableHydratedBloc<
error: event.error error: event.error
))); )));
on<Reset<TState>>((event, emit) => emit(const LoadableState(
isLoading: false,
data: null,
lastFetch: null,
reFetch: null,
error: null,
)));
_repository = repository(); _repository = repository();
fetch(); 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<void> reset() async {
await clear();
add(Reset<TState>());
}
TState? get innerState => state.data; TState? get innerState => state.data;
TRepository get repo => _repository; TRepository get repo => _repository;
@@ -14,3 +14,4 @@ class Error<TState> extends LoadableHydratedBlocEvent<TState> {
Error(this.error); Error(this.error);
} }
class RefetchStarted<TState> extends LoadableHydratedBlocEvent<TState> {} class RefetchStarted<TState> extends LoadableHydratedBlocEvent<TState> {}
class Reset<TState> extends LoadableHydratedBlocEvent<TState> {}
+1 -1
View File
@@ -10,7 +10,7 @@ class Roomplan extends StatelessWidget {
title: const Text('Raumplan'), title: const Text('Raumplan'),
), ),
body: PhotoView( body: PhotoView(
imageProvider: Image.asset('assets/img/raumplan.jpg').image, imageProvider: Image.asset('assets/img/raumplan.png').image,
minScale: 0.5, minScale: 0.5,
maxScale: 2.0, maxScale: 2.0,
backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.surface),
@@ -1,12 +1,8 @@
import 'package:flutter/material.dart'; 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 '../../../../model/account_data.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../widget/centered_leading.dart'; import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/debug/cache_view.dart';
class AccountSection extends StatelessWidget { class AccountSection extends StatelessWidget {
const AccountSection({super.key}); const AccountSection({super.key});
@@ -26,16 +22,13 @@ class AccountSection extends StatelessWidget {
title: 'Abmelden?', title: 'Abmelden?',
content: 'Möchtest du dich wirklich abmelden?', content: 'Möchtest du dich wirklich abmelden?',
confirmButton: 'Abmelden', confirmButton: 'Abmelden',
onConfirmAsync: () async { // Cleanup of caches, hydrated bloc storage and bloc in-memory state is
final prefs = await SharedPreferences.getInstance(); // handled by the AccountBloc listener in main.dart on the loggedOut
await prefs.clear(); // transition. Doing the cleanup *before* setting loggedOut caused
PaintingBinding.instance.imageCache.clear(); // rebuilds in the still-mounted App tree (TimetableBloc/ChatListBloc
if (!context.mounted) return; // emitting empty states) which raced with the home-route swap and
await context.read<SettingsCubit>().reset(); // produced a black screen.
await const CacheView().clear(); onConfirmAsync: () => AccountData().removeData(context: context),
if (!context.mounted) return;
await AccountData().removeData(context: context);
},
), ),
); );
} }
@@ -24,7 +24,6 @@ class ChatBubbleStyles {
BubbleStyle getSystemStyle() => BubbleStyle( BubbleStyle getSystemStyle() => BubbleStyle(
color: AppTheme.isDarkMode(context) ? const Color(0xff182229) : Colors.white, color: AppTheme.isDarkMode(context) ? const Color(0xff182229) : Colors.white,
borderWidth: 1,
elevation: 2, elevation: 2,
margin: const BubbleEdges.only(bottom: 20, top: 10), margin: const BubbleEdges.only(bottom: 20, top: 10),
alignment: Alignment.center, alignment: Alignment.center,
@@ -35,7 +34,6 @@ class ChatBubbleStyles {
return BubbleStyle( return BubbleStyle(
nip: BubbleNip.leftTop, nip: BubbleNip.leftTop,
color: seamless ? Colors.transparent : color, color: seamless ? Colors.transparent : color,
borderWidth: seamless ? 0 : 1,
elevation: seamless ? 0 : 1, elevation: seamless ? 0 : 1,
margin: const BubbleEdges.only(bottom: 10, left: 10, right: 50), margin: const BubbleEdges.only(bottom: 10, left: 10, right: 50),
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
@@ -47,7 +45,6 @@ class ChatBubbleStyles {
return BubbleStyle( return BubbleStyle(
nip: BubbleNip.rightBottom, nip: BubbleNip.rightBottom,
color: seamless ? Colors.transparent : color, color: seamless ? Colors.transparent : color,
borderWidth: seamless ? 0 : 1,
elevation: seamless ? 0 : 1, elevation: seamless ? 0 : 1,
margin: const BubbleEdges.only(bottom: 10, right: 10, left: 50), margin: const BubbleEdges.only(bottom: 10, right: 10, left: 50),
alignment: Alignment.topRight, alignment: Alignment.topRight,
@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart'; import 'package:jiffy/jiffy.dart';
@@ -18,8 +19,9 @@ class WebuntisLessonSheet {
final state = bloc.state.data; final state = bloc.state.data;
if (state == null) return; if (state == null) return;
final subject = _resolveSubject(state, lesson); final headerSubject = _resolveSubject(state, lesson.su.firstOrNull?.id);
final room = _resolveRoom(state, lesson); final headerTitle = _firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']);
final headerLongName = headerSubject.longName.isNotEmpty && headerSubject.longName != headerTitle ? headerSubject.longName : '';
showAppointmentBottomSheet( showAppointmentBottomSheet(
context, context,
@@ -28,12 +30,12 @@ class WebuntisLessonSheet {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
'${_codePrefix(lesson.code)}${subject.alternateName}', '${_codePrefix(lesson.code)}$headerTitle',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(fontSize: 25), style: const TextStyle(fontSize: 25),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
Text(subject.longName), if (headerLongName.isNotEmpty) Text(headerLongName),
Text( Text(
'${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - ' '${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - '
'${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}', '${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}',
@@ -42,47 +44,193 @@ class WebuntisLessonSheet {
], ],
), ),
), ),
body: (_) => SliverChildListDelegate([ body: (_) => SliverChildListDelegate(<Widget>[
const Divider(), const Divider(),
ListTile( ListTile(
leading: const Icon(Icons.notifications_active), leading: const Icon(Icons.notifications_active),
title: Text('Status: ${lesson.code != null ? "Geändert" : "Regulär"}'), title: Text('Status: ${_statusLabel(lesson.code)}'),
), ),
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(),
),
_roomTile(context, state, lesson),
_teacherTile(context, lesson),
if ((lesson.activityType ?? '').trim().isNotEmpty)
ListTile( ListTile(
leading: const Icon(Icons.room), leading: const Icon(Icons.abc),
title: Text('Raum: ${room.name} (${room.longName})'), title: Text('Typ: ${lesson.activityType}'),
trailing: IconButton( ),
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), icon: const Icon(Icons.house_outlined),
onPressed: () => AppRoutes.openRoomplan(context), onPressed: () => AppRoutes.openRoomplan(context),
), );
),
ListTile( if (lesson.ro.isEmpty) {
leading: const Icon(Icons.person), return ListTile(
title: lesson.te.isNotEmpty leading: const Icon(Icons.room),
? Text( title: const Text('Raum: ?'),
'Lehrkraft: ${lesson.te[0].name}' trailing: trailing,
'${lesson.te[0].longname.isNotEmpty ? " (${lesson.te[0].longname})" : ""}', );
) }
: const Text('?'),
trailing: Visibility( 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, visible: !kReleaseMode,
child: IconButton( child: IconButton(
icon: const Icon(Icons.textsms_outlined), icon: const Icon(Icons.textsms_outlined),
onPressed: () => UnimplementedDialog.show(context), 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()),
]),
); );
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<String> 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<Widget>(Text.new).toList(),
),
trailing: trailing,
);
}
static List<Widget> _optionalTextTiles(GetTimetableResponseObject lesson) {
return <Widget?>[
_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<Widget>().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 = <String>[
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<String> 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) { static String _codePrefix(String? code) {
@@ -91,19 +239,15 @@ class WebuntisLessonSheet {
return code ?? ''; return code ?? '';
} }
static GetSubjectsResponseObject _resolveSubject(TimetableState state, GetTimetableResponseObject lesson) { static GetSubjectsResponseObject _resolveSubject(TimetableState state, int? id) {
try { final fallback = GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true);
return state.subjects!.result.firstWhere((s) => s.id == lesson.su[0].id); if (id == null) return fallback;
} catch (_) { return state.subjects?.result.firstWhereOrNull((s) => s.id == id) ?? fallback;
return GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true);
}
} }
static GetRoomsResponseObject _resolveRoom(TimetableState state, GetTimetableResponseObject lesson) { static GetRoomsResponseObject _resolveRoom(TimetableState state, int? id) {
try { final fallback = GetRoomsResponseObject(0, '?', 'Unbekannt', true, '');
return state.rooms!.result.firstWhere((r) => r.id == lesson.ro[0].id); if (id == null) return fallback;
} catch (_) { return state.rooms?.result.firstWhereOrNull((r) => r.id == id) ?? fallback;
return GetRoomsResponseObject(0, '?', 'Unbekannt', true, '?');
}
} }
} }