custom login implementation, period-based timetable layout with overlap handling, enhanced error dialogs, and unified bottom sheets

This commit is contained in:
2026-05-06 20:42:09 +02:00
parent 50d2941e52
commit 86d12884fc
32 changed files with 1038 additions and 377 deletions
@@ -1,51 +1,32 @@
import 'package:flutter/material.dart';
/// Shows a modal bottom sheet for an appointment, matching the design of the
/// other sheets in the app (file details, file actions, overflow lessons):
/// drag handle on top, default theme background, ListTile-style header
/// followed by a divider, scrollable body below.
void showAppointmentBottomSheet(
BuildContext context, {
required Widget Function(BuildContext context) header,
required SliverChildListDelegate Function(BuildContext context) body,
required Widget header,
required List<Widget> Function(BuildContext sheetContext) children,
}) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
useSafeArea: true,
backgroundColor: Theme.of(context).colorScheme.surface,
builder: (sheetContext) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.4,
minChildSize: 0.2,
maxChildSize: 0.7,
snap: true,
snapSizes: const [0.4],
builder: (_, scrollController) => CustomScrollView(
controller: scrollController,
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: _StickyHeader(child: header(sheetContext)),
),
SliverList(delegate: body(sheetContext)),
],
builder: (sheetContext) => SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
header,
const Divider(height: 1),
...children(sheetContext),
],
),
),
),
);
}
class _StickyHeader extends SliverPersistentHeaderDelegate {
_StickyHeader({required this.child});
final Widget child;
@override
double get minExtent => 100;
@override
double get maxExtent => 100;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => Material(
color: Theme.of(context).colorScheme.surface,
child: SizedBox.expand(child: child),
);
@override
bool shouldRebuild(covariant _StickyHeader oldDelegate) => oldDelegate.child != child;
}
@@ -11,51 +11,49 @@ import 'delete_custom_event.dart';
class CustomEventSheet {
static void show(BuildContext context, CustomTimetableEvent event) {
final timeRange =
'${Jiffy.parseFromDateTime(event.startDate).format(pattern: 'HH:mm')} - '
'${Jiffy.parseFromDateTime(event.endDate).format(pattern: 'HH:mm')}';
showAppointmentBottomSheet(
context,
header: (_) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(event.title, style: const TextStyle(fontSize: 25, overflow: TextOverflow.ellipsis)),
Text(
'${Jiffy.parseFromDateTime(event.startDate).format(pattern: 'HH:mm')} - '
'${Jiffy.parseFromDateTime(event.endDate).format(pattern: 'HH:mm')}',
style: const TextStyle(fontSize: 15),
),
],
),
header: ListTile(
leading: const Icon(Icons.event_outlined, size: 32),
title: Text(event.title, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(timeRange),
),
body: (sheetCtx) => SliverChildListDelegate([
const Divider(),
Center(
child: Wrap(
children: [
TextButton.icon(
onPressed: () {
Navigator.of(sheetCtx).pop();
showDialog(
context: context,
builder: (_) => CustomEventEditDialog(existingEvent: event),
);
},
label: const Text('Bearbeiten'),
icon: const Icon(Icons.edit_outlined),
),
TextButton.icon(
onPressed: () {
showDeleteCustomEventDialog(context, event).future.then((_) {
if (!sheetCtx.mounted) return;
children: (sheetCtx) => [
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Center(
child: Wrap(
children: [
TextButton.icon(
onPressed: () {
Navigator.of(sheetCtx).pop();
});
},
label: const Text('Löschen'),
icon: const Icon(Icons.delete_outline),
),
],
showDialog(
context: context,
builder: (_) => CustomEventEditDialog(existingEvent: event),
);
},
label: const Text('Bearbeiten'),
icon: const Icon(Icons.edit_outlined),
),
TextButton.icon(
onPressed: () {
showDeleteCustomEventDialog(context, event).future.then((_) {
if (!sheetCtx.mounted) return;
Navigator.of(sheetCtx).pop();
});
},
label: const Text('Löschen'),
icon: const Icon(Icons.delete_outline),
),
],
),
),
),
const Divider(),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.info_outline),
title: Text(event.description.isEmpty ? 'Keine Beschreibung' : event.description),
@@ -82,7 +80,7 @@ class CustomEventSheet {
),
),
DebugTile(sheetCtx).jsonData(event.toJson()),
]),
],
);
}
}
@@ -23,29 +23,24 @@ class WebuntisLessonSheet {
final headerTitle = _firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']);
final headerLongName = headerSubject.longName.isNotEmpty && headerSubject.longName != headerTitle ? headerSubject.longName : '';
final timeRange =
'${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - '
'${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}';
showAppointmentBottomSheet(
context,
header: (_) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${_codePrefix(lesson.code)}$headerTitle',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 25),
overflow: TextOverflow.ellipsis,
),
if (headerLongName.isNotEmpty) Text(headerLongName),
Text(
'${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - '
'${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}',
style: const TextStyle(fontSize: 15),
),
],
header: ListTile(
leading: Icon(_iconForCode(lesson.code), size: 32),
title: Text(
'${_codePrefix(lesson.code)}$headerTitle',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(headerLongName.isNotEmpty
? '$timeRange\n$headerLongName'
: timeRange),
isThreeLine: headerLongName.isNotEmpty,
),
body: (_) => SliverChildListDelegate(<Widget>[
const Divider(),
children: (_) => <Widget>[
ListTile(
leading: const Icon(Icons.notifications_active),
title: Text('Status: ${_statusLabel(lesson.code)}'),
@@ -82,10 +77,21 @@ class WebuntisLessonSheet {
),
..._optionalTextTiles(lesson),
DebugTile(context).jsonData(lesson.toJson()),
]),
],
);
}
static IconData _iconForCode(String? code) {
switch (code) {
case 'cancelled':
return Icons.event_busy_outlined;
case 'irregular':
return Icons.swap_horiz;
default:
return Icons.school_outlined;
}
}
static Widget _roomTile(BuildContext context, TimetableState state, GetTimetableResponseObject lesson) {
final trailing = IconButton(
icon: const Icon(Icons.house_outlined),
@@ -193,7 +199,7 @@ class WebuntisLessonSheet {
static Widget? _textTile(IconData icon, String label, String? value) {
final text = (value ?? '').trim();
if (text.isEmpty) return null;
if (text.isEmpty || text == '-') return null;
return ListTile(
leading: Icon(icon),
title: Text(label),