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

This commit is contained in:
2026-05-06 22:37:41 +02:00
parent 86d12884fc
commit 95ef29fb09
19 changed files with 1114 additions and 253 deletions
@@ -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<MarianumDatesBloc, LoadableState<MarianumDatesState>>(
create: (context) => MarianumDatesBloc(),
autoRebuild: true,
child: (context, bloc, state) => Scaffold(
appBar: AppBar(
title: const Text('Marianum Termine'),
actions: [
PopupMenuButton<bool>(
initialValue: bloc.showPastEvents(),
icon: const Icon(Icons.history),
itemBuilder: (context) => [true, false].map((e) => PopupMenuItem<bool>(
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<MarianumDatesBloc, MarianumDatesState>(
child: (state, loading) => ListViewUtil.fromList<MarianumDate>(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<MarianumDate> events) {
final byMonth = <String, List<MarianumDate>>{};
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<MarianumDatesBloc, LoadableState<MarianumDatesState>>(
create: (context) => MarianumDatesBloc(),
autoRebuild: true,
child: (context, bloc, state) => Scaffold(
appBar: AppBar(
title: const Text('Marianum Termine'),
actions: [
PopupMenuButton<bool>(
initialValue: bloc.showPastEvents(),
icon: const Icon(Icons.history),
itemBuilder: (context) => [true, false]
.map((e) => PopupMenuItem<bool>(
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 <MarianumDate>[];
showSearch(context: context, delegate: SearchMarianumDates(events));
},
),
],
),
body: LoadableStateConsumer<MarianumDatesBloc, MarianumDatesState>(
child: (state, loading) {
final events = bloc.getEvents() ?? const <MarianumDate>[];
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<MarianumDate> 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')}';
}
}
@@ -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<MarianumDate?> {
final List<MarianumDate> events;
SearchMarianumDates(this.events);
List<MarianumDate> _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<Widget>? 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);
}