diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5202b16 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# MarianumMobile Client + +Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Files, Custom MHSL-Backend (Breaker, Custom Events, Push). + +## Stack + +- **Flutter** (Dart >= 3.8) +- **State:** `flutter_bloc` + `hydrated_bloc` (persistente BLoCs pro Modul) +- **Navigation:** `persistent_bottom_nav_bar_v2` mit zentraler `AppRoutes`-Klasse als Single Entry Point +- **HTTP:** `dio`, lokales Caching via `localstore` (Generic `RequestCache`) +- **Calendar:** `syncfusion_flutter_calendar` +- **Datum/Zeit:** `jiffy` – wird **nur** über die Extensions in `lib/extensions/date_time.dart` verwendet +- **Code-Gen:** `freezed`, `json_serializable` + +## Ordnerstruktur + +``` +lib/ +├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/) +├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...) +├── state/app/infrastructure LoadableState, DataLoader, geteilte BLoC-Bausteine +├── view/ Screens +│ ├── login/ Login-Flow +│ └── pages/ ein Verzeichnis pro Modul (timetable, files, talk, ...) +├── widget/ Geteilte UI-Komponenten (Dialoge, Buttons, Sheets) +├── extensions/ DateTime-, Text-, TimeOfDay-Extensions +├── routing/ AppRoutes (Single Navigation Entry) +├── theming/ Light/Dark Theme +├── storage/ Freezed Settings-Modelle (HydratedBloc-persistent) +├── notification/ Firebase + flutter_local_notifications +└── utils/ Helper (clipboard_helper, debouncer, download_manager, ...) +``` + +## Konventionen + +**Navigation:** Ausschließlich über `AppRoutes.openX(context, ...)`. Direkte `Navigator.push(...)` für volle Pages sind nicht erlaubt – `Navigator.pop` für Sheets/Dialogs bleibt am Call-Site. + +**Dialoge:** +- Info/Fehler: `InfoDialog.show(context, body, copyable: true, title: '...')` aus `lib/widget/info_dialog.dart`. +- Bestätigung: `ConfirmDialog(...).asDialog(context)` aus `lib/widget/confirm_dialog.dart`. Async-Bestätigung nutzt `onConfirmAsync` (zeigt Spinner und Inline-Fehler über `AsyncDialogAction`). +- **Kein** inline `AlertDialog`/`SimpleDialog` mehr. + +**Bottom-Sheets:** Detail-Sheets gehen über `showDetailsBottomSheet(context, header: ..., children: (ctx) => [...])` aus `lib/widget/details_bottom_sheet.dart`. Header ist optional. + +**Async-Actions:** Statt manuelles Spinner+Try/Catch die `AsyncActionButton`-Familie aus `lib/widget/async_action_button.dart` (`AsyncActionButton`, `AsyncTextButton`, `AsyncIconButton`, `AsyncFab`, `AsyncListTile`, `AsyncDialogAction`, `runWithErrorDialog`). Fehler-Mapping läuft über `errorBuilder` oder zentral über `errorToUserMessage` aus `lib/api/errors/error_mapper.dart`. + +**Clipboard:** Über `copyToClipboard(context, text)` aus `lib/utils/clipboard_helper.dart`. Zeigt automatisch SnackBar. + +**Datum/Zeit-Formatierung:** Über die Extensions in `lib/extensions/date_time.dart`: +`dt.formatHm()`, `dt.formatDate()`, `dt.formatDateTime()`, `dt.formatDateShort()`, `dt.formatRelative()`, `start.timeRangeTo(end)`. **Kein** direktes `Jiffy.parseFromDateTime(...).format(pattern: '...')` im View-Code. + +**Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc. + +## Build / Run + +```bash +flutter pub get +dart run build_runner build --delete-conflicting-outputs # nach Änderungen an Freezed/JSON-Modellen +flutter run # Debug auf angeschlossenem Device +flutter analyze # statische Analyse, muss 0 Issues melden +flutter test # Tests (siehe test/) +``` + +## Backend-Integrationen + +| Backend | Pfad | Zweck | +|---------------------------|-----------------------|----------------------------------------| +| Webuntis | `lib/api/webuntis/` | Stundenplan, Klassen, Räume, Lehrer | +| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung | +| Custom MHSL-Server | `lib/api/mhsl/` | Breaker, Custom Events, Notify, Noten | +| Holiday-Calendar | `lib/api/holidays/` | Ferien | + +`nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`). + +## Tests + +`test/` deckt aktuell nur Kern-Funktionen ab (DateTime-Extensions, AsyncActionController, LessonResolver). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu. diff --git a/lib/api/marianumcloud/talk/chat/get_chat_response.dart b/lib/api/marianumcloud/talk/chat/get_chat_response.dart index 6470b19..3c6416d 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_response.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_response.dart @@ -1,6 +1,6 @@ -import 'package:jiffy/jiffy.dart'; import 'package:json_annotation/json_annotation.dart'; +import '../../../../extensions/date_time.dart'; import '../../../api_response.dart'; import '../room/get_room_response.dart'; @@ -63,7 +63,7 @@ class GetChatResponseObject { static GetChatResponseObject getDateDummy(int timestamp) { var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); - return getTextDummy(Jiffy.parseFromDateTime(elementDate).format(pattern: 'dd.MM.yyyy')); + return getTextDummy(elementDate.formatDate()); } static GetChatResponseObject getTextDummy(String text) => GetChatResponseObject( diff --git a/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart index 1983583..8614f3e 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart @@ -1,7 +1,7 @@ import 'package:jiffy/jiffy.dart'; import 'package:json_annotation/json_annotation.dart'; -import '../../../../../view/pages/files/files.dart'; +import '../../../../../view/pages/files/data/sort_options.dart'; import '../../../../api_response.dart'; import 'cacheable_file.dart'; diff --git a/lib/api/webuntis/services/lesson_resolver.dart b/lib/api/webuntis/services/lesson_resolver.dart new file mode 100644 index 0000000..77128e5 --- /dev/null +++ b/lib/api/webuntis/services/lesson_resolver.dart @@ -0,0 +1,73 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import '../../../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../queries/get_rooms/get_rooms_response.dart'; +import '../queries/get_subjects/get_subjects_response.dart'; + +/// Resolves Webuntis IDs (subject, room) against the cached `TimetableState`. +/// When a record is missing the resolver returns a placeholder fallback +/// instead of `null` so call sites stay branch-free. +class LessonResolver { + static GetSubjectsResponseObject resolveSubject(TimetableState state, int? id) { + final fallback = GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true); + if (id == null) return fallback; + return state.subjects?.result.firstWhereOrNull((s) => s.id == id) ?? fallback; + } + + static GetRoomsResponseObject resolveRoom(TimetableState state, int? id) { + final fallback = GetRoomsResponseObject(0, '?', 'Unbekannt', true, ''); + if (id == null) return fallback; + return state.rooms?.result.firstWhereOrNull((r) => r.id == id) ?? fallback; + } +} + +/// Pure formatting/labelling helpers for Webuntis lessons (status code → +/// icon/label, "Name (Longname) · Extra" lines, subject prefix). No widgets, +/// safe to unit-test. +class LessonFormatter { + 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 String statusLabel(String? code) { + switch (code) { + case null: + case '': + return 'Regulär'; + case 'cancelled': + return 'Entfällt'; + case 'irregular': + return 'Geändert'; + default: + return code; + } + } + + static String codePrefix(String? code) { + if (code == 'cancelled') return 'Entfällt: '; + if (code == 'irregular') return 'Änderung: '; + return code ?? ''; + } + + /// Builds a single display line from the typical Webuntis triple of name, + /// optional longname (rendered in parentheses if it differs from `name`), + /// and optional extra info (joined with `·`). + static String formatLine(String name, {String? longname, String? extra}) { + final parts = [ + if (name.isNotEmpty) name else '?', + ]; + final ln = (longname ?? '').trim(); + if (ln.isNotEmpty && ln != name) parts.add('($ln)'); + final ex = (extra ?? '').trim(); + if (ex.isNotEmpty) parts.add('· $ex'); + return parts.join(' '); + } +} diff --git a/lib/extensions/date_time.dart b/lib/extensions/date_time.dart index 7852873..7dc0d52 100644 --- a/lib/extensions/date_time.dart +++ b/lib/extensions/date_time.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; extension IsSameDay on DateTime { bool isSameDay(DateTime other) => year == other.year && month == other.month && day == other.day; @@ -18,3 +19,23 @@ extension IsSameDay on DateTime { bool isSameOrAfter(DateTime other) => isSameDateTime(other) || isAfter(other); } + +/// Formatting helpers backed by Jiffy. Centralises the patterns that previously +/// were repeated as `Jiffy.parseFromDateTime(dt).format(pattern: '...')`. +extension DateTimeFormatting on DateTime { + String formatHm() => Jiffy.parseFromDateTime(this).format(pattern: 'HH:mm'); + + String formatDate() => Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM.yyyy'); + + String formatDateTime() => Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM.yyyy HH:mm'); + + String formatDateShort() => Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM.'); + + String formatDateShortHm() => Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM. HH:mm'); + + String formatMonthYear() => Jiffy.parseFromDateTime(this).format(pattern: 'MMMM yyyy'); + + String formatRelative() => Jiffy.parseFromDateTime(this).fromNow(); + + String timeRangeTo(DateTime end) => '${formatHm()} - ${end.formatHm()}'; +} diff --git a/lib/extensions/text.dart b/lib/extensions/text.dart index 0cf90a2..735a860 100644 --- a/lib/extensions/text.dart +++ b/lib/extensions/text.dart @@ -10,3 +10,11 @@ extension TextExt on Text { return textPainter.size; } } + +/// Returns the first non-empty (after trim) entry, or '' if none match. +String firstNonEmpty(List values) { + for (final v in values) { + if (v != null && v.trim().isNotEmpty) return v; + } + return ''; +} diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 644b5f1..bbe31c7 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -29,7 +29,7 @@ import '../widget/user_avatar.dart'; /// /// Every full-page push in modules should go through one of these methods. /// Dialogs (`showDialog`), bottom sheets (`showStickyFlexibleBottomSheet`, -/// `showAppointmentBottomSheet`), and `Navigator.pop` for closing those +/// `showDetailsBottomSheet`), and `Navigator.pop` for closing those /// remain unchanged and live at the call sites. class AppRoutes { AppRoutes._(); diff --git a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart index 5d3bd43..daaca67 100644 --- a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart +++ b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart @@ -3,8 +3,8 @@ import 'dart:async'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:jiffy/jiffy.dart'; +import '../../../../../extensions/date_time.dart'; import 'loadable_state_event.dart'; import 'loadable_state_state.dart'; @@ -73,7 +73,7 @@ class LoadableStateBloc extends Bloc String connectionText({int? lastUpdated}) => connectivityStatusKnown() ? isConnected() ? 'Verbindung fehlgeschlagen' - : 'Offline${lastUpdated == null ? '' : ' - Stand von ${Jiffy.parseFromMillisecondsSinceEpoch(lastUpdated).fromNow()}'}' + : 'Offline${lastUpdated == null ? '' : ' - Stand von ${DateTime.fromMillisecondsSinceEpoch(lastUpdated).formatRelative()}'}' : 'Unbekannte Fehlerursache'; @override diff --git a/lib/storage/file_settings.dart b/lib/storage/file_settings.dart index c493f7a..3b76ffa 100644 --- a/lib/storage/file_settings.dart +++ b/lib/storage/file_settings.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../view/pages/files/files.dart'; +import '../view/pages/files/data/sort_options.dart'; part 'file_settings.g.dart'; diff --git a/lib/utils/clipboard_helper.dart b/lib/utils/clipboard_helper.dart new file mode 100644 index 0000000..df08022 --- /dev/null +++ b/lib/utils/clipboard_helper.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Copies [text] to the system clipboard and shows a SnackBar confirmation. +/// Safe to await: respects context lifecycle via the provided [context]. +Future copyToClipboard( + BuildContext context, + String text, { + String successMessage = 'In Zwischenablage kopiert', +}) async { + await Clipboard.setData(ClipboardData(text: text)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(successMessage), + duration: const Duration(seconds: 2), + ), + ); +} diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index 92753c6..286f7e4 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -1,17 +1,12 @@ -import 'dart:developer'; - import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../api/errors/auth_exception.dart'; -import '../../api/errors/error_mapper.dart'; -import '../../api/marianumcloud/talk/room/get_room.dart'; -import '../../api/marianumcloud/talk/room/get_room_params.dart'; -import '../../model/account_data.dart'; import '../../state/app/modules/account/bloc/account_bloc.dart'; import '../../state/app/modules/account/bloc/account_state.dart'; import '../../theming/light_app_theme.dart'; +import 'login_controller.dart'; +import 'widgets/login_branding.dart'; +import 'widgets/login_card.dart'; class Login extends StatefulWidget { const Login({super.key}); @@ -23,14 +18,7 @@ class Login extends StatefulWidget { class _LoginState extends State { static const _marianumRed = LightAppTheme.marianumRed; - final _formKey = GlobalKey(); - final _usernameController = TextEditingController(); - final _passwordController = TextEditingController(); - final _passwordFocus = FocusNode(); - - bool _loading = false; - String? _errorMessage; - String? _errorDetails; + final LoginController _controller = LoginController(); @override void didChangeDependencies() { @@ -40,379 +28,44 @@ class _LoginState extends State { @override void dispose() { - _usernameController.dispose(); - _passwordController.dispose(); - _passwordFocus.dispose(); + _controller.dispose(); super.dispose(); } - String? _required(String? value) => - (value ?? '').trim().isEmpty ? 'Eingabe erforderlich' : null; - - Future _submit() async { - if (_loading) return; - if (!(_formKey.currentState?.validate() ?? false)) return; - - setState(() { - _loading = true; - _errorMessage = null; - _errorDetails = null; - }); - - final username = _usernameController.text.trim().toLowerCase(); - final password = _passwordController.text; - - try { - await AccountData().removeData(); - await AccountData().setData(username, password); - await GetRoom(GetRoomParams(includeStatus: false)).run(); - if (!mounted) return; - context.read().setStatus(AccountStatus.loggedIn); - } catch (e) { - log(e.toString()); - await AccountData().removeData(); - if (!mounted) return; - // 401 from the probe means the credentials were wrong; everything else - // (no network, server down, TLS errors, …) gets the generic mapped - // message so the user knows it isn't their typo. - final isWrongCredentials = e is AuthException && e.statusCode == 401; - setState(() { - _errorMessage = isWrongCredentials - ? 'Benutzername oder Passwort falsch.' - : errorToUserMessage(e); - _errorDetails = errorToTechnicalDetails(e); - }); - } finally { - if (mounted) setState(() => _loading = false); - } - } - - void _showErrorDetails() { - final details = _errorDetails; - if (details == null) return; - showDialog( - context: context, - builder: (dialogContext) { - final theme = Theme.of(dialogContext); - return AlertDialog( - icon: Icon(Icons.error_outline, color: theme.colorScheme.error), - title: const Text('Fehlerdetails'), - content: SingleChildScrollView( - child: SelectableText(details, style: theme.textTheme.bodySmall), - ), - actions: [ - TextButton.icon( - onPressed: () async { - await Clipboard.setData(ClipboardData(text: details)); - if (!dialogContext.mounted) return; - ScaffoldMessenger.of(dialogContext).showSnackBar( - const SnackBar( - content: Text('In Zwischenablage kopiert'), - duration: Duration(seconds: 2), - ), - ); - }, - icon: const Icon(Icons.copy_outlined, size: 18), - label: const Text('Kopieren'), - ), - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('Schließen'), - ), - ], - ); - }, - ); + void _onLoginSuccess() { + context.read().setStatus(AccountStatus.loggedIn); } @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Scaffold( - backgroundColor: _marianumRed, - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Center( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - maxWidth: 420, - ), - child: IntrinsicHeight( - child: Column( - children: [ - const SizedBox(height: 40), - Image.asset( - 'assets/logo/icon.png', - height: 110, - fit: BoxFit.contain, - gaplessPlayback: true, - ), - const SizedBox(height: 20), - const Text( - 'Marianum Fulda', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white, - fontSize: 26, - fontWeight: FontWeight.w600, - letterSpacing: 0.3, - ), - ), - const SizedBox(height: 6), - Text( - 'Stundenplan, Talk & Dateien an einem Ort.', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.85), - fontSize: 14, - height: 1.3, - ), - ), - const SizedBox(height: 28), - Card( - elevation: 8, - shadowColor: Colors.black.withValues(alpha: 0.35), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - color: theme.colorScheme.surface, - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 20), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Anmelden', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 6), - Text( - 'Melde dich mit deinen Marianum-Zugangsdaten an.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 20), - TextFormField( - controller: _usernameController, - enabled: !_loading, - validator: _required, - autocorrect: false, - textInputAction: TextInputAction.next, - onFieldSubmitted: (_) => - _passwordFocus.requestFocus(), - decoration: InputDecoration( - labelText: 'Nutzername', - prefixIcon: const Icon( - Icons.person_outline, - ), - filled: true, - fillColor: theme - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.4), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: theme.colorScheme.primary, - width: 1.5, - ), - ), - ), - ), - const SizedBox(height: 12), - TextFormField( - controller: _passwordController, - focusNode: _passwordFocus, - enabled: !_loading, - validator: _required, - obscureText: true, - obscuringCharacter: '•', - autocorrect: false, - enableSuggestions: false, - keyboardType: TextInputType.visiblePassword, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => _submit(), - decoration: InputDecoration( - labelText: 'Passwort', - prefixIcon: const Icon(Icons.lock_outline), - filled: true, - fillColor: theme - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.4), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: theme.colorScheme.primary, - width: 1.5, - ), - ), - ), - ), - AnimatedSize( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOut, - child: _errorMessage == null - ? const SizedBox( - height: 0, - width: double.infinity, - ) - : Padding( - padding: const EdgeInsets.only( - top: 14, - ), - child: Material( - color: theme - .colorScheme - .errorContainer - .withValues(alpha: 0.6), - borderRadius: BorderRadius.circular( - 12, - ), - child: InkWell( - onTap: _errorDetails != null - ? _showErrorDetails - : null, - borderRadius: - BorderRadius.circular(12), - child: Padding( - padding: - const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - child: Row( - children: [ - Icon( - Icons.error_outline, - size: 20, - color: theme - .colorScheme - .onErrorContainer, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - _errorMessage!, - style: TextStyle( - color: theme - .colorScheme - .onErrorContainer, - fontSize: 13, - height: 1.3, - ), - ), - ), - if (_errorDetails != - null) ...[ - const SizedBox(width: 8), - Icon( - Icons.chevron_right, - size: 20, - color: theme - .colorScheme - .onErrorContainer - .withValues( - alpha: 0.7, - ), - ), - ], - ], - ), - ), - ), - ), - ), - ), - const SizedBox(height: 20), - SizedBox( - height: 50, - child: FilledButton( - onPressed: _loading ? null : _submit, - style: FilledButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - textStyle: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), - child: _loading - ? const SizedBox( - height: 22, - width: 22, - child: CircularProgressIndicator( - strokeWidth: 2.5, - color: Colors.white, - ), - ) - : const Text('Anmelden'), - ), - ), - ], - ), - ), - ), - ), - const SizedBox(height: 18), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - 'Inoffizieller Nextcloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.75), - fontSize: 11, - height: 1.4, - ), - ), - ), - const Spacer(), - Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8), - child: Text( - 'Marianum Fulda. Die persönliche Schule.', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.7), - fontSize: 12, - fontStyle: FontStyle.italic, - ), - ), - ), - ], + Widget build(BuildContext context) => Scaffold( + backgroundColor: _marianumRed, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + maxWidth: 420, + ), + child: IntrinsicHeight( + child: Column( + children: [ + const LoginHeader(), + const SizedBox(height: 28), + LoginCard(controller: _controller, onSuccess: _onLoginSuccess), + const SizedBox(height: 18), + const LoginDisclaimer(), + const Spacer(), + const LoginFooter(), + ], + ), ), ), ), ), ), ), - ), - ); - } + ); } diff --git a/lib/view/login/login_controller.dart b/lib/view/login/login_controller.dart new file mode 100644 index 0000000..2d7126f --- /dev/null +++ b/lib/view/login/login_controller.dart @@ -0,0 +1,55 @@ +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; + +import '../../api/errors/auth_exception.dart'; +import '../../api/errors/error_mapper.dart'; +import '../../api/marianumcloud/talk/room/get_room.dart'; +import '../../api/marianumcloud/talk/room/get_room_params.dart'; +import '../../model/account_data.dart'; + +/// Owns the login flow's transient state (loading, last error) so it can be +/// driven from a thin Stateful view and unit-tested without a widget tree. +class LoginController extends ChangeNotifier { + bool _loading = false; + String? _errorMessage; + String? _errorDetails; + + bool get loading => _loading; + String? get errorMessage => _errorMessage; + String? get errorDetails => _errorDetails; + + /// Returns `true` when the credential probe succeeded. The view should + /// then transition the AccountBloc to `loggedIn`. + Future submit(String username, String password) async { + if (_loading) return false; + _loading = true; + _errorMessage = null; + _errorDetails = null; + notifyListeners(); + + final user = username.trim().toLowerCase(); + try { + await AccountData().removeData(); + await AccountData().setData(user, password); + await GetRoom(GetRoomParams(includeStatus: false)).run(); + _loading = false; + notifyListeners(); + return true; + } catch (e) { + log(e.toString()); + await AccountData().removeData(); + // 401 from the probe means the credentials were wrong; everything else + // (no network, server down, TLS errors, …) gets the generic mapped + // message so the user knows it isn't their typo. + final isWrongCredentials = e is AuthException && e.statusCode == 401; + _errorMessage = isWrongCredentials + ? 'Benutzername oder Passwort falsch.' + : errorToUserMessage(e); + _errorDetails = errorToTechnicalDetails(e); + _loading = false; + notifyListeners(); + return false; + } + } +} diff --git a/lib/view/login/widgets/login_branding.dart b/lib/view/login/widgets/login_branding.dart new file mode 100644 index 0000000..07475ce --- /dev/null +++ b/lib/view/login/widgets/login_branding.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +class LoginHeader extends StatelessWidget { + const LoginHeader({super.key}); + + @override + Widget build(BuildContext context) => Column( + children: [ + const SizedBox(height: 40), + Image.asset( + 'assets/logo/icon.png', + height: 110, + fit: BoxFit.contain, + gaplessPlayback: true, + ), + const SizedBox(height: 20), + const Text( + 'Marianum Fulda', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + const SizedBox(height: 6), + Text( + 'Stundenplan, Talk & Dateien an einem Ort.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.85), + fontSize: 14, + height: 1.3, + ), + ), + ], + ); +} + +class LoginDisclaimer extends StatelessWidget { + const LoginDisclaimer({super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + 'Inoffizieller Nextcloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.75), + fontSize: 11, + height: 1.4, + ), + ), + ); +} + +class LoginFooter extends StatelessWidget { + const LoginFooter({super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8), + child: Text( + 'Marianum Fulda. Die persönliche Schule.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 12, + fontStyle: FontStyle.italic, + ), + ), + ); +} diff --git a/lib/view/login/widgets/login_card.dart b/lib/view/login/widgets/login_card.dart new file mode 100644 index 0000000..61d1129 --- /dev/null +++ b/lib/view/login/widgets/login_card.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; + +import '../login_controller.dart'; +import 'login_error_banner.dart'; + +/// White Card hosting the login form (heading, two text fields, error +/// banner, submit button). Submitting calls [controller.submit] and signals +/// success via [onSuccess]. +class LoginCard extends StatefulWidget { + final LoginController controller; + final VoidCallback onSuccess; + + const LoginCard({required this.controller, required this.onSuccess, super.key}); + + @override + State createState() => _LoginCardState(); +} + +class _LoginCardState extends State { + final _formKey = GlobalKey(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _passwordFocus = FocusNode(); + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onControllerChange); + } + + @override + void dispose() { + widget.controller.removeListener(_onControllerChange); + _usernameController.dispose(); + _passwordController.dispose(); + _passwordFocus.dispose(); + super.dispose(); + } + + void _onControllerChange() { + if (mounted) setState(() {}); + } + + String? _required(String? value) => + (value ?? '').trim().isEmpty ? 'Eingabe erforderlich' : null; + + Future _submit() async { + if (widget.controller.loading) return; + if (!(_formKey.currentState?.validate() ?? false)) return; + final ok = await widget.controller.submit( + _usernameController.text, + _passwordController.text, + ); + if (ok && mounted) widget.onSuccess(); + } + + InputDecoration _decoration(ThemeData theme, String label, IconData icon) => + InputDecoration( + labelText: label, + prefixIcon: Icon(icon), + filled: true, + fillColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.primary, width: 1.5), + ), + ); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final loading = widget.controller.loading; + return Card( + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.35), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + color: theme.colorScheme.surface, + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Anmelden', + style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + Text( + 'Melde dich mit deinen Marianum-Zugangsdaten an.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + TextFormField( + controller: _usernameController, + enabled: !loading, + validator: _required, + autocorrect: false, + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => _passwordFocus.requestFocus(), + decoration: _decoration(theme, 'Nutzername', Icons.person_outline), + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordController, + focusNode: _passwordFocus, + enabled: !loading, + validator: _required, + obscureText: true, + obscuringCharacter: '•', + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.visiblePassword, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + decoration: _decoration(theme, 'Passwort', Icons.lock_outline), + ), + LoginErrorBanner( + message: widget.controller.errorMessage, + details: widget.controller.errorDetails, + ), + const SizedBox(height: 20), + SizedBox( + height: 50, + child: FilledButton( + onPressed: loading ? null : _submit, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + ), + child: loading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white), + ) + : const Text('Anmelden'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/view/login/widgets/login_error_banner.dart b/lib/view/login/widgets/login_error_banner.dart new file mode 100644 index 0000000..df6395f --- /dev/null +++ b/lib/view/login/widgets/login_error_banner.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +import '../../../widget/info_dialog.dart'; + +/// Tappable error banner shown beneath the login form. Animates in/out via +/// AnimatedSize. When [details] is non-null, tapping opens an InfoDialog +/// with the technical error text. +class LoginErrorBanner extends StatelessWidget { + final String? message; + final String? details; + + const LoginErrorBanner({required this.message, required this.details, super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + child: message == null + ? const SizedBox(height: 0, width: double.infinity) + : Padding( + padding: const EdgeInsets.only(top: 14), + child: Material( + color: theme.colorScheme.errorContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: details != null + ? () => InfoDialog.show(context, details!, copyable: true, title: 'Fehlerdetails') + : null, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(Icons.error_outline, size: 20, color: theme.colorScheme.onErrorContainer), + const SizedBox(width: 10), + Expanded( + child: Text( + message!, + style: TextStyle( + color: theme.colorScheme.onErrorContainer, + fontSize: 13, + height: 1.3, + ), + ), + ), + if (details != null) ...[ + const SizedBox(width: 8), + Icon( + Icons.chevron_right, + size: 20, + color: theme.colorScheme.onErrorContainer.withValues(alpha: 0.7), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/view/pages/files/data/sort_options.dart b/lib/view/pages/files/data/sort_options.dart new file mode 100644 index 0000000..941e7d1 --- /dev/null +++ b/lib/view/pages/files/data/sort_options.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; + +enum SortOption { name, date, size } + +class BetterSortOption { + final String displayName; + final int Function(CacheableFile, CacheableFile) compare; + final IconData icon; + + BetterSortOption({required this.displayName, required this.icon, required this.compare}); +} + +class SortOptions { + static final Map options = { + SortOption.name: BetterSortOption( + displayName: 'Name', + icon: Icons.sort_by_alpha_outlined, + compare: (a, b) => a.name.compareTo(b.name), + ), + SortOption.date: BetterSortOption( + displayName: 'Datum', + icon: Icons.history_outlined, + compare: (a, b) => a.modifiedAt!.compareTo(b.modifiedAt!), + ), + SortOption.size: BetterSortOption( + displayName: 'Größe', + icon: Icons.sd_card_outlined, + compare: (a, b) { + if (a.isDirectory || b.isDirectory) return a.isDirectory ? 1 : 0; + if (a.size == null) return 0; + if (b.size == null) return 1; + return a.size!.compareTo(b.size!); + }, + ), + }; + + static BetterSortOption getOption(SortOption option) => options[option]!; +} diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index d673417..73bed9d 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -2,12 +2,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:nextcloud/nextcloud.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import '../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; -import '../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; -import '../../../api/marianumcloud/webdav/webdav_api.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'; @@ -15,49 +11,13 @@ import '../../../state/app/modules/files/bloc/files_bloc.dart'; import '../../../state/app/modules/files/bloc/files_state.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../utils/cache_invalidation_bus.dart'; -import '../../../utils/file_clipboard.dart'; -import '../../../widget/async_action_button.dart'; -import '../../../widget/file_pick.dart'; import '../../../widget/placeholder_view.dart'; +import 'data/sort_options.dart'; import 'files_upload_dialog.dart'; +import 'widgets/add_file_menu.dart'; +import 'widgets/clipboard_banner.dart'; import 'widgets/file_element.dart'; - -class BetterSortOption { - String displayName; - int Function(CacheableFile, CacheableFile) compare; - IconData icon; - - BetterSortOption({required this.displayName, required this.icon, required this.compare}); -} - -enum SortOption { name, date, size } - -class SortOptions { - static Map options = { - SortOption.name: BetterSortOption( - displayName: 'Name', - icon: Icons.sort_by_alpha_outlined, - compare: (a, b) => a.name.compareTo(b.name), - ), - SortOption.date: BetterSortOption( - displayName: 'Datum', - icon: Icons.history_outlined, - compare: (a, b) => a.modifiedAt!.compareTo(b.modifiedAt!), - ), - SortOption.size: BetterSortOption( - displayName: 'Größe', - icon: Icons.sd_card_outlined, - compare: (a, b) { - if (a.isDirectory || b.isDirectory) return a.isDirectory ? 1 : 0; - if (a.size == null) return 0; - if (b.size == null) return 1; - return a.size!.compareTo(b.size!); - }, - ), - }; - - static BetterSortOption getOption(SortOption option) => options[option]!; -} +import 'widgets/files_sort_actions.dart'; class Files extends StatelessWidget { final List path; @@ -89,6 +49,10 @@ class _FilesViewState extends State<_FilesView> { // segments joined without leading/trailing slash. String get _myPathString => widget.path.isEmpty ? '/' : widget.path.join('/'); + // Relative folder path matching the WebDAV format used by `CacheableFile.path` + // (no leading slash; trailing slash for non-root). Empty string means root. + String get _currentFolderPath => widget.path.isEmpty ? '' : '${widget.path.join('/')}/'; + @override void initState() { super.initState(); @@ -110,7 +74,7 @@ class _FilesViewState extends State<_FilesView> { super.dispose(); } - Future mediaUpload(List? paths) async { + Future _mediaUpload(List? paths) async { if (paths == null) return; final bloc = context.read(); unawaited(pushScreen( @@ -131,47 +95,16 @@ class _FilesViewState extends State<_FilesView> { appBar: AppBar( title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'), actions: [ - PopupMenuButton( - icon: Icon(currentSortDirection ? Icons.text_rotate_up : Icons.text_rotation_down), - itemBuilder: (context) => [true, false] - .map((e) => PopupMenuItem( - value: e, - enabled: e != currentSortDirection, - child: Row( - children: [ - Icon( - e ? Icons.text_rotate_up : Icons.text_rotation_down, - color: Theme.of(context).colorScheme.onSurface, - ), - const SizedBox(width: 15), - Text(e ? 'Aufsteigend' : 'Absteigend'), - ], - ), - )) - .toList(), - onSelected: (e) { + FilesSortActions( + currentSort: currentSort, + ascending: currentSortDirection, + onDirectionChanged: (e) { setState(() { currentSortDirection = e; settings.val(write: true).fileSettings.ascending = e; }); }, - ), - PopupMenuButton( - icon: const Icon(Icons.sort), - itemBuilder: (context) => SortOptions.options.keys - .map((key) => PopupMenuItem( - value: key, - enabled: key != currentSort, - child: Row( - children: [ - Icon(SortOptions.getOption(key).icon, color: Theme.of(context).colorScheme.onSurface), - const SizedBox(width: 15), - Text(SortOptions.getOption(key).displayName), - ], - ), - )) - .toList(), - onSelected: (e) { + onSortChanged: (e) { setState(() { currentSort = e; settings.val(write: true).fileSettings.sortBy = e; @@ -183,12 +116,12 @@ class _FilesViewState extends State<_FilesView> { floatingActionButton: FloatingActionButton( heroTag: 'uploadFile', backgroundColor: Theme.of(context).primaryColor, - onPressed: () => _showAddDialog(context, bloc), + onPressed: () => showAddFileSheet(context, bloc: bloc, onPickedFiles: _mediaUpload), child: const Icon(Icons.add), ), body: Column( children: [ - _ClipboardBanner(currentFolder: _currentFolderPath, onPasteDone: bloc.refresh), + ClipboardBanner(currentFolder: _currentFolderPath, onPasteDone: bloc.refresh), Expanded( child: LoadableStateConsumer( isReady: (state) => state.listing != null, @@ -214,217 +147,4 @@ class _FilesViewState extends State<_FilesView> { ), ); } - - // Relative folder path matching the WebDAV format used by `CacheableFile.path` - // (no leading slash; trailing slash for non-root). Empty string means root. - String get _currentFolderPath => widget.path.isEmpty ? '' : '${widget.path.join('/')}/'; - - void _showAddDialog(BuildContext context, FilesBloc bloc) { - showDialog( - context: context, - builder: (dialogCtx) => SimpleDialog(children: [ - ListTile( - leading: const Icon(Icons.create_new_folder_outlined), - title: const Text('Ordner erstellen'), - onTap: () { - Navigator.of(dialogCtx).pop(); - _showCreateFolderDialog(context, bloc); - }, - ), - ListTile( - leading: const Icon(Icons.upload_file), - title: const Text('Aus Dateien hochladen'), - onTap: () { - FilePick.documentPick().then(mediaUpload); - Navigator.of(dialogCtx).pop(); - }, - ), - ListTile( - leading: const Icon(Icons.add_a_photo_outlined), - title: const Text('Aus Galerie hochladen'), - onTap: () { - FilePick.multipleGalleryPick().then((value) { - if (value != null) mediaUpload(value.map((e) => e.path).toList()); - }); - Navigator.of(dialogCtx).pop(); - }, - ), - ListTile( - leading: const Icon(Icons.camera_alt_outlined), - title: const Text('Foto aufnehmen'), - onTap: () { - FilePick.cameraPick().then((image) { - if (image != null) mediaUpload([image.path]); - }); - Navigator.of(dialogCtx).pop(); - }, - ), - ]), - ); - } - - void _showCreateFolderDialog(BuildContext context, FilesBloc bloc) { - final inputController = TextEditingController(); - showDialog( - context: context, - builder: (dialogCtx) => AlertDialog( - title: const Text('Neuer Ordner'), - content: TextField( - controller: inputController, - decoration: const InputDecoration(labelText: 'Name'), - autofocus: true, - ), - actions: [ - AsyncDialogAction( - confirmLabel: 'Ordner erstellen', - onConfirm: () async { - if (inputController.text.trim().isEmpty) { - throw Exception('Bitte einen Namen eingeben.'); - } - await bloc.createFolder(inputController.text.trim()); - }, - ), - ], - ), - ); - } -} - -class _ClipboardBanner extends StatefulWidget { - const _ClipboardBanner({required this.currentFolder, required this.onPasteDone}); - final String currentFolder; - final void Function() onPasteDone; - - @override - State<_ClipboardBanner> createState() => _ClipboardBannerState(); -} - -class _ClipboardBannerState extends State<_ClipboardBanner> { - bool _busy = false; - - // All paths here are relative to the WebDAV root (matching `CacheableFile.path`). - // Root is the empty string ''. Folders end with '/'. - String _normalised(String path) { - final stripped = path.replaceAll(RegExp(r'^/+|/+$'), ''); - return stripped.isEmpty ? '' : '$stripped/'; - } - - String _joinPath(String folder, String name, {required bool isDirectory}) => - isDirectory ? '$folder$name/' : '$folder$name'; - - // Disabled when: - // - clipboard is empty - // - we'd be pasting a folder into itself or one of its descendants - // - every entry already lives in the current folder (paste would be a no-op) - bool get _canPaste { - final cb = FileClipboard.instance; - if (cb.isEmpty) return false; - final dst = _normalised(widget.currentFolder); - var atLeastOneActionable = false; - for (final f in cb.files) { - if (f.isDirectory) { - final src = _normalised(f.path); - if (dst == src || dst.startsWith(src)) return false; - } - final destination = _joinPath(widget.currentFolder, f.name, isDirectory: f.isDirectory); - if (destination != f.path) atLeastOneActionable = true; - } - return atLeastOneActionable; - } - - // Cache key format used by ListFilesCache (matches FilesBloc's pathString: - // relative, no leading or trailing slash; root is '/'). - String _parentCacheKey(String relativePath) { - final stripped = relativePath.replaceAll(RegExp(r'^/+|/+$'), ''); - if (!stripped.contains('/')) return '/'; - final parts = stripped.split('/')..removeLast(); - return parts.isEmpty ? '/' : parts.join('/'); - } - - Future _paste() async { - final cb = FileClipboard.instance; - if (_busy || !_canPaste) return; - setState(() => _busy = true); - final operation = cb.operation; - final errors = []; - final invalidatedSourceFolders = {}; - try { - final webdav = await WebdavApi.webdav; - for (final file in cb.files) { - final destination = _joinPath(widget.currentFolder, file.name, isDirectory: file.isDirectory); - if (destination == file.path) continue; - try { - if (operation == FileClipboardOperation.cut) { - await webdav.move(PathUri.parse(file.path), PathUri.parse(destination)); - invalidatedSourceFolders.add(_parentCacheKey(file.path)); - } else { - await webdav.copy(PathUri.parse(file.path), PathUri.parse(destination)); - } - } on Object catch (e) { - errors.add('${file.name}: $e'); - } - } - // After cut, the source folders no longer contain the moved files. Drop - // their cached listings so the next visit fetches fresh data instead of - // briefly showing the moved file as still present. - for (final folder in invalidatedSourceFolders) { - await ListFilesCache.invalidate(folder); - } - if (operation == FileClipboardOperation.cut) cb.clear(); - widget.onPasteDone(); - } finally { - if (mounted) setState(() => _busy = false); - } - if (errors.isNotEmpty && mounted) { - await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Einfügen teilweise fehlgeschlagen'), - content: SingleChildScrollView(child: Text(errors.join('\n\n'))), - actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('OK'))], - ), - ); - } - } - - @override - Widget build(BuildContext context) => ListenableBuilder( - listenable: FileClipboard.instance, - builder: (context, _) { - final cb = FileClipboard.instance; - if (cb.isEmpty) return const SizedBox.shrink(); - final cut = cb.operation == FileClipboardOperation.cut; - final count = cb.files.length; - final label = count == 1 ? '"${cb.files.first.name}"' : '$count Elemente'; - return Material( - color: Theme.of(context).colorScheme.secondaryContainer, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - children: [ - Icon(cut ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 20), - const SizedBox(width: 12), - Expanded( - child: Text( - cut ? '$label verschieben' : '$label kopieren', - overflow: TextOverflow.ellipsis, - ), - ), - TextButton( - onPressed: _busy || !_canPaste ? null : _paste, - child: _busy - ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Hier einfügen'), - ), - IconButton( - tooltip: 'Verwerfen', - icon: const Icon(Icons.close, size: 20), - onPressed: _busy ? null : cb.clear, - ), - ], - ), - ), - ); - }, - ); } diff --git a/lib/view/pages/files/widgets/add_file_menu.dart b/lib/view/pages/files/widgets/add_file_menu.dart new file mode 100644 index 0000000..508fe7d --- /dev/null +++ b/lib/view/pages/files/widgets/add_file_menu.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import '../../../../state/app/modules/files/bloc/files_bloc.dart'; +import '../../../../widget/async_action_button.dart'; +import '../../../../widget/details_bottom_sheet.dart'; +import '../../../../widget/file_pick.dart'; + +/// Opens the "Element hinzufügen" sheet (create folder, upload, take photo, …). +/// [onPickedFiles] receives selected/captured file paths (gallery, file picker +/// or camera) and is responsible for kicking off the upload flow. +void showAddFileSheet( + BuildContext context, { + required FilesBloc bloc, + required Future Function(List? paths) onPickedFiles, +}) { + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + ListTile( + leading: const Icon(Icons.create_new_folder_outlined), + title: const Text('Ordner erstellen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _showCreateFolderDialog(context, bloc); + }, + ), + ListTile( + leading: const Icon(Icons.upload_file), + title: const Text('Aus Dateien hochladen'), + onTap: () { + FilePick.documentPick().then(onPickedFiles); + Navigator.of(sheetCtx).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.add_a_photo_outlined), + title: const Text('Aus Galerie hochladen'), + onTap: () { + FilePick.multipleGalleryPick().then((value) { + if (value != null) onPickedFiles(value.map((e) => e.path).toList()); + }); + Navigator.of(sheetCtx).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.camera_alt_outlined), + title: const Text('Foto aufnehmen'), + onTap: () { + FilePick.cameraPick().then((image) { + if (image != null) onPickedFiles([image.path]); + }); + Navigator.of(sheetCtx).pop(); + }, + ), + ], + ); +} + +void _showCreateFolderDialog(BuildContext context, FilesBloc bloc) { + final inputController = TextEditingController(); + showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: const Text('Neuer Ordner'), + content: TextField( + controller: inputController, + decoration: const InputDecoration(labelText: 'Name'), + autofocus: true, + ), + actions: [ + AsyncDialogAction( + confirmLabel: 'Ordner erstellen', + onConfirm: () async { + if (inputController.text.trim().isEmpty) { + throw Exception('Bitte einen Namen eingeben.'); + } + await bloc.createFolder(inputController.text.trim()); + }, + ), + ], + ), + ); +} diff --git a/lib/view/pages/files/widgets/clipboard_banner.dart b/lib/view/pages/files/widgets/clipboard_banner.dart new file mode 100644 index 0000000..ac73360 --- /dev/null +++ b/lib/view/pages/files/widgets/clipboard_banner.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:nextcloud/nextcloud.dart'; + +import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; +import '../../../../api/marianumcloud/webdav/webdav_api.dart'; +import '../../../../utils/file_clipboard.dart'; +import '../../../../widget/info_dialog.dart'; + +/// Banner that appears at the top of a Files folder while there is something +/// in the file clipboard. Shows the cut/copy state and offers a "Hier +/// einfügen" button. +class ClipboardBanner extends StatefulWidget { + final String currentFolder; + final VoidCallback onPasteDone; + + const ClipboardBanner({ + required this.currentFolder, + required this.onPasteDone, + super.key, + }); + + @override + State createState() => _ClipboardBannerState(); +} + +class _ClipboardBannerState extends State { + bool _busy = false; + + // All paths here are relative to the WebDAV root (matching `CacheableFile.path`). + // Root is the empty string ''. Folders end with '/'. + String _normalised(String path) { + final stripped = path.replaceAll(RegExp(r'^/+|/+$'), ''); + return stripped.isEmpty ? '' : '$stripped/'; + } + + String _joinPath(String folder, String name, {required bool isDirectory}) => + isDirectory ? '$folder$name/' : '$folder$name'; + + // Disabled when: + // - clipboard is empty + // - we'd be pasting a folder into itself or one of its descendants + // - every entry already lives in the current folder (paste would be a no-op) + bool get _canPaste { + final cb = FileClipboard.instance; + if (cb.isEmpty) return false; + final dst = _normalised(widget.currentFolder); + var atLeastOneActionable = false; + for (final f in cb.files) { + if (f.isDirectory) { + final src = _normalised(f.path); + if (dst == src || dst.startsWith(src)) return false; + } + final destination = _joinPath(widget.currentFolder, f.name, isDirectory: f.isDirectory); + if (destination != f.path) atLeastOneActionable = true; + } + return atLeastOneActionable; + } + + // Cache key format used by ListFilesCache (matches FilesBloc's pathString: + // relative, no leading or trailing slash; root is '/'). + String _parentCacheKey(String relativePath) { + final stripped = relativePath.replaceAll(RegExp(r'^/+|/+$'), ''); + if (!stripped.contains('/')) return '/'; + final parts = stripped.split('/')..removeLast(); + return parts.isEmpty ? '/' : parts.join('/'); + } + + Future _paste() async { + final cb = FileClipboard.instance; + if (_busy || !_canPaste) return; + setState(() => _busy = true); + final operation = cb.operation; + final errors = []; + final invalidatedSourceFolders = {}; + try { + final webdav = await WebdavApi.webdav; + for (final file in cb.files) { + final destination = _joinPath(widget.currentFolder, file.name, isDirectory: file.isDirectory); + if (destination == file.path) continue; + try { + if (operation == FileClipboardOperation.cut) { + await webdav.move(PathUri.parse(file.path), PathUri.parse(destination)); + invalidatedSourceFolders.add(_parentCacheKey(file.path)); + } else { + await webdav.copy(PathUri.parse(file.path), PathUri.parse(destination)); + } + } on Object catch (e) { + errors.add('${file.name}: $e'); + } + } + // After cut, the source folders no longer contain the moved files. Drop + // their cached listings so the next visit fetches fresh data instead of + // briefly showing the moved file as still present. + for (final folder in invalidatedSourceFolders) { + await ListFilesCache.invalidate(folder); + } + if (operation == FileClipboardOperation.cut) cb.clear(); + widget.onPasteDone(); + } finally { + if (mounted) setState(() => _busy = false); + } + if (errors.isNotEmpty && mounted) { + InfoDialog.show( + context, + errors.join('\n\n'), + copyable: true, + title: 'Einfügen teilweise fehlgeschlagen', + ); + } + } + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: FileClipboard.instance, + builder: (context, _) { + final cb = FileClipboard.instance; + if (cb.isEmpty) return const SizedBox.shrink(); + final cut = cb.operation == FileClipboardOperation.cut; + final count = cb.files.length; + final label = count == 1 ? '"${cb.files.first.name}"' : '$count Elemente'; + return Material( + color: Theme.of(context).colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Icon(cut ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + cut ? '$label verschieben' : '$label kopieren', + overflow: TextOverflow.ellipsis, + ), + ), + TextButton( + onPressed: _busy || !_canPaste ? null : _paste, + child: _busy + ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Hier einfügen'), + ), + IconButton( + tooltip: 'Verwerfen', + icon: const Icon(Icons.close, size: 20), + onPressed: _busy ? null : cb.clear, + ), + ], + ), + ), + ); + }, + ); +} diff --git a/lib/view/pages/files/widgets/file_details_sheet.dart b/lib/view/pages/files/widgets/file_details_sheet.dart index 418b50e..2e59564 100644 --- a/lib/view/pages/files/widgets/file_details_sheet.dart +++ b/lib/view/pages/files/widgets/file_details_sheet.dart @@ -1,48 +1,33 @@ import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:jiffy/jiffy.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import '../../../../extensions/date_time.dart'; +import '../../../../utils/clipboard_helper.dart'; +import '../../../../widget/details_bottom_sheet.dart'; /// Shows a modal bottom sheet with technical metadata about a single file or /// folder: full path, MIME type, size, timestamps, ETag. -Future showFileDetailsSheet(BuildContext context, CacheableFile file) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - builder: (context) => SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: Icon(file.isDirectory ? Icons.folder : Icons.description_outlined, size: 32), - title: Text(file.name, style: const TextStyle(fontWeight: FontWeight.bold)), - subtitle: Text(file.isDirectory ? 'Ordner' : (file.mimeType ?? '–')), - ), - const Divider(), - _DetailRow(label: 'Pfad', value: file.path, copyable: true), - if (!file.isDirectory) _DetailRow(label: 'Größe', value: filesize(file.size)), - if (file.modifiedAt != null) - _DetailRow( - label: 'Geändert', - value: '${Jiffy.parseFromDateTime(file.modifiedAt!).format(pattern: 'dd.MM.yyyy HH:mm')} ' - '(${Jiffy.parseFromDateTime(file.modifiedAt!).fromNow()})', - ), - if (file.createdAt != null) - _DetailRow( - label: 'Erstellt', - value: Jiffy.parseFromDateTime(file.createdAt!).format(pattern: 'dd.MM.yyyy HH:mm'), - ), - if (file.eTag != null) _DetailRow(label: 'ETag', value: file.eTag!, copyable: true), - ], - ), - ), +void showFileDetailsSheet(BuildContext context, CacheableFile file) { + showDetailsBottomSheet( + context, + header: ListTile( + leading: Icon(file.isDirectory ? Icons.folder : Icons.description_outlined, size: 32), + title: Text(file.name, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(file.isDirectory ? 'Ordner' : (file.mimeType ?? '–')), ), + children: (_) => [ + _DetailRow(label: 'Pfad', value: file.path, copyable: true), + if (!file.isDirectory) _DetailRow(label: 'Größe', value: filesize(file.size)), + if (file.modifiedAt != null) + _DetailRow( + label: 'Geändert', + value: '${file.modifiedAt!.formatDateTime()} (${file.modifiedAt!.formatRelative()})', + ), + if (file.createdAt != null) + _DetailRow(label: 'Erstellt', value: file.createdAt!.formatDateTime()), + if (file.eTag != null) _DetailRow(label: 'ETag', value: file.eTag!, copyable: true), + ], ); } @@ -54,7 +39,7 @@ class _DetailRow extends StatelessWidget { @override Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -67,12 +52,7 @@ class _DetailRow extends StatelessWidget { IconButton( tooltip: 'Kopieren', icon: const Icon(Icons.copy, size: 18), - onPressed: () { - Clipboard.setData(ClipboardData(text: value)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('In Zwischenablage kopiert')), - ); - }, + onPressed: () => copyToClipboard(context, value), ), ], ), diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart index 3718c14..d70d856 100644 --- a/lib/view/pages/files/widgets/file_element.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -1,10 +1,10 @@ import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; import 'package:nextcloud/nextcloud.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; import '../../../../api/marianumcloud/webdav/webdav_api.dart'; +import '../../../../extensions/date_time.dart'; import '../../../../model/endpoint_data.dart'; import '../../../../routing/app_routes.dart'; import '../../../../utils/download_manager.dart'; @@ -135,9 +135,10 @@ class _FileElementState extends State { ], ); } + final modified = widget.file.modifiedAt ?? DateTime.now(); return widget.file.isDirectory - ? Text('geändert ${Jiffy.parseFromDateTime(widget.file.modifiedAt ?? DateTime.now()).fromNow()}') - : Text('${filesize(widget.file.size)}, ${Jiffy.parseFromDateTime(widget.file.modifiedAt ?? DateTime.now()).fromNow()}'); + ? Text('geändert ${modified.formatRelative()}') + : Text('${filesize(widget.file.size)}, ${modified.formatRelative()}'); } void _onTap() { diff --git a/lib/view/pages/files/widgets/files_sort_actions.dart b/lib/view/pages/files/widgets/files_sort_actions.dart new file mode 100644 index 0000000..58abb23 --- /dev/null +++ b/lib/view/pages/files/widgets/files_sort_actions.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../data/sort_options.dart'; + +/// AppBar action buttons for sort direction (asc/desc) and sort field +/// (name/date/size). Pure UI – owners pass current values + selection +/// callbacks. +class FilesSortActions extends StatelessWidget { + final SortOption currentSort; + final bool ascending; + final ValueChanged onDirectionChanged; + final ValueChanged onSortChanged; + + const FilesSortActions({ + required this.currentSort, + required this.ascending, + required this.onDirectionChanged, + required this.onSortChanged, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + PopupMenuButton( + icon: Icon(ascending ? Icons.text_rotate_up : Icons.text_rotation_down), + itemBuilder: (context) => [true, false] + .map((e) => PopupMenuItem( + value: e, + enabled: e != ascending, + child: Row( + children: [ + Icon(e ? Icons.text_rotate_up : Icons.text_rotation_down, + color: theme.colorScheme.onSurface), + const SizedBox(width: 15), + Text(e ? 'Aufsteigend' : 'Absteigend'), + ], + ), + )) + .toList(), + onSelected: onDirectionChanged, + ), + PopupMenuButton( + icon: const Icon(Icons.sort), + itemBuilder: (context) => SortOptions.options.keys + .map((key) => PopupMenuItem( + value: key, + enabled: key != currentSort, + child: Row( + children: [ + Icon(SortOptions.getOption(key).icon, color: theme.colorScheme.onSurface), + const SizedBox(width: 15), + Text(SortOptions.getOption(key).displayName), + ], + ), + )) + .toList(), + onSelected: onSortChanged, + ), + ], + ); + } +} diff --git a/lib/view/pages/marianum_dates/data/event_formatter.dart b/lib/view/pages/marianum_dates/data/event_formatter.dart new file mode 100644 index 0000000..c5f31d9 --- /dev/null +++ b/lib/view/pages/marianum_dates/data/event_formatter.dart @@ -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()}'; + } +} diff --git a/lib/view/pages/marianum_dates/marianum_dates_view.dart b/lib/view/pages/marianum_dates/marianum_dates_view.dart index 1805539..3574031 100644 --- a/lib/view/pages/marianum_dates/marianum_dates_view.dart +++ b/lib/view/pages/marianum_dates/marianum_dates_view.dart @@ -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 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')}'; - } -} diff --git a/lib/view/pages/marianum_dates/search_marianum_dates.dart b/lib/view/pages/marianum_dates/search_marianum_dates.dart index 7dadb5a..8a76c98 100644 --- a/lib/view/pages/marianum_dates/search_marianum_dates.dart +++ b/lib/view/pages/marianum_dates/search_marianum_dates.dart @@ -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 { final List events; diff --git a/lib/view/pages/marianum_dates/widgets/event_details_sheet.dart b/lib/view/pages/marianum_dates/widgets/event_details_sheet.dart new file mode 100644 index 0000000..cf14e48 --- /dev/null +++ b/lib/view/pages/marianum_dates/widgets/event_details_sheet.dart @@ -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()), + ], + ); +} diff --git a/lib/view/pages/marianum_dates/widgets/event_list_tile.dart b/lib/view/pages/marianum_dates/widgets/event_list_tile.dart new file mode 100644 index 0000000..ba4b2f2 --- /dev/null +++ b/lib/view/pages/marianum_dates/widgets/event_list_tile.dart @@ -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), + ), + ), + ], + ), + ); +} diff --git a/lib/view/pages/marianum_dates/widgets/month_section_header.dart b/lib/view/pages/marianum_dates/widgets/month_section_header.dart new file mode 100644 index 0000000..944b44c --- /dev/null +++ b/lib/view/pages/marianum_dates/widgets/month_section_header.dart @@ -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; +} diff --git a/lib/view/pages/marianum_message/marianum_message_view.dart b/lib/view/pages/marianum_message/marianum_message_view.dart index cb518d8..6968e8c 100644 --- a/lib/view/pages/marianum_message/marianum_message_view.dart +++ b/lib/view/pages/marianum_message/marianum_message_view.dart @@ -4,6 +4,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; import '../../../widget/confirm_dialog.dart'; +import '../../../widget/info_dialog.dart'; class MessageView extends StatefulWidget { final String basePath; @@ -26,15 +27,11 @@ class _MessageViewState extends State { enableHyperlinkNavigation: true, onDocumentLoadFailed: (PdfDocumentLoadFailedDetails e) { Navigator.of(context).pop(); - showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Fehler beim öffnen'), - content: Text("Dokument '${widget.message.name}' konnte nicht geladen werden:\n${e.description}"), - actions: [ - TextButton(onPressed: () { - Navigator.of(context).pop(); - }, child: const Text('Ok')) - ], - )); + InfoDialog.show( + context, + "Dokument '${widget.message.name}' konnte nicht geladen werden:\n${e.description}", + title: 'Fehler beim öffnen', + ); }, onHyperlinkClicked: (PdfHyperlinkClickedDetails e) { showDialog( diff --git a/lib/view/pages/settings/data/default_settings.dart b/lib/view/pages/settings/data/default_settings.dart index d835ee5..d893b77 100644 --- a/lib/view/pages/settings/data/default_settings.dart +++ b/lib/view/pages/settings/data/default_settings.dart @@ -13,7 +13,7 @@ import '../../../../storage/settings.dart'; import '../../../../storage/talk_settings.dart'; import '../../../../storage/timetable_settings.dart'; import '../../../../view/pages/timetable/data/timetable_name_mode.dart'; -import '../../files/files.dart'; +import '../../files/data/sort_options.dart'; class DefaultSettings { static Settings get() => Settings( diff --git a/lib/view/pages/settings/sections/talk_section.dart b/lib/view/pages/settings/sections/talk_section.dart index 1596222..0b808fb 100644 --- a/lib/view/pages/settings/sections/talk_section.dart +++ b/lib/view/pages/settings/sections/talk_section.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../notification/notify_updater.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/centered_leading.dart'; +import '../../../../widget/info_dialog.dart'; class TalkSection extends StatelessWidget { const TalkSection({super.key}); @@ -51,22 +52,13 @@ class TalkSection extends StatelessWidget { ); } - void _showInfoDialog(BuildContext context) => showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Info über Push'), - content: const SingleChildScrollView( - child: Text( - "Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n" - 'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n' - 'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n' - 'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n' - 'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!', - ), - ), - actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Zurück')), - ], - ), + void _showInfoDialog(BuildContext context) => InfoDialog.show( + context, + "Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n" + 'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n' + 'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n' + 'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n' + 'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!', + title: 'Info über Push', ); } diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index 1ea1dab..f226c8e 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -1,26 +1,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:jiffy/jiffy.dart'; import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; -import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message.dart'; -import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart'; -import '../../../../api/marianumcloud/talk/get_poll/get_poll_state.dart'; -import '../../../../api/marianumcloud/talk/react_message/react_message.dart'; -import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../extensions/date_time.dart'; import '../../../../extensions/text.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../utils/download_manager.dart'; -import '../../../../widget/async_action_button.dart'; -import '../../../../widget/loading_spinner.dart'; +import '../../../../widget/confirm_dialog.dart'; +import '../../../../widget/info_dialog.dart'; import '../data/chat_bubble_styles.dart'; import '../data/chat_message.dart'; import 'answer_reference.dart'; import 'bubble.dart'; +import 'chat_bubble_poll.dart'; +import 'chat_bubble_reactions.dart'; import 'chat_message_options_dialog.dart'; -import 'poll_options_list.dart'; class ChatBubble extends StatefulWidget { final BuildContext context; @@ -54,8 +50,8 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM late ChatMessage message; DownloadJob? _job; - late Offset _position = const Offset(0, 0); - late Offset _dragStartPosition = Offset.zero; + Offset _position = Offset.zero; + Offset _dragStartPosition = Offset.zero; @override void initState() { @@ -99,7 +95,7 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM DownloadManager.instance.clear(job.remotePath); _detachJob(); setState(() {}); - showDialog(context: context, builder: (context) => AlertDialog(content: Text(message))); + InfoDialog.show(context, message, title: 'Download fehlgeschlagen'); } else if (status is DownloadCancelled) { DownloadManager.instance.clear(job.remotePath); _detachJob(); @@ -122,66 +118,69 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM } void _confirmCancel() { - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Download abbrechen?'), - content: const Text('Möchtest du den Download abbrechen?'), - actions: [ - TextButton(onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Nein')), - TextButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - _job?.cancel(); - }, - child: const Text('Ja, Abbrechen'), - ), - ], - ), - ); + ConfirmDialog( + title: 'Download abbrechen?', + content: 'Möchtest du den Download abbrechen?', + confirmButton: 'Ja, Abbrechen', + cancelButton: 'Nein', + onConfirm: () => _job?.cancel(), + ).asDialog(context); } - BubbleStyle getStyle() { - var styles = ChatBubbleStyles(context); - if(widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment) { - if(widget.isSender) { - return styles.getSelfStyle(false); - } else { - return styles.getRemoteStyle(false); - } - } else { + BubbleStyle _getStyle() { + final styles = ChatBubbleStyles(context); + if (widget.bubbleData.messageType != GetRoomResponseObjectMessageType.comment) { return styles.getSystemStyle(); } + return widget.isSender ? styles.getSelfStyle(false) : styles.getRemoteStyle(false); } - void showOptionsDialog() { - showChatMessageOptionsDialog( - context, - chatData: widget.chatData, - bubbleData: widget.bubbleData, - isSender: widget.isSender, - onRefetch: widget.refetch, - ); - } + void _showOptionsDialog() => showChatMessageOptionsDialog( + context, + chatData: widget.chatData, + bubbleData: widget.bubbleData, + isSender: widget.isSender, + onRefetch: widget.refetch, + ); + void _onTap() { + final obj = message.originalData?['object']; + if (obj?.type == RichObjectStringObjectType.talkPoll) { + showChatBubblePollDialog( + context, + chatToken: widget.chatData.token, + messageToken: widget.bubbleData.token, + pollId: int.parse(obj!.id), + pollName: obj.name, + ); + return; + } + if (message.file == null) return; + if (_job?.status.value is DownloadInProgress) { + _confirmCancel(); + } else { + _startFileDownload(); + } + } @override Widget build(BuildContext context) { message = ChatMessage(originalMessage: widget.bubbleData.message, originalData: widget.bubbleData.messageParameters); - var showActorDisplayName = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment && widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne; - var showBubbleTime = widget.bubbleData.messageType != GetRoomResponseObjectMessageType.system - && widget.bubbleData.messageType != GetRoomResponseObjectMessageType.deletedComment; + final showActorDisplayName = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment + && widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne; + final showBubbleTime = widget.bubbleData.messageType != GetRoomResponseObjectMessageType.system + && widget.bubbleData.messageType != GetRoomResponseObjectMessageType.deletedComment; - var parent = widget.bubbleData.parent; - var actorText = Text( + final parent = widget.bubbleData.parent; + final actorText = Text( widget.bubbleData.actorDisplayName, textAlign: TextAlign.start, overflow: TextOverflow.ellipsis, style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold), ); - var timeText = Text( - Jiffy.parseFromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).format(pattern: 'HH:mm'), + final timeText = Text( + DateTime.fromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).formatHm(), textAlign: TextAlign.end, style: TextStyle(color: widget.timeIconColor, fontSize: widget.timeIconSize), ); @@ -191,191 +190,161 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM mainAxisAlignment: MainAxisAlignment.end, textDirection: TextDirection.ltr, crossAxisAlignment: CrossAxisAlignment.end, - children: [ GestureDetector( - onHorizontalDragStart: (details) { - _dragStartPosition = _position; - }, + onHorizontalDragStart: (_) => _dragStartPosition = _position, onHorizontalDragUpdate: (details) { - if(!widget.bubbleData.isReplyable) return; - var dx = details.delta.dx - _dragStartPosition.dx; + if (!widget.bubbleData.isReplyable) return; + final dx = details.delta.dx - _dragStartPosition.dx; setState(() { - _position = (_position.dx + dx).abs() > 60 ? Offset(_position.dx, 0) : Offset(_position.dx + dx, 0); + _position = (_position.dx + dx).abs() > 60 + ? Offset(_position.dx, 0) + : Offset(_position.dx + dx, 0); }); }, - onHorizontalDragEnd: (DragEndDetails details) { - var isAction = _position.dx.abs() > 50; - setState(() { - _position = const Offset(0, 0); - }); - if(widget.bubbleData.isReplyable && isAction) { + onHorizontalDragEnd: (_) { + final isAction = _position.dx.abs() > 50; + setState(() => _position = Offset.zero); + if (widget.bubbleData.isReplyable && isAction) { context.read().setReferenceMessageId(widget.bubbleData.id); } }, - onLongPress: showOptionsDialog, - onDoubleTap: showOptionsDialog, - onTap: () { - if(message.originalData?['object']?.type == RichObjectStringObjectType.talkPoll) { - var pollId = int.parse(message.originalData!['object']!.id); - var pollState = GetPollState(token: widget.bubbleData.token, pollId: pollId).run(); - showDialog(context: context, builder: (context) => AlertDialog( - title: Text(message.originalData!['object']!.name, overflow: TextOverflow.ellipsis), - content: FutureBuilder( - future: pollState, - builder: (context, snapshot) { - if(snapshot.connectionState == ConnectionState.waiting) return const Column(mainAxisSize: MainAxisSize.min, children: [LoadingSpinner()]); - - var pollData = snapshot.data!.data; - return SingleChildScrollView( - child: PollOptionsList( - pollData: pollData, - chatToken: widget.chatData.token, - ), - ); - } - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Zurück') - ), - ], - )); - } - - if (message.file == null) return; - if (_job?.status.value is DownloadInProgress) { - _confirmCancel(); - } else { - _startFileDownload(); - } - }, + onLongPress: _showOptionsDialog, + onDoubleTap: _showOptionsDialog, + onTap: _onTap, child: Transform.translate( offset: _position, child: Bubble( - style: getStyle(), - child: Column( - children: [ - Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.9, - minWidth: showActorDisplayName - ? actorText.size.width - : timeText.size.width + (widget.isSender ? widget.spacing + widget.timeIconSize : 0) + 3, - ), - child: Stack( - children: [ - Visibility( - visible: showActorDisplayName, - child: Positioned( - top: 0, - left: 0, - child: actorText - ), - ), - Padding( - padding: EdgeInsets.only(bottom: showBubbleTime ? 18 : 0, top: showActorDisplayName ? 18 : 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if(parent != null && widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment) ...[ - AnswerReference( - context: context, - referenceMessage: parent, - selfId: widget.selfId, - ), - const SizedBox(height: 5), - ], - message.getWidget(), - ], - ), - ), - Visibility( - visible: showBubbleTime, - child: Positioned( - bottom: 0, - right: 0, - child: Row( - children: [ - timeText, - if(widget.isSender) ...[ - SizedBox(width: widget.spacing), - Icon( - widget.isRead ? Icons.done_all_outlined: Icons.done_outlined, - size: widget.timeIconSize, - color: widget.timeIconColor - ) - ] - ], - ) - ), - ), - if (_job?.status.value is DownloadInProgress) - Positioned( - bottom: 0, - right: 0, - left: 0, - child: LinearProgressIndicator( - value: () { - final s = _job!.status.value as DownloadInProgress; - return s.percent <= 0 ? null : s.percent / 100; - }(), - ), - ), - ], - ), - ), - ], + style: _getStyle(), + child: _BubbleContent( + actorText: actorText, + timeText: timeText, + messageWidget: message.getWidget(), + parent: parent, + bubbleData: widget.bubbleData, + isSender: widget.isSender, + isRead: widget.isRead, + selfId: widget.selfId, + spacing: widget.spacing, + timeIconSize: widget.timeIconSize, + timeIconColor: widget.timeIconColor, + showActorDisplayName: showActorDisplayName, + showBubbleTime: showBubbleTime, + downloadJob: _job, ), ), ), ), - Visibility( - visible: widget.bubbleData.reactions != null, - child: Transform.translate( - offset: const Offset(0, -10), - child: Container( - width: MediaQuery.of(context).size.width, - margin: const EdgeInsets.only(left: 15, right: 15), - child: Wrap( - alignment: widget.isSender ? WrapAlignment.end : WrapAlignment.start, - crossAxisAlignment: WrapCrossAlignment.start, - children: widget.bubbleData.reactions?.entries.map((e) { - var hasSelfReacted = widget.bubbleData.reactionsSelf?.contains(e.key) ?? false; - return Container( - margin: const EdgeInsets.only(right: 2.5, left: 2.5), - child: ActionChip( - label: Text('${e.key} ${e.value}'), - visualDensity: const VisualDensity(vertical: VisualDensity.minimumDensity, horizontal: VisualDensity.minimumDensity), - padding: EdgeInsets.zero, - backgroundColor: hasSelfReacted ? Theme.of(context).primaryColor : null, - onPressed: () { - runWithErrorDialog(context, () async { - if (hasSelfReacted) { - await DeleteReactMessage( - chatToken: widget.chatData.token, - messageId: widget.bubbleData.id, - params: DeleteReactMessageParams(e.key), - ).run(); - } else { - await ReactMessage( - chatToken: widget.chatData.token, - messageId: widget.bubbleData.id, - params: ReactMessageParams(e.key), - ).run(); - } - widget.refetch(renew: true); - }); - }, - ), - ); - }).toList() ?? [], - ), - ), - ), + ChatBubbleReactions( + bubbleData: widget.bubbleData, + chatData: widget.chatData, + isSender: widget.isSender, + onChanged: widget.refetch, ), ], ); } } + +/// Stack inside the bubble: actor name (top-left, optional), message body +/// (centre), timestamp + read marker (bottom-right, optional), and a +/// download progress bar overlaid at the bottom while a job is running. +class _BubbleContent extends StatelessWidget { + final Text actorText; + final Text timeText; + final Widget messageWidget; + final GetChatResponseObject? parent; + final GetChatResponseObject bubbleData; + final bool isSender; + final bool isRead; + final String? selfId; + final double spacing; + final double timeIconSize; + final Color timeIconColor; + final bool showActorDisplayName; + final bool showBubbleTime; + final DownloadJob? downloadJob; + + const _BubbleContent({ + required this.actorText, + required this.timeText, + required this.messageWidget, + required this.parent, + required this.bubbleData, + required this.isSender, + required this.isRead, + required this.selfId, + required this.spacing, + required this.timeIconSize, + required this.timeIconColor, + required this.showActorDisplayName, + required this.showBubbleTime, + required this.downloadJob, + }); + + @override + Widget build(BuildContext context) => Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.9, + minWidth: showActorDisplayName + ? actorText.size.width + : timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3, + ), + child: Stack( + children: [ + if (showActorDisplayName) + Positioned(top: 0, left: 0, child: actorText), + Padding( + padding: EdgeInsets.only( + bottom: showBubbleTime ? 18 : 0, + top: showActorDisplayName ? 18 : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (parent != null && bubbleData.messageType == GetRoomResponseObjectMessageType.comment) ...[ + AnswerReference( + context: context, + referenceMessage: parent!, + selfId: selfId, + ), + const SizedBox(height: 5), + ], + messageWidget, + ], + ), + ), + if (showBubbleTime) + Positioned( + bottom: 0, + right: 0, + child: Row( + children: [ + timeText, + if (isSender) ...[ + SizedBox(width: spacing), + Icon( + isRead ? Icons.done_all_outlined : Icons.done_outlined, + size: timeIconSize, + color: timeIconColor, + ), + ], + ], + ), + ), + if (downloadJob?.status.value is DownloadInProgress) + Positioned( + bottom: 0, + right: 0, + left: 0, + child: LinearProgressIndicator( + value: () { + final s = downloadJob!.status.value as DownloadInProgress; + return s.percent <= 0 ? null : s.percent / 100; + }(), + ), + ), + ], + ), + ); +} diff --git a/lib/view/pages/talk/widgets/chat_bubble_poll.dart b/lib/view/pages/talk/widgets/chat_bubble_poll.dart new file mode 100644 index 0000000..96c25ed --- /dev/null +++ b/lib/view/pages/talk/widgets/chat_bubble_poll.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import '../../../../api/marianumcloud/talk/get_poll/get_poll_state.dart'; +import '../../../../widget/loading_spinner.dart'; +import 'poll_options_list.dart'; + +/// Opens the poll dialog that lets a user vote on a Talk poll attached to +/// a message. Loads the poll state lazily and renders the option list. +void showChatBubblePollDialog( + BuildContext context, { + required String chatToken, + required String messageToken, + required int pollId, + required String pollName, +}) { + final pollState = GetPollState(token: messageToken, pollId: pollId).run(); + showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: Text(pollName, overflow: TextOverflow.ellipsis), + content: FutureBuilder( + future: pollState, + builder: (_, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Column(mainAxisSize: MainAxisSize.min, children: [LoadingSpinner()]); + } + final pollData = snapshot.data!.data; + return SingleChildScrollView( + child: PollOptionsList( + pollData: pollData, + chatToken: chatToken, + ), + ); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(), + child: const Text('Zurück'), + ), + ], + ), + ); +} diff --git a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart new file mode 100644 index 0000000..9761474 --- /dev/null +++ b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message.dart'; +import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart'; +import '../../../../api/marianumcloud/talk/react_message/react_message.dart'; +import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart'; +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../widget/async_action_button.dart'; + +/// Reactions wrap shown beneath a chat bubble. Tapping a reaction toggles +/// the user's own reaction via the Talk API and notifies via [onChanged]. +class ChatBubbleReactions extends StatelessWidget { + final GetChatResponseObject bubbleData; + final GetRoomResponseObject chatData; + final bool isSender; + final void Function({bool renew}) onChanged; + + const ChatBubbleReactions({ + required this.bubbleData, + required this.chatData, + required this.isSender, + required this.onChanged, + super.key, + }); + + @override + Widget build(BuildContext context) { + final reactions = bubbleData.reactions; + if (reactions == null) return const SizedBox.shrink(); + return Transform.translate( + offset: const Offset(0, -10), + child: Container( + width: MediaQuery.of(context).size.width, + margin: const EdgeInsets.only(left: 15, right: 15), + child: Wrap( + alignment: isSender ? WrapAlignment.end : WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.start, + children: reactions.entries.map((e) { + final hasSelfReacted = bubbleData.reactionsSelf?.contains(e.key) ?? false; + return Container( + margin: const EdgeInsets.only(right: 2.5, left: 2.5), + child: ActionChip( + label: Text('${e.key} ${e.value}'), + visualDensity: const VisualDensity(vertical: VisualDensity.minimumDensity, horizontal: VisualDensity.minimumDensity), + padding: EdgeInsets.zero, + backgroundColor: hasSelfReacted ? Theme.of(context).primaryColor : null, + onPressed: () { + runWithErrorDialog(context, () async { + if (hasSelfReacted) { + await DeleteReactMessage( + chatToken: chatData.token, + messageId: bubbleData.id, + params: DeleteReactMessageParams(e.key), + ).run(); + } else { + await ReactMessage( + chatToken: chatData.token, + messageId: bubbleData.id, + params: ReactMessageParams(e.key), + ).run(); + } + onChanged(renew: true); + }); + }, + ), + ); + }).toList(), + ), + ), + ); + } +} diff --git a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart index f5c5802..8ba2641 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -1,7 +1,6 @@ import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emojis; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; @@ -11,6 +10,7 @@ import '../../../../api/marianumcloud/talk/react_message/react_message_params.da import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../../../../utils/clipboard_helper.dart'; import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/debug/debug_tile.dart'; @@ -69,7 +69,7 @@ Future showChatMessageOptionsDialog( leading: const Icon(Icons.copy), title: const Text('Nachricht kopieren'), onTap: () { - Clipboard.setData(ClipboardData(text: bubbleData.message)); + copyToClipboard(parentContext, bubbleData.message); Navigator.of(dialogCtx).pop(); }, ), diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index f3bc333..8cea791 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -14,6 +14,7 @@ import '../../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/async_action_button.dart'; +import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/file_pick.dart'; import '../../../../widget/focus_behaviour.dart'; import '../../files/files_upload_dialog.dart'; @@ -172,36 +173,39 @@ class _ChatTextfieldState extends State { Row(children: [ GestureDetector( onTap: () { - showDialog(context: context, builder: (dialogCtx) => SimpleDialog(children: [ - ListTile( - leading: const Icon(Icons.file_open), - title: const Text('Aus Dateien auswählen'), - onTap: () { - FilePick.documentPick().then(mediaUpload); - Navigator.of(dialogCtx).pop(); - }, - ), - ListTile( - leading: const Icon(Icons.image), - title: const Text('Aus Galerie auswählen'), - onTap: () { - FilePick.multipleGalleryPick().then((value) { - if (value != null) mediaUpload(value.map((e) => e.path).toList()); - }); - Navigator.of(dialogCtx).pop(); - }, - ), - ListTile( - leading: const Icon(Icons.camera_alt_outlined), - title: const Text('Foto aufnehmen'), - onTap: () { - FilePick.cameraPick().then((image) { - if (image != null) mediaUpload([image.path]); - }); - Navigator.of(dialogCtx).pop(); - }, - ), - ])); + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + ListTile( + leading: const Icon(Icons.file_open), + title: const Text('Aus Dateien auswählen'), + onTap: () { + FilePick.documentPick().then(mediaUpload); + Navigator.of(sheetCtx).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.image), + title: const Text('Aus Galerie auswählen'), + onTap: () { + FilePick.multipleGalleryPick().then((value) { + if (value != null) mediaUpload(value.map((e) => e.path).toList()); + }); + Navigator.of(sheetCtx).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.camera_alt_outlined), + title: const Text('Foto aufnehmen'), + onTap: () { + FilePick.cameraPick().then((image) { + if (image != null) mediaUpload([image.path]); + }); + Navigator.of(sheetCtx).pop(); + }, + ), + ], + ); }, child: Material( elevation: 5, diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index 0f44672..c9e8deb 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -2,13 +2,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:jiffy/jiffy.dart'; import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart'; import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart'; +import '../../../../extensions/date_time.dart'; import '../../../../model/account_data.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; @@ -96,7 +96,7 @@ class _ChatTileState extends State { ], ), subtitle: Text( - '${Jiffy.parseFromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).fromNow()}: ' + '${DateTime.fromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).formatRelative()}: ' '${RichObjectStringProcessor.parseToString(widget.data.lastMessage.message.replaceAll("\n", " "), widget.data.lastMessage.messageParameters)}', overflow: TextOverflow.ellipsis, ), diff --git a/lib/view/pages/timetable/custom_events/custom_events_view.dart b/lib/view/pages/timetable/custom_events/custom_events_view.dart index a7c4272..83d0b24 100644 --- a/lib/view/pages/timetable/custom_events/custom_events_view.dart +++ b/lib/view/pages/timetable/custom_events/custom_events_view.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; +import '../../../../extensions/date_time.dart'; import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; @@ -51,7 +51,7 @@ class CustomEventsView extends StatelessWidget { title: Text(e.title), subtitle: Text( '${e.rrule.isNotEmpty ? "wiederholend, " : ""}' - 'beginnend ${Jiffy.parseFromDateTime(e.startDate).fromNow()}', + 'beginnend ${e.startDate.formatRelative()}', ), leading: CenteredLeading(Icon(e.rrule.isEmpty ? Icons.event_outlined : Icons.event_repeat_outlined)), trailing: Row( diff --git a/lib/view/pages/timetable/data/calendar_logic.dart b/lib/view/pages/timetable/data/calendar_logic.dart new file mode 100644 index 0000000..e16faa9 --- /dev/null +++ b/lib/view/pages/timetable/data/calendar_logic.dart @@ -0,0 +1,356 @@ +import 'package:rrule/rrule.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../extensions/date_time.dart'; +import 'arbitrary_appointment.dart'; +import 'calendar_layout.dart'; +import 'lesson_period_schedule.dart'; + +/// Either explicitly marked as all-day, or so long it's effectively a full +/// day from the user's perspective. We compare in minutes (not hours) because +/// `Duration.inHours` truncates: a 9h 30min event would otherwise count as 9. +bool isAllDayLike(Appointment a) => + a.isAllDay || a.endTime.difference(a.startTime).inMinutes >= 8 * 60; + +/// True when the appointment doesn't fit into the school-hours grid: +/// all-day, fully before the grid start, fully after the grid end, engulfing +/// the grid entirely, or lasting 10+ hours (treated as a de-facto all-day +/// event the source system happens to represent with explicit times). +bool isOutsideSchoolHours(Appointment a) { + if (isAllDayLike(a)) return true; + final schoolStart = (kCalendarStartHour * 60).round(); + final schoolEnd = (kCalendarEndHour * 60).round(); + final startMin = a.startTime.hour * 60 + a.startTime.minute; + final endMin = a.endTime.hour * 60 + a.endTime.minute; + if (endMin <= schoolStart) return true; + if (startMin >= schoolEnd) return true; + if (startMin <= schoolStart && endMin >= schoolEnd) return true; + return false; +} + +int dayIndex(DateTime t, DateTime weekStart) => + DateTime(t.year, t.month, t.day).difference(weekStart).inDays; + +class BoundRegion { + final TimeRegion region; + final DateTime start; + final DateTime end; + + BoundRegion({required this.region, required this.start, required this.end}); +} + +List expandRegionsForDay(List regions, DateTime day) { + final result = []; + final dayStart = DateTime(day.year, day.month, day.day); + for (final region in regions) { + final isRecurringDaily = region.recurrenceRule != null && + region.recurrenceRule!.toUpperCase().contains('FREQ=DAILY'); + if (isRecurringDaily) { + final start = dayStart.add(Duration( + hours: region.startTime.hour, + minutes: region.startTime.minute, + )); + final end = dayStart.add(Duration( + hours: region.endTime.hour, + minutes: region.endTime.minute, + )); + result.add(BoundRegion(region: region, start: start, end: end)); + } else if (region.startTime.isSameDay(day)) { + result.add(BoundRegion( + region: region, + start: region.startTime, + end: region.endTime, + )); + } + } + return result; +} + +/// Expands the given list of appointments across the visible 5-day work week +/// (resolving RRULE recurrences) and splits each day's events into two +/// buckets: those that fit within the school-hours grid (`inside`) and those +/// that don't (`outside` — all-day events and events that start before +/// [kCalendarStartHour] or end after [kCalendarEndHour]). The outside bucket +/// is rendered as chips above the grid. +({List> inside, List> outside}) + partitionAppointmentsForWeek( + List appointments, DateTime weekStart) { + final inside = List>.generate(5, (_) => []); + final outside = List>.generate(5, (_) => []); + final weekEnd = weekStart.add(const Duration(days: 5)); + final weekStartUtc = weekStart.toUtc(); + final weekEndUtc = weekEnd.toUtc(); + + void place(int idx, Appointment a) { + if (isOutsideSchoolHours(a)) { + outside[idx].add(a); + } else { + inside[idx].add(a); + } + } + + for (final a in appointments) { + final rule = a.recurrenceRule; + if (rule == null || rule.isEmpty) { + final idx = dayIndex(a.startTime, weekStart); + if (idx >= 0 && idx < 5) place(idx, a); + continue; + } + try { + final parsed = RecurrenceRule.fromString(rule); + final anchorUtc = a.startTime.toUtc(); + final duration = a.endTime.difference(a.startTime); + for (final occUtc in parsed.getInstances(start: anchorUtc)) { + if (!occUtc.isBefore(weekEndUtc)) break; + if (occUtc.isBefore(weekStartUtc)) continue; + final occLocal = occUtc.toLocal(); + final idx = DateTime(occLocal.year, occLocal.month, occLocal.day) + .difference(weekStart) + .inDays; + if (idx < 0 || idx >= 5) continue; + final newStart = DateTime(occLocal.year, occLocal.month, occLocal.day, + a.startTime.hour, a.startTime.minute); + place( + idx, + Appointment( + id: a.id, + startTime: newStart, + endTime: newStart.add(duration), + subject: a.subject, + color: a.color, + location: a.location, + notes: a.notes, + isAllDay: a.isAllDay, + ), + ); + } + } catch (_) { + final idx = dayIndex(a.startTime, weekStart); + if (idx >= 0 && idx < 5) place(idx, a); + } + } + return (inside: inside, outside: outside); +} + +/// Maps lesson periods to vertical screen positions. Every non-break period +/// gets the same fixed height (`lessonHeight`), every break gets `breakHeight`. +/// Short transition gaps (Wechselzeiten) between periods are not represented +/// at all — periods are rendered back-to-back, so a 5-minute gap simply +/// disappears visually. +class PeriodLayout { + final List periods; + final double lessonHeight; + final double breakHeight; + + const PeriodLayout({ + required this.periods, + required this.lessonHeight, + required this.breakHeight, + }); + + double _h(LessonPeriod p) => p.isBreak ? breakHeight : lessonHeight; + + double get totalHeight => + periods.fold(0, (sum, p) => sum + _h(p)); + + double topOf(LessonPeriod period) { + var y = 0.0; + for (final p in periods) { + if (identical(p, period)) return y; + y += _h(p); + } + return y; + } + + double heightOf(LessonPeriod period) => _h(period); + + /// Vertical offset for a given time of day. Times inside a period are mapped + /// proportionally; times that fall into a transition gap are clipped to the + /// end of the preceding period. Times before the first / after the last + /// period clip to 0 / [totalHeight]. + double yOfDateTime(DateTime t) { + final tMin = t.hour * 60 + t.minute + t.second / 60.0; + var y = 0.0; + for (final p in periods) { + final pStart = p.start.hour * 60 + p.start.minute; + final pEnd = p.end.hour * 60 + p.end.minute; + final h = _h(p); + if (tMin < pStart) return y; + if (tMin <= pEnd) { + final span = pEnd - pStart; + final ratio = span > 0 ? (tMin - pStart) / span : 0.0; + return y + ratio * h; + } + y += h; + } + return y; + } + + /// Period at a given y-offset. If y falls into a break, returns the next + /// non-break period. Returns null when y is past the last period. + LessonPeriod? periodAtY(double y) { + var cursor = 0.0; + for (var i = 0; i < periods.length; i++) { + final p = periods[i]; + final h = _h(p); + if (y >= cursor && y < cursor + h) { + if (p.isBreak) { + for (var j = i + 1; j < periods.length; j++) { + if (!periods[j].isBreak) return periods[j]; + } + return null; + } + return p; + } + cursor += h; + } + return null; + } +} + +/// One cell rendered in the day column — either a regular appointment or an +/// overflow placeholder representing several hidden appointments. +sealed class LaidOutCell { + int get lane; + int get laneCount; + DateTime get startTime; + DateTime get endTime; +} + +class LaidOutAppointment extends LaidOutCell { + final Appointment appointment; + @override + final int lane; + @override + final int laneCount; + LaidOutAppointment(this.appointment, this.lane, this.laneCount); + + @override + DateTime get startTime => appointment.startTime; + @override + DateTime get endTime => appointment.endTime; +} + +class LaidOutOverflow extends LaidOutCell { + final List appointments; + @override + final int lane; + @override + final int laneCount; + @override + final DateTime startTime; + @override + final DateTime endTime; + LaidOutOverflow(this.appointments, this.lane, this.laneCount, this.startTime, this.endTime); +} + +/// Horizontal ordering rank for parallel appointments. Lower = further left. +/// User-owned custom events sit on the leftmost lane, cancelled lessons after +/// them, every other lesson last. Only used as a tiebreaker — the greedy lane +/// assignment still has to honor actual time-overlap constraints, so events +/// that start later can't jump left of events that started earlier and are +/// still occupying that lane. +int _appointmentPriority(Appointment a) { + final id = a.id; + if (id is CustomAppointment) return 0; + if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 1; + return 2; +} + +/// Assigns each appointment a lane index using a greedy sweep, then collapses +/// clusters that exceed [maxLanes] into `(maxLanes - 1)` visible appointments +/// + one trailing overflow cell. +/// +/// Greedy sweep: +/// 1. Sort by `startTime` ascending, then [_appointmentPriority] (custom → +/// cancelled → other) so parallel events land in the requested left-to- +/// right order, then `endTime` descending as a final tiebreaker. +/// 2. Walk the list, placing each appointment in the lowest-index lane that +/// is free at its `startTime`. When no lane is free, open a new one. +/// 3. A cluster ends as soon as every active lane's end is at or before the +/// next appointment's start. +List assignLanes(List appts, {required int maxLanes}) { + assert(maxLanes >= 2, 'maxLanes must reserve at least one slot for overflow'); + if (appts.isEmpty) return const []; + + final sorted = [...appts]..sort((a, b) { + final c = a.startTime.compareTo(b.startTime); + if (c != 0) return c; + final p = _appointmentPriority(a).compareTo(_appointmentPriority(b)); + if (p != 0) return p; + return b.endTime.compareTo(a.endTime); + }); + + // Phase 1: greedy lane assignment, grouped by cluster. + final clusters = >[]; + var current = <({Appointment apt, int lane})>[]; + var laneEnds = []; + + for (final apt in sorted) { + final allFree = + laneEnds.isNotEmpty && laneEnds.every((end) => !end.isAfter(apt.startTime)); + if (allFree) { + clusters.add(current); + current = <({Appointment apt, int lane})>[]; + laneEnds = []; + } + + var laneIdx = -1; + for (var i = 0; i < laneEnds.length; i++) { + if (!laneEnds[i].isAfter(apt.startTime)) { + laneIdx = i; + break; + } + } + if (laneIdx == -1) { + laneIdx = laneEnds.length; + laneEnds.add(apt.endTime); + } else { + laneEnds[laneIdx] = apt.endTime; + } + + current.add((apt: apt, lane: laneIdx)); + } + if (current.isNotEmpty) clusters.add(current); + + // Phase 2: emit cells per cluster, collapsing if too wide. + final result = []; + for (final cluster in clusters) { + final laneCount = + cluster.fold(0, (m, e) => e.lane + 1 > m ? e.lane + 1 : m); + + if (laneCount <= maxLanes) { + for (final entry in cluster) { + result.add(LaidOutAppointment(entry.apt, entry.lane, laneCount)); + } + } else { + // Too many parallel appointments: keep the highest-priority + // (maxLanes - 1) and collapse the rest into a single overflow cell in + // the trailing lane. Sorting by priority first means custom and + // cancelled lessons stay visible when the cluster has to be trimmed, + // matching the requested left-to-right order in the visible lanes. + final visibleCount = maxLanes - 1; + final byPriority = [...cluster.map((e) => e.apt)] + ..sort((a, b) { + final p = _appointmentPriority(a).compareTo(_appointmentPriority(b)); + if (p != 0) return p; + return a.startTime.compareTo(b.startTime); + }); + + for (var i = 0; i < visibleCount; i++) { + result.add(LaidOutAppointment(byPriority[i], i, maxLanes)); + } + + final overflow = byPriority.sublist(visibleCount); + var earliest = overflow.first.startTime; + var latest = overflow.first.endTime; + for (final a in overflow.skip(1)) { + if (a.startTime.isBefore(earliest)) earliest = a.startTime; + if (a.endTime.isAfter(latest)) latest = a.endTime; + } + result.add(LaidOutOverflow( + overflow, maxLanes - 1, maxLanes, earliest, latest)); + } + } + return result; +} diff --git a/lib/view/pages/timetable/details/custom_event_sheet.dart b/lib/view/pages/timetable/details/custom_event_sheet.dart index 1a66504..1615a9e 100644 --- a/lib/view/pages/timetable/details/custom_event_sheet.dart +++ b/lib/view/pages/timetable/details/custom_event_sheet.dart @@ -1,21 +1,19 @@ import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; import 'package:rrule/rrule.dart'; import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../../../extensions/date_time.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/debug/debug_tile.dart'; +import '../../../../widget/details_bottom_sheet.dart'; import '../custom_events/custom_event_edit_dialog.dart'; -import 'bottom_sheet.dart'; 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')}'; + final timeRange = event.startDate.timeRangeTo(event.endDate); - showAppointmentBottomSheet( + showDetailsBottomSheet( context, header: ListTile( leading: const Icon(Icons.event_outlined, size: 32), diff --git a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart index 2ada2ae..afd3230 100644 --- a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart +++ b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart @@ -1,38 +1,35 @@ -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -import '../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart'; -import '../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart'; import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; +import '../../../../api/webuntis/services/lesson_resolver.dart'; +import '../../../../extensions/date_time.dart'; +import '../../../../extensions/text.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../../widget/debug/debug_tile.dart'; +import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/unimplemented_dialog.dart'; -import 'bottom_sheet.dart'; class WebuntisLessonSheet { static void show(BuildContext context, TimetableBloc bloc, Appointment appointment, GetTimetableResponseObject lesson) { final state = bloc.state.data; if (state == null) return; - final headerSubject = _resolveSubject(state, lesson.su.firstOrNull?.id); - final headerTitle = _firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']); + final headerSubject = LessonResolver.resolveSubject(state, lesson.su.firstOrNull?.id); + 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')}'; + final timeRange = appointment.startTime.timeRangeTo(appointment.endTime); - showAppointmentBottomSheet( + showDetailsBottomSheet( context, header: ListTile( - leading: Icon(_iconForCode(lesson.code), size: 32), + leading: Icon(LessonFormatter.iconForCode(lesson.code), size: 32), title: Text( - '${_codePrefix(lesson.code)}$headerTitle', + '${LessonFormatter.codePrefix(lesson.code)}$headerTitle', style: const TextStyle(fontWeight: FontWeight.bold), ), subtitle: Text(headerLongName.isNotEmpty @@ -43,17 +40,17 @@ class WebuntisLessonSheet { children: (_) => [ ListTile( leading: const Icon(Icons.notifications_active), - title: Text('Status: ${_statusLabel(lesson.code)}'), + title: Text('Status: ${LessonFormatter.statusLabel(lesson.code)}'), ), if (lesson.su.length > 1) _listTile( icon: Icons.book_outlined, label: 'Fächer', entries: lesson.su.map((s) { - final resolved = _resolveSubject(state, s.id); - return _formatLine( - _firstNonEmpty([resolved.name, s.name, '?']), - longname: _firstNonEmpty([resolved.longName, s.longname, '']), + final resolved = LessonResolver.resolveSubject(state, s.id); + return LessonFormatter.formatLine( + firstNonEmpty([resolved.name, s.name, '?']), + longname: firstNonEmpty([resolved.longName, s.longname, '']), ); }).toList(), ), @@ -69,7 +66,7 @@ class WebuntisLessonSheet { icon: Icons.people, label: lesson.kl.length == 1 ? 'Klasse' : 'Klassen', entries: lesson.kl - .map((k) => _formatLine( + .map((k) => LessonFormatter.formatLine( k.name.isNotEmpty ? k.name : '?', longname: k.longname, )) @@ -81,17 +78,6 @@ class WebuntisLessonSheet { ); } - 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), @@ -107,11 +93,11 @@ class WebuntisLessonSheet { } final entries = lesson.ro.map((r) { - final resolved = _resolveRoom(state, r.id); - final name = _firstNonEmpty([resolved.name, r.name, '?']); - final longname = _firstNonEmpty([resolved.longName, r.longname, '']); + final resolved = LessonResolver.resolveRoom(state, r.id); + final name = firstNonEmpty([resolved.name, r.name, '?']); + final longname = firstNonEmpty([resolved.longName, r.longname, '']); final building = resolved.building.trim(); - return _formatLine( + return LessonFormatter.formatLine( name, longname: longname, extra: (building.isNotEmpty && building != '?') ? building : null, @@ -144,7 +130,7 @@ class WebuntisLessonSheet { } final entries = lesson.te.map((t) { - final base = _formatLine( + final base = LessonFormatter.formatLine( t.name.isNotEmpty ? t.name : '?', longname: t.longname, ); @@ -206,54 +192,4 @@ class WebuntisLessonSheet { subtitle: Text(text), ); } - - static String _formatLine(String name, {String? longname, String? extra}) { - final parts = [ - if (name.isNotEmpty) name else '?', - ]; - final ln = (longname ?? '').trim(); - if (ln.isNotEmpty && ln != name) parts.add('($ln)'); - final ex = (extra ?? '').trim(); - if (ex.isNotEmpty) parts.add('· $ex'); - return parts.join(' '); - } - - static String _firstNonEmpty(List values) { - for (final v in values) { - if (v.trim().isNotEmpty) return v; - } - return ''; - } - - static String _statusLabel(String? code) { - switch (code) { - case null: - case '': - return 'Regulär'; - case 'cancelled': - return 'Entfällt'; - case 'irregular': - return 'Geändert'; - default: - return code; - } - } - - static String _codePrefix(String? code) { - if (code == 'cancelled') return 'Entfällt: '; - if (code == 'irregular') return 'Änderung: '; - return code ?? ''; - } - - static GetSubjectsResponseObject _resolveSubject(TimetableState state, int? id) { - final fallback = GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true); - if (id == null) return fallback; - return state.subjects?.result.firstWhereOrNull((s) => s.id == id) ?? fallback; - } - - static GetRoomsResponseObject _resolveRoom(TimetableState state, int? id) { - final fallback = GetRoomsResponseObject(0, '?', 'Unbekannt', true, ''); - if (id == null) return fallback; - return state.rooms?.result.firstWhereOrNull((r) => r.id == id) ?? fallback; - } } diff --git a/lib/view/pages/timetable/widgets/calendar/day_header.dart b/lib/view/pages/timetable/widgets/calendar/day_header.dart new file mode 100644 index 0000000..3bd683d --- /dev/null +++ b/lib/view/pages/timetable/widgets/calendar/day_header.dart @@ -0,0 +1,84 @@ +part of '../custom_workweek_calendar.dart'; + +class _DayHeaderStrip extends StatelessWidget { + final DateTime weekStart; + final DateTime today; + final double rulerWidth; + + const _DayHeaderStrip({ + super.key, + required this.weekStart, + required this.today, + required this.rulerWidth, + }); + + @override + Widget build(BuildContext context) => Row( + children: [ + SizedBox(width: rulerWidth), + for (var d = 0; d < 5; d++) + Expanded( + child: _DayHeaderCell( + date: weekStart.add(Duration(days: d)), + today: today, + ), + ), + ], + ); +} + +class _DayHeaderCell extends StatelessWidget { + final DateTime date; + final DateTime today; + + const _DayHeaderCell({required this.date, required this.today}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isToday = date.isSameDay(today); + final dayName = DateFormat('EE', Localizations.localeOf(context).toString()).format(date).toUpperCase(); + + final accent = theme.colorScheme.primary; + final onAccent = theme.colorScheme.onPrimary; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + dayName, + style: theme.textTheme.labelSmall?.copyWith( + color: isToday ? accent : theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + fontSize: 12, + height: 1.1, + ), + ), + const SizedBox(height: 2), + AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + width: 28, + height: 28, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isToday ? accent : Colors.transparent, + ), + alignment: Alignment.center, + child: Text( + '${date.day}', + style: theme.textTheme.titleSmall?.copyWith( + color: isToday ? onAccent : theme.colorScheme.onSurface, + fontWeight: isToday ? FontWeight.bold : FontWeight.normal, + height: 1.0, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart new file mode 100644 index 0000000..098da70 --- /dev/null +++ b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart @@ -0,0 +1,271 @@ +part of '../custom_workweek_calendar.dart'; + +class _OutsideHoursStrip extends StatelessWidget { + static const int _maxVisibleChips = 2; + static const double _chipHeight = 22; + static const double _chipSpacing = 3; + static const double _verticalPadding = 3; + + final DateTime weekStart; + final List appointments; + final double rulerWidth; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + + const _OutsideHoursStrip({ + super.key, + required this.weekStart, + required this.appointments, + required this.rulerWidth, + required this.onAppointmentTap, + required this.isCrossedOut, + }); + + @override + Widget build(BuildContext context) { + final outside = partitionAppointmentsForWeek(appointments, weekStart).outside; + if (outside.every((day) => day.isEmpty)) return const SizedBox.shrink(); + + final theme = Theme.of(context); + final maxChipsPerDay = outside + .map((day) => day.length > _maxVisibleChips ? _maxVisibleChips : day.length) + .fold(0, (m, c) => c > m ? c : m); + final stripHeight = _verticalPadding * 2 + + maxChipsPerDay * _chipHeight + + (maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * _chipSpacing : 0); + + return Container( + color: theme.colorScheme.surfaceContainerLowest, + padding: const EdgeInsets.symmetric(vertical: _verticalPadding), + child: SizedBox( + height: stripHeight - _verticalPadding * 2, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: rulerWidth), + for (var d = 0; d < 5; d++) + Expanded( + child: _OutsideDayColumn( + appointments: outside[d], + maxVisible: _maxVisibleChips, + chipHeight: _chipHeight, + chipSpacing: _chipSpacing, + onAppointmentTap: onAppointmentTap, + isCrossedOut: isCrossedOut, + ), + ), + ], + ), + ), + ); + } +} + +class _OutsideDayColumn extends StatelessWidget { + final List appointments; + final int maxVisible; + final double chipHeight; + final double chipSpacing; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + + const _OutsideDayColumn({ + required this.appointments, + required this.maxVisible, + required this.chipHeight, + required this.chipSpacing, + required this.onAppointmentTap, + required this.isCrossedOut, + }); + + void _showOverflow(BuildContext context, List hidden) { + showDetailsBottomSheet( + context, + children: (sheetCtx) { + final tiles = []; + for (var i = 0; i < hidden.length; i++) { + if (i > 0) tiles.add(const Divider(height: 1)); + final apt = hidden[i]; + tiles.add(ListTile( + leading: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: apt.color, + borderRadius: BorderRadius.circular(3), + ), + ), + title: Text( + apt.subject, + style: isCrossedOut(apt) + ? const TextStyle(decoration: TextDecoration.lineThrough) + : null, + ), + subtitle: Text(_subtitleFor(apt)), + onTap: () { + Navigator.of(sheetCtx).pop(); + onAppointmentTap(apt); + }, + )); + } + return tiles; + }, + ); + } + + static String _subtitleFor(Appointment a) { + if (isAllDayLike(a)) return 'Ganztägig'; + return '${_hm(a.startTime)}–${_hm(a.endTime)}'; + } + + static String _hm(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + + @override + Widget build(BuildContext context) { + if (appointments.isEmpty) return const SizedBox.shrink(); + final sorted = [...appointments] + ..sort((a, b) { + final aLike = isAllDayLike(a); + final bLike = isAllDayLike(b); + if (aLike && !bLike) return -1; + if (!aLike && bLike) return 1; + return a.startTime.compareTo(b.startTime); + }); + final visible = sorted.length <= maxVisible + ? sorted + : sorted.take(maxVisible - 1).toList(); + final overflow = + sorted.length <= maxVisible ? const [] : sorted.skip(maxVisible - 1).toList(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < visible.length; i++) ...[ + if (i > 0) SizedBox(height: chipSpacing), + SizedBox( + height: chipHeight, + child: _OutsideChip( + appointment: visible[i], + onTap: () => onAppointmentTap(visible[i]), + ), + ), + ], + if (overflow.isNotEmpty) ...[ + SizedBox(height: chipSpacing), + SizedBox( + height: chipHeight, + child: _OutsideOverflowChip( + count: overflow.length, + onTap: () => _showOverflow(context, overflow), + ), + ), + ], + ], + ), + ); + } +} + +class _OutsideChip extends StatelessWidget { + final Appointment appointment; + final VoidCallback onTap; + + const _OutsideChip({required this.appointment, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final allDay = isAllDayLike(appointment); + final timeLabel = allDay + ? null + : '${appointment.startTime.hour.toString().padLeft(2, '0')}:${appointment.startTime.minute.toString().padLeft(2, '0')}'; + + // Past chips fade further, future/ongoing ones get a more saturated tint + // so the strip no longer reads as one uniform grey block. + final isPast = appointment.endTime.isBefore(DateTime.now()); + final backgroundAlpha = isPast ? 38 : 120; + final subjectColor = isPast + ? theme.colorScheme.onSurfaceVariant + : theme.colorScheme.onSurface; + final subjectWeight = isPast ? FontWeight.w400 : FontWeight.w600; + + return Material( + color: appointment.color.withAlpha(backgroundAlpha), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(7)), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Text( + appointment.subject, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + style: theme.textTheme.labelSmall?.copyWith( + color: subjectColor, + fontWeight: subjectWeight, + ), + ), + ), + if (timeLabel != null) ...[ + const SizedBox(width: 4), + Flexible( + child: Text( + timeLabel, + maxLines: 1, + overflow: TextOverflow.fade, + softWrap: false, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 10, + ), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _OutsideOverflowChip extends StatelessWidget { + final int count; + final VoidCallback onTap; + + const _OutsideOverflowChip({required this.count, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.secondaryContainer, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Center( + child: Text( + '+$count weitere', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } +} diff --git a/lib/view/pages/timetable/widgets/calendar/week_grid.dart b/lib/view/pages/timetable/widgets/calendar/week_grid.dart new file mode 100644 index 0000000..df4741f --- /dev/null +++ b/lib/view/pages/timetable/widgets/calendar/week_grid.dart @@ -0,0 +1,489 @@ +part of '../custom_workweek_calendar.dart'; + +class _WeekGrid extends StatelessWidget { + final DateTime weekStart; + final LessonPeriodSchedule schedule; + final List appointments; + final List timeRegions; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + final void Function(DateTime start, DateTime end)? onCreateEvent; + final DateTime today; + final ValueListenable nowNotifier; + final double rulerWidth; + final PeriodLayout layout; + + const _WeekGrid({ + required this.weekStart, + required this.schedule, + required this.appointments, + required this.timeRegions, + required this.onAppointmentTap, + required this.isCrossedOut, + required this.onCreateEvent, + required this.today, + required this.nowNotifier, + required this.rulerWidth, + required this.layout, + }); + + @override + Widget build(BuildContext context) { + final partitioned = partitionAppointmentsForWeek(appointments, weekStart); + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _PeriodRuler( + schedule: schedule, + layout: layout, + width: rulerWidth, + ), + for (var d = 0; d < 5; d++) + Expanded( + child: _DayColumn( + date: weekStart.add(Duration(days: d)), + schedule: schedule, + appointments: partitioned.inside[d], + timeRegions: timeRegions, + layout: layout, + today: today, + nowNotifier: nowNotifier, + onAppointmentTap: onAppointmentTap, + isCrossedOut: isCrossedOut, + onCreateEvent: onCreateEvent, + ), + ), + ], + ); + } +} + +class _PeriodRuler extends StatelessWidget { + final LessonPeriodSchedule schedule; + final PeriodLayout layout; + final double width; + + const _PeriodRuler({ + required this.schedule, + required this.layout, + required this.width, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + width: width, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (final period in schedule.periods) + Positioned( + top: layout.topOf(period), + height: layout.heightOf(period), + left: 0, + right: 0, + child: _PeriodLabel(period: period, theme: theme), + ), + ], + ), + ); + } +} + +class _PeriodLabel extends StatelessWidget { + final LessonPeriod period; + final ThemeData theme; + + const _PeriodLabel({required this.period, required this.theme}); + + @override + Widget build(BuildContext context) { + final dividerColor = theme.dividerColor.withAlpha(110); + final secondaryTextColor = theme.colorScheme.onSurfaceVariant; + + if (period.isBreak) { + return Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: dividerColor, width: 0.5), + bottom: BorderSide(color: dividerColor, width: 0.5), + ), + ), + alignment: Alignment.center, + child: Icon(Icons.coffee_outlined, size: 12, color: secondaryTextColor.withAlpha(180)), + ); + } + + final timeStyle = theme.textTheme.labelSmall?.copyWith( + color: secondaryTextColor.withAlpha(140), + height: 1.0, + fontSize: 9, + ); + const tightTextHeight = TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + ); + + return LayoutBuilder( + builder: (context, constraints) { + final showTimes = constraints.maxHeight >= 38; + return DecoratedBox( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: dividerColor, width: 0.5)), + ), + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + if (showTimes) + Positioned( + top: 3, + left: 0, + right: 0, + child: Text( + _format(period.start), + style: timeStyle, + textAlign: TextAlign.center, + textHeightBehavior: tightTextHeight, + ), + ), + Text( + period.name, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w500, + height: 1.0, + ), + textAlign: TextAlign.center, + textHeightBehavior: tightTextHeight, + ), + if (showTimes) + Positioned( + bottom: 3, + left: 0, + right: 0, + child: Text( + _format(period.end), + style: timeStyle, + textAlign: TextAlign.center, + textHeightBehavior: tightTextHeight, + ), + ), + ], + ), + ); + }, + ); + } + + static String _format(TimeOfDay t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; +} + +class _DayColumn extends StatelessWidget { + final DateTime date; + final LessonPeriodSchedule schedule; + final List appointments; + final List timeRegions; + final PeriodLayout layout; + final DateTime today; + final ValueListenable nowNotifier; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + final void Function(DateTime start, DateTime end)? onCreateEvent; + + const _DayColumn({ + required this.date, + required this.schedule, + required this.appointments, + required this.timeRegions, + required this.layout, + required this.today, + required this.nowNotifier, + required this.onAppointmentTap, + required this.isCrossedOut, + required this.onCreateEvent, + }); + + bool _overlapsExistingAppointment(DateTime start, DateTime end, List dayAppts) { + for (final a in dayAppts) { + if (a.endTime.isAfter(start) && a.startTime.isBefore(end)) return true; + } + return false; + } + + void _handleLongPress(LongPressStartDetails details, List dayAppts) { + if (onCreateEvent == null) return; + final period = layout.periodAtY(details.localPosition.dy); + if (period == null) return; + + final start = DateTime(date.year, date.month, date.day, period.start.hour, period.start.minute); + final end = DateTime(date.year, date.month, date.day, period.end.hour, period.end.minute); + if (_overlapsExistingAppointment(start, end, dayAppts)) return; + + HapticFeedback.mediumImpact(); + onCreateEvent!(start, end); + } + + void _showOverflowSheet(BuildContext context, List appointments) { + final sorted = [...appointments] + ..sort((a, b) => a.startTime.compareTo(b.startTime)); + showDetailsBottomSheet( + context, + children: (sheetContext) { + final tiles = []; + for (var i = 0; i < sorted.length; i++) { + if (i > 0) tiles.add(const Divider(height: 1)); + final apt = sorted[i]; + tiles.add(ListTile( + leading: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: apt.color, + borderRadius: BorderRadius.circular(3), + ), + ), + title: Text( + apt.subject, + style: isCrossedOut(apt) + ? const TextStyle(decoration: TextDecoration.lineThrough) + : null, + ), + subtitle: Text(_overflowSubtitle(apt)), + onTap: () { + Navigator.of(sheetContext).pop(); + onAppointmentTap(apt); + }, + )); + } + return tiles; + }, + ); + } + + static String _overflowSubtitle(Appointment apt) { + final time = '${_formatHm(apt.startTime)}–${_formatHm(apt.endTime)}'; + final loc = apt.location?.replaceAll('\n', ' · '); + return loc != null && loc.isNotEmpty ? '$time · $loc' : time; + } + + static String _formatHm(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final dayAppointments = appointments; + final dayRegions = expandRegionsForDay(timeRegions, date); + final isToday = date.isSameDay(today); + + final isTablet = MediaQuery.of(context).size.shortestSide >= 600; + final laidOut = assignLanes(dayAppointments, maxLanes: isTablet ? 3 : 2); + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPressStart: (details) => _handleLongPress(details, dayAppointments), + child: DecoratedBox( + decoration: BoxDecoration( + color: isToday ? theme.colorScheme.primary.withAlpha(14) : null, + border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + return Stack( + clipBehavior: Clip.none, + children: [ + for (final period in schedule.periods) + Positioned( + top: layout.topOf(period), + left: 0, + right: 0, + child: Container( + height: 0.5, + color: theme.dividerColor.withAlpha(60), + ), + ), + for (final region in dayRegions) + Positioned( + top: layout.yOfDateTime(region.start), + height: (layout.yOfDateTime(region.end) - + layout.yOfDateTime(region.start)) + .clamp(0, double.infinity), + left: 0, + right: 0, + child: TimeRegionTile(region: region.region), + ), + for (final cell in laidOut) + Positioned( + top: layout.yOfDateTime(cell.startTime), + height: (layout.yOfDateTime(cell.endTime) - + layout.yOfDateTime(cell.startTime)) + .clamp(0, double.infinity), + left: cell.lane * width / cell.laneCount, + width: width / cell.laneCount, + child: switch (cell) { + LaidOutAppointment(:final appointment) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onAppointmentTap(appointment), + child: AppointmentTile( + appointment: appointment, + crossedOut: isCrossedOut(appointment), + ), + ), + LaidOutOverflow(:final appointments) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => + _showOverflowSheet(context, appointments), + child: _OverflowTile(count: appointments.length), + ), + }, + ), + if (isToday) + ValueListenableBuilder( + valueListenable: nowNotifier, + builder: (_, now, child) => + _CurrentTimeMarker(now: now, layout: layout, theme: theme), + ), + ], + ); + }, + ), + ), + ); + } +} + +class _CurrentTimeMarker extends StatelessWidget { + final DateTime now; + final PeriodLayout layout; + final ThemeData theme; + + const _CurrentTimeMarker({ + required this.now, + required this.layout, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + final periods = layout.periods; + if (periods.isEmpty) return const SizedBox.shrink(); + final tMin = now.hour * 60 + now.minute; + final firstStart = + periods.first.start.hour * 60 + periods.first.start.minute; + final lastEnd = + periods.last.end.hour * 60 + periods.last.end.minute; + if (tMin < firstStart || tMin > lastEnd) return const SizedBox.shrink(); + + final y = layout.yOfDateTime(now); + + return AnimatedPositioned( + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + top: y - 1, + left: 0, + right: 0, + child: IgnorePointer( + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + height: 2, + color: theme.colorScheme.primary, + ), + Positioned( + top: -3, + left: -4, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: theme.colorScheme.primary, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _OverflowTile extends StatelessWidget { + final int count; + const _OverflowTile({required this.count}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + const radius = BorderRadius.all(Radius.circular(7)); + + return Padding( + padding: const EdgeInsets.all(1), + child: Stack( + children: [ + // Card peeking out at the bottom — visual hint that more cards lie + // underneath the visible one. + Positioned( + top: 4, + left: 2, + right: 2, + bottom: 0, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: radius, + color: scheme.secondaryContainer.withAlpha(120), + ), + ), + ), + // Front card with the "+N" indicator. + Positioned( + top: 0, + left: 0, + right: 0, + bottom: 4, + child: Container( + decoration: BoxDecoration( + borderRadius: radius, + color: scheme.secondaryContainer, + ), + alignment: Alignment.center, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.unfold_more_rounded, + size: 18, + color: scheme.onSecondaryContainer, + ), + Text( + '+$count', + style: theme.textTheme.titleSmall?.copyWith( + color: scheme.onSecondaryContainer, + fontWeight: FontWeight.w700, + height: 1.0, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 8a6ba56..642618f 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -1,18 +1,30 @@ +/// Custom 5-day work-week calendar (replaces Syncfusion's `WorkWeek` view). +/// +/// Implementation is split across `calendar/` for readability; everything +/// stays in this single library so private widgets and helpers (`_DayColumn`, +/// `_PeriodLayout`, `_isAllDayLike`, …) can remain library-private. +library; + import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; -import 'package:rrule/rrule.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -import '../data/arbitrary_appointment.dart'; +import '../../../../extensions/date_time.dart'; +import '../../../../widget/details_bottom_sheet.dart'; import '../data/calendar_layout.dart'; +import '../data/calendar_logic.dart'; import '../data/lesson_period_schedule.dart'; import 'appointment_tile.dart'; import 'time_region_tile.dart'; +part 'calendar/day_header.dart'; +part 'calendar/outside_chips.dart'; +part 'calendar/week_grid.dart'; + class CustomWorkWeekCalendar extends StatefulWidget { final LessonPeriodSchedule schedule; final List appointments; @@ -166,7 +178,7 @@ class CustomWorkWeekCalendarState extends State { final lessonH = fitLessonH < kLessonBlockMinHeight ? kLessonBlockMinHeight : fitLessonH; - final layout = _PeriodLayout( + final layout = PeriodLayout( periods: periods, lessonHeight: lessonH, breakHeight: kBreakBlockHeight, @@ -211,1223 +223,3 @@ class CustomWorkWeekCalendarState extends State { ); } } - -class _OutsideHoursStrip extends StatelessWidget { - static const int _maxVisibleChips = 2; - static const double _chipHeight = 22; - static const double _chipSpacing = 3; - static const double _verticalPadding = 3; - - final DateTime weekStart; - final List appointments; - final double rulerWidth; - final void Function(Appointment) onAppointmentTap; - final bool Function(Appointment) isCrossedOut; - - const _OutsideHoursStrip({ - super.key, - required this.weekStart, - required this.appointments, - required this.rulerWidth, - required this.onAppointmentTap, - required this.isCrossedOut, - }); - - @override - Widget build(BuildContext context) { - final outside = _partitionAppointmentsForWeek(appointments, weekStart).outside; - if (outside.every((day) => day.isEmpty)) return const SizedBox.shrink(); - - final theme = Theme.of(context); - final maxChipsPerDay = outside - .map((day) => day.length > _maxVisibleChips ? _maxVisibleChips : day.length) - .fold(0, (m, c) => c > m ? c : m); - final stripHeight = _verticalPadding * 2 + - maxChipsPerDay * _chipHeight + - (maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * _chipSpacing : 0); - - return Container( - color: theme.colorScheme.surfaceContainerLowest, - padding: const EdgeInsets.symmetric(vertical: _verticalPadding), - child: SizedBox( - height: stripHeight - _verticalPadding * 2, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(width: rulerWidth), - for (var d = 0; d < 5; d++) - Expanded( - child: _OutsideDayColumn( - appointments: outside[d], - maxVisible: _maxVisibleChips, - chipHeight: _chipHeight, - chipSpacing: _chipSpacing, - onAppointmentTap: onAppointmentTap, - isCrossedOut: isCrossedOut, - ), - ), - ], - ), - ), - ); - } -} - -class _OutsideDayColumn extends StatelessWidget { - final List appointments; - final int maxVisible; - final double chipHeight; - final double chipSpacing; - final void Function(Appointment) onAppointmentTap; - final bool Function(Appointment) isCrossedOut; - - const _OutsideDayColumn({ - required this.appointments, - required this.maxVisible, - required this.chipHeight, - required this.chipSpacing, - required this.onAppointmentTap, - required this.isCrossedOut, - }); - - void _showOverflow(BuildContext context, List hidden) { - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetCtx) => SafeArea( - child: ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: 4), - itemCount: hidden.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (_, i) { - final apt = hidden[i]; - return ListTile( - leading: Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: apt.color, - borderRadius: BorderRadius.circular(3), - ), - ), - title: Text( - apt.subject, - style: isCrossedOut(apt) - ? const TextStyle(decoration: TextDecoration.lineThrough) - : null, - ), - subtitle: Text(_subtitleFor(apt)), - onTap: () { - Navigator.of(sheetCtx).pop(); - onAppointmentTap(apt); - }, - ); - }, - ), - ), - ); - } - - static String _subtitleFor(Appointment a) { - if (_isAllDayLike(a)) return 'Ganztägig'; - return '${_hm(a.startTime)}–${_hm(a.endTime)}'; - } - - static String _hm(DateTime t) => - '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; - - @override - Widget build(BuildContext context) { - if (appointments.isEmpty) return const SizedBox.shrink(); - final sorted = [...appointments] - ..sort((a, b) { - final aLike = _isAllDayLike(a); - final bLike = _isAllDayLike(b); - if (aLike && !bLike) return -1; - if (!aLike && bLike) return 1; - return a.startTime.compareTo(b.startTime); - }); - final visible = sorted.length <= maxVisible - ? sorted - : sorted.take(maxVisible - 1).toList(); - final overflow = - sorted.length <= maxVisible ? const [] : sorted.skip(maxVisible - 1).toList(); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (var i = 0; i < visible.length; i++) ...[ - if (i > 0) SizedBox(height: chipSpacing), - SizedBox( - height: chipHeight, - child: _OutsideChip( - appointment: visible[i], - onTap: () => onAppointmentTap(visible[i]), - ), - ), - ], - if (overflow.isNotEmpty) ...[ - SizedBox(height: chipSpacing), - SizedBox( - height: chipHeight, - child: _OutsideOverflowChip( - count: overflow.length, - onTap: () => _showOverflow(context, overflow), - ), - ), - ], - ], - ), - ); - } -} - -class _OutsideChip extends StatelessWidget { - final Appointment appointment; - final VoidCallback onTap; - - const _OutsideChip({required this.appointment, required this.onTap}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final allDay = _isAllDayLike(appointment); - final timeLabel = allDay - ? null - : '${appointment.startTime.hour.toString().padLeft(2, '0')}:${appointment.startTime.minute.toString().padLeft(2, '0')}'; - - // Past chips fade further, future/ongoing ones get a more saturated tint - // so the strip no longer reads as one uniform grey block. - final isPast = appointment.endTime.isBefore(DateTime.now()); - final backgroundAlpha = isPast ? 38 : 120; - final subjectColor = isPast - ? theme.colorScheme.onSurfaceVariant - : theme.colorScheme.onSurface; - final subjectWeight = isPast ? FontWeight.w400 : FontWeight.w600; - - return Material( - color: appointment.color.withAlpha(backgroundAlpha), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(7)), - ), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - child: Row( - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - child: Text( - appointment.subject, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: theme.textTheme.labelSmall?.copyWith( - color: subjectColor, - fontWeight: subjectWeight, - ), - ), - ), - if (timeLabel != null) ...[ - const SizedBox(width: 4), - Flexible( - child: Text( - timeLabel, - maxLines: 1, - overflow: TextOverflow.fade, - softWrap: false, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontSize: 10, - ), - ), - ), - ], - ], - ), - ), - ), - ); - } -} - -class _OutsideOverflowChip extends StatelessWidget { - final int count; - final VoidCallback onTap; - - const _OutsideOverflowChip({required this.count, required this.onTap}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Material( - color: theme.colorScheme.secondaryContainer, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Center( - child: Text( - '+$count weitere', - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSecondaryContainer, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ); - } -} - -class _DayHeaderStrip extends StatelessWidget { - final DateTime weekStart; - final DateTime today; - final double rulerWidth; - - const _DayHeaderStrip({ - super.key, - required this.weekStart, - required this.today, - required this.rulerWidth, - }); - - @override - Widget build(BuildContext context) => Row( - children: [ - SizedBox(width: rulerWidth), - for (var d = 0; d < 5; d++) - Expanded( - child: _DayHeaderCell( - date: weekStart.add(Duration(days: d)), - today: today, - ), - ), - ], - ); -} - -class _DayHeaderCell extends StatelessWidget { - final DateTime date; - final DateTime today; - - const _DayHeaderCell({required this.date, required this.today}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isToday = _isSameDay(date, today); - final dayName = DateFormat('EE', Localizations.localeOf(context).toString()).format(date).toUpperCase(); - - final accent = theme.colorScheme.primary; - final onAccent = theme.colorScheme.onPrimary; - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - dayName, - style: theme.textTheme.labelSmall?.copyWith( - color: isToday ? accent : theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - fontSize: 12, - height: 1.1, - ), - ), - const SizedBox(height: 2), - AnimatedContainer( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - width: 28, - height: 28, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isToday ? accent : Colors.transparent, - ), - alignment: Alignment.center, - child: Text( - '${date.day}', - style: theme.textTheme.titleSmall?.copyWith( - color: isToday ? onAccent : theme.colorScheme.onSurface, - fontWeight: isToday ? FontWeight.bold : FontWeight.normal, - height: 1.0, - ), - ), - ), - ], - ), - ); - } -} - -class _WeekGrid extends StatelessWidget { - final DateTime weekStart; - final LessonPeriodSchedule schedule; - final List appointments; - final List timeRegions; - final void Function(Appointment) onAppointmentTap; - final bool Function(Appointment) isCrossedOut; - final void Function(DateTime start, DateTime end)? onCreateEvent; - final DateTime today; - final ValueListenable nowNotifier; - final double rulerWidth; - final _PeriodLayout layout; - - const _WeekGrid({ - required this.weekStart, - required this.schedule, - required this.appointments, - required this.timeRegions, - required this.onAppointmentTap, - required this.isCrossedOut, - required this.onCreateEvent, - required this.today, - required this.nowNotifier, - required this.rulerWidth, - required this.layout, - }); - - @override - Widget build(BuildContext context) { - final partitioned = _partitionAppointmentsForWeek(appointments, weekStart); - - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _PeriodRuler( - schedule: schedule, - layout: layout, - width: rulerWidth, - ), - for (var d = 0; d < 5; d++) - Expanded( - child: _DayColumn( - date: weekStart.add(Duration(days: d)), - schedule: schedule, - appointments: partitioned.inside[d], - timeRegions: timeRegions, - layout: layout, - today: today, - nowNotifier: nowNotifier, - onAppointmentTap: onAppointmentTap, - isCrossedOut: isCrossedOut, - onCreateEvent: onCreateEvent, - ), - ), - ], - ); - } -} - -class _PeriodRuler extends StatelessWidget { - final LessonPeriodSchedule schedule; - final _PeriodLayout layout; - final double width; - - const _PeriodRuler({ - required this.schedule, - required this.layout, - required this.width, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return SizedBox( - width: width, - child: Stack( - clipBehavior: Clip.none, - children: [ - for (final period in schedule.periods) - Positioned( - top: layout.topOf(period), - height: layout.heightOf(period), - left: 0, - right: 0, - child: _PeriodLabel(period: period, theme: theme), - ), - ], - ), - ); - } -} - -class _PeriodLabel extends StatelessWidget { - final LessonPeriod period; - final ThemeData theme; - - const _PeriodLabel({required this.period, required this.theme}); - - @override - Widget build(BuildContext context) { - final dividerColor = theme.dividerColor.withAlpha(110); - final secondaryTextColor = theme.colorScheme.onSurfaceVariant; - - if (period.isBreak) { - return Container( - decoration: BoxDecoration( - border: Border( - top: BorderSide(color: dividerColor, width: 0.5), - bottom: BorderSide(color: dividerColor, width: 0.5), - ), - ), - alignment: Alignment.center, - child: Icon(Icons.coffee_outlined, size: 12, color: secondaryTextColor.withAlpha(180)), - ); - } - - final timeStyle = theme.textTheme.labelSmall?.copyWith( - color: secondaryTextColor.withAlpha(140), - height: 1.0, - fontSize: 9, - ); - const tightTextHeight = TextHeightBehavior( - applyHeightToFirstAscent: false, - applyHeightToLastDescent: false, - ); - - return LayoutBuilder( - builder: (context, constraints) { - final showTimes = constraints.maxHeight >= 38; - return DecoratedBox( - decoration: BoxDecoration( - border: Border(top: BorderSide(color: dividerColor, width: 0.5)), - ), - child: Stack( - clipBehavior: Clip.none, - alignment: Alignment.center, - children: [ - if (showTimes) - Positioned( - top: 3, - left: 0, - right: 0, - child: Text( - _format(period.start), - style: timeStyle, - textAlign: TextAlign.center, - textHeightBehavior: tightTextHeight, - ), - ), - Text( - period.name, - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurface, - fontWeight: FontWeight.w500, - height: 1.0, - ), - textAlign: TextAlign.center, - textHeightBehavior: tightTextHeight, - ), - if (showTimes) - Positioned( - bottom: 3, - left: 0, - right: 0, - child: Text( - _format(period.end), - style: timeStyle, - textAlign: TextAlign.center, - textHeightBehavior: tightTextHeight, - ), - ), - ], - ), - ); - }, - ); - } - - static String _format(TimeOfDay t) => - '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; -} - -class _DayColumn extends StatelessWidget { - final DateTime date; - final LessonPeriodSchedule schedule; - final List appointments; - final List timeRegions; - final _PeriodLayout layout; - final DateTime today; - final ValueListenable nowNotifier; - final void Function(Appointment) onAppointmentTap; - final bool Function(Appointment) isCrossedOut; - final void Function(DateTime start, DateTime end)? onCreateEvent; - - const _DayColumn({ - required this.date, - required this.schedule, - required this.appointments, - required this.timeRegions, - required this.layout, - required this.today, - required this.nowNotifier, - required this.onAppointmentTap, - required this.isCrossedOut, - required this.onCreateEvent, - }); - - bool _overlapsExistingAppointment(DateTime start, DateTime end, List dayAppts) { - for (final a in dayAppts) { - if (a.endTime.isAfter(start) && a.startTime.isBefore(end)) return true; - } - return false; - } - - void _handleLongPress(LongPressStartDetails details, List dayAppts) { - if (onCreateEvent == null) return; - final period = layout.periodAtY(details.localPosition.dy); - if (period == null) return; - - final start = DateTime(date.year, date.month, date.day, period.start.hour, period.start.minute); - final end = DateTime(date.year, date.month, date.day, period.end.hour, period.end.minute); - if (_overlapsExistingAppointment(start, end, dayAppts)) return; - - HapticFeedback.mediumImpact(); - onCreateEvent!(start, end); - } - - void _showOverflowSheet(BuildContext context, List appointments) { - final sorted = [...appointments] - ..sort((a, b) => a.startTime.compareTo(b.startTime)); - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: 4), - itemCount: sorted.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (_, i) { - final apt = sorted[i]; - return ListTile( - leading: Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: apt.color, - borderRadius: BorderRadius.circular(3), - ), - ), - title: Text( - apt.subject, - style: isCrossedOut(apt) - ? const TextStyle(decoration: TextDecoration.lineThrough) - : null, - ), - subtitle: Text(_overflowSubtitle(apt)), - onTap: () { - Navigator.of(sheetContext).pop(); - onAppointmentTap(apt); - }, - ); - }, - ), - ), - ); - } - - static String _overflowSubtitle(Appointment apt) { - final time = '${_formatHm(apt.startTime)}–${_formatHm(apt.endTime)}'; - final loc = apt.location?.replaceAll('\n', ' · '); - return loc != null && loc.isNotEmpty ? '$time · $loc' : time; - } - - static String _formatHm(DateTime t) => - '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - final dayAppointments = appointments; - final dayRegions = _expandRegionsForDay(timeRegions, date); - final isToday = _isSameDay(date, today); - - final isTablet = MediaQuery.of(context).size.shortestSide >= 600; - final laidOut = _assignLanes(dayAppointments, maxLanes: isTablet ? 3 : 2); - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onLongPressStart: (details) => _handleLongPress(details, dayAppointments), - child: DecoratedBox( - decoration: BoxDecoration( - color: isToday ? theme.colorScheme.primary.withAlpha(14) : null, - border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)), - ), - child: LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - return Stack( - clipBehavior: Clip.none, - children: [ - for (final period in schedule.periods) - Positioned( - top: layout.topOf(period), - left: 0, - right: 0, - child: Container( - height: 0.5, - color: theme.dividerColor.withAlpha(60), - ), - ), - for (final region in dayRegions) - Positioned( - top: layout.yOfDateTime(region.start), - height: (layout.yOfDateTime(region.end) - - layout.yOfDateTime(region.start)) - .clamp(0, double.infinity), - left: 0, - right: 0, - child: TimeRegionTile(region: region.region), - ), - for (final cell in laidOut) - Positioned( - top: layout.yOfDateTime(cell.startTime), - height: (layout.yOfDateTime(cell.endTime) - - layout.yOfDateTime(cell.startTime)) - .clamp(0, double.infinity), - left: cell.lane * width / cell.laneCount, - width: width / cell.laneCount, - child: switch (cell) { - _LaidOutAppointment(:final appointment) => GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onAppointmentTap(appointment), - child: AppointmentTile( - appointment: appointment, - crossedOut: isCrossedOut(appointment), - ), - ), - _LaidOutOverflow(:final appointments) => GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => - _showOverflowSheet(context, appointments), - child: _OverflowTile(count: appointments.length), - ), - }, - ), - if (isToday) - ValueListenableBuilder( - valueListenable: nowNotifier, - builder: (_, now, child) => - _CurrentTimeMarker(now: now, layout: layout, theme: theme), - ), - ], - ); - }, - ), - ), - ); - } -} - -class _CurrentTimeMarker extends StatelessWidget { - final DateTime now; - final _PeriodLayout layout; - final ThemeData theme; - - const _CurrentTimeMarker({ - required this.now, - required this.layout, - required this.theme, - }); - - @override - Widget build(BuildContext context) { - final periods = layout.periods; - if (periods.isEmpty) return const SizedBox.shrink(); - final tMin = now.hour * 60 + now.minute; - final firstStart = - periods.first.start.hour * 60 + periods.first.start.minute; - final lastEnd = - periods.last.end.hour * 60 + periods.last.end.minute; - if (tMin < firstStart || tMin > lastEnd) return const SizedBox.shrink(); - - final y = layout.yOfDateTime(now); - - return AnimatedPositioned( - duration: const Duration(milliseconds: 400), - curve: Curves.easeInOut, - top: y - 1, - left: 0, - right: 0, - child: IgnorePointer( - child: Stack( - clipBehavior: Clip.none, - children: [ - Container( - height: 2, - color: theme.colorScheme.primary, - ), - Positioned( - top: -3, - left: -4, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: theme.colorScheme.primary, - shape: BoxShape.circle, - ), - ), - ), - ], - ), - ), - ); - } -} - -class _BoundRegion { - final TimeRegion region; - final DateTime start; - final DateTime end; - - _BoundRegion({required this.region, required this.start, required this.end}); -} - -List<_BoundRegion> _expandRegionsForDay(List regions, DateTime day) { - final result = <_BoundRegion>[]; - final dayStart = DateTime(day.year, day.month, day.day); - for (final region in regions) { - final isRecurringDaily = region.recurrenceRule != null && - region.recurrenceRule!.toUpperCase().contains('FREQ=DAILY'); - if (isRecurringDaily) { - final start = dayStart.add(Duration( - hours: region.startTime.hour, - minutes: region.startTime.minute, - )); - final end = dayStart.add(Duration( - hours: region.endTime.hour, - minutes: region.endTime.minute, - )); - result.add(_BoundRegion(region: region, start: start, end: end)); - } else if (_isSameDay(region.startTime, day)) { - result.add(_BoundRegion( - region: region, - start: region.startTime, - end: region.endTime, - )); - } - } - return result; -} - -bool _isSameDay(DateTime a, DateTime b) => - a.year == b.year && a.month == b.month && a.day == b.day; - -/// Expands the given list of appointments across the visible 5-day work week -/// (resolving RRULE recurrences) and splits each day's events into two -/// buckets: those that fit within the school-hours grid (`inside`) and those -/// that don't (`outside` — all-day events and events that start before -/// [kCalendarStartHour] or end after [kCalendarEndHour]). The outside bucket -/// is rendered as chips above the grid. -({List> inside, List> outside}) - _partitionAppointmentsForWeek( - List appointments, DateTime weekStart) { - final inside = List>.generate(5, (_) => []); - final outside = List>.generate(5, (_) => []); - final weekEnd = weekStart.add(const Duration(days: 5)); - final weekStartUtc = weekStart.toUtc(); - final weekEndUtc = weekEnd.toUtc(); - - void place(int idx, Appointment a) { - if (_isOutsideSchoolHours(a)) { - outside[idx].add(a); - } else { - inside[idx].add(a); - } - } - - for (final a in appointments) { - final rule = a.recurrenceRule; - if (rule == null || rule.isEmpty) { - final idx = _dayIndex(a.startTime, weekStart); - if (idx >= 0 && idx < 5) place(idx, a); - continue; - } - try { - final parsed = RecurrenceRule.fromString(rule); - final anchorUtc = a.startTime.toUtc(); - final duration = a.endTime.difference(a.startTime); - for (final occUtc in parsed.getInstances(start: anchorUtc)) { - if (!occUtc.isBefore(weekEndUtc)) break; - if (occUtc.isBefore(weekStartUtc)) continue; - final occLocal = occUtc.toLocal(); - final idx = DateTime(occLocal.year, occLocal.month, occLocal.day) - .difference(weekStart) - .inDays; - if (idx < 0 || idx >= 5) continue; - final newStart = DateTime(occLocal.year, occLocal.month, occLocal.day, - a.startTime.hour, a.startTime.minute); - place( - idx, - Appointment( - id: a.id, - startTime: newStart, - endTime: newStart.add(duration), - subject: a.subject, - color: a.color, - location: a.location, - notes: a.notes, - isAllDay: a.isAllDay, - ), - ); - } - } catch (_) { - final idx = _dayIndex(a.startTime, weekStart); - if (idx >= 0 && idx < 5) place(idx, a); - } - } - return (inside: inside, outside: outside); -} - -int _dayIndex(DateTime t, DateTime weekStart) => - DateTime(t.year, t.month, t.day).difference(weekStart).inDays; - -/// True when the appointment doesn't fit into the school-hours grid: -/// all-day, fully before the grid start, fully after the grid end, engulfing -/// the grid entirely, or lasting 10+ hours (treated as a de-facto all-day -/// event the source system happens to represent with explicit times). -bool _isOutsideSchoolHours(Appointment a) { - if (_isAllDayLike(a)) return true; - final schoolStart = (kCalendarStartHour * 60).round(); - final schoolEnd = (kCalendarEndHour * 60).round(); - final startMin = a.startTime.hour * 60 + a.startTime.minute; - final endMin = a.endTime.hour * 60 + a.endTime.minute; - if (endMin <= schoolStart) return true; - if (startMin >= schoolEnd) return true; - if (startMin <= schoolStart && endMin >= schoolEnd) return true; - return false; -} - -/// Either explicitly marked as all-day, or so long it's effectively a full -/// day from the user's perspective. We compare in minutes (not hours) because -/// `Duration.inHours` truncates: a 9h 30min event would otherwise count as 9. -bool _isAllDayLike(Appointment a) => - a.isAllDay || a.endTime.difference(a.startTime).inMinutes >= 8 * 60; - -/// Maps lesson periods to vertical screen positions. Every non-break period -/// gets the same fixed height (`lessonHeight`), every break gets `breakHeight`. -/// Short transition gaps (Wechselzeiten) between periods are not represented -/// at all — periods are rendered back-to-back, so a 5-minute gap simply -/// disappears visually. -class _PeriodLayout { - final List periods; - final double lessonHeight; - final double breakHeight; - - const _PeriodLayout({ - required this.periods, - required this.lessonHeight, - required this.breakHeight, - }); - - double _h(LessonPeriod p) => p.isBreak ? breakHeight : lessonHeight; - - double get totalHeight => - periods.fold(0, (sum, p) => sum + _h(p)); - - double topOf(LessonPeriod period) { - var y = 0.0; - for (final p in periods) { - if (identical(p, period)) return y; - y += _h(p); - } - return y; - } - - double heightOf(LessonPeriod period) => _h(period); - - /// Vertical offset for a given time of day. Times inside a period are mapped - /// proportionally; times that fall into a transition gap are clipped to the - /// end of the preceding period. Times before the first / after the last - /// period clip to 0 / [totalHeight]. - double yOf(TimeOfDay t) { - final tMin = t.hour * 60 + t.minute; - var y = 0.0; - for (final p in periods) { - final pStart = p.start.hour * 60 + p.start.minute; - final pEnd = p.end.hour * 60 + p.end.minute; - final h = _h(p); - if (tMin < pStart) return y; - if (tMin <= pEnd) { - final span = pEnd - pStart; - final ratio = span > 0 ? (tMin - pStart) / span : 0.0; - return y + ratio * h; - } - y += h; - } - return y; - } - - double yOfDateTime(DateTime t) { - final tMin = t.hour * 60 + t.minute + t.second / 60.0; - var y = 0.0; - for (final p in periods) { - final pStart = p.start.hour * 60 + p.start.minute; - final pEnd = p.end.hour * 60 + p.end.minute; - final h = _h(p); - if (tMin < pStart) return y; - if (tMin <= pEnd) { - final span = pEnd - pStart; - final ratio = span > 0 ? (tMin - pStart) / span : 0.0; - return y + ratio * h; - } - y += h; - } - return y; - } - - /// Period at a given y-offset. If y falls into a break, returns the next - /// non-break period. Returns null when y is past the last period. - LessonPeriod? periodAtY(double y) { - var cursor = 0.0; - for (var i = 0; i < periods.length; i++) { - final p = periods[i]; - final h = _h(p); - if (y >= cursor && y < cursor + h) { - if (p.isBreak) { - for (var j = i + 1; j < periods.length; j++) { - if (!periods[j].isBreak) return periods[j]; - } - return null; - } - return p; - } - cursor += h; - } - return null; - } -} - - -class _OverflowTile extends StatelessWidget { - final int count; - const _OverflowTile({required this.count}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - const radius = BorderRadius.all(Radius.circular(7)); - - return Padding( - padding: const EdgeInsets.all(1), - child: Stack( - children: [ - // Card peeking out at the bottom — visual hint that more cards lie - // underneath the visible one. - Positioned( - top: 4, - left: 2, - right: 2, - bottom: 0, - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: radius, - color: scheme.secondaryContainer.withAlpha(120), - ), - ), - ), - // Front card with the "+N" indicator. - Positioned( - top: 0, - left: 0, - right: 0, - bottom: 4, - child: Container( - decoration: BoxDecoration( - borderRadius: radius, - color: scheme.secondaryContainer, - ), - alignment: Alignment.center, - child: FittedBox( - fit: BoxFit.scaleDown, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.unfold_more_rounded, - size: 18, - color: scheme.onSecondaryContainer, - ), - Text( - '+$count', - style: theme.textTheme.titleSmall?.copyWith( - color: scheme.onSecondaryContainer, - fontWeight: FontWeight.w700, - height: 1.0, - ), - ), - ], - ), - ), - ), - ), - ), - ], - ), - ); - } -} - -/// One cell rendered in the day column — either a regular appointment or an -/// overflow placeholder representing several hidden appointments. -sealed class _LaidOutCell { - int get lane; - int get laneCount; - DateTime get startTime; - DateTime get endTime; -} - -class _LaidOutAppointment extends _LaidOutCell { - final Appointment appointment; - @override - final int lane; - @override - final int laneCount; - _LaidOutAppointment(this.appointment, this.lane, this.laneCount); - - @override - DateTime get startTime => appointment.startTime; - @override - DateTime get endTime => appointment.endTime; -} - -class _LaidOutOverflow extends _LaidOutCell { - final List appointments; - @override - final int lane; - @override - final int laneCount; - @override - final DateTime startTime; - @override - final DateTime endTime; - _LaidOutOverflow(this.appointments, this.lane, this.laneCount, this.startTime, this.endTime); -} - -/// Horizontal ordering rank for parallel appointments. Lower = further left. -/// User-owned custom events sit on the leftmost lane, cancelled lessons after -/// them, every other lesson last. Only used as a tiebreaker — the greedy lane -/// assignment still has to honor actual time-overlap constraints, so events -/// that start later can't jump left of events that started earlier and are -/// still occupying that lane. -int _appointmentPriority(Appointment a) { - final id = a.id; - if (id is CustomAppointment) return 0; - if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 1; - return 2; -} - -/// Assigns each appointment a lane index using a greedy sweep, then collapses -/// clusters that exceed [maxLanes] into `(maxLanes - 1)` visible appointments -/// + one trailing overflow cell. -/// -/// Greedy sweep: -/// 1. Sort by `startTime` ascending, then [_appointmentPriority] (custom → -/// cancelled → other) so parallel events land in the requested left-to- -/// right order, then `endTime` descending as a final tiebreaker. -/// 2. Walk the list, placing each appointment in the lowest-index lane that -/// is free at its `startTime`. When no lane is free, open a new one. -/// 3. A cluster ends as soon as every active lane's end is at or before the -/// next appointment's start. -List<_LaidOutCell> _assignLanes(List appts, {required int maxLanes}) { - assert(maxLanes >= 2, 'maxLanes must reserve at least one slot for overflow'); - if (appts.isEmpty) return const <_LaidOutCell>[]; - - final sorted = [...appts]..sort((a, b) { - final c = a.startTime.compareTo(b.startTime); - if (c != 0) return c; - final p = _appointmentPriority(a).compareTo(_appointmentPriority(b)); - if (p != 0) return p; - return b.endTime.compareTo(a.endTime); - }); - - // Phase 1: greedy lane assignment, grouped by cluster. - final clusters = >[]; - var current = <({Appointment apt, int lane})>[]; - var laneEnds = []; - - for (final apt in sorted) { - final allFree = - laneEnds.isNotEmpty && laneEnds.every((end) => !end.isAfter(apt.startTime)); - if (allFree) { - clusters.add(current); - current = <({Appointment apt, int lane})>[]; - laneEnds = []; - } - - var laneIdx = -1; - for (var i = 0; i < laneEnds.length; i++) { - if (!laneEnds[i].isAfter(apt.startTime)) { - laneIdx = i; - break; - } - } - if (laneIdx == -1) { - laneIdx = laneEnds.length; - laneEnds.add(apt.endTime); - } else { - laneEnds[laneIdx] = apt.endTime; - } - - current.add((apt: apt, lane: laneIdx)); - } - if (current.isNotEmpty) clusters.add(current); - - // Phase 2: emit cells per cluster, collapsing if too wide. - final result = <_LaidOutCell>[]; - for (final cluster in clusters) { - final laneCount = - cluster.fold(0, (m, e) => e.lane + 1 > m ? e.lane + 1 : m); - - if (laneCount <= maxLanes) { - for (final entry in cluster) { - result.add(_LaidOutAppointment(entry.apt, entry.lane, laneCount)); - } - } else { - // Too many parallel appointments: keep the highest-priority - // (maxLanes - 1) and collapse the rest into a single overflow cell in - // the trailing lane. Sorting by priority first means custom and - // cancelled lessons stay visible when the cluster has to be trimmed, - // matching the requested left-to-right order in the visible lanes. - final visibleCount = maxLanes - 1; - final byPriority = [...cluster.map((e) => e.apt)] - ..sort((a, b) { - final p = _appointmentPriority(a).compareTo(_appointmentPriority(b)); - if (p != 0) return p; - return a.startTime.compareTo(b.startTime); - }); - - for (var i = 0; i < visibleCount; i++) { - result.add(_LaidOutAppointment(byPriority[i], i, maxLanes)); - } - - final overflow = byPriority.sublist(visibleCount); - var earliest = overflow.first.startTime; - var latest = overflow.first.endTime; - for (final a in overflow.skip(1)) { - if (a.startTime.isBefore(earliest)) earliest = a.startTime; - if (a.endTime.isAfter(latest)) latest = a.endTime; - } - result.add(_LaidOutOverflow( - overflow, maxLanes - 1, maxLanes, earliest, latest)); - } - } - return result; -} diff --git a/lib/widget/async_action_button.dart b/lib/widget/async_action_button.dart index 1044e82..96ef4eb 100644 --- a/lib/widget/async_action_button.dart +++ b/lib/widget/async_action_button.dart @@ -1,543 +1,20 @@ +/// Family of async-aware buttons + helpers. Implementation is split across +/// `async_actions/` for readability; everything still lives in this single +/// library so private widgets like `_AsyncMixin` and `_InlineErrorWrapper` +/// can stay private and shared. +library; + import 'package:flutter/material.dart'; import '../api/errors/error_mapper.dart'; import 'app_progress_indicator.dart'; import 'info_dialog.dart'; -Future runWithErrorDialog( - BuildContext context, - AsyncActionCallback action, { - AsyncErrorBuilder? errorBuilder, -}) async { - try { - await action(); - return true; - } catch (e) { - if (!context.mounted) return false; - final message = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e); - final details = errorToTechnicalDetails(e); - final body = details != null && details != message ? '$message\n\n$details' : message; - InfoDialog.show(context, body, copyable: true, title: 'Fehler'); - return false; - } -} - -typedef AsyncActionCallback = Future Function(); -typedef AsyncErrorBuilder = String Function(Object error); - -class AsyncActionController extends ChangeNotifier { - bool _busy = false; - String? _error; - - bool get busy => _busy; - String? get error => _error; - - Future run( - AsyncActionCallback action, { - AsyncErrorBuilder? errorBuilder, - }) async { - if (_busy) return false; - _busy = true; - _error = null; - notifyListeners(); - try { - await action(); - _busy = false; - notifyListeners(); - return true; - } catch (e) { - _busy = false; - _error = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e); - notifyListeners(); - return false; - } - } - - void clearError() { - if (_error == null) return; - _error = null; - notifyListeners(); - } -} - -class _AsyncMixin extends StatefulWidget { - final AsyncActionCallback? onPressed; - final AsyncActionController? controller; - final AsyncErrorBuilder? errorBuilder; - final void Function(String message)? onError; - final VoidCallback? onSuccess; - final Widget Function(BuildContext context, bool busy, VoidCallback? handler) builder; - - const _AsyncMixin({ - required this.onPressed, - required this.builder, - this.controller, - this.errorBuilder, - this.onError, - this.onSuccess, - }); - - @override - State<_AsyncMixin> createState() => _AsyncMixinState(); -} - -class _AsyncMixinState extends State<_AsyncMixin> { - late final AsyncActionController _internal; - AsyncActionController get _controller => widget.controller ?? _internal; - - @override - void initState() { - super.initState(); - if (widget.controller == null) { - _internal = AsyncActionController(); - } - _controller.addListener(_onControllerChange); - } - - @override - void didUpdateWidget(covariant _AsyncMixin oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - (oldWidget.controller ?? _internal).removeListener(_onControllerChange); - _controller.addListener(_onControllerChange); - } - } - - @override - void dispose() { - _controller.removeListener(_onControllerChange); - if (widget.controller == null) { - _internal.dispose(); - } - super.dispose(); - } - - void _onControllerChange() { - if (mounted) setState(() {}); - } - - Future _trigger() async { - final action = widget.onPressed; - if (action == null) return; - final success = await _controller.run(action, errorBuilder: widget.errorBuilder); - if (!mounted) return; - if (success) { - widget.onSuccess?.call(); - } else if (widget.onError != null && _controller.error != null) { - widget.onError!(_controller.error!); - } - } - - @override - Widget build(BuildContext context) { - final handler = widget.onPressed == null ? null : _trigger; - return widget.builder(context, _controller.busy, _controller.busy ? null : handler); - } -} - -class AsyncActionButton extends StatelessWidget { - final AsyncActionCallback? onPressed; - final Widget child; - final IconData? icon; - final ButtonStyle? style; - final AsyncActionController? controller; - final AsyncErrorBuilder? errorBuilder; - final void Function(String message)? onError; - final VoidCallback? onSuccess; - final bool showInlineError; - - const AsyncActionButton({ - required this.onPressed, - required this.child, - this.icon, - this.style, - this.controller, - this.errorBuilder, - this.onError, - this.onSuccess, - this.showInlineError = true, - super.key, - }); - - @override - Widget build(BuildContext context) => _AsyncMixin( - onPressed: onPressed, - controller: controller, - errorBuilder: errorBuilder, - onError: onError, - onSuccess: onSuccess, - builder: (context, busy, handler) { - final spinner = AppProgressIndicator.small( - color: Theme.of(context).colorScheme.onPrimary, - ); - final content = busy - ? Row( - mainAxisSize: MainAxisSize.min, - children: [spinner, const SizedBox(width: 8), child], - ) - : (icon != null - ? Row( - mainAxisSize: MainAxisSize.min, - children: [Icon(icon), const SizedBox(width: 8), child], - ) - : child); - final button = ElevatedButton( - onPressed: handler, - style: style, - child: content, - ); - return _withInlineError(context, button); - }, - ); - - Widget _withInlineError(BuildContext context, Widget button) { - if (!showInlineError) return button; - return _InlineErrorWrapper(controller: controller, child: button); - } -} - -class AsyncTextButton extends StatelessWidget { - final AsyncActionCallback? onPressed; - final Widget child; - final AsyncActionController? controller; - final AsyncErrorBuilder? errorBuilder; - final void Function(String message)? onError; - final VoidCallback? onSuccess; - final bool showInlineError; - - const AsyncTextButton({ - required this.onPressed, - required this.child, - this.controller, - this.errorBuilder, - this.onError, - this.onSuccess, - this.showInlineError = true, - super.key, - }); - - @override - Widget build(BuildContext context) => _AsyncMixin( - onPressed: onPressed, - controller: controller, - errorBuilder: errorBuilder, - onError: onError, - onSuccess: onSuccess, - builder: (context, busy, handler) { - final content = busy - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - AppProgressIndicator.small( - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - child, - ], - ) - : child; - return _InlineErrorWrapper( - controller: controller, - child: TextButton(onPressed: handler, child: content), - ); - }, - ); -} - -class AsyncIconButton extends StatelessWidget { - final AsyncActionCallback? onPressed; - final IconData icon; - final Color? color; - final String? tooltip; - final AsyncActionController? controller; - final AsyncErrorBuilder? errorBuilder; - final void Function(String message)? onError; - final VoidCallback? onSuccess; - - const AsyncIconButton({ - required this.onPressed, - required this.icon, - this.color, - this.tooltip, - this.controller, - this.errorBuilder, - this.onError, - this.onSuccess, - super.key, - }); - - @override - Widget build(BuildContext context) => _AsyncMixin( - onPressed: onPressed, - controller: controller, - errorBuilder: errorBuilder, - onError: onError, - onSuccess: onSuccess, - builder: (context, busy, handler) { - if (busy) { - return Padding( - padding: const EdgeInsets.all(12), - child: AppProgressIndicator.small(color: color), - ); - } - return IconButton( - icon: Icon(icon, color: color), - tooltip: tooltip, - onPressed: handler, - ); - }, - ); -} - -class AsyncFab extends StatelessWidget { - final AsyncActionCallback? onPressed; - final IconData icon; - final Color? backgroundColor; - final Color? foregroundColor; - final Object? heroTag; - final AsyncActionController? controller; - final AsyncErrorBuilder? errorBuilder; - final void Function(String message)? onError; - final VoidCallback? onSuccess; - final bool mini; - - const AsyncFab({ - required this.onPressed, - required this.icon, - this.backgroundColor, - this.foregroundColor, - this.heroTag, - this.controller, - this.errorBuilder, - this.onError, - this.onSuccess, - this.mini = false, - super.key, - }); - - @override - Widget build(BuildContext context) => _AsyncMixin( - onPressed: onPressed, - controller: controller, - errorBuilder: errorBuilder, - onError: onError, - onSuccess: onSuccess, - builder: (context, busy, handler) { - final fg = foregroundColor ?? Theme.of(context).colorScheme.onPrimary; - return FloatingActionButton( - heroTag: heroTag, - backgroundColor: backgroundColor, - foregroundColor: fg, - mini: mini, - onPressed: handler, - child: busy ? AppProgressIndicator.small(color: fg) : Icon(icon), - ); - }, - ); -} - -class AsyncListTile extends StatefulWidget { - final AsyncActionCallback onPressed; - final Widget? leading; - final Widget title; - final Widget? subtitle; - final bool closeOnSuccess; - final VoidCallback? onSuccess; - final AsyncErrorBuilder? errorBuilder; - final bool enabled; - - const AsyncListTile({ - required this.onPressed, - required this.title, - this.leading, - this.subtitle, - this.closeOnSuccess = true, - this.onSuccess, - this.errorBuilder, - this.enabled = true, - super.key, - }); - - @override - State createState() => _AsyncListTileState(); -} - -class _AsyncListTileState extends State { - final AsyncActionController _controller = AsyncActionController(); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - Future _handleTap() async { - final ok = await _controller.run(widget.onPressed, errorBuilder: widget.errorBuilder); - if (!mounted) return; - if (ok) { - widget.onSuccess?.call(); - if (widget.closeOnSuccess && Navigator.of(context).canPop()) { - Navigator.of(context).pop(); - } - } - } - - @override - Widget build(BuildContext context) => AnimatedBuilder( - animation: _controller, - builder: (context, _) { - final busy = _controller.busy; - final err = _controller.error; - final leading = busy - ? const SizedBox( - width: 24, - height: 24, - child: AppProgressIndicator.small(), - ) - : widget.leading; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ListTile( - leading: leading, - title: widget.title, - subtitle: widget.subtitle, - enabled: widget.enabled && !busy, - onTap: busy ? null : _handleTap, - ), - if (err != null) - Padding( - padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), - child: Text( - err, - style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13), - ), - ), - ], - ); - }, - ); -} - -class _InlineErrorWrapper extends StatelessWidget { - final AsyncActionController? controller; - final Widget child; - const _InlineErrorWrapper({required this.controller, required this.child}); - - @override - Widget build(BuildContext context) { - final c = controller; - if (c == null) return child; - return AnimatedBuilder( - animation: c, - builder: (context, _) { - final err = c.error; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - child, - if (err != null) ...[ - const SizedBox(height: 8), - Text( - err, - textAlign: TextAlign.center, - style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13), - ), - ], - ], - ); - }, - ); - } -} - -class AsyncDialogAction extends StatefulWidget { - final String confirmLabel; - final AsyncActionCallback onConfirm; - final String? cancelLabel; - final AsyncErrorBuilder? errorBuilder; - final ButtonStyle? confirmStyle; - - const AsyncDialogAction({ - required this.confirmLabel, - required this.onConfirm, - this.cancelLabel = 'Abbrechen', - this.errorBuilder, - this.confirmStyle, - super.key, - }); - - @override - State createState() => _AsyncDialogActionState(); -} - -class _AsyncDialogActionState extends State { - final AsyncActionController _controller = AsyncActionController(); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => AnimatedBuilder( - animation: _controller, - builder: (context, _) { - final err = _controller.error; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (err != null) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - err, - textAlign: TextAlign.center, - style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13), - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (widget.cancelLabel != null) - TextButton( - onPressed: _controller.busy ? null : () => Navigator.of(context).pop(), - child: Text(widget.cancelLabel!), - ), - TextButton( - style: widget.confirmStyle, - onPressed: _controller.busy - ? null - : () async { - final ok = await _controller.run( - widget.onConfirm, - errorBuilder: widget.errorBuilder, - ); - if (ok && context.mounted) { - Navigator.of(context).pop(true); - } - }, - child: _controller.busy - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - AppProgressIndicator.small( - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Text(widget.confirmLabel), - ], - ) - : Text(widget.confirmLabel), - ), - ], - ), - ], - ); - }, - ); -} +part 'async_actions/async_action_controller.dart'; +part 'async_actions/async_action_button.dart'; +part 'async_actions/async_dialog_action.dart'; +part 'async_actions/async_fab.dart'; +part 'async_actions/async_icon_button.dart'; +part 'async_actions/async_list_tile.dart'; +part 'async_actions/async_mixin.dart'; +part 'async_actions/async_text_button.dart'; diff --git a/lib/widget/async_actions/async_action_button.dart b/lib/widget/async_actions/async_action_button.dart new file mode 100644 index 0000000..29367b2 --- /dev/null +++ b/lib/widget/async_actions/async_action_button.dart @@ -0,0 +1,58 @@ +part of '../async_action_button.dart'; + +class AsyncActionButton extends StatelessWidget { + final AsyncActionCallback? onPressed; + final Widget child; + final IconData? icon; + final ButtonStyle? style; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + final bool showInlineError; + + const AsyncActionButton({ + required this.onPressed, + required this.child, + this.icon, + this.style, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + this.showInlineError = true, + super.key, + }); + + @override + Widget build(BuildContext context) => _AsyncMixin( + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + final spinner = AppProgressIndicator.small( + color: Theme.of(context).colorScheme.onPrimary, + ); + final content = busy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [spinner, const SizedBox(width: 8), child], + ) + : (icon != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [Icon(icon), const SizedBox(width: 8), child], + ) + : child); + final button = ElevatedButton( + onPressed: handler, + style: style, + child: content, + ); + if (!showInlineError) return button; + return _InlineErrorWrapper(controller: controller, child: button); + }, + ); +} diff --git a/lib/widget/async_actions/async_action_controller.dart b/lib/widget/async_actions/async_action_controller.dart new file mode 100644 index 0000000..8b63cbe --- /dev/null +++ b/lib/widget/async_actions/async_action_controller.dart @@ -0,0 +1,63 @@ +part of '../async_action_button.dart'; + +typedef AsyncActionCallback = Future Function(); +typedef AsyncErrorBuilder = String Function(Object error); + +/// Wraps [action] with a try/catch that pops up an [InfoDialog] on failure +/// (using [errorBuilder] or the default error mapper). Returns `true` on +/// success, `false` on caught failure. +Future runWithErrorDialog( + BuildContext context, + AsyncActionCallback action, { + AsyncErrorBuilder? errorBuilder, +}) async { + try { + await action(); + return true; + } catch (e) { + if (!context.mounted) return false; + final message = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e); + final details = errorToTechnicalDetails(e); + final body = details != null && details != message ? '$message\n\n$details' : message; + InfoDialog.show(context, body, copyable: true, title: 'Fehler'); + return false; + } +} + +/// Reusable busy/error state for the async-button family. Multiple buttons +/// can share the same controller (e.g. a parent toolbar wanting to disable +/// while any one child is running). +class AsyncActionController extends ChangeNotifier { + bool _busy = false; + String? _error; + + bool get busy => _busy; + String? get error => _error; + + Future run( + AsyncActionCallback action, { + AsyncErrorBuilder? errorBuilder, + }) async { + if (_busy) return false; + _busy = true; + _error = null; + notifyListeners(); + try { + await action(); + _busy = false; + notifyListeners(); + return true; + } catch (e) { + _busy = false; + _error = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e); + notifyListeners(); + return false; + } + } + + void clearError() { + if (_error == null) return; + _error = null; + notifyListeners(); + } +} diff --git a/lib/widget/async_actions/async_dialog_action.dart b/lib/widget/async_actions/async_dialog_action.dart new file mode 100644 index 0000000..24309c2 --- /dev/null +++ b/lib/widget/async_actions/async_dialog_action.dart @@ -0,0 +1,90 @@ +part of '../async_action_button.dart'; + +class AsyncDialogAction extends StatefulWidget { + final String confirmLabel; + final AsyncActionCallback onConfirm; + final String? cancelLabel; + final AsyncErrorBuilder? errorBuilder; + final ButtonStyle? confirmStyle; + + const AsyncDialogAction({ + required this.confirmLabel, + required this.onConfirm, + this.cancelLabel = 'Abbrechen', + this.errorBuilder, + this.confirmStyle, + super.key, + }); + + @override + State createState() => _AsyncDialogActionState(); +} + +class _AsyncDialogActionState extends State { + final AsyncActionController _controller = AsyncActionController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final err = _controller.error; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (err != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + err, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (widget.cancelLabel != null) + TextButton( + onPressed: _controller.busy ? null : () => Navigator.of(context).pop(), + child: Text(widget.cancelLabel!), + ), + TextButton( + style: widget.confirmStyle, + onPressed: _controller.busy + ? null + : () async { + final ok = await _controller.run( + widget.onConfirm, + errorBuilder: widget.errorBuilder, + ); + if (ok && context.mounted) { + Navigator.of(context).pop(true); + } + }, + child: _controller.busy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppProgressIndicator.small( + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text(widget.confirmLabel), + ], + ) + : Text(widget.confirmLabel), + ), + ], + ), + ], + ); + }, + ); +} diff --git a/lib/widget/async_actions/async_fab.dart b/lib/widget/async_actions/async_fab.dart new file mode 100644 index 0000000..04eff65 --- /dev/null +++ b/lib/widget/async_actions/async_fab.dart @@ -0,0 +1,48 @@ +part of '../async_action_button.dart'; + +class AsyncFab extends StatelessWidget { + final AsyncActionCallback? onPressed; + final IconData icon; + final Color? backgroundColor; + final Color? foregroundColor; + final Object? heroTag; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + final bool mini; + + const AsyncFab({ + required this.onPressed, + required this.icon, + this.backgroundColor, + this.foregroundColor, + this.heroTag, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + this.mini = false, + super.key, + }); + + @override + Widget build(BuildContext context) => _AsyncMixin( + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + final fg = foregroundColor ?? Theme.of(context).colorScheme.onPrimary; + return FloatingActionButton( + heroTag: heroTag, + backgroundColor: backgroundColor, + foregroundColor: fg, + mini: mini, + onPressed: handler, + child: busy ? AppProgressIndicator.small(color: fg) : Icon(icon), + ); + }, + ); +} diff --git a/lib/widget/async_actions/async_icon_button.dart b/lib/widget/async_actions/async_icon_button.dart new file mode 100644 index 0000000..de9cea4 --- /dev/null +++ b/lib/widget/async_actions/async_icon_button.dart @@ -0,0 +1,46 @@ +part of '../async_action_button.dart'; + +class AsyncIconButton extends StatelessWidget { + final AsyncActionCallback? onPressed; + final IconData icon; + final Color? color; + final String? tooltip; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + + const AsyncIconButton({ + required this.onPressed, + required this.icon, + this.color, + this.tooltip, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + super.key, + }); + + @override + Widget build(BuildContext context) => _AsyncMixin( + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + if (busy) { + return Padding( + padding: const EdgeInsets.all(12), + child: AppProgressIndicator.small(color: color), + ); + } + return IconButton( + icon: Icon(icon, color: color), + tooltip: tooltip, + onPressed: handler, + ); + }, + ); +} diff --git a/lib/widget/async_actions/async_list_tile.dart b/lib/widget/async_actions/async_list_tile.dart new file mode 100644 index 0000000..526ee25 --- /dev/null +++ b/lib/widget/async_actions/async_list_tile.dart @@ -0,0 +1,85 @@ +part of '../async_action_button.dart'; + +class AsyncListTile extends StatefulWidget { + final AsyncActionCallback onPressed; + final Widget? leading; + final Widget title; + final Widget? subtitle; + final bool closeOnSuccess; + final VoidCallback? onSuccess; + final AsyncErrorBuilder? errorBuilder; + final bool enabled; + + const AsyncListTile({ + required this.onPressed, + required this.title, + this.leading, + this.subtitle, + this.closeOnSuccess = true, + this.onSuccess, + this.errorBuilder, + this.enabled = true, + super.key, + }); + + @override + State createState() => _AsyncListTileState(); +} + +class _AsyncListTileState extends State { + final AsyncActionController _controller = AsyncActionController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _handleTap() async { + final ok = await _controller.run(widget.onPressed, errorBuilder: widget.errorBuilder); + if (!mounted) return; + if (ok) { + widget.onSuccess?.call(); + if (widget.closeOnSuccess && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + } + } + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final busy = _controller.busy; + final err = _controller.error; + final leading = busy + ? const SizedBox( + width: 24, + height: 24, + child: AppProgressIndicator.small(), + ) + : widget.leading; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + leading: leading, + title: widget.title, + subtitle: widget.subtitle, + enabled: widget.enabled && !busy, + onTap: busy ? null : _handleTap, + ), + if (err != null) + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + child: Text( + err, + style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13), + ), + ), + ], + ); + }, + ); +} diff --git a/lib/widget/async_actions/async_mixin.dart b/lib/widget/async_actions/async_mixin.dart new file mode 100644 index 0000000..72ff984 --- /dev/null +++ b/lib/widget/async_actions/async_mixin.dart @@ -0,0 +1,109 @@ +part of '../async_action_button.dart'; + +class _AsyncMixin extends StatefulWidget { + final AsyncActionCallback? onPressed; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + final Widget Function(BuildContext context, bool busy, VoidCallback? handler) builder; + + const _AsyncMixin({ + required this.onPressed, + required this.builder, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + }); + + @override + State<_AsyncMixin> createState() => _AsyncMixinState(); +} + +class _AsyncMixinState extends State<_AsyncMixin> { + late final AsyncActionController _internal; + AsyncActionController get _controller => widget.controller ?? _internal; + + @override + void initState() { + super.initState(); + if (widget.controller == null) { + _internal = AsyncActionController(); + } + _controller.addListener(_onControllerChange); + } + + @override + void didUpdateWidget(covariant _AsyncMixin oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + (oldWidget.controller ?? _internal).removeListener(_onControllerChange); + _controller.addListener(_onControllerChange); + } + } + + @override + void dispose() { + _controller.removeListener(_onControllerChange); + if (widget.controller == null) { + _internal.dispose(); + } + super.dispose(); + } + + void _onControllerChange() { + if (mounted) setState(() {}); + } + + Future _trigger() async { + final action = widget.onPressed; + if (action == null) return; + final success = await _controller.run(action, errorBuilder: widget.errorBuilder); + if (!mounted) return; + if (success) { + widget.onSuccess?.call(); + } else if (widget.onError != null && _controller.error != null) { + widget.onError!(_controller.error!); + } + } + + @override + Widget build(BuildContext context) { + final handler = widget.onPressed == null ? null : _trigger; + return widget.builder(context, _controller.busy, _controller.busy ? null : handler); + } +} + +class _InlineErrorWrapper extends StatelessWidget { + final AsyncActionController? controller; + final Widget child; + const _InlineErrorWrapper({required this.controller, required this.child}); + + @override + Widget build(BuildContext context) { + final c = controller; + if (c == null) return child; + return AnimatedBuilder( + animation: c, + builder: (context, _) { + final err = c.error; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + child, + if (err != null) ...[ + const SizedBox(height: 8), + Text( + err, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13), + ), + ], + ], + ); + }, + ); + } +} diff --git a/lib/widget/async_actions/async_text_button.dart b/lib/widget/async_actions/async_text_button.dart new file mode 100644 index 0000000..2938089 --- /dev/null +++ b/lib/widget/async_actions/async_text_button.dart @@ -0,0 +1,49 @@ +part of '../async_action_button.dart'; + +class AsyncTextButton extends StatelessWidget { + final AsyncActionCallback? onPressed; + final Widget child; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + final bool showInlineError; + + const AsyncTextButton({ + required this.onPressed, + required this.child, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + this.showInlineError = true, + super.key, + }); + + @override + Widget build(BuildContext context) => _AsyncMixin( + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + final content = busy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppProgressIndicator.small( + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + child, + ], + ) + : child; + return _InlineErrorWrapper( + controller: controller, + child: TextButton(onPressed: handler, child: content), + ); + }, + ); +} diff --git a/lib/widget/debug/json_viewer.dart b/lib/widget/debug/json_viewer.dart index 71f85ee..461a15e 100644 --- a/lib/widget/debug/json_viewer.dart +++ b/lib/widget/debug/json_viewer.dart @@ -1,7 +1,8 @@ import 'dart:convert'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; + +import '../../utils/clipboard_helper.dart'; class JsonViewer extends StatelessWidget { final String title; @@ -19,30 +20,26 @@ class JsonViewer extends StatelessWidget { child: Text(format(data)), ), ); - + static final _encoder = const JsonEncoder.withIndent(' '); static String format(Map jsonInput) => _encoder.convert(jsonInput); static void asDialog(BuildContext context, Map dataMap) { - showDialog(context: context, builder: (context) => AlertDialog( + showDialog(context: context, builder: (dialogCtx) => AlertDialog( scrollable: true, title: const Row(children: [Icon(Icons.bug_report_outlined), Text('Rohdaten')]), content: Text(JsonViewer.format(dataMap)), actions: [ - TextButton(onPressed: () { - Clipboard.setData(ClipboardData(text: JsonViewer.format(dataMap))).then((value) { - if (!context.mounted) return; - showDialog(context: context, builder: (context) => const AlertDialog(content: Text('Formatiertes JSON wurde erfolgreich in deiner Zwischenlage abgelegt.'))); - }); - }, child: const Text('Kopieren')), - TextButton(onPressed: () { - Clipboard.setData(ClipboardData(text: dataMap.toString())).then((value) { - if (!context.mounted) return; - showDialog(context: context, builder: (context) => const AlertDialog(content: Text('Unformatiertes JSON wurde erfolgreich in deiner Zwischenablage abgelegt.'))); - }); - }, child: const Text('Inline Kopieren')), - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Schließen')) + TextButton( + onPressed: () => copyToClipboard(dialogCtx, JsonViewer.format(dataMap), successMessage: 'Formatiertes JSON kopiert'), + child: const Text('Kopieren'), + ), + TextButton( + onPressed: () => copyToClipboard(dialogCtx, dataMap.toString(), successMessage: 'Inline JSON kopiert'), + child: const Text('Inline Kopieren'), + ), + TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Schließen')) ], )); } diff --git a/lib/view/pages/timetable/details/bottom_sheet.dart b/lib/widget/details_bottom_sheet.dart similarity index 63% rename from lib/view/pages/timetable/details/bottom_sheet.dart rename to lib/widget/details_bottom_sheet.dart index c0066b1..0618f40 100644 --- a/lib/view/pages/timetable/details/bottom_sheet.dart +++ b/lib/widget/details_bottom_sheet.dart @@ -1,12 +1,12 @@ 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 +/// Shows a modal bottom sheet for a detail view (appointment, file, lesson, +/// custom event, etc.). All detail sheets in the app share this layout: drag +/// handle on top, default theme background, optional ListTile-style header /// followed by a divider, scrollable body below. -void showAppointmentBottomSheet( +void showDetailsBottomSheet( BuildContext context, { - required Widget header, + Widget? header, required List Function(BuildContext sheetContext) children, }) { showModalBottomSheet( @@ -21,8 +21,10 @@ void showAppointmentBottomSheet( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - header, - const Divider(height: 1), + if (header != null) ...[ + header, + const Divider(height: 1), + ], ...children(sheetContext), ], ), diff --git a/lib/widget/info_dialog.dart b/lib/widget/info_dialog.dart index 59be35c..22c4624 100644 --- a/lib/widget/info_dialog.dart +++ b/lib/widget/info_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; + +import '../utils/clipboard_helper.dart'; class InfoDialog { /// Shows a single-text dialog. When [copyable] is true (default for error @@ -26,16 +27,7 @@ class InfoDialog { actions: [ if (copyable) TextButton.icon( - onPressed: () async { - await Clipboard.setData(ClipboardData(text: info)); - if (!dialogContext.mounted) return; - ScaffoldMessenger.of(dialogContext).showSnackBar( - const SnackBar( - content: Text('In Zwischenablage kopiert'), - duration: Duration(seconds: 2), - ), - ); - }, + onPressed: () => copyToClipboard(dialogContext, info), icon: const Icon(Icons.copy_outlined, size: 18), label: const Text('Kopieren'), ), diff --git a/pubspec.yaml b/pubspec.yaml index 92eb3e2..e6cd8c4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,6 +71,10 @@ dependencies: enough_icalendar: ^0.17.0 dev_dependencies: + flutter_test: + sdk: flutter + fake_async: ^1.3.1 + flutter_launcher_icons: ^0.14.3 flutter_native_splash: ^2.4.4 diff --git a/test/api/errors/error_mapper_test.dart b/test/api/errors/error_mapper_test.dart new file mode 100644 index 0000000..2f8bab8 --- /dev/null +++ b/test/api/errors/error_mapper_test.dart @@ -0,0 +1,105 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:marianum_mobile/api/api_error.dart'; +import 'package:marianum_mobile/api/errors/auth_exception.dart'; +import 'package:marianum_mobile/api/errors/error_mapper.dart'; +import 'package:marianum_mobile/api/errors/network_exception.dart'; +import 'package:marianum_mobile/api/errors/parse_exception.dart'; + +void main() { + group('errorToUserMessage', () { + test('null falls back to the default message', () { + expect(errorToUserMessage(null), contains('Etwas ist schiefgelaufen')); + }); + + test('AppException returns its own userMessage', () { + final exception = AuthException.unauthorized(); + expect(errorToUserMessage(exception), exception.userMessage); + }); + + test('SocketException maps to NetworkException message', () { + expect(errorToUserMessage(const SocketException('boom')), + const NetworkException().userMessage); + }); + + test('TimeoutException maps to the timeout-specific NetworkException message', () { + expect(errorToUserMessage(TimeoutException('slow')), + NetworkException.timeout().userMessage); + }); + + test('http.ClientException maps to NetworkException message', () { + expect(errorToUserMessage(http.ClientException('failed')), + const NetworkException().userMessage); + }); + + test('HandshakeException maps to a TLS-specific message', () { + expect(errorToUserMessage(const HandshakeException('bad cert')), + 'Sichere Verbindung konnte nicht hergestellt werden.'); + }); + + test('FormatException maps to ParseException message', () { + expect(errorToUserMessage(const FormatException('bad json')), + const ParseException().userMessage); + }); + + test('ApiError surfaces only the first line of its message', () { + final err = ApiError('Boom\nGET https://example.com/foo'); + expect(errorToUserMessage(err), 'Boom'); + }); + + test('ApiError with empty message falls back to default', () { + final err = ApiError(''); + expect(errorToUserMessage(err), contains('Etwas ist schiefgelaufen')); + }); + + test('unknown error type falls back', () { + expect(errorToUserMessage(StateError('weird')), + contains('Etwas ist schiefgelaufen')); + }); + + test('custom fallback overrides the default', () { + expect(errorToUserMessage(null, fallback: 'meins'), 'meins'); + }); + }); + + group('errorToTechnicalDetails', () { + test('null returns null', () { + expect(errorToTechnicalDetails(null), isNull); + }); + + test('AppException uses its technicalDetails when set', () { + final ex = AuthException.unauthorized(technicalDetails: 'http 401, foo'); + expect(errorToTechnicalDetails(ex), 'http 401, foo'); + }); + + test('AppException without details falls back to toString()', () { + final ex = AuthException.unauthorized(); + expect(errorToTechnicalDetails(ex), ex.toString()); + }); + + test('arbitrary object stringifies', () { + expect(errorToTechnicalDetails(StateError('x')), contains('x')); + }); + }); + + group('errorAllowsRetry', () { + test('null allows retry by default', () { + expect(errorAllowsRetry(null), isTrue); + }); + + test('AuthException disallows retry (allowRetry=false)', () { + expect(errorAllowsRetry(AuthException.unauthorized()), isFalse); + }); + + test('NetworkException allows retry (allowRetry=true)', () { + expect(errorAllowsRetry(const NetworkException()), isTrue); + }); + + test('non-AppException allows retry by default', () { + expect(errorAllowsRetry(StateError('x')), isTrue); + }); + }); +} diff --git a/test/api/talk/rich_object_string_processor_test.dart b/test/api/talk/rich_object_string_processor_test.dart new file mode 100644 index 0000000..8b346d2 --- /dev/null +++ b/test/api/talk/rich_object_string_processor_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/chat/get_chat_response.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/chat/rich_object_string_processor.dart'; + +RichObjectString _r(String name, {RichObjectStringObjectType type = RichObjectStringObjectType.user}) => + RichObjectString(type, 'id-$name', name, null, null); + +void main() { + group('RichObjectStringProcessor.parseToString', () { + test('null data returns the message unchanged', () { + expect( + RichObjectStringProcessor.parseToString('Hallo {actor}', null), + 'Hallo {actor}', + ); + }); + + test('substitutes a single placeholder by .name', () { + expect( + RichObjectStringProcessor.parseToString( + '{actor} hat eine Datei geteilt', + {'actor': _r('Elias')}, + ), + 'Elias hat eine Datei geteilt', + ); + }); + + test('substitutes multiple placeholders independently', () { + expect( + RichObjectStringProcessor.parseToString( + '{actor} hat {file} mit {target} geteilt', + { + 'actor': _r('Elias'), + 'file': _r('foo.pdf', type: RichObjectStringObjectType.file), + 'target': _r('Klasse 11a', type: RichObjectStringObjectType.group), + }, + ), + 'Elias hat foo.pdf mit Klasse 11a geteilt', + ); + }); + + test('replaces every occurrence of the same placeholder', () { + expect( + RichObjectStringProcessor.parseToString( + '{actor} {actor} {actor}', + {'actor': _r('A')}, + ), + 'A A A', + ); + }); + + test('placeholders with no matching key remain unchanged', () { + expect( + RichObjectStringProcessor.parseToString( + '{actor} sah {file}', + {'actor': _r('Elias')}, + ), + 'Elias sah {file}', + ); + }); + + test('empty data map returns the message unchanged', () { + expect( + RichObjectStringProcessor.parseToString('Hallo {actor}', const {}), + 'Hallo {actor}', + ); + }); + + test('messages without placeholders are returned verbatim', () { + expect( + RichObjectStringProcessor.parseToString('reine Textnachricht', + {'actor': _r('A')}), + 'reine Textnachricht', + ); + }); + }); +} diff --git a/test/api/webuntis/lesson_resolver_test.dart b/test/api/webuntis/lesson_resolver_test.dart new file mode 100644 index 0000000..4c900c7 --- /dev/null +++ b/test/api/webuntis/lesson_resolver_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/webuntis/queries/get_rooms/get_rooms_response.dart'; +import 'package:marianum_mobile/api/webuntis/queries/get_subjects/get_subjects_response.dart'; +import 'package:marianum_mobile/api/webuntis/services/lesson_resolver.dart'; +import 'package:marianum_mobile/state/app/modules/timetable/bloc/timetable_state.dart'; + +TimetableState _state({ + Set subjects = const {}, + Set rooms = const {}, +}) => + TimetableState( + subjects: subjects.isEmpty ? null : GetSubjectsResponse(subjects), + rooms: rooms.isEmpty ? null : GetRoomsResponse(rooms), + startDate: DateTime(2026, 1, 1), + endDate: DateTime(2026, 12, 31), + ); + +void main() { + group('LessonResolver.resolveSubject', () { + test('returns the matching subject when the id is found', () { + final math = GetSubjectsResponseObject(7, 'M', 'Mathe', 'Ma', true); + final state = _state(subjects: {math}); + + final result = LessonResolver.resolveSubject(state, 7); + expect(result.id, 7); + expect(result.name, 'M'); + expect(result.longName, 'Mathe'); + }); + + test('returns the placeholder fallback when id is null', () { + final state = _state(subjects: const {}); + final result = LessonResolver.resolveSubject(state, null); + expect(result.id, 0); + expect(result.name, '?'); + expect(result.longName, 'Unbekannt'); + }); + + test('returns the placeholder fallback when id is unknown', () { + final math = GetSubjectsResponseObject(7, 'M', 'Mathe', 'Ma', true); + final state = _state(subjects: {math}); + + final result = LessonResolver.resolveSubject(state, 999); + expect(result.id, 0); + expect(result.longName, 'Unbekannt'); + }); + }); + + group('LessonResolver.resolveRoom', () { + test('returns the matching room when the id is found', () { + final room = GetRoomsResponseObject(3, 'A1', 'Aula 1', true, 'Hauptgebäude'); + final state = _state(rooms: {room}); + + final result = LessonResolver.resolveRoom(state, 3); + expect(result.id, 3); + expect(result.name, 'A1'); + expect(result.building, 'Hauptgebäude'); + }); + + test('returns the placeholder fallback when id is unknown', () { + final state = _state(rooms: const {}); + final result = LessonResolver.resolveRoom(state, 42); + expect(result.id, 0); + expect(result.name, '?'); + }); + }); + + group('LessonFormatter', () { + test('iconForCode picks the right icon per status', () { + expect(LessonFormatter.iconForCode('cancelled').codePoint, + isNot(LessonFormatter.iconForCode('irregular').codePoint)); + expect(LessonFormatter.iconForCode(null).codePoint, + isNot(LessonFormatter.iconForCode('cancelled').codePoint)); + }); + + test('statusLabel maps known codes to German labels', () { + expect(LessonFormatter.statusLabel(null), 'Regulär'); + expect(LessonFormatter.statusLabel(''), 'Regulär'); + expect(LessonFormatter.statusLabel('cancelled'), 'Entfällt'); + expect(LessonFormatter.statusLabel('irregular'), 'Geändert'); + expect(LessonFormatter.statusLabel('something-else'), 'something-else'); + }); + + test('codePrefix prepends a label for known codes', () { + expect(LessonFormatter.codePrefix('cancelled'), 'Entfällt: '); + expect(LessonFormatter.codePrefix('irregular'), 'Änderung: '); + expect(LessonFormatter.codePrefix(null), ''); + }); + + test('formatLine renders name + (longname) + · extra in that order', () { + expect( + LessonFormatter.formatLine('Mathe', longname: 'Mathematik', extra: 'Hauptgebäude'), + 'Mathe (Mathematik) · Hauptgebäude', + ); + }); + + test('formatLine omits longname when it equals name', () { + expect(LessonFormatter.formatLine('Mathe', longname: 'Mathe'), 'Mathe'); + }); + + test('formatLine substitutes ? when name is empty', () { + expect(LessonFormatter.formatLine(''), '?'); + }); + }); +} diff --git a/test/extensions/date_time_test.dart b/test/extensions/date_time_test.dart new file mode 100644 index 0000000..2f174a9 --- /dev/null +++ b/test/extensions/date_time_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:marianum_mobile/extensions/date_time.dart'; + +void main() { + setUpAll(() async { + // Jiffy needs locale data once before any formatting calls. + await Jiffy.setLocale('de'); + }); + + group('IsSameDay', () { + test('isSameDay matches by year/month/day, ignoring time', () { + final a = DateTime(2026, 5, 8, 9, 30); + final b = DateTime(2026, 5, 8, 22, 0); + expect(a.isSameDay(b), isTrue); + }); + + test('isSameDay differs across midnight', () { + final a = DateTime(2026, 5, 8, 23, 59); + final b = DateTime(2026, 5, 9, 0, 0); + expect(a.isSameDay(b), isFalse); + }); + + test('isSameOrAfter is inclusive', () { + final a = DateTime(2026, 5, 8, 12); + final b = DateTime(2026, 5, 8, 12); + expect(a.isSameOrAfter(b), isTrue); + expect(a.add(const Duration(seconds: 1)).isSameOrAfter(b), isTrue); + expect(a.subtract(const Duration(seconds: 1)).isSameOrAfter(b), isFalse); + }); + }); + + group('DateTimeFormatting', () { + final dt = DateTime(2026, 5, 8, 9, 7); + + test('formatHm pads hours and minutes to two digits', () { + expect(dt.formatHm(), '09:07'); + }); + + test('formatDate uses dd.MM.yyyy', () { + expect(dt.formatDate(), '08.05.2026'); + }); + + test('formatDateTime combines date and time', () { + expect(dt.formatDateTime(), '08.05.2026 09:07'); + }); + + test('formatDateShort drops the year', () { + expect(dt.formatDateShort(), '08.05.'); + }); + + test('formatDateShortHm combines short date and time', () { + expect(dt.formatDateShortHm(), '08.05. 09:07'); + }); + + test('timeRangeTo joins start and end with a hyphen', () { + final end = dt.add(const Duration(minutes: 45)); + expect(dt.timeRangeTo(end), '09:07 - 09:52'); + }); + }); +} diff --git a/test/utils/debouncer_test.dart b/test/utils/debouncer_test.dart new file mode 100644 index 0000000..6084188 --- /dev/null +++ b/test/utils/debouncer_test.dart @@ -0,0 +1,112 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/utils/debouncer.dart'; + +void main() { + // Each test is wrapped in fakeAsync so timers fire deterministically. + group('Debouncer.debounce', () { + test('runs the action once after the delay elapses without further calls', () { + fakeAsync((async) { + var calls = 0; + Debouncer.debounce('tag', const Duration(milliseconds: 100), () => calls++); + + async.elapse(const Duration(milliseconds: 99)); + expect(calls, 0); + + async.elapse(const Duration(milliseconds: 1)); + expect(calls, 1); + }); + }); + + test('subsequent calls within the delay reset the timer (coalesce)', () { + fakeAsync((async) { + var calls = 0; + void schedule() => Debouncer.debounce( + 'tag', const Duration(milliseconds: 100), () => calls++); + + schedule(); + async.elapse(const Duration(milliseconds: 80)); + schedule(); // resets + async.elapse(const Duration(milliseconds: 80)); + schedule(); // resets + async.elapse(const Duration(milliseconds: 80)); + expect(calls, 0, reason: 'each schedule() resets the timer'); + + async.elapse(const Duration(milliseconds: 100)); + expect(calls, 1); + }); + }); + + test('different tags are independent', () { + fakeAsync((async) { + var aCalls = 0; + var bCalls = 0; + Debouncer.debounce('a', const Duration(milliseconds: 100), () => aCalls++); + Debouncer.debounce('b', const Duration(milliseconds: 100), () => bCalls++); + + async.elapse(const Duration(milliseconds: 100)); + expect(aCalls, 1); + expect(bCalls, 1); + }); + }); + }); + + group('Debouncer.throttle', () { + test('first call runs immediately, subsequent calls within window are dropped', () { + fakeAsync((async) { + var calls = 0; + Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++); + expect(calls, 1, reason: 'throttle fires the first call synchronously'); + + Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++); + Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++); + expect(calls, 1, reason: 'subsequent calls within the gate are ignored'); + + async.elapse(const Duration(milliseconds: 100)); + Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++); + expect(calls, 2, reason: 'after the window elapses, throttle fires again'); + }); + }); + + test('different tags throttle independently', () { + fakeAsync((async) { + var aCalls = 0; + var bCalls = 0; + Debouncer.throttle('a', const Duration(milliseconds: 100), () => aCalls++); + Debouncer.throttle('b', const Duration(milliseconds: 100), () => bCalls++); + expect(aCalls, 1); + expect(bCalls, 1); + + async.elapse(const Duration(milliseconds: 100)); + }); + }); + }); + + group('Debouncer.cancel', () { + test('cancels a pending debounce so the action never runs', () { + fakeAsync((async) { + var calls = 0; + Debouncer.debounce('tag', const Duration(milliseconds: 100), () => calls++); + Debouncer.cancel('tag'); + + async.elapse(const Duration(milliseconds: 200)); + expect(calls, 0); + }); + }); + + test('cancels an active throttle gate so the next call fires immediately', () { + fakeAsync((async) { + var calls = 0; + Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++); + expect(calls, 1); + + Debouncer.cancel('tag'); + Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++); + expect(calls, 2, + reason: 'cancel removed the gate so the next throttle fires again'); + + async.elapse(const Duration(milliseconds: 100)); + }); + }); + }); +} diff --git a/test/utils/file_clipboard_test.dart b/test/utils/file_clipboard_test.dart new file mode 100644 index 0000000..52ceaa6 --- /dev/null +++ b/test/utils/file_clipboard_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import 'package:marianum_mobile/utils/file_clipboard.dart'; + +CacheableFile _file(String name) => + CacheableFile(path: '/$name', isDirectory: false, name: name); + +void main() { + // FileClipboard is a singleton — clear between tests so state doesn't leak. + setUp(FileClipboard.instance.clear); + + group('FileClipboard.cut', () { + test('switches to cut state and notifies listeners', () { + final cb = FileClipboard.instance; + var notifyCount = 0; + void listener() => notifyCount++; + cb.addListener(listener); + addTearDown(() => cb.removeListener(listener)); + + cb.cut([_file('a.txt')]); + + expect(cb.operation, FileClipboardOperation.cut); + expect(cb.files.map((f) => f.name), ['a.txt']); + expect(cb.isEmpty, isFalse); + expect(notifyCount, 1); + }); + + test('empty input is a no-op', () { + final cb = FileClipboard.instance; + var notifyCount = 0; + void listener() => notifyCount++; + cb.addListener(listener); + addTearDown(() => cb.removeListener(listener)); + + cb.cut(const []); + + expect(cb.operation, isNull); + expect(cb.isEmpty, isTrue); + expect(notifyCount, 0, reason: 'no state change → no notifyListeners'); + }); + + test('files getter returns an unmodifiable view', () { + final cb = FileClipboard.instance; + cb.cut([_file('a.txt')]); + expect(() => cb.files.add(_file('b.txt')), throwsUnsupportedError); + }); + }); + + group('FileClipboard.copy', () { + test('switches to copy state and notifies listeners', () { + final cb = FileClipboard.instance; + cb.copy([_file('a.txt'), _file('b.txt')]); + + expect(cb.operation, FileClipboardOperation.copy); + expect(cb.files, hasLength(2)); + }); + + test('overwrites a previous cut state', () { + final cb = FileClipboard.instance; + cb.cut([_file('cut.txt')]); + cb.copy([_file('copy.txt')]); + + expect(cb.operation, FileClipboardOperation.copy); + expect(cb.files.single.name, 'copy.txt'); + }); + }); + + group('FileClipboard.clear', () { + test('resets state and notifies', () { + final cb = FileClipboard.instance; + cb.copy([_file('a.txt')]); + var notifyCount = 0; + void listener() => notifyCount++; + cb.addListener(listener); + addTearDown(() => cb.removeListener(listener)); + + cb.clear(); + + expect(cb.operation, isNull); + expect(cb.isEmpty, isTrue); + expect(notifyCount, 1); + }); + + test('clearing an already-empty clipboard is a no-op', () { + final cb = FileClipboard.instance; + var notifyCount = 0; + void listener() => notifyCount++; + cb.addListener(listener); + addTearDown(() => cb.removeListener(listener)); + + cb.clear(); + cb.clear(); + + expect(notifyCount, 0); + }); + }); +} diff --git a/test/view/files/sort_options_test.dart b/test/view/files/sort_options_test.dart new file mode 100644 index 0000000..d7ddbe6 --- /dev/null +++ b/test/view/files/sort_options_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; +import 'package:marianum_mobile/view/pages/files/data/sort_options.dart'; + +CacheableFile _file({ + required String name, + bool isDirectory = false, + int? size, + DateTime? modifiedAt, +}) => + CacheableFile( + path: '/$name', + isDirectory: isDirectory, + name: name, + size: size, + modifiedAt: modifiedAt, + ); + +void main() { + group('SortOptions.options', () { + test('name comparator is alphabetic', () { + final cmp = SortOptions.getOption(SortOption.name).compare; + expect(cmp(_file(name: 'a'), _file(name: 'b')), lessThan(0)); + expect(cmp(_file(name: 'b'), _file(name: 'a')), greaterThan(0)); + expect(cmp(_file(name: 'a'), _file(name: 'a')), 0); + }); + + test('date comparator is chronological by modifiedAt', () { + final cmp = SortOptions.getOption(SortOption.date).compare; + final older = _file(name: 'a', modifiedAt: DateTime(2026, 1, 1)); + final newer = _file(name: 'b', modifiedAt: DateTime(2026, 5, 1)); + expect(cmp(older, newer), lessThan(0)); + expect(cmp(newer, older), greaterThan(0)); + }); + + test('size comparator pushes directories to the end (positional 1 vs 0)', () { + final cmp = SortOptions.getOption(SortOption.size).compare; + final dir = _file(name: 'd', isDirectory: true); + final file = _file(name: 'f', size: 100); + // (dir, file) → returns 1 (dir.isDirectory true) → file sorts before dir. + expect(cmp(dir, file), 1); + expect(cmp(file, dir), 0); + }); + + test('size comparator handles null sizes', () { + final cmp = SortOptions.getOption(SortOption.size).compare; + final noSize = _file(name: 'a'); + final withSize = _file(name: 'b', size: 100); + // a.size == null → returns 0 + expect(cmp(noSize, withSize), 0); + // b.size == null → returns 1 + expect(cmp(withSize, noSize), 1); + }); + + test('size comparator orders by file size when both known', () { + final cmp = SortOptions.getOption(SortOption.size).compare; + expect(cmp(_file(name: 'a', size: 100), _file(name: 'b', size: 200)), lessThan(0)); + expect(cmp(_file(name: 'a', size: 200), _file(name: 'b', size: 100)), greaterThan(0)); + }); + + test('options map contains all enum values exactly once', () { + expect(SortOptions.options.keys.toSet(), SortOption.values.toSet()); + }); + }); + + group('ListFilesResponse.sortBy', () { + final folderA = _file(name: 'A', isDirectory: true); + final folderB = _file(name: 'B', isDirectory: true); + final fileA = _file(name: 'aaa', size: 100, modifiedAt: DateTime(2026, 1, 1)); + final fileB = _file(name: 'bbb', size: 50, modifiedAt: DateTime(2026, 5, 1)); + + // Note: sortBy uses a string-buffer sort + compareTo descending. The actual + // list ordering reflects what users see in the file list. + test('foldersToTop=true keeps folders before files regardless of name', () { + final response = ListFilesResponse({fileA, fileB, folderA, folderB}); + final sorted = response.sortBy(sortOption: SortOption.name); + final folderCount = sorted.takeWhile((f) => f.isDirectory).length; + expect(folderCount, 2, reason: 'both folders should sit at the top'); + }); + + test('foldersToTop=false intermixes folders and files', () { + final response = ListFilesResponse({fileA, fileB, folderA, folderB}); + final sorted = response.sortBy(sortOption: SortOption.name, foldersToTop: false); + final folderPositions = []; + for (var i = 0; i < sorted.length; i++) { + if (sorted[i].isDirectory) folderPositions.add(i); + } + // Without foldersToTop, folders aren't guaranteed to be at the front: + // assert at least one folder is somewhere other than the very top of + // a folders-first ordering. + expect(folderPositions, isNot([0, 1])); + }); + + test('reversed flips the order within each section', () { + final response = ListFilesResponse({fileA, fileB}); + final asc = response.sortBy(sortOption: SortOption.name, foldersToTop: false); + final desc = response.sortBy( + sortOption: SortOption.name, foldersToTop: false, reversed: true); + expect(desc, asc.reversed.toList()); + }); + + test('empty input yields an empty list', () { + final response = ListFilesResponse({}); + expect(response.sortBy(), isEmpty); + }); + }); +} diff --git a/test/view/marianum_dates/event_formatter_test.dart b/test/view/marianum_dates/event_formatter_test.dart new file mode 100644 index 0000000..3dabafc --- /dev/null +++ b/test/view/marianum_dates/event_formatter_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:marianum_mobile/state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; +import 'package:marianum_mobile/view/pages/marianum_dates/data/event_formatter.dart'; + +MarianumDate _event({ + required DateTime start, + required DateTime end, + bool isAllDay = false, +}) => + MarianumDate( + uid: 't', + title: 't', + description: null, + start: start, + end: end, + isAllDay: isAllDay, + ); + +void main() { + setUpAll(() async { + await Jiffy.setLocale('de'); + }); + + group('EventFormatter.trailingLabel', () { + test('all-day events show "Ganztägig"', () { + final e = _event( + start: DateTime(2026, 5, 8), + end: DateTime(2026, 5, 9), + isAllDay: true, + ); + expect(EventFormatter.trailingLabel(e), 'Ganztägig'); + }); + + test('zero-length same-day event shows a single time', () { + final at = DateTime(2026, 5, 8, 9, 30); + final e = _event(start: at, end: at); + expect(EventFormatter.trailingLabel(e), '09:30'); + }); + + test('same-day event shows time range', () { + final e = _event( + start: DateTime(2026, 5, 8, 9), + end: DateTime(2026, 5, 8, 10, 30), + ); + expect(EventFormatter.trailingLabel(e), '09:00–10:30'); + }); + + test('multi-day event shows date+time on both sides', () { + final e = _event( + start: DateTime(2026, 5, 8, 9), + end: DateTime(2026, 5, 9, 11), + ); + expect(EventFormatter.trailingLabel(e), '08.05. 09:00–09.05. 11:00'); + }); + }); + + group('EventFormatter.longRange', () { + test('all-day single-day collapses inclusive end to start date', () { + // ICS-style all-day: end is exclusive (next midnight). Display drops it. + final e = _event( + start: DateTime(2026, 5, 8), + end: DateTime(2026, 5, 9), + isAllDay: true, + ); + expect(EventFormatter.longRange(e), '08.05.2026 · Ganztägig'); + }); + + test('all-day multi-day shows inclusive end (one day before exclusive end)', () { + final e = _event( + start: DateTime(2026, 5, 8), + end: DateTime(2026, 5, 11), // exclusive → display "until 10.05." + isAllDay: true, + ); + expect(EventFormatter.longRange(e), '08.05.2026 – 10.05.2026 · Ganztägig'); + }); + + test('all-day event whose end equals start (degenerate) renders as single day', () { + final e = _event( + start: DateTime(2026, 5, 8), + end: DateTime(2026, 5, 8), + isAllDay: true, + ); + expect(EventFormatter.longRange(e), '08.05.2026 · Ganztägig'); + }); + + test('zero-length same-day timed event shows single time', () { + final at = DateTime(2026, 5, 8, 9, 30); + final e = _event(start: at, end: at); + expect(EventFormatter.longRange(e), '08.05.2026 · 09:30'); + }); + + test('same-day timed event shows date · range', () { + final e = _event( + start: DateTime(2026, 5, 8, 9), + end: DateTime(2026, 5, 8, 10, 30), + ); + expect(EventFormatter.longRange(e), '08.05.2026 · 09:00 – 10:30'); + }); + + test('multi-day timed event shows full datetimes on both sides', () { + final e = _event( + start: DateTime(2026, 5, 8, 9), + end: DateTime(2026, 5, 9, 11), + ); + expect(EventFormatter.longRange(e), '08.05.2026 09:00 – 09.05.2026 11:00'); + }); + }); +} diff --git a/test/view/timetable/calendar_logic_test.dart b/test/view/timetable/calendar_logic_test.dart new file mode 100644 index 0000000..fcd0a63 --- /dev/null +++ b/test/view/timetable/calendar_logic_test.dart @@ -0,0 +1,346 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import 'package:marianum_mobile/api/webuntis/queries/get_timetable/get_timetable_response.dart'; +import 'package:marianum_mobile/view/pages/timetable/data/arbitrary_appointment.dart'; +import 'package:marianum_mobile/view/pages/timetable/data/calendar_logic.dart'; +import 'package:marianum_mobile/view/pages/timetable/data/lesson_period_schedule.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +DateTime _at(int year, int month, int day, [int hour = 0, int minute = 0]) => + DateTime(year, month, day, hour, minute); + +Appointment _appt({ + required DateTime start, + required DateTime end, + String subject = 'Test', + bool isAllDay = false, + Object? id, + String? rrule, +}) => + Appointment( + id: id, + startTime: start, + endTime: end, + subject: subject, + color: Colors.blue, + isAllDay: isAllDay, + recurrenceRule: rrule, + ); + +GetTimetableResponseObject _lesson({String? code}) => GetTimetableResponseObject( + id: 0, + date: 0, + startTime: 0, + endTime: 0, + kl: const [], + te: const [], + su: const [], + ro: const [], + code: code, + ); + +CustomTimetableEvent _customEvent() => CustomTimetableEvent( + id: 'x', + title: '', + description: '', + startDate: DateTime(2026), + endDate: DateTime(2026), + color: null, + rrule: '', + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + +void main() { + group('isAllDayLike', () { + test('explicit isAllDay flag wins', () { + final a = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 10), isAllDay: true); + expect(isAllDayLike(a), isTrue); + }); + + test('events under 8 hours are not all-day-like', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 15, 59)); + expect(isAllDayLike(a), isFalse); + }); + + test('events of exactly 8 hours count as all-day-like', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 16)); + expect(isAllDayLike(a), isTrue); + }); + + test('Duration.inHours truncation does not let a 9h 30min event escape', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 17, 30)); + expect(isAllDayLike(a), isTrue, + reason: 'inHours would say 9; we compare in minutes (570 ≥ 480)'); + }); + }); + + group('isOutsideSchoolHours', () { + // School hours run 7:30 → 17:15 (kCalendarStartHour = 7.5, kCalendarEndHour = 17.25). + + test('lessons fully inside the grid are inside', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 9)); + expect(isOutsideSchoolHours(a), isFalse); + }); + + test('all-day-like events are always outside', () { + final a = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 10), isAllDay: true); + expect(isOutsideSchoolHours(a), isTrue); + }); + + test('events ending at or before grid start are outside', () { + final a = _appt(start: _at(2026, 5, 8, 6), end: _at(2026, 5, 8, 7, 30)); + expect(isOutsideSchoolHours(a), isTrue); + }); + + test('events starting at or after grid end are outside', () { + final a = _appt(start: _at(2026, 5, 8, 17, 15), end: _at(2026, 5, 8, 18)); + expect(isOutsideSchoolHours(a), isTrue); + }); + + test('events that engulf the entire grid are outside', () { + final a = _appt(start: _at(2026, 5, 8, 6), end: _at(2026, 5, 8, 18)); + expect(isOutsideSchoolHours(a), isTrue); + }); + + test('events that cross only the start boundary are inside', () { + final a = _appt(start: _at(2026, 5, 8, 7), end: _at(2026, 5, 8, 8)); + expect(isOutsideSchoolHours(a), isFalse); + }); + + test('events that cross only the end boundary are inside', () { + final a = _appt(start: _at(2026, 5, 8, 17), end: _at(2026, 5, 8, 18)); + expect(isOutsideSchoolHours(a), isFalse); + }); + }); + + group('partitionAppointmentsForWeek', () { + final monday = _at(2026, 5, 4); // a Monday + + test('single non-recurring lesson lands in the right day bucket', () { + final wednesday9 = _appt( + start: _at(2026, 5, 6, 9), end: _at(2026, 5, 6, 10)); + final result = partitionAppointmentsForWeek([wednesday9], monday); + expect(result.inside[0], isEmpty); + expect(result.inside[1], isEmpty); + expect(result.inside[2], hasLength(1)); + expect(result.inside[3], isEmpty); + expect(result.outside.expand((e) => e), isEmpty); + }); + + test('all-day events go to the outside bucket on their day', () { + final tuesdayAllDay = _appt( + start: _at(2026, 5, 5), + end: _at(2026, 5, 6), + isAllDay: true); + final result = partitionAppointmentsForWeek([tuesdayAllDay], monday); + expect(result.inside.expand((e) => e), isEmpty); + expect(result.outside[1], hasLength(1)); + }); + + test('events outside the visible week are dropped', () { + final lastWeek = _appt( + start: _at(2026, 4, 27, 9), end: _at(2026, 4, 27, 10)); + final nextWeek = _appt( + start: _at(2026, 5, 11, 9), end: _at(2026, 5, 11, 10)); + final result = partitionAppointmentsForWeek([lastWeek, nextWeek], monday); + expect(result.inside.expand((e) => e), isEmpty); + expect(result.outside.expand((e) => e), isEmpty); + }); + + test('weekend events (Sat/Sun) are dropped, only Mon–Fri counted', () { + final saturday = _appt( + start: _at(2026, 5, 9, 9), end: _at(2026, 5, 9, 10)); + final result = partitionAppointmentsForWeek([saturday], monday); + expect(result.inside.expand((e) => e), isEmpty); + }); + + test('weekly RRULE expands to one occurrence per matching week', () { + // Anchor on the Monday before our visible week, repeating weekly. + // The visible week's Monday should produce one occurrence. + final anchor = _appt( + start: _at(2026, 4, 27, 9), + end: _at(2026, 4, 27, 10), + rrule: 'RRULE:FREQ=WEEKLY;BYDAY=MO'); + final result = partitionAppointmentsForWeek([anchor], monday); + expect(result.inside[0], hasLength(1), + reason: 'Monday of the visible week should get one expansion'); + expect(result.inside[0].first.startTime, _at(2026, 5, 4, 9)); + }); + + test('malformed RRULE falls back to placing the anchor', () { + final broken = _appt( + start: _at(2026, 5, 6, 9), + end: _at(2026, 5, 6, 10), + rrule: 'this is not a valid rrule'); + final result = partitionAppointmentsForWeek([broken], monday); + expect(result.inside[2], hasLength(1)); + }); + }); + + group('PeriodLayout', () { + final p1 = const LessonPeriod( + name: '1', start: TimeOfDay(hour: 8, minute: 0), end: TimeOfDay(hour: 9, minute: 0)); + final brk = const LessonPeriod( + name: 'Pause', + start: TimeOfDay(hour: 9, minute: 0), + end: TimeOfDay(hour: 9, minute: 15), + isBreak: true); + final p2 = const LessonPeriod( + name: '2', + start: TimeOfDay(hour: 9, minute: 15), + end: TimeOfDay(hour: 10, minute: 15)); + + final layout = PeriodLayout( + periods: [p1, brk, p2], + lessonHeight: 60, // 60px per lesson + breakHeight: 20, + ); + + test('totalHeight sums lessons and breaks', () { + expect(layout.totalHeight, 60 + 20 + 60); + }); + + test('topOf returns cumulative height of preceding periods', () { + expect(layout.topOf(p1), 0); + expect(layout.topOf(brk), 60); + expect(layout.topOf(p2), 80); + }); + + test('heightOf returns the period-type-specific height', () { + expect(layout.heightOf(p1), 60); + expect(layout.heightOf(brk), 20); + }); + + test('yOfDateTime maps proportionally inside a period', () { + // 8:30 = halfway through the 1st lesson → y = 30 + expect(layout.yOfDateTime(_at(2026, 5, 8, 8, 30)), 30); + }); + + test('yOfDateTime clips to 0 before the first period', () { + expect(layout.yOfDateTime(_at(2026, 5, 8, 6)), 0); + }); + + test('yOfDateTime clips to totalHeight after the last period', () { + expect(layout.yOfDateTime(_at(2026, 5, 8, 18)), layout.totalHeight); + }); + + test('periodAtY returns the lesson under the cursor', () { + expect(layout.periodAtY(0), p1); + expect(layout.periodAtY(59), p1); + }); + + test('periodAtY skips a break to the next non-break lesson', () { + // y=70 falls in the break range; periodAtY should jump to p2. + expect(layout.periodAtY(70), p2); + }); + + test('periodAtY returns null past the last period', () { + expect(layout.periodAtY(layout.totalHeight + 10), isNull); + }); + }); + + group('assignLanes', () { + test('non-overlapping appointments stay on lane 0', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 9)); + final b = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11)); + final result = assignLanes([a, b], maxLanes: 2); + expect(result, hasLength(2)); + for (final cell in result) { + expect(cell.lane, 0); + expect(cell.laneCount, 1, reason: 'separate clusters → laneCount=1 each'); + } + }); + + test('two overlapping appointments split into 2 lanes', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10)); + final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 11)); + final result = assignLanes([a, b], maxLanes: 2); + expect(result, hasLength(2)); + expect(result.map((c) => c.lane).toSet(), {0, 1}); + expect(result.every((c) => c.laneCount == 2), isTrue); + }); + + test('three overlapping appointments with maxLanes=2 collapse the third into overflow', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 11)); + final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 11)); + final c = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11)); + final result = assignLanes([a, b, c], maxLanes: 2); + + final visible = result.whereType().toList(); + final overflow = result.whereType().toList(); + expect(visible, hasLength(1), reason: 'maxLanes-1 = 1 visible appointment'); + expect(overflow, hasLength(1)); + expect(overflow.first.appointments, hasLength(2)); + expect(overflow.first.lane, 1); + expect(overflow.first.laneCount, 2); + }); + + test('CustomAppointment beats a regular lesson on lane priority', () { + final custom = _appt( + id: CustomAppointment(_customEvent()), + start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10), + ); + final regular = _appt( + id: WebuntisAppointment(_lesson()), + start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10), + ); + + final result = assignLanes([regular, custom], maxLanes: 2) + .whereType() + .toList(); + // Same startTime → priority decides: custom (0) goes left of regular (2). + final customCell = result.firstWhere((c) => c.appointment.id is CustomAppointment); + final regularCell = result.firstWhere((c) => c.appointment.id is WebuntisAppointment); + expect(customCell.lane, lessThan(regularCell.lane)); + }); + + test('cancelled lesson lands left of a non-cancelled one on tie', () { + final cancelled = _appt( + id: WebuntisAppointment(_lesson(code: 'cancelled')), + start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10), + ); + final regular = _appt( + id: WebuntisAppointment(_lesson()), + start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10), + ); + + final result = assignLanes([regular, cancelled], maxLanes: 2) + .whereType() + .toList(); + String? codeOf(LaidOutAppointment c) { + final id = c.appointment.id; + return id is WebuntisAppointment ? id.lesson.code : null; + } + final cancelledCell = result.firstWhere((c) => codeOf(c) == 'cancelled'); + final regularCell = result.firstWhere((c) => codeOf(c) == null); + expect(cancelledCell.lane, lessThan(regularCell.lane)); + }); + + test('overflow time-range spans earliest start to latest end of collapsed appointments', () { + // 4 overlapping appointments, maxLanes = 2 → 1 visible + overflow of 3. + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 12)); + final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 10)); + final c = _appt(start: _at(2026, 5, 8, 9, 30), end: _at(2026, 5, 8, 14)); + final d = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11)); + + final overflow = assignLanes([a, b, c, d], maxLanes: 2) + .whereType() + .single; + expect(overflow.appointments, hasLength(3)); + expect(overflow.startTime, _at(2026, 5, 8, 9), + reason: 'earliest non-visible start time'); + expect(overflow.endTime, _at(2026, 5, 8, 14), + reason: 'latest non-visible end time'); + }); + + test('empty input returns an empty list', () { + expect(assignLanes(const [], maxLanes: 2), isEmpty); + }); + + test('asserts maxLanes >= 2', () { + expect(() => assignLanes(const [], maxLanes: 1), throwsA(isA())); + }); + }); +} diff --git a/test/widget/async_action_controller_test.dart b/test/widget/async_action_controller_test.dart new file mode 100644 index 0000000..8bf8d18 --- /dev/null +++ b/test/widget/async_action_controller_test.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/widget/async_action_button.dart'; + +void main() { + group('AsyncActionController.run', () { + test('toggles busy true while running and false after success', () async { + final controller = AsyncActionController(); + addTearDown(controller.dispose); + + var seenBusyInsideCallback = false; + final ok = await controller.run(() async { + seenBusyInsideCallback = controller.busy; + }); + + expect(seenBusyInsideCallback, isTrue, + reason: 'busy must be true while the callback is running'); + expect(ok, isTrue); + expect(controller.busy, isFalse); + expect(controller.error, isNull); + }); + + test('captures mapped error message on failure and returns false', () async { + final controller = AsyncActionController(); + addTearDown(controller.dispose); + + final ok = await controller.run( + () async => throw Exception('boom'), + errorBuilder: (e) => 'custom: $e', + ); + + expect(ok, isFalse); + expect(controller.busy, isFalse); + expect(controller.error, contains('custom:')); + expect(controller.error, contains('boom')); + }); + + test('rejects re-entry while busy', () async { + final controller = AsyncActionController(); + addTearDown(controller.dispose); + + final firstStarted = Completer(); + final firstCanFinish = Completer(); + final firstFuture = controller.run(() async { + firstStarted.complete(); + await firstCanFinish.future; + }); + + await firstStarted.future; + expect(controller.busy, isTrue); + + final reentrant = await controller.run(() async {}); + expect(reentrant, isFalse, + reason: 'second run while busy must be rejected without invoking callback'); + + firstCanFinish.complete(); + expect(await firstFuture, isTrue); + expect(controller.busy, isFalse); + }); + + test('clearError resets error and notifies listeners', () async { + final controller = AsyncActionController(); + addTearDown(controller.dispose); + + var notifyCount = 0; + controller.addListener(() => notifyCount++); + + await controller.run(() async => throw Exception('x')); + expect(controller.error, isNotNull); + final beforeClear = notifyCount; + + controller.clearError(); + expect(controller.error, isNull); + expect(notifyCount, beforeClear + 1); + + // No-op when already cleared. + controller.clearError(); + expect(notifyCount, beforeClear + 1); + }); + }); +}