import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart'; import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_event.dart'; 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/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}); /// 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 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) => 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, ), ), ); } @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( context: context, builder: (context) => SimpleDialog( title: Text(event.title.isEmpty ? '(ohne Titel)' : event.title), children: [ ListTile( leading: const CenteredLeading(Icon(Icons.date_range_outlined)), title: Text(_formatLongRange()), ), if (event.description != null && event.description!.trim().isNotEmpty) ListTile( leading: const CenteredLeading(Icon(Icons.notes_outlined)), title: Text(event.description!.trim()), ), Visibility( visible: !event.start.difference(DateTime.now()).isNegative, replacement: ListTile( leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)), title: Text(Jiffy.parseFromDateTime(event.start).fromNow()), ), child: ListTile( leading: const CenteredLeading(Icon(Icons.timer_outlined)), title: AnimatedTime(callback: () => event.start.difference(DateTime.now())), subtitle: Text(Jiffy.parseFromDateTime(event.start).fromNow()), ), ), DebugTile(context).jsonData(event.toJson()), ], ), ); } 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')}'; } }