refactored lesson details, centralized logout logic, and added resume re-fetch
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 101 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+17
@@ -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;
|
||||||
|
|
||||||
|
|||||||
+1
@@ -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> {}
|
||||||
|
|||||||
@@ -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,68 +44,210 @@ 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)}'),
|
||||||
),
|
),
|
||||||
ListTile(
|
if (lesson.su.length > 1)
|
||||||
leading: const Icon(Icons.room),
|
_listTile(
|
||||||
title: Text('Raum: ${room.name} (${room.longName})'),
|
icon: Icons.book_outlined,
|
||||||
trailing: IconButton(
|
label: 'Fächer',
|
||||||
icon: const Icon(Icons.house_outlined),
|
entries: lesson.su.map((s) {
|
||||||
onPressed: () => AppRoutes.openRoomplan(context),
|
final resolved = _resolveSubject(state, s.id);
|
||||||
|
return _formatLine(
|
||||||
|
_firstNonEmpty([resolved.name, s.name, '?']),
|
||||||
|
longname: _firstNonEmpty([resolved.longName, s.longname, '']),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
_roomTile(context, state, lesson),
|
||||||
ListTile(
|
_teacherTile(context, lesson),
|
||||||
leading: const Icon(Icons.person),
|
if ((lesson.activityType ?? '').trim().isNotEmpty)
|
||||||
title: lesson.te.isNotEmpty
|
ListTile(
|
||||||
? Text(
|
leading: const Icon(Icons.abc),
|
||||||
'Lehrkraft: ${lesson.te[0].name}'
|
title: Text('Typ: ${lesson.activityType}'),
|
||||||
'${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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
if (lesson.kl.isNotEmpty)
|
||||||
ListTile(
|
_listTile(
|
||||||
leading: const Icon(Icons.abc),
|
icon: Icons.people,
|
||||||
title: Text('Typ: ${lesson.activityType}'),
|
label: lesson.kl.length == 1 ? 'Klasse' : 'Klassen',
|
||||||
),
|
entries: lesson.kl
|
||||||
ListTile(
|
.map((k) => _formatLine(
|
||||||
leading: const Icon(Icons.people),
|
k.name.isNotEmpty ? k.name : '?',
|
||||||
title: Text('Klasse(n): ${lesson.kl.map((e) => e.name).join(", ")}'),
|
longname: k.longname,
|
||||||
),
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
..._optionalTextTiles(lesson),
|
||||||
DebugTile(context).jsonData(lesson.toJson()),
|
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<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) {
|
||||||
if (code == 'cancelled') return 'Entfällt: ';
|
if (code == 'cancelled') return 'Entfällt: ';
|
||||||
if (code == 'irregular') return 'Änderung: ';
|
if (code == 'irregular') return 'Änderung: ';
|
||||||
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, '?');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user