From 95ef29fb09439bfa4885024a5a3acf3cfe6f1661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 6 May 2026 22:37:41 +0200 Subject: [PATCH] implemented dynamic module settings and configurable bottom bar, added all-day event support to timetable, and overhauled marianum dates UI with month grouping and search --- lib/app.dart | 116 ++++-- lib/routing/app_routes.dart | 5 + .../loadable_hydrated_bloc.dart | 5 + lib/state/app/modules/app_modules.dart | 38 +- lib/storage/modules_settings.dart | 6 +- lib/storage/modules_settings.g.dart | 4 + lib/storage/timetable_settings.dart | 2 +- .../marianum_dates/marianum_dates_view.dart | 371 +++++++++++++---- .../marianum_dates/search_marianum_dates.dart | 51 +++ lib/view/pages/overhang.dart | 71 +--- .../pages/settings/data/default_settings.dart | 4 +- .../pages/settings/modules_settings_page.dart | 116 ++++++ .../settings/sections/modules_section.dart | 16 + lib/view/pages/settings/settings.dart | 3 + .../custom_event_edit_dialog.dart | 103 +++-- .../data/timetable_appointment_factory.dart | 50 ++- .../details/delete_custom_event.dart | 5 +- lib/view/pages/timetable/timetable.dart | 15 +- .../widgets/custom_workweek_calendar.dart | 386 ++++++++++++++++-- 19 files changed, 1114 insertions(+), 253 deletions(-) create mode 100644 lib/view/pages/marianum_dates/search_marianum_dates.dart create mode 100644 lib/view/pages/settings/modules_settings_page.dart create mode 100644 lib/view/pages/settings/sections/modules_section.dart diff --git a/lib/app.dart b/lib/app.dart index 9f549c9..b33a5e5 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -18,6 +18,7 @@ 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 'storage/settings.dart' as model; import 'utils/debouncer.dart'; import 'view/pages/overhang.dart'; import 'widget/breaker/breaker.dart'; @@ -32,6 +33,15 @@ class App extends StatefulWidget { class _AppState extends State with WidgetsBindingObserver { late Timer _refetchChats; late Timer _updateTimings; + // Tracked via the bottom-nav controller's listener so it always reflects the + // user's actual position, even between rapid setting emits where the + // controller hasn't caught up to a scheduled jump yet. + int _knownTotalTabs = 1; + bool _userOnLastTab = false; + + void _onTabControllerChanged() { + _userOnLastTab = Main.bottomNavigator.index == _knownTotalTabs - 1; + } @override void didChangeAppLifecycleState(AppLifecycleState state) { @@ -49,6 +59,7 @@ class _AppState extends State with WidgetsBindingObserver { void initState() { super.initState(); Main.bottomNavigator = PersistentTabController(initialIndex: 0); + Main.bottomNavigator.addListener(_onTabControllerChanged); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -102,38 +113,89 @@ class _AppState extends State with WidgetsBindingObserver { void dispose() { _refetchChats.cancel(); _updateTimings.cancel(); + Main.bottomNavigator.removeListener(_onTabControllerChanged); 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: true, - 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', + Widget build(BuildContext context) => BlocBuilder( + builder: (context, _) { + final bottomBarModules = AppModule.getBottomBarModules(context); + final totalTabs = bottomBarModules.length + 1; + final currentIndex = Main.bottomNavigator.index; + + // The bottom-bar layout is identified by the ordered list of module + // names plus the trailing 'more' slot. Whenever this layout changes + // — slot count, reordering, or hiding a module — we recreate the + // entire PersistentTabView via the [layoutKey] below. The package + // caches per-tab navigator state by index in `_navigatorKeys`, and + // its internal `alignLength` only ever appends or trims at the end. + // So when the module sitting at e.g. index 3 changes, the navigator + // at that index still serves the old screen's route stack and the + // user sees stale content. Re-mounting clears those stacks; the + // trade-off (losing in-tab pushed routes on a settings change) is + // acceptable since the user explicitly re-shaped the bar. + final layoutKey = ValueKey('${bottomBarModules.map((m) => m.module.name).join('|')}|more'); + + if (totalTabs != _knownTotalTabs) { + var targetIndex = currentIndex; + if (_userOnLastTab) { + targetIndex = totalTabs - 1; + } else if (currentIndex >= totalTabs) { + targetIndex = totalTabs - 1; + } + // Re-mounting PTV with a new key constructs fresh internals from + // its controller's current index. If the controller still points + // past the new tab list, Style6BottomNavBar (and others) crash on + // out-of-range access during initState. Replace the controller + // atomically with one initialised at the safe target index so the + // new PTV mounts cleanly. + if (targetIndex != currentIndex) { + Main.bottomNavigator.removeListener(_onTabControllerChanged); + Main.bottomNavigator = PersistentTabController(initialIndex: targetIndex); + Main.bottomNavigator.addListener(_onTabControllerChanged); + _userOnLastTab = targetIndex == totalTabs - 1; + } + } + + _knownTotalTabs = totalTabs; + + return PersistentTabView( + key: layoutKey, + controller: Main.bottomNavigator, + navBarOverlap: const NavBarOverlap.none(), + backgroundColor: Theme.of(context).colorScheme.primary, + handleAndroidBackButtonPress: true, + screenTransitionAnimation: const ScreenTransitionAnimation( + curve: Curves.easeOutQuad, + duration: Duration(milliseconds: 200), ), - ), - ], - navBarBuilder: (config) => Style6BottomNavBar( - navBarConfig: config, - navBarDecoration: NavBarDecoration( - border: const Border(top: BorderSide(width: 1, color: Colors.grey)), - color: Theme.of(context).colorScheme.surface, - ), - ), + tabs: [ + ...bottomBarModules.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( + // Style6BottomNavBar builds its internal animation controller list + // in initState and never grows it on didUpdateWidget. Keying by the + // item count forces a fresh State whenever the slot count changes, + // which avoids a RangeError when more tabs slide in. + key: ValueKey(config.items.length), + navBarConfig: config, + navBarDecoration: NavBarDecoration( + border: const Border(top: BorderSide(width: 1, color: Colors.grey)), + color: Theme.of(context).colorScheme.surface, + ), + ), + ); + }, ); } diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 082d5ac..644b5f1 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -15,6 +15,7 @@ import '../view/pages/marianum_message/marianum_message_view.dart'; import '../view/pages/more/feedback/feedback_dialog.dart'; import '../view/pages/more/roomplan/roomplan.dart'; import '../view/pages/more/share/qr_share_view.dart'; +import '../view/pages/settings/modules_settings_page.dart'; import '../view/pages/settings/settings.dart'; import '../view/pages/talk/chat_view.dart'; import '../view/pages/talk/details/message_reactions.dart'; @@ -78,6 +79,10 @@ class AppRoutes { pushScreen(context, withNavBar: false, screen: const Settings()); } + static void openModulesSettings(BuildContext context) { + pushScreen(context, withNavBar: false, screen: const ModulesSettingsPage()); + } + static void openFeedback(BuildContext context) { pushScreen(context, withNavBar: false, screen: const FeedbackDialog()); } 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 74e9f00..ff5aaa3 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 @@ -95,6 +95,11 @@ abstract class LoadableHydratedBloc< gatherData().catchError( (e) { log('Error while fetching ${TState.toString()}: ${e.toString()}'); + // The bloc may have been closed before this async error landed (e.g. + // when its scoping widget tree was disposed mid-fetch). Adding to a + // closed bloc throws "Cannot add new events after calling close", + // so swallow that case quietly. + if (isClosed) return; add(Error(LoadingError( message: errorToUserMessage(e), technicalDetails: errorToTechnicalDetails(e), diff --git a/lib/state/app/modules/app_modules.dart b/lib/state/app/modules/app_modules.dart index 1d960f0..07b64bf 100644 --- a/lib/state/app/modules/app_modules.dart +++ b/lib/state/app/modules/app_modules.dart @@ -113,8 +113,42 @@ class AppModule { return { for (var element in settings.val().modulesSettings.moduleOrder.where((element) => available.containsKey(element))) element : available[element]! }; } - static List getBottomBarModules(BuildContext context) => modules(context).values.toList().getRange(0, 3).toList(); - static List getOverhangModules(BuildContext context) => modules(context).values.skip(3).toList(); + static const int minBottomBarSlots = 3; + static const int maxBottomBarSlots = 5; + + static int resolveBottomBarSlotCount(BuildContext context) { + final settings = context.read().val().modulesSettings; + final available = modules(context).length; + + int desired; + if (settings.autoFillBottomBar) { + final width = MediaQuery.of(context).size.width; + if (width >= 840) { + desired = 5; + } else if (width >= 600) { + desired = 4; + } else { + desired = 3; + } + } else { + desired = settings.fixedBottomBarSlots; + } + + desired = desired.clamp(minBottomBarSlots, maxBottomBarSlots); + return desired.clamp(0, available); + } + + static List getBottomBarModules(BuildContext context) { + final all = modules(context).values.toList(); + final slots = resolveBottomBarSlotCount(context); + return all.take(slots).toList(); + } + + static List getOverhangModules(BuildContext context) { + final all = modules(context).values.toList(); + final slots = resolveBottomBarSlotCount(context); + return all.skip(slots).toList(); + } Widget toListTile(BuildContext context, {Key? key, bool isReorder = false, Function()? onVisibleChange, bool isVisible = true}) => ListTile( key: key, diff --git a/lib/storage/modules_settings.dart b/lib/storage/modules_settings.dart index 14edc66..117f354 100644 --- a/lib/storage/modules_settings.dart +++ b/lib/storage/modules_settings.dart @@ -8,10 +8,14 @@ part 'modules_settings.g.dart'; class ModulesSettings { List moduleOrder; List hiddenModules; + bool autoFillBottomBar; + int fixedBottomBarSlots; ModulesSettings({ required this.moduleOrder, - required this.hiddenModules + required this.hiddenModules, + this.autoFillBottomBar = true, + this.fixedBottomBarSlots = 3, }); factory ModulesSettings.fromJson(Map json) => _$ModulesSettingsFromJson(json); diff --git a/lib/storage/modules_settings.g.dart b/lib/storage/modules_settings.g.dart index fd51b41..e97774b 100644 --- a/lib/storage/modules_settings.g.dart +++ b/lib/storage/modules_settings.g.dart @@ -14,6 +14,8 @@ ModulesSettings _$ModulesSettingsFromJson(Map json) => hiddenModules: (json['hiddenModules'] as List) .map((e) => $enumDecode(_$ModulesEnumMap, e)) .toList(), + autoFillBottomBar: json['autoFillBottomBar'] as bool? ?? true, + fixedBottomBarSlots: (json['fixedBottomBarSlots'] as num?)?.toInt() ?? 3, ); Map _$ModulesSettingsToJson( @@ -23,6 +25,8 @@ Map _$ModulesSettingsToJson( 'hiddenModules': instance.hiddenModules .map((e) => _$ModulesEnumMap[e]!) .toList(), + 'autoFillBottomBar': instance.autoFillBottomBar, + 'fixedBottomBarSlots': instance.fixedBottomBarSlots, }; const _$ModulesEnumMap = { diff --git a/lib/storage/timetable_settings.dart b/lib/storage/timetable_settings.dart index c4db103..75faf6a 100644 --- a/lib/storage/timetable_settings.dart +++ b/lib/storage/timetable_settings.dart @@ -11,7 +11,7 @@ class TimetableSettings { TimetableSettings({ required this.connectDoubleLessons, - required this.timetableNameMode + required this.timetableNameMode, }); factory TimetableSettings.fromJson(Map json) => _$TimetableSettingsFromJson(json); diff --git a/lib/view/pages/marianum_dates/marianum_dates_view.dart b/lib/view/pages/marianum_dates/marianum_dates_view.dart index 74e1a84..1805539 100644 --- a/lib/view/pages/marianum_dates/marianum_dates_view.dart +++ b/lib/view/pages/marianum_dates/marianum_dates_view.dart @@ -10,95 +10,287 @@ import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart import '../../../widget/animated_time.dart'; import '../../../widget/centered_leading.dart'; import '../../../widget/debug/debug_tile.dart'; -import '../../../widget/list_view_util.dart'; +import '../../../widget/placeholder_view.dart'; import '../timetable/custom_events/custom_event_edit_dialog.dart'; +import 'search_marianum_dates.dart'; class MarianumDatesView extends StatelessWidget { const MarianumDatesView({super.key}); - @override - Widget build(BuildContext context) => BlocModule>( - create: (context) => MarianumDatesBloc(), - autoRebuild: true, - child: (context, bloc, state) => Scaffold( - appBar: AppBar( - title: const Text('Marianum Termine'), - actions: [ - PopupMenuButton( - initialValue: bloc.showPastEvents(), - icon: const Icon(Icons.history), - itemBuilder: (context) => [true, false].map((e) => PopupMenuItem( - value: e, - enabled: e != bloc.showPastEvents(), - child: Row( - children: [ - Icon(e ? Icons.history_outlined : Icons.history_toggle_off_outlined, color: Theme.of(context).colorScheme.onSurface), - const SizedBox(width: 15), - Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen'), - ], - ), - )).toList(), - onSelected: (e) => bloc.add(SetPastEventsVisible(e)), - ), - ], - ), - body: LoadableStateConsumer( - child: (state, loading) => ListViewUtil.fromList(bloc.getEvents(), (event) => _MarianumDateTile(event: event)), - ), - ), - ); -} - -class _MarianumDateTile extends StatelessWidget { - final MarianumDate event; - const _MarianumDateTile({required this.event}); - - String _formatSubtitle() { - final start = Jiffy.parseFromDateTime(event.start); - final end = Jiffy.parseFromDateTime(event.end); - - if (event.isAllDay) { - // iCal end is exclusive for multi-day all-day events. The feed sets - // DTSTART == DTEND for single-day all-day events, so only subtract a - // day when end actually advances past start. - final inclusiveEnd = event.end.isAfter(event.start) ? end.subtract(days: 1) : end; - final sameAllDay = start.format(pattern: 'yyyy-MM-dd') == inclusiveEnd.format(pattern: 'yyyy-MM-dd'); - return sameAllDay - ? '${start.format(pattern: 'dd.MM.yyyy')} · Ganztägig' - : '${start.format(pattern: 'dd.MM.yyyy')} – ${inclusiveEnd.format(pattern: 'dd.MM.yyyy')} · Ganztägig'; + /// Groups events by `yyyy-MM` (chronological). Uses the event's start date. + static List<_MonthGroup> _groupByMonth(List events) { + final byMonth = >{}; + for (final e in events) { + final key = '${e.start.year.toString().padLeft(4, '0')}-${e.start.month.toString().padLeft(2, '0')}'; + byMonth.putIfAbsent(key, () => []).add(e); } - - final sameDay = start.format(pattern: 'yyyy-MM-dd') == end.format(pattern: 'yyyy-MM-dd'); - if (sameDay) { - if (event.start == event.end) { - return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')}'; - } - return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')} – ${end.format(pattern: 'HH:mm')}'; - } - return '${start.format(pattern: 'dd.MM.yyyy HH:mm')} – ${end.format(pattern: 'dd.MM.yyyy HH:mm')}'; + final keys = byMonth.keys.toList()..sort(); + return keys.map((key) { + final first = byMonth[key]!.first.start; + final label = Jiffy.parseFromDateTime(first).format(pattern: 'MMMM yyyy').toUpperCase(); + return _MonthGroup(key: key, label: label, events: byMonth[key]!); + }).toList(); } @override - Widget build(BuildContext context) => ListTile( - leading: const CenteredLeading(Icon(Icons.event)), - title: Text(event.title.isEmpty ? '(ohne Titel)' : event.title), - subtitle: Text(_formatSubtitle()), - onTap: () => _showDetails(context), - trailing: IconButton( - icon: const Icon(Icons.add_circle_outline), - tooltip: 'In Stundenplan übernehmen', - onPressed: () => showDialog( - context: context, - builder: (_) => CustomEventEditDialog( - initialTitle: event.title, - initialDescription: event.description, - initialStart: event.start, - initialEnd: event.end, + Widget build(BuildContext context) => BlocModule>( + create: (context) => MarianumDatesBloc(), + autoRebuild: true, + child: (context, bloc, state) => Scaffold( + appBar: AppBar( + title: const Text('Marianum Termine'), + actions: [ + PopupMenuButton( + initialValue: bloc.showPastEvents(), + icon: const Icon(Icons.history), + itemBuilder: (context) => [true, false] + .map((e) => PopupMenuItem( + value: e, + enabled: e != bloc.showPastEvents(), + child: Row( + children: [ + Icon(e ? Icons.history_outlined : Icons.history_toggle_off_outlined, + color: Theme.of(context).colorScheme.onSurface), + const SizedBox(width: 15), + Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen'), + ], + ), + )) + .toList(), + onSelected: (e) => bloc.add(SetPastEventsVisible(e)), + ), + IconButton( + icon: const Icon(Icons.search), + onPressed: () { + final events = bloc.getEvents() ?? const []; + showSearch(context: context, delegate: SearchMarianumDates(events)); + }, + ), + ], + ), + body: LoadableStateConsumer( + child: (state, loading) { + final events = bloc.getEvents() ?? const []; + final groups = _groupByMonth(events); + + if (groups.isEmpty) { + return const PlaceholderView( + icon: Icons.event_busy_outlined, + text: 'Keine Termine', + ); + } + + return CustomScrollView( + slivers: [ + for (final group in groups) + SliverMainAxisGroup( + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: MonthHeaderDelegate(label: group.label), + ), + SliverList.builder( + itemCount: group.events.length, + itemBuilder: (_, i) => MarianumDateRow(event: group.events[i]), + ), + ], + ), + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], + ); + }, + ), + ), + ); +} + +class _MonthGroup { + final String key; + final String label; + final List events; + _MonthGroup({required this.key, required this.label, required this.events}); +} + +class MonthHeaderDelegate extends SliverPersistentHeaderDelegate { + final String label; + MonthHeaderDelegate({required this.label}); + + static const double _height = 38; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + final theme = Theme.of(context); + return Container( + height: _height, + color: theme.colorScheme.surfaceContainer, + padding: const EdgeInsets.symmetric(horizontal: 16), + alignment: Alignment.centerLeft, + child: Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, ), - barrierDismissible: false, ), - ), - ); + ); + } + + @override + double get maxExtent => _height; + + @override + double get minExtent => _height; + + @override + bool shouldRebuild(covariant MonthHeaderDelegate oldDelegate) => oldDelegate.label != label; +} + +/// Composite icon: calendar with a small plus badge in the bottom-right. +/// Material's bundled icon set has no `calendar_add_on`, so we layer +/// `Icons.event_outlined` and `Icons.add` to get the same affordance. +class _CalendarPlusIcon extends StatelessWidget { + final Color color; + const _CalendarPlusIcon({required this.color}); + + @override + Widget build(BuildContext context) => SizedBox( + width: 22, + height: 22, + child: Stack( + clipBehavior: Clip.none, + children: [ + Icon(Icons.event_outlined, size: 22, color: color), + Positioned( + right: -2, + bottom: -2, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(1), + child: Icon(Icons.add_circle, size: 12, color: color), + ), + ), + ], + ), + ); +} + +class MarianumDateRow extends StatelessWidget { + final MarianumDate event; + const MarianumDateRow({required this.event, super.key}); + + String _dayLabel() => event.start.day.toString().padLeft(2, '0'); + + String _monthYearLabel() => + '${event.start.month.toString().padLeft(2, '0')}.${event.start.year}'; + + String _trailingLabel() { + final start = Jiffy.parseFromDateTime(event.start); + final end = Jiffy.parseFromDateTime(event.end); + if (event.isAllDay) return 'Ganztägig'; + final sameDay = start.format(pattern: 'yyyy-MM-dd') == end.format(pattern: 'yyyy-MM-dd'); + if (sameDay) { + if (event.start == event.end) return start.format(pattern: 'HH:mm'); + return '${start.format(pattern: 'HH:mm')}–${end.format(pattern: 'HH:mm')}'; + } + return '${start.format(pattern: 'dd.MM. HH:mm')}–${end.format(pattern: 'dd.MM. HH:mm')}'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return InkWell( + onTap: () => _showDetails(context), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 4, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 44, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _dayLabel(), + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + height: 1.1, + ), + ), + Text( + _monthYearLabel(), + textAlign: TextAlign.center, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.visible, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 10, + height: 1.1, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + event.title.isEmpty ? '(ohne Titel)' : event.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface), + ), + if (event.description != null && event.description!.trim().isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + event.description!.trim(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Text( + _trailingLabel(), + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 4), + IconButton( + icon: _CalendarPlusIcon(color: theme.colorScheme.onSurfaceVariant), + tooltip: 'In Stundenplan übernehmen', + onPressed: () => showDialog( + context: context, + builder: (_) => CustomEventEditDialog( + initialTitle: event.title, + initialDescription: event.description, + initialStart: event.start, + initialEnd: event.end, + ), + barrierDismissible: false, + ), + ), + ], + ), + ), + ); + } void _showDetails(BuildContext context) { showDialog( @@ -108,7 +300,7 @@ class _MarianumDateTile extends StatelessWidget { children: [ ListTile( leading: const CenteredLeading(Icon(Icons.date_range_outlined)), - title: Text(_formatSubtitle()), + title: Text(_formatLongRange()), ), if (event.description != null && event.description!.trim().isNotEmpty) ListTile( @@ -132,4 +324,25 @@ class _MarianumDateTile extends StatelessWidget { ), ); } + + String _formatLongRange() { + final start = Jiffy.parseFromDateTime(event.start); + final end = Jiffy.parseFromDateTime(event.end); + if (event.isAllDay) { + final inclusiveEnd = event.end.isAfter(event.start) ? end.subtract(days: 1) : end; + final sameAllDay = + start.format(pattern: 'yyyy-MM-dd') == inclusiveEnd.format(pattern: 'yyyy-MM-dd'); + return sameAllDay + ? '${start.format(pattern: 'dd.MM.yyyy')} · Ganztägig' + : '${start.format(pattern: 'dd.MM.yyyy')} – ${inclusiveEnd.format(pattern: 'dd.MM.yyyy')} · Ganztägig'; + } + final sameDay = start.format(pattern: 'yyyy-MM-dd') == end.format(pattern: 'yyyy-MM-dd'); + if (sameDay) { + if (event.start == event.end) { + return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')}'; + } + return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')} – ${end.format(pattern: 'HH:mm')}'; + } + return '${start.format(pattern: 'dd.MM.yyyy HH:mm')} – ${end.format(pattern: 'dd.MM.yyyy HH:mm')}'; + } } diff --git a/lib/view/pages/marianum_dates/search_marianum_dates.dart b/lib/view/pages/marianum_dates/search_marianum_dates.dart new file mode 100644 index 0000000..7dadb5a --- /dev/null +++ b/lib/view/pages/marianum_dates/search_marianum_dates.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; +import '../../../widget/placeholder_view.dart'; +import 'marianum_dates_view.dart'; + +class SearchMarianumDates extends SearchDelegate { + final List events; + + SearchMarianumDates(this.events); + + List _matches() { + if (query.trim().isEmpty) return events; + final q = query.trim().toLowerCase(); + return events.where((e) { + final title = e.title.toLowerCase(); + final desc = e.description?.toLowerCase() ?? ''; + return title.contains(q) || desc.contains(q); + }).toList(); + } + + @override + List? buildActions(BuildContext context) => [ + if (query.isNotEmpty) + IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)), + ]; + + @override + Widget? buildLeading(BuildContext context) => IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => close(context, null), + ); + + @override + Widget buildResults(BuildContext context) { + final matches = _matches(); + if (matches.isEmpty) { + return const PlaceholderView( + icon: Icons.search_off_outlined, + text: 'Keine Treffer', + ); + } + return ListView.builder( + itemCount: matches.length, + itemBuilder: (_, i) => MarianumDateRow(event: matches[i]), + ); + } + + @override + Widget buildSuggestions(BuildContext context) => buildResults(context); +} diff --git a/lib/view/pages/overhang.dart b/lib/view/pages/overhang.dart index 01b4c50..214d500 100644 --- a/lib/view/pages/overhang.dart +++ b/lib/view/pages/overhang.dart @@ -2,18 +2,14 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:in_app_review/in_app_review.dart'; import '../../extensions/render_not_null.dart'; import '../../routing/app_routes.dart'; import '../../state/app/modules/app_modules.dart'; -import '../../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../../storage/settings.dart' as model; import '../../widget/centered_leading.dart'; import '../../widget/info_dialog.dart'; import 'more/share/select_share_type_dialog.dart'; -import 'settings/data/default_settings.dart'; class Overhang extends StatefulWidget { const Overhang({super.key}); @@ -23,65 +19,16 @@ class Overhang extends StatefulWidget { } class _OverhangState extends State { - bool editMode = false; - @override - Widget build(BuildContext context) => BlocBuilder(builder: (context, _) { - final settings = context.read(); - return Scaffold( - appBar: AppBar( - title: const Text('Mehr'), - actions: [ - if(editMode) IconButton( - onPressed: settings.val().modulesSettings.toJson().toString() != DefaultSettings.get().modulesSettings.toJson().toString() - ? () => settings.val(write: true).modulesSettings = DefaultSettings.get().modulesSettings - : null, - icon: Icon(Icons.undo_outlined) - ), - IconButton(onPressed: () => setState(() => editMode = !editMode), icon: Icon(Icons.edit_note_outlined), color: editMode ? Theme.of(context).primaryColor : null), - IconButton(onPressed: editMode ? null : () => AppRoutes.openSettings(context), icon: const Icon(Icons.settings)), - ], - ), - body: editMode ? _sorting() : _overhang(), - ); - }); - - Widget _sorting() => BlocBuilder(builder: (context, _) { - final settings = context.read(); - void changeVisibility(Modules module) { - var hidden = settings.val(write: true).modulesSettings.hiddenModules; - if (hidden.contains(module)) { - hidden.remove(module); - } else if (hidden.length < 3) { - hidden.add(module); - } - } - - return ReorderableListView( - header: const Center( - heightFactor: 2, - child: Text('Halte und ziehe einen Eintrag, um ihn zu verschieben.\nEs können 3 Bereiche ausgeblendet werden.', textAlign: TextAlign.center) - ), - children: AppModule.modules(context, showFiltered: true) - .map((key, value) => MapEntry(key, value.toListTile( - context, - key: Key(key.name), - isReorder: true, - onVisibleChange: () => changeVisibility(key), - isVisible: !settings.val().modulesSettings.hiddenModules.contains(key) - ))) - .values - .toList(), - onReorder: (oldIndex, newIndex) { - if (newIndex > oldIndex) newIndex -= 1; - - var order = settings.val().modulesSettings.moduleOrder.toList(); - final movedModule = order.removeAt(oldIndex); - order.insert(newIndex, movedModule); - settings.val(write: true).modulesSettings.moduleOrder = order; - } - ); - }); + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Mehr'), + actions: [ + IconButton(onPressed: () => AppRoutes.openSettings(context), icon: const Icon(Icons.settings)), + ], + ), + body: _overhang(), + ); Widget _overhang() => ListView( children: [ diff --git a/lib/view/pages/settings/data/default_settings.dart b/lib/view/pages/settings/data/default_settings.dart index 63e060a..d835ee5 100644 --- a/lib/view/pages/settings/data/default_settings.dart +++ b/lib/view/pages/settings/data/default_settings.dart @@ -31,10 +31,12 @@ class DefaultSettings { Modules.marianumDates, ], hiddenModules: [], + autoFillBottomBar: true, + fixedBottomBarSlots: 3, ), timetableSettings: TimetableSettings( connectDoubleLessons: true, - timetableNameMode: TimetableNameMode.name + timetableNameMode: TimetableNameMode.name, ), talkSettings: TalkSettings( sortFavoritesToTop: true, diff --git a/lib/view/pages/settings/modules_settings_page.dart b/lib/view/pages/settings/modules_settings_page.dart new file mode 100644 index 0000000..c414d20 --- /dev/null +++ b/lib/view/pages/settings/modules_settings_page.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../state/app/modules/app_modules.dart'; +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../storage/settings.dart' as model; +import 'data/default_settings.dart'; + +/// Reorderable list with bottom-bar slot configuration on top. +/// +/// Used both inline in the "Mehr" edit mode and as the body of +/// [ModulesSettingsPage] from the main settings. +class ModuleSortBody extends StatelessWidget { + const ModuleSortBody({super.key}); + + @override + Widget build(BuildContext context) => BlocBuilder(builder: (context, _) { + final settings = context.read(); + final modulesSettings = settings.val().modulesSettings; + + void changeVisibility(Modules module) { + var hidden = settings.val(write: true).modulesSettings.hiddenModules; + if (hidden.contains(module)) { + hidden.remove(module); + } else if (hidden.length < 3) { + hidden.add(module); + } + } + + return ReorderableListView( + header: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Text( + 'Halte und ziehe einen Eintrag, um ihn zu verschieben.\nEs können 3 Bereiche ausgeblendet werden.', + textAlign: TextAlign.center, + ), + ), + SwitchListTile( + title: const Text('Modulleiste automatisch füllen'), + subtitle: const Text('Auf größeren Bildschirmen werden mehr Module direkt angezeigt'), + value: modulesSettings.autoFillBottomBar, + onChanged: (value) => settings.val(write: true).modulesSettings.autoFillBottomBar = value, + ), + if (!modulesSettings.autoFillBottomBar) + ListTile( + title: const Text('Anzahl Slots in der Modulleiste'), + subtitle: Text('${modulesSettings.fixedBottomBarSlots} Module (zzgl. „Mehr")'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.remove_circle_outline), + onPressed: modulesSettings.fixedBottomBarSlots > AppModule.minBottomBarSlots + ? () => settings.val(write: true).modulesSettings.fixedBottomBarSlots -= 1 + : null, + ), + Text('${modulesSettings.fixedBottomBarSlots}'), + IconButton( + icon: const Icon(Icons.add_circle_outline), + onPressed: modulesSettings.fixedBottomBarSlots < AppModule.maxBottomBarSlots + ? () => settings.val(write: true).modulesSettings.fixedBottomBarSlots += 1 + : null, + ), + ], + ), + ), + const Divider(), + ], + ), + children: AppModule.modules(context, showFiltered: true) + .map((key, value) => MapEntry(key, value.toListTile( + context, + key: Key(key.name), + isReorder: true, + onVisibleChange: () => changeVisibility(key), + isVisible: !settings.val().modulesSettings.hiddenModules.contains(key), + ))) + .values + .toList(), + onReorder: (oldIndex, newIndex) { + if (newIndex > oldIndex) newIndex -= 1; + + var order = settings.val().modulesSettings.moduleOrder.toList(); + final movedModule = order.removeAt(oldIndex); + order.insert(newIndex, movedModule); + settings.val(write: true).modulesSettings.moduleOrder = order; + }, + ); + }); +} + +class ModulesSettingsPage extends StatelessWidget { + const ModulesSettingsPage({super.key}); + + @override + Widget build(BuildContext context) => BlocBuilder(builder: (context, _) { + final settings = context.read(); + final isModified = settings.val().modulesSettings.toJson().toString() != DefaultSettings.get().modulesSettings.toJson().toString(); + return Scaffold( + appBar: AppBar( + title: const Text('Module'), + actions: [ + IconButton( + tooltip: 'Auf Standard zurücksetzen', + onPressed: isModified ? () => settings.val(write: true).modulesSettings = DefaultSettings.get().modulesSettings : null, + icon: const Icon(Icons.undo_outlined), + ), + ], + ), + body: const ModuleSortBody(), + ); + }); +} diff --git a/lib/view/pages/settings/sections/modules_section.dart b/lib/view/pages/settings/sections/modules_section.dart new file mode 100644 index 0000000..b3fe499 --- /dev/null +++ b/lib/view/pages/settings/sections/modules_section.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +import '../../../../routing/app_routes.dart'; + +class ModulesSection extends StatelessWidget { + const ModulesSection({super.key}); + + @override + Widget build(BuildContext context) => ListTile( + leading: const Icon(Icons.apps_outlined), + title: const Text('Module'), + subtitle: const Text('Reihenfolge, Sichtbarkeit und Modulleiste anpassen'), + trailing: const Icon(Icons.arrow_right), + onTap: () => AppRoutes.openModulesSettings(context), + ); +} diff --git a/lib/view/pages/settings/settings.dart b/lib/view/pages/settings/settings.dart index ebb45ea..040d6eb 100644 --- a/lib/view/pages/settings/settings.dart +++ b/lib/view/pages/settings/settings.dart @@ -4,6 +4,7 @@ import 'sections/about_section.dart'; import 'sections/account_section.dart'; import 'sections/appearance_section.dart'; import 'sections/files_section.dart'; +import 'sections/modules_section.dart'; import 'sections/talk_section.dart'; import 'sections/timetable_section.dart'; @@ -19,6 +20,8 @@ class Settings extends StatelessWidget { Divider(), AppearanceSection(), Divider(), + ModulesSection(), + Divider(), TimetableSection(), Divider(), TalkSection(), 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 index a85b652..db1d06b 100644 --- a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart +++ b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart @@ -9,8 +9,8 @@ import 'package:time_range_picker/time_range_picker.dart'; import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../extensions/date_time.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../../widget/async_action_button.dart'; import '../../../../widget/focus_behaviour.dart'; -import '../../../../widget/info_dialog.dart'; import 'custom_event_colors.dart'; class CustomEventEditDialog extends StatefulWidget { @@ -34,15 +34,18 @@ class CustomEventEditDialog extends StatefulWidget { } class _CustomEventEditDialogState extends State { - // Visible window of the timetable / time picker (matches `_pickTimeRange`'s - // `disabledTime`). Pre-filled times from outside this window are clamped in. + // Selectable window for non-all-day events. Times outside this range are + // clamped in. For events outside school hours, use the all-day toggle. static const TimeOfDay _windowStart = TimeOfDay(hour: 8, minute: 0); static const TimeOfDay _windowEnd = TimeOfDay(hour: 16, minute: 30); + static const TimeOfDay _defaultStart = _windowStart; + static const TimeOfDay _defaultEnd = TimeOfDay(hour: 9, minute: 30); static const int _minDurationMinutes = 15; late DateTime _date = widget.existingEvent?.startDate ?? widget.initialStart ?? DateTime.now(); late TimeOfDay _startTime; late TimeOfDay _endTime; + late bool _isAllDay; late final TextEditingController _name = TextEditingController( text: widget.existingEvent?.title ?? widget.initialTitle, ); @@ -61,12 +64,22 @@ class _CustomEventEditDialogState extends State { void initState() { super.initState(); if (_isEditing) { - _startTime = widget.existingEvent!.startDate.toTimeOfDay(); - _endTime = widget.existingEvent!.endDate.toTimeOfDay(); + final s = widget.existingEvent!.startDate; + final e = widget.existingEvent!.endDate; + _isAllDay = isAllDayConvention(s, e); + if (_isAllDay) { + _startTime = _defaultStart; + _endTime = _defaultEnd; + } else { + final clamped = _clampToVisibleWindow(s.toTimeOfDay(), e.toTimeOfDay()); + _startTime = clamped.$1; + _endTime = clamped.$2; + } return; } - final rawStart = widget.initialStart?.toTimeOfDay() ?? _windowStart; - final rawEnd = widget.initialEnd?.toTimeOfDay() ?? const TimeOfDay(hour: 9, minute: 30); + _isAllDay = false; + final rawStart = widget.initialStart?.toTimeOfDay() ?? _defaultStart; + final rawEnd = widget.initialEnd?.toTimeOfDay() ?? _defaultEnd; final clamped = _clampToVisibleWindow(rawStart, rawEnd); _startTime = clamped.$1; _endTime = clamped.$2; @@ -88,17 +101,38 @@ class _CustomEventEditDialogState extends State { return (fromMin(start), fromMin(end)); } - bool _validate() => _name.text.isNotEmpty; + /// All-day convention shared with [TimetableAppointmentFactory]: a custom + /// event is treated as all-day when its start and end both land on midnight + /// of the same day. We piggyback on this so we don't need a backend schema + /// change. + static bool isAllDayConvention(DateTime start, DateTime end) => + start.year == end.year && + start.month == end.month && + start.day == end.day && + start.hour == 0 && + start.minute == 0 && + start.second == 0 && + end.hour == 0 && + end.minute == 0 && + end.second == 0; - void _save() { - if (!_validate()) return; + Future _save() async { + if (_name.text.trim().isEmpty) { + throw Exception('Bitte einen Terminnamen eingeben.'); + } + + // All-day convention: store start and end as midnight of the chosen day. + // The factory recognises this on read. + final midnight = DateTime(_date.year, _date.month, _date.day); + final startDate = _isAllDay ? midnight : _date.withTime(_startTime); + final endDate = _isAllDay ? midnight : _date.withTime(_endTime); final edited = CustomTimetableEvent( id: widget.existingEvent?.id ?? '', title: _name.text, description: _description.text, - startDate: _date.withTime(_startTime), - endDate: _date.withTime(_endTime), + startDate: startDate, + endDate: endDate, color: _color.name, rrule: _rrule, createdAt: DateTime.now(), @@ -106,17 +140,11 @@ class _CustomEventEditDialogState extends State { ); 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(), copyable: true, title: 'Fehler'); - }); + if (_isEditing) { + await bloc.updateCustomEvent(widget.existingEvent!.id, edited); + } else { + await bloc.addCustomEvent(edited); + } } Future _pickDate() async { @@ -138,8 +166,8 @@ class _CustomEventEditDialogState extends State { start: _startTime, end: _endTime, disabledTime: TimeRange( - startTime: const TimeOfDay(hour: 16, minute: 30), - endTime: const TimeOfDay(hour: 8, minute: 0), + startTime: _windowEnd, + endTime: _windowStart, ), disabledColor: Colors.grey, paintingStyle: PaintingStyle.fill, @@ -147,7 +175,7 @@ class _CustomEventEditDialogState extends State { fromText: 'Beginnend', toText: 'Endend', strokeColor: Theme.of(context).colorScheme.secondary, - minDuration: const Duration(minutes: 15), + minDuration: Duration(minutes: _minDurationMinutes), selectedColor: Theme.of(context).primaryColor, ticks: 24, ); @@ -191,12 +219,19 @@ class _CustomEventEditDialogState extends State { 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, + SwitchListTile( + secondary: const Icon(Icons.today_outlined), + title: const Text('Ganztägig'), + value: _isAllDay, + onChanged: (v) => setState(() => _isAllDay = v), ), + if (!_isAllDay) + 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), @@ -246,8 +281,10 @@ class _CustomEventEditDialogState extends State { ), ), actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')), - TextButton(onPressed: _save, child: Text(_isEditing ? 'Speichern' : 'Erstellen')), + AsyncDialogAction( + confirmLabel: _isEditing ? 'Speichern' : 'Erstellen', + onConfirm: _save, + ), ], ); } diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index 985dfd7..258c359 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -68,27 +68,51 @@ class TimetableAppointmentFactory { } } - Appointment _customEventToAppointment(CustomTimetableEvent event) => Appointment( - id: CustomAppointment(event), - startTime: event.startDate, - endTime: event.endDate, - location: _collapseWhitespace(event.description), - subject: _collapseWhitespace(event.title) ?? event.title, - recurrenceRule: event.rrule, - color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name), - startTimeZone: '', - endTimeZone: '', - ); + Appointment _customEventToAppointment(CustomTimetableEvent event) { + final allDay = isCustomEventAllDay(event); + return Appointment( + id: CustomAppointment(event), + startTime: event.startDate, + endTime: allDay + ? DateTime(event.startDate.year, event.startDate.month, event.startDate.day, 23, 59) + : event.endDate, + isAllDay: allDay, + location: _collapseWhitespace(event.description), + subject: _collapseWhitespace(event.title) ?? event.title, + recurrenceRule: event.rrule, + color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name), + startTimeZone: '', + endTimeZone: '', + ); + } + + /// All-day convention: a `CustomTimetableEvent` is treated as all-day when + /// its `startDate` and `endDate` both land on midnight of the same day. + /// Keeps the backend schema unchanged — the editor stores all-day events as + /// `start == end == midnight(date)`. + static bool isCustomEventAllDay(CustomTimetableEvent event) { + final s = event.startDate; + final e = event.endDate; + return s.year == e.year && + s.month == e.month && + s.day == e.day && + s.hour == 0 && + s.minute == 0 && + s.second == 0 && + e.hour == 0 && + e.minute == 0 && + e.second == 0; + } String _subjectName(GetTimetableResponseObject lesson) { final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id); - if (subject == null) return 'Unbekannt'; + if (subject == null) return 'Event'; final name = switch (settings.timetableNameMode) { TimetableNameMode.name => subject.name, TimetableNameMode.longName => subject.longName, TimetableNameMode.alternateName => subject.alternateName, }; - return _collapseWhitespace(name) ?? 'Unbekannt'; + return _collapseWhitespace(name) ?? 'Event'; } String _locationLabel(GetTimetableResponseObject lesson) { diff --git a/lib/view/pages/timetable/details/delete_custom_event.dart b/lib/view/pages/timetable/details/delete_custom_event.dart index 7361a70..1ada219 100644 --- a/lib/view/pages/timetable/details/delete_custom_event.dart +++ b/lib/view/pages/timetable/details/delete_custom_event.dart @@ -14,8 +14,9 @@ Completer showDeleteCustomEventDialog(BuildContext context, CustomTimetabl 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(completer.completeError); + onConfirmAsync: () async { + await bloc.removeCustomEvent(event.id); + completer.complete(); }, ).asDialog(context); return completer; diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index cd119e6..ffced37 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -78,14 +78,27 @@ class _TimetableState extends State { return false; } + bool _isOnInitialWeek(TimetableState state) { + final target = _initialDisplayDate(); + final targetMonday = target.subtract(Duration(days: target.weekday - 1)); + final mondayOnly = DateTime(targetMonday.year, targetMonday.month, targetMonday.day); + return state.startDate == mondayOnly; + } + @override Widget build(BuildContext context) { final bloc = context.read(); + final loadableState = context.watch().state; + final innerState = loadableState.data; + final atToday = innerState != null && _isOnInitialWeek(innerState); return Scaffold( appBar: AppBar( title: const Text('Stunden & Vertretungsplan'), actions: [ - IconButton(icon: const Icon(Icons.home_outlined), onPressed: _jumpToToday), + IconButton( + icon: const Icon(Icons.home_outlined), + onPressed: atToday ? null : _jumpToToday, + ), PopupMenuButton<_CalendarAction>( icon: const Icon(Icons.edit_calendar_outlined), onSelected: _onAction, diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 08a1384..53c70ef 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -43,7 +43,7 @@ class CustomWorkWeekCalendar extends StatefulWidget { } class CustomWorkWeekCalendarState extends State { - static const double _rulerWidth = 50; + static const double _rulerWidth = 36; late PageController _pageController; late int _currentWeekIndex; @@ -128,6 +128,28 @@ class CustomWorkWeekCalendarState extends State { ), ), ), + ClipRect( + child: AnimatedSize( + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 280), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: _OutsideHoursStrip( + key: ValueKey(visibleWeekStart), + weekStart: visibleWeekStart, + appointments: widget.appointments, + rulerWidth: _rulerWidth, + onAppointmentTap: widget.onAppointmentTap, + isCrossedOut: widget.isCrossedOut, + ), + ), + ), + ), Container(height: 0.5, color: theme.dividerColor.withAlpha(110)), Expanded( child: LayoutBuilder( @@ -189,6 +211,271 @@ class CustomWorkWeekCalendarState extends State { } } +class _OutsideHoursStrip extends StatelessWidget { + static const int _maxVisibleChips = 2; + static const double _chipHeight = 22; + static const double _chipSpacing = 3; + static const double _verticalPadding = 3; + + final DateTime weekStart; + final List appointments; + final double rulerWidth; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + + const _OutsideHoursStrip({ + super.key, + required this.weekStart, + required this.appointments, + required this.rulerWidth, + required this.onAppointmentTap, + required this.isCrossedOut, + }); + + @override + Widget build(BuildContext context) { + final outside = _partitionAppointmentsForWeek(appointments, weekStart).outside; + if (outside.every((day) => day.isEmpty)) return const SizedBox.shrink(); + + final theme = Theme.of(context); + final maxChipsPerDay = outside + .map((day) => day.length > _maxVisibleChips ? _maxVisibleChips : day.length) + .fold(0, (m, c) => c > m ? c : m); + final stripHeight = _verticalPadding * 2 + + maxChipsPerDay * _chipHeight + + (maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * _chipSpacing : 0); + + return Container( + color: theme.colorScheme.surfaceContainerLowest, + padding: const EdgeInsets.symmetric(vertical: _verticalPadding), + child: SizedBox( + height: stripHeight - _verticalPadding * 2, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: rulerWidth), + for (var d = 0; d < 5; d++) + Expanded( + child: _OutsideDayColumn( + appointments: outside[d], + maxVisible: _maxVisibleChips, + chipHeight: _chipHeight, + chipSpacing: _chipSpacing, + onAppointmentTap: onAppointmentTap, + isCrossedOut: isCrossedOut, + ), + ), + ], + ), + ), + ); + } +} + +class _OutsideDayColumn extends StatelessWidget { + final List appointments; + final int maxVisible; + final double chipHeight; + final double chipSpacing; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + + const _OutsideDayColumn({ + required this.appointments, + required this.maxVisible, + required this.chipHeight, + required this.chipSpacing, + required this.onAppointmentTap, + required this.isCrossedOut, + }); + + void _showOverflow(BuildContext context, List hidden) { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetCtx) => SafeArea( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: hidden.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (_, i) { + final apt = hidden[i]; + return ListTile( + leading: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: apt.color, + borderRadius: BorderRadius.circular(3), + ), + ), + title: Text( + apt.subject, + style: isCrossedOut(apt) + ? const TextStyle(decoration: TextDecoration.lineThrough) + : null, + ), + subtitle: Text(_subtitleFor(apt)), + onTap: () { + Navigator.of(sheetCtx).pop(); + onAppointmentTap(apt); + }, + ); + }, + ), + ), + ); + } + + static String _subtitleFor(Appointment a) { + if (_isAllDayLike(a)) return 'Ganztägig'; + return '${_hm(a.startTime)}–${_hm(a.endTime)}'; + } + + static String _hm(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + + @override + Widget build(BuildContext context) { + if (appointments.isEmpty) return const SizedBox.shrink(); + final sorted = [...appointments] + ..sort((a, b) { + final aLike = _isAllDayLike(a); + final bLike = _isAllDayLike(b); + if (aLike && !bLike) return -1; + if (!aLike && bLike) return 1; + return a.startTime.compareTo(b.startTime); + }); + final visible = sorted.length <= maxVisible + ? sorted + : sorted.take(maxVisible - 1).toList(); + final overflow = + sorted.length <= maxVisible ? const [] : sorted.skip(maxVisible - 1).toList(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < visible.length; i++) ...[ + if (i > 0) SizedBox(height: chipSpacing), + SizedBox( + height: chipHeight, + child: _OutsideChip( + appointment: visible[i], + onTap: () => onAppointmentTap(visible[i]), + ), + ), + ], + if (overflow.isNotEmpty) ...[ + SizedBox(height: chipSpacing), + SizedBox( + height: chipHeight, + child: _OutsideOverflowChip( + count: overflow.length, + onTap: () => _showOverflow(context, overflow), + ), + ), + ], + ], + ), + ); + } +} + +class _OutsideChip extends StatelessWidget { + final Appointment appointment; + final VoidCallback onTap; + + const _OutsideChip({required this.appointment, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final allDay = _isAllDayLike(appointment); + final timeLabel = allDay + ? null + : '${appointment.startTime.hour.toString().padLeft(2, '0')}:${appointment.startTime.minute.toString().padLeft(2, '0')}'; + + return Material( + color: appointment.color.withAlpha(60), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(7)), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Text( + appointment.subject, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + if (timeLabel != null) ...[ + const SizedBox(width: 4), + Flexible( + child: Text( + timeLabel, + maxLines: 1, + overflow: TextOverflow.fade, + softWrap: false, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 10, + ), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _OutsideOverflowChip extends StatelessWidget { + final int count; + final VoidCallback onTap; + + const _OutsideOverflowChip({required this.count, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.secondaryContainer, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Center( + child: Text( + '+$count weitere', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } +} + class _DayHeaderStrip extends StatelessWidget { final DateTime weekStart; final DateTime today; @@ -301,7 +588,7 @@ class _WeekGrid extends StatelessWidget { @override Widget build(BuildContext context) { - final perDay = _expandAppointmentsForWeek(appointments, weekStart); + final partitioned = _partitionAppointmentsForWeek(appointments, weekStart); return Row( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -316,7 +603,7 @@ class _WeekGrid extends StatelessWidget { child: _DayColumn( date: weekStart.add(Duration(days: d)), schedule: schedule, - appointments: perDay[d], + appointments: partitioned.inside[d], timeRegions: timeRegions, layout: layout, today: today, @@ -389,9 +676,9 @@ class _PeriodLabel extends StatelessWidget { } final timeStyle = theme.textTheme.labelSmall?.copyWith( - color: secondaryTextColor, + color: secondaryTextColor.withAlpha(140), height: 1.0, - fontSize: 10, + fontSize: 9, ); const tightTextHeight = TextHeightBehavior( applyHeightToFirstAscent: false, @@ -422,7 +709,7 @@ class _PeriodLabel extends StatelessWidget { ), ), Text( - '${period.name}.', + period.name, style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onSurface, fontWeight: FontWeight.w500, @@ -728,25 +1015,34 @@ List<_BoundRegion> _expandRegionsForDay(List regions, DateTime day) bool _isSameDay(DateTime a, DateTime b) => a.year == b.year && a.month == b.month && a.day == b.day; -/// Expands the given list of appointments across the visible 5-day work week, -/// resolving any RRULE-based recurrences into per-day synthetic instances. -/// Returns a list of length 5 (Monday..Friday); each entry holds the -/// appointments occurring on that day, with `startTime` and `endTime` shifted -/// to the actual occurrence date (preserving time-of-day and duration). The -/// original `id`, `subject`, `color`, `location` and `notes` are kept so taps -/// still resolve to the correct underlying event. -List> _expandAppointmentsForWeek( - List appointments, DateTime weekStart) { - final perDay = List>.generate(5, (_) => []); +/// Expands the given list of appointments across the visible 5-day work week +/// (resolving RRULE recurrences) and splits each day's events into two +/// buckets: those that fit within the school-hours grid (`inside`) and those +/// that don't (`outside` — all-day events and events that start before +/// [kCalendarStartHour] or end after [kCalendarEndHour]). The outside bucket +/// is rendered as chips above the grid. +({List> inside, List> outside}) + _partitionAppointmentsForWeek( + List appointments, DateTime weekStart) { + final inside = List>.generate(5, (_) => []); + final outside = List>.generate(5, (_) => []); final weekEnd = weekStart.add(const Duration(days: 5)); final weekStartUtc = weekStart.toUtc(); final weekEndUtc = weekEnd.toUtc(); + void place(int idx, Appointment a) { + if (_isOutsideSchoolHours(a)) { + outside[idx].add(a); + } else { + inside[idx].add(a); + } + } + for (final a in appointments) { final rule = a.recurrenceRule; if (rule == null || rule.isEmpty) { - final idx = a.startTime.difference(weekStart).inDays; - if (idx >= 0 && idx < 5) perDay[idx].add(a); + final idx = _dayIndex(a.startTime, weekStart); + if (idx >= 0 && idx < 5) place(idx, a); continue; } try { @@ -763,25 +1059,53 @@ List> _expandAppointmentsForWeek( if (idx < 0 || idx >= 5) continue; final newStart = DateTime(occLocal.year, occLocal.month, occLocal.day, a.startTime.hour, a.startTime.minute); - perDay[idx].add(Appointment( - id: a.id, - startTime: newStart, - endTime: newStart.add(duration), - subject: a.subject, - color: a.color, - location: a.location, - notes: a.notes, - )); + place( + idx, + Appointment( + id: a.id, + startTime: newStart, + endTime: newStart.add(duration), + subject: a.subject, + color: a.color, + location: a.location, + notes: a.notes, + isAllDay: a.isAllDay, + ), + ); } } catch (_) { - // Malformed RRULE → behave as non-recurring (anchor day only). - final idx = a.startTime.difference(weekStart).inDays; - if (idx >= 0 && idx < 5) perDay[idx].add(a); + final idx = _dayIndex(a.startTime, weekStart); + if (idx >= 0 && idx < 5) place(idx, a); } } - return perDay; + return (inside: inside, outside: outside); } +int _dayIndex(DateTime t, DateTime weekStart) => + DateTime(t.year, t.month, t.day).difference(weekStart).inDays; + +/// True when the appointment doesn't fit into the school-hours grid: +/// all-day, fully before the grid start, fully after the grid end, engulfing +/// the grid entirely, or lasting 10+ hours (treated as a de-facto all-day +/// event the source system happens to represent with explicit times). +bool _isOutsideSchoolHours(Appointment a) { + if (_isAllDayLike(a)) return true; + final schoolStart = (kCalendarStartHour * 60).round(); + final schoolEnd = (kCalendarEndHour * 60).round(); + final startMin = a.startTime.hour * 60 + a.startTime.minute; + final endMin = a.endTime.hour * 60 + a.endTime.minute; + if (endMin <= schoolStart) return true; + if (startMin >= schoolEnd) return true; + if (startMin <= schoolStart && endMin >= schoolEnd) return true; + return false; +} + +/// Either explicitly marked as all-day, or so long it's effectively a full +/// day from the user's perspective. We compare in minutes (not hours) because +/// `Duration.inHours` truncates: a 9h 30min event would otherwise count as 9. +bool _isAllDayLike(Appointment a) => + a.isAllDay || a.endTime.difference(a.startTime).inMinutes >= 8 * 60; + /// Maps lesson periods to vertical screen positions. Every non-break period /// gets the same fixed height (`lessonHeight`), every break gets `breakHeight`. /// Short transition gaps (Wechselzeiten) between periods are not represented