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:
@@ -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')}';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user