refactored broad range of the application, split files, modularized calendar and file views, centralized bottom sheets and clipboard handling, and implemented unit test coverage

This commit is contained in:
2026-05-08 19:05:16 +02:00
parent 3b1b0d0c19
commit c62a14645a
68 changed files with 4633 additions and 3141 deletions
@@ -0,0 +1,38 @@
import '../../../../extensions/date_time.dart';
import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
/// Pure formatting helpers for `MarianumDate` events. Held outside the view
/// so the view can stay focused on layout and these helpers remain
/// unit-testable.
class EventFormatter {
/// Compact trailing label shown in the list row: "HH:mmHH:mm" for same-day,
/// "dd.MM. HH:mmdd.MM. HH:mm" otherwise, or "Ganztägig" for all-day events.
static String trailingLabel(MarianumDate event) {
if (event.isAllDay) return 'Ganztägig';
if (event.start.isSameDay(event.end)) {
if (event.start == event.end) return event.start.formatHm();
return '${event.start.formatHm()}${event.end.formatHm()}';
}
return '${event.start.formatDateShortHm()}${event.end.formatDateShortHm()}';
}
/// Verbose date+time line shown in the details sheet. Drops the trailing
/// time when the event is all-day, and de-duplicates same-day endpoints.
static String longRange(MarianumDate event) {
if (event.isAllDay) {
final inclusiveEnd = event.end.isAfter(event.start)
? event.end.subtract(const Duration(days: 1))
: event.end;
return event.start.isSameDay(inclusiveEnd)
? '${event.start.formatDate()} · Ganztägig'
: '${event.start.formatDate()} ${inclusiveEnd.formatDate()} · Ganztägig';
}
if (event.start.isSameDay(event.end)) {
if (event.start == event.end) {
return '${event.start.formatDate()} · ${event.start.formatHm()}';
}
return '${event.start.formatDate()} · ${event.start.formatHm()} ${event.end.formatHm()}';
}
return '${event.start.formatDateTime()} ${event.end.formatDateTime()}';
}
}
@@ -1,18 +1,16 @@
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import '../../../extensions/date_time.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';
import 'widgets/event_list_tile.dart';
import 'widgets/month_section_header.dart';
class MarianumDatesView extends StatelessWidget {
const MarianumDatesView({super.key});
@@ -27,7 +25,7 @@ class MarianumDatesView extends StatelessWidget {
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();
final label = first.formatMonthYear().toUpperCase();
return _MonthGroup(key: key, label: label, events: byMonth[key]!);
}).toList();
}
@@ -110,239 +108,3 @@ class _MonthGroup {
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')}';
}
}
@@ -2,7 +2,7 @@ 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';
import 'widgets/event_list_tile.dart';
class SearchMarianumDates extends SearchDelegate<MarianumDate?> {
final List<MarianumDate> events;
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import '../../../../extensions/date_time.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/details_bottom_sheet.dart';
import '../data/event_formatter.dart';
void showEventDetailsSheet(BuildContext context, MarianumDate event) {
final isUpcoming = !event.start.difference(DateTime.now()).isNegative;
showDetailsBottomSheet(
context,
header: ListTile(
leading: const Icon(Icons.event_outlined, size: 32),
title: Text(
event.title.isEmpty ? '(ohne Titel)' : event.title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
children: (sheetContext) => [
ListTile(
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
title: Text(EventFormatter.longRange(event)),
),
if (event.description != null && event.description!.trim().isNotEmpty)
ListTile(
leading: const CenteredLeading(Icon(Icons.notes_outlined)),
title: Text(event.description!.trim()),
),
if (isUpcoming)
ListTile(
leading: const CenteredLeading(Icon(Icons.timer_outlined)),
title: AnimatedTime(callback: () => event.start.difference(DateTime.now())),
subtitle: Text(event.start.formatRelative()),
)
else
ListTile(
leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)),
title: Text(event.start.formatRelative()),
),
DebugTile(sheetContext).jsonData(event.toJson()),
],
);
}
@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
import '../../timetable/custom_events/custom_event_edit_dialog.dart';
import '../data/event_formatter.dart';
import 'event_details_sheet.dart';
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}';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: () => showEventDetailsSheet(context, event),
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(
EventFormatter.trailingLabel(event),
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,
),
),
],
),
),
);
}
}
/// 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),
),
),
],
),
);
}
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
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;
}