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:
@@ -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:mm–HH:mm" for same-day,
|
||||
/// "dd.MM. HH:mm–dd.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;
|
||||
}
|
||||
Reference in New Issue
Block a user