349 lines
13 KiB
Dart
349 lines
13 KiB
Dart
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<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 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<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,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
@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')}';
|
||
}
|
||
}
|