refactored broad range of the application, split files, modularized calendar and file views, centralized bottom sheets and clipboard handling, and implemented unit test coverage
This commit is contained in:
@@ -0,0 +1,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<T>`)
|
||||||
|
- **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<T>, 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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:jiffy/jiffy.dart';
|
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../api_response.dart';
|
import '../../../api_response.dart';
|
||||||
import '../room/get_room_response.dart';
|
import '../room/get_room_response.dart';
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ class GetChatResponseObject {
|
|||||||
|
|
||||||
static GetChatResponseObject getDateDummy(int timestamp) {
|
static GetChatResponseObject getDateDummy(int timestamp) {
|
||||||
var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
|
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(
|
static GetChatResponseObject getTextDummy(String text) => GetChatResponseObject(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:jiffy/jiffy.dart';
|
import 'package:jiffy/jiffy.dart';
|
||||||
import 'package:json_annotation/json_annotation.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 '../../../../api_response.dart';
|
||||||
import 'cacheable_file.dart';
|
import 'cacheable_file.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 = <String>[
|
||||||
|
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(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:jiffy/jiffy.dart';
|
||||||
|
|
||||||
extension IsSameDay on DateTime {
|
extension IsSameDay on DateTime {
|
||||||
bool isSameDay(DateTime other) => year == other.year && month == other.month && day == other.day;
|
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);
|
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()}';
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,3 +10,11 @@ extension TextExt on Text {
|
|||||||
return textPainter.size;
|
return textPainter.size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the first non-empty (after trim) entry, or '' if none match.
|
||||||
|
String firstNonEmpty(List<String?> values) {
|
||||||
|
for (final v in values) {
|
||||||
|
if (v != null && v.trim().isNotEmpty) return v;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import '../widget/user_avatar.dart';
|
|||||||
///
|
///
|
||||||
/// Every full-page push in modules should go through one of these methods.
|
/// Every full-page push in modules should go through one of these methods.
|
||||||
/// Dialogs (`showDialog`), bottom sheets (`showStickyFlexibleBottomSheet`,
|
/// 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.
|
/// remain unchanged and live at the call sites.
|
||||||
class AppRoutes {
|
class AppRoutes {
|
||||||
AppRoutes._();
|
AppRoutes._();
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import 'dart:async';
|
|||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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_event.dart';
|
||||||
import 'loadable_state_state.dart';
|
import 'loadable_state_state.dart';
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState>
|
|||||||
String connectionText({int? lastUpdated}) => connectivityStatusKnown()
|
String connectionText({int? lastUpdated}) => connectivityStatusKnown()
|
||||||
? isConnected()
|
? isConnected()
|
||||||
? 'Verbindung fehlgeschlagen'
|
? 'Verbindung fehlgeschlagen'
|
||||||
: 'Offline${lastUpdated == null ? '' : ' - Stand von ${Jiffy.parseFromMillisecondsSinceEpoch(lastUpdated).fromNow()}'}'
|
: 'Offline${lastUpdated == null ? '' : ' - Stand von ${DateTime.fromMillisecondsSinceEpoch(lastUpdated).formatRelative()}'}'
|
||||||
: 'Unbekannte Fehlerursache';
|
: 'Unbekannte Fehlerursache';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
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';
|
part 'file_settings.g.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<void> 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
+11
-358
@@ -1,17 +1,12 @@
|
|||||||
import 'dart:developer';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.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_bloc.dart';
|
||||||
import '../../state/app/modules/account/bloc/account_state.dart';
|
import '../../state/app/modules/account/bloc/account_state.dart';
|
||||||
import '../../theming/light_app_theme.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 {
|
class Login extends StatefulWidget {
|
||||||
const Login({super.key});
|
const Login({super.key});
|
||||||
@@ -23,14 +18,7 @@ class Login extends StatefulWidget {
|
|||||||
class _LoginState extends State<Login> {
|
class _LoginState extends State<Login> {
|
||||||
static const _marianumRed = LightAppTheme.marianumRed;
|
static const _marianumRed = LightAppTheme.marianumRed;
|
||||||
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
final LoginController _controller = LoginController();
|
||||||
final _usernameController = TextEditingController();
|
|
||||||
final _passwordController = TextEditingController();
|
|
||||||
final _passwordFocus = FocusNode();
|
|
||||||
|
|
||||||
bool _loading = false;
|
|
||||||
String? _errorMessage;
|
|
||||||
String? _errorDetails;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
@@ -40,95 +28,16 @@ class _LoginState extends State<Login> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_usernameController.dispose();
|
_controller.dispose();
|
||||||
_passwordController.dispose();
|
|
||||||
_passwordFocus.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _required(String? value) =>
|
void _onLoginSuccess() {
|
||||||
(value ?? '').trim().isEmpty ? 'Eingabe erforderlich' : null;
|
|
||||||
|
|
||||||
Future<void> _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<AccountBloc>().setStatus(AccountStatus.loggedIn);
|
context.read<AccountBloc>().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<void>(
|
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => Scaffold(
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: _marianumRed,
|
backgroundColor: _marianumRed,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
@@ -143,268 +52,13 @@ class _LoginState extends State<Login> {
|
|||||||
child: IntrinsicHeight(
|
child: IntrinsicHeight(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 40),
|
const LoginHeader(),
|
||||||
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),
|
const SizedBox(height: 28),
|
||||||
Card(
|
LoginCard(controller: _controller, onSuccess: _onLoginSuccess),
|
||||||
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),
|
const SizedBox(height: 18),
|
||||||
Padding(
|
const LoginDisclaimer(),
|
||||||
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(),
|
const Spacer(),
|
||||||
Padding(
|
const LoginFooter(),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -414,5 +68,4 @@ class _LoginState extends State<Login> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<LoginCard> createState() => _LoginCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginCardState extends State<LoginCard> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
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<void> _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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SortOption, BetterSortOption> 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]!;
|
||||||
|
}
|
||||||
+16
-296
@@ -2,12 +2,8 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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 '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/loadable_state.dart';
|
||||||
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||||
import '../../../state/app/infrastructure/utility_widgets/bloc_module.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/files/bloc/files_state.dart';
|
||||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../utils/cache_invalidation_bus.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 '../../../widget/placeholder_view.dart';
|
||||||
|
import 'data/sort_options.dart';
|
||||||
import 'files_upload_dialog.dart';
|
import 'files_upload_dialog.dart';
|
||||||
|
import 'widgets/add_file_menu.dart';
|
||||||
|
import 'widgets/clipboard_banner.dart';
|
||||||
import 'widgets/file_element.dart';
|
import 'widgets/file_element.dart';
|
||||||
|
import 'widgets/files_sort_actions.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<SortOption, BetterSortOption> 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]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
class Files extends StatelessWidget {
|
class Files extends StatelessWidget {
|
||||||
final List<String> path;
|
final List<String> path;
|
||||||
@@ -89,6 +49,10 @@ class _FilesViewState extends State<_FilesView> {
|
|||||||
// segments joined without leading/trailing slash.
|
// segments joined without leading/trailing slash.
|
||||||
String get _myPathString => widget.path.isEmpty ? '/' : widget.path.join('/');
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -110,7 +74,7 @@ class _FilesViewState extends State<_FilesView> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> mediaUpload(List<String>? paths) async {
|
Future<void> _mediaUpload(List<String>? paths) async {
|
||||||
if (paths == null) return;
|
if (paths == null) return;
|
||||||
final bloc = context.read<FilesBloc>();
|
final bloc = context.read<FilesBloc>();
|
||||||
unawaited(pushScreen(
|
unawaited(pushScreen(
|
||||||
@@ -131,47 +95,16 @@ class _FilesViewState extends State<_FilesView> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'),
|
title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'),
|
||||||
actions: [
|
actions: [
|
||||||
PopupMenuButton<bool>(
|
FilesSortActions(
|
||||||
icon: Icon(currentSortDirection ? Icons.text_rotate_up : Icons.text_rotation_down),
|
currentSort: currentSort,
|
||||||
itemBuilder: (context) => [true, false]
|
ascending: currentSortDirection,
|
||||||
.map((e) => PopupMenuItem<bool>(
|
onDirectionChanged: (e) {
|
||||||
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) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
currentSortDirection = e;
|
currentSortDirection = e;
|
||||||
settings.val(write: true).fileSettings.ascending = e;
|
settings.val(write: true).fileSettings.ascending = e;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
onSortChanged: (e) {
|
||||||
PopupMenuButton<SortOption>(
|
|
||||||
icon: const Icon(Icons.sort),
|
|
||||||
itemBuilder: (context) => SortOptions.options.keys
|
|
||||||
.map((key) => PopupMenuItem<SortOption>(
|
|
||||||
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) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
currentSort = e;
|
currentSort = e;
|
||||||
settings.val(write: true).fileSettings.sortBy = e;
|
settings.val(write: true).fileSettings.sortBy = e;
|
||||||
@@ -183,12 +116,12 @@ class _FilesViewState extends State<_FilesView> {
|
|||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
heroTag: 'uploadFile',
|
heroTag: 'uploadFile',
|
||||||
backgroundColor: Theme.of(context).primaryColor,
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
onPressed: () => _showAddDialog(context, bloc),
|
onPressed: () => showAddFileSheet(context, bloc: bloc, onPickedFiles: _mediaUpload),
|
||||||
child: const Icon(Icons.add),
|
child: const Icon(Icons.add),
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
_ClipboardBanner(currentFolder: _currentFolderPath, onPasteDone: bloc.refresh),
|
ClipboardBanner(currentFolder: _currentFolderPath, onPasteDone: bloc.refresh),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: LoadableStateConsumer<FilesBloc, FilesState>(
|
child: LoadableStateConsumer<FilesBloc, FilesState>(
|
||||||
isReady: (state) => state.listing != null,
|
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<void> _paste() async {
|
|
||||||
final cb = FileClipboard.instance;
|
|
||||||
if (_busy || !_canPaste) return;
|
|
||||||
setState(() => _busy = true);
|
|
||||||
final operation = cb.operation;
|
|
||||||
final errors = <String>[];
|
|
||||||
final invalidatedSourceFolders = <String>{};
|
|
||||||
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<void>(
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> Function(List<String>? 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());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<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<void> _paste() async {
|
||||||
|
final cb = FileClipboard.instance;
|
||||||
|
if (_busy || !_canPaste) return;
|
||||||
|
setState(() => _busy = true);
|
||||||
|
final operation = cb.operation;
|
||||||
|
final errors = <String>[];
|
||||||
|
final invalidatedSourceFolders = <String>{};
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,48 +1,33 @@
|
|||||||
import 'package:filesize/filesize.dart';
|
import 'package:filesize/filesize.dart';
|
||||||
import 'package:flutter/material.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 '../../../../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
|
/// Shows a modal bottom sheet with technical metadata about a single file or
|
||||||
/// folder: full path, MIME type, size, timestamps, ETag.
|
/// folder: full path, MIME type, size, timestamps, ETag.
|
||||||
Future<void> showFileDetailsSheet(BuildContext context, CacheableFile file) {
|
void showFileDetailsSheet(BuildContext context, CacheableFile file) {
|
||||||
return showModalBottomSheet<void>(
|
showDetailsBottomSheet(
|
||||||
context: context,
|
context,
|
||||||
isScrollControlled: true,
|
header: ListTile(
|
||||||
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),
|
leading: Icon(file.isDirectory ? Icons.folder : Icons.description_outlined, size: 32),
|
||||||
title: Text(file.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
title: Text(file.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
subtitle: Text(file.isDirectory ? 'Ordner' : (file.mimeType ?? '–')),
|
subtitle: Text(file.isDirectory ? 'Ordner' : (file.mimeType ?? '–')),
|
||||||
),
|
),
|
||||||
const Divider(),
|
children: (_) => [
|
||||||
_DetailRow(label: 'Pfad', value: file.path, copyable: true),
|
_DetailRow(label: 'Pfad', value: file.path, copyable: true),
|
||||||
if (!file.isDirectory) _DetailRow(label: 'Größe', value: filesize(file.size)),
|
if (!file.isDirectory) _DetailRow(label: 'Größe', value: filesize(file.size)),
|
||||||
if (file.modifiedAt != null)
|
if (file.modifiedAt != null)
|
||||||
_DetailRow(
|
_DetailRow(
|
||||||
label: 'Geändert',
|
label: 'Geändert',
|
||||||
value: '${Jiffy.parseFromDateTime(file.modifiedAt!).format(pattern: 'dd.MM.yyyy HH:mm')} '
|
value: '${file.modifiedAt!.formatDateTime()} (${file.modifiedAt!.formatRelative()})',
|
||||||
'(${Jiffy.parseFromDateTime(file.modifiedAt!).fromNow()})',
|
|
||||||
),
|
),
|
||||||
if (file.createdAt != null)
|
if (file.createdAt != null)
|
||||||
_DetailRow(
|
_DetailRow(label: 'Erstellt', value: file.createdAt!.formatDateTime()),
|
||||||
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),
|
if (file.eTag != null) _DetailRow(label: 'ETag', value: file.eTag!, copyable: true),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +39,7 @@ class _DetailRow extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Padding(
|
Widget build(BuildContext context) => Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -67,12 +52,7 @@ class _DetailRow extends StatelessWidget {
|
|||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Kopieren',
|
tooltip: 'Kopieren',
|
||||||
icon: const Icon(Icons.copy, size: 18),
|
icon: const Icon(Icons.copy, size: 18),
|
||||||
onPressed: () {
|
onPressed: () => copyToClipboard(context, value),
|
||||||
Clipboard.setData(ClipboardData(text: value));
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('In Zwischenablage kopiert')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'package:filesize/filesize.dart';
|
import 'package:filesize/filesize.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:jiffy/jiffy.dart';
|
|
||||||
import 'package:nextcloud/nextcloud.dart';
|
import 'package:nextcloud/nextcloud.dart';
|
||||||
|
|
||||||
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||||
import '../../../../api/marianumcloud/webdav/webdav_api.dart';
|
import '../../../../api/marianumcloud/webdav/webdav_api.dart';
|
||||||
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../../model/endpoint_data.dart';
|
import '../../../../model/endpoint_data.dart';
|
||||||
import '../../../../routing/app_routes.dart';
|
import '../../../../routing/app_routes.dart';
|
||||||
import '../../../../utils/download_manager.dart';
|
import '../../../../utils/download_manager.dart';
|
||||||
@@ -135,9 +135,10 @@ class _FileElementState extends State<FileElement> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
final modified = widget.file.modifiedAt ?? DateTime.now();
|
||||||
return widget.file.isDirectory
|
return widget.file.isDirectory
|
||||||
? Text('geändert ${Jiffy.parseFromDateTime(widget.file.modifiedAt ?? DateTime.now()).fromNow()}')
|
? Text('geändert ${modified.formatRelative()}')
|
||||||
: Text('${filesize(widget.file.size)}, ${Jiffy.parseFromDateTime(widget.file.modifiedAt ?? DateTime.now()).fromNow()}');
|
: Text('${filesize(widget.file.size)}, ${modified.formatRelative()}');
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onTap() {
|
void _onTap() {
|
||||||
|
|||||||
@@ -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<bool> onDirectionChanged;
|
||||||
|
final ValueChanged<SortOption> 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<bool>(
|
||||||
|
icon: Icon(ascending ? Icons.text_rotate_up : Icons.text_rotation_down),
|
||||||
|
itemBuilder: (context) => [true, false]
|
||||||
|
.map((e) => PopupMenuItem<bool>(
|
||||||
|
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<SortOption>(
|
||||||
|
icon: const Icon(Icons.sort),
|
||||||
|
itemBuilder: (context) => SortOptions.options.keys
|
||||||
|
.map((key) => PopupMenuItem<SortOption>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import '../../../../extensions/date_time.dart';
|
||||||
|
import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
|
||||||
|
|
||||||
|
/// Pure formatting helpers for `MarianumDate` events. Held outside the view
|
||||||
|
/// so the view can stay focused on layout and these helpers remain
|
||||||
|
/// unit-testable.
|
||||||
|
class EventFormatter {
|
||||||
|
/// Compact trailing label shown in the list row: "HH:mm–HH:mm" for same-day,
|
||||||
|
/// "dd.MM. HH:mm–dd.MM. HH:mm" otherwise, or "Ganztägig" for all-day events.
|
||||||
|
static String trailingLabel(MarianumDate event) {
|
||||||
|
if (event.isAllDay) return 'Ganztägig';
|
||||||
|
if (event.start.isSameDay(event.end)) {
|
||||||
|
if (event.start == event.end) return event.start.formatHm();
|
||||||
|
return '${event.start.formatHm()}–${event.end.formatHm()}';
|
||||||
|
}
|
||||||
|
return '${event.start.formatDateShortHm()}–${event.end.formatDateShortHm()}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verbose date+time line shown in the details sheet. Drops the trailing
|
||||||
|
/// time when the event is all-day, and de-duplicates same-day endpoints.
|
||||||
|
static String longRange(MarianumDate event) {
|
||||||
|
if (event.isAllDay) {
|
||||||
|
final inclusiveEnd = event.end.isAfter(event.start)
|
||||||
|
? event.end.subtract(const Duration(days: 1))
|
||||||
|
: event.end;
|
||||||
|
return event.start.isSameDay(inclusiveEnd)
|
||||||
|
? '${event.start.formatDate()} · Ganztägig'
|
||||||
|
: '${event.start.formatDate()} – ${inclusiveEnd.formatDate()} · Ganztägig';
|
||||||
|
}
|
||||||
|
if (event.start.isSameDay(event.end)) {
|
||||||
|
if (event.start == event.end) {
|
||||||
|
return '${event.start.formatDate()} · ${event.start.formatHm()}';
|
||||||
|
}
|
||||||
|
return '${event.start.formatDate()} · ${event.start.formatHm()} – ${event.end.formatHm()}';
|
||||||
|
}
|
||||||
|
return '${event.start.formatDateTime()} – ${event.end.formatDateTime()}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package: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/loadable_state.dart';
|
||||||
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||||
import '../../../state/app/infrastructure/utility_widgets/bloc_module.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_bloc.dart';
|
||||||
import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_event.dart';
|
import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_event.dart';
|
||||||
import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.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 '../../../widget/placeholder_view.dart';
|
||||||
import '../timetable/custom_events/custom_event_edit_dialog.dart';
|
|
||||||
import 'search_marianum_dates.dart';
|
import 'search_marianum_dates.dart';
|
||||||
|
import 'widgets/event_list_tile.dart';
|
||||||
|
import 'widgets/month_section_header.dart';
|
||||||
|
|
||||||
class MarianumDatesView extends StatelessWidget {
|
class MarianumDatesView extends StatelessWidget {
|
||||||
const MarianumDatesView({super.key});
|
const MarianumDatesView({super.key});
|
||||||
@@ -27,7 +25,7 @@ class MarianumDatesView extends StatelessWidget {
|
|||||||
final keys = byMonth.keys.toList()..sort();
|
final keys = byMonth.keys.toList()..sort();
|
||||||
return keys.map((key) {
|
return keys.map((key) {
|
||||||
final first = byMonth[key]!.first.start;
|
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]!);
|
return _MonthGroup(key: key, label: label, events: byMonth[key]!);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
@@ -110,239 +108,3 @@ class _MonthGroup {
|
|||||||
final List<MarianumDate> events;
|
final List<MarianumDate> events;
|
||||||
_MonthGroup({required this.key, required this.label, required this.events});
|
_MonthGroup({required this.key, required this.label, required this.events});
|
||||||
}
|
}
|
||||||
|
|
||||||
class MonthHeaderDelegate extends SliverPersistentHeaderDelegate {
|
|
||||||
final String label;
|
|
||||||
MonthHeaderDelegate({required this.label});
|
|
||||||
|
|
||||||
static const double _height = 38;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Container(
|
|
||||||
height: _height,
|
|
||||||
color: theme.colorScheme.surfaceContainer,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: 1.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
double get maxExtent => _height;
|
|
||||||
|
|
||||||
@override
|
|
||||||
double get minExtent => _height;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRebuild(covariant MonthHeaderDelegate oldDelegate) => oldDelegate.label != label;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Composite icon: calendar with a small plus badge in the bottom-right.
|
|
||||||
/// Material's bundled icon set has no `calendar_add_on`, so we layer
|
|
||||||
/// `Icons.event_outlined` and `Icons.add` to get the same affordance.
|
|
||||||
class _CalendarPlusIcon extends StatelessWidget {
|
|
||||||
final Color color;
|
|
||||||
const _CalendarPlusIcon({required this.color});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => SizedBox(
|
|
||||||
width: 22,
|
|
||||||
height: 22,
|
|
||||||
child: Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.event_outlined, size: 22, color: color),
|
|
||||||
Positioned(
|
|
||||||
right: -2,
|
|
||||||
bottom: -2,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.surface,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(1),
|
|
||||||
child: Icon(Icons.add_circle, size: 12, color: color),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class MarianumDateRow extends StatelessWidget {
|
|
||||||
final MarianumDate event;
|
|
||||||
const MarianumDateRow({required this.event, super.key});
|
|
||||||
|
|
||||||
String _dayLabel() => event.start.day.toString().padLeft(2, '0');
|
|
||||||
|
|
||||||
String _monthYearLabel() =>
|
|
||||||
'${event.start.month.toString().padLeft(2, '0')}.${event.start.year}';
|
|
||||||
|
|
||||||
String _trailingLabel() {
|
|
||||||
final start = Jiffy.parseFromDateTime(event.start);
|
|
||||||
final end = Jiffy.parseFromDateTime(event.end);
|
|
||||||
if (event.isAllDay) return 'Ganztägig';
|
|
||||||
final sameDay = start.format(pattern: 'yyyy-MM-dd') == end.format(pattern: 'yyyy-MM-dd');
|
|
||||||
if (sameDay) {
|
|
||||||
if (event.start == event.end) return start.format(pattern: 'HH:mm');
|
|
||||||
return '${start.format(pattern: 'HH:mm')}–${end.format(pattern: 'HH:mm')}';
|
|
||||||
}
|
|
||||||
return '${start.format(pattern: 'dd.MM. HH:mm')}–${end.format(pattern: 'dd.MM. HH:mm')}';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
return InkWell(
|
|
||||||
onTap: () => _showDetails(context),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 10, 4, 10),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 44,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_dayLabel(),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: theme.colorScheme.onSurface,
|
|
||||||
height: 1.1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
_monthYearLabel(),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
maxLines: 1,
|
|
||||||
softWrap: false,
|
|
||||||
overflow: TextOverflow.visible,
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
fontSize: 10,
|
|
||||||
height: 1.1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
event.title.isEmpty ? '(ohne Titel)' : event.title,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface),
|
|
||||||
),
|
|
||||||
if (event.description != null && event.description!.trim().isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
event.description!.trim(),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
_trailingLabel(),
|
|
||||||
style: theme.textTheme.labelMedium?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
IconButton(
|
|
||||||
icon: _CalendarPlusIcon(color: theme.colorScheme.onSurfaceVariant),
|
|
||||||
tooltip: 'In Stundenplan übernehmen',
|
|
||||||
onPressed: () => showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => CustomEventEditDialog(
|
|
||||||
initialTitle: event.title,
|
|
||||||
initialDescription: event.description,
|
|
||||||
initialStart: event.start,
|
|
||||||
initialEnd: event.end,
|
|
||||||
),
|
|
||||||
barrierDismissible: false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showDetails(BuildContext context) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => SimpleDialog(
|
|
||||||
title: Text(event.title.isEmpty ? '(ohne Titel)' : event.title),
|
|
||||||
children: [
|
|
||||||
ListTile(
|
|
||||||
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
|
|
||||||
title: Text(_formatLongRange()),
|
|
||||||
),
|
|
||||||
if (event.description != null && event.description!.trim().isNotEmpty)
|
|
||||||
ListTile(
|
|
||||||
leading: const CenteredLeading(Icon(Icons.notes_outlined)),
|
|
||||||
title: Text(event.description!.trim()),
|
|
||||||
),
|
|
||||||
Visibility(
|
|
||||||
visible: !event.start.difference(DateTime.now()).isNegative,
|
|
||||||
replacement: ListTile(
|
|
||||||
leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)),
|
|
||||||
title: Text(Jiffy.parseFromDateTime(event.start).fromNow()),
|
|
||||||
),
|
|
||||||
child: ListTile(
|
|
||||||
leading: const CenteredLeading(Icon(Icons.timer_outlined)),
|
|
||||||
title: AnimatedTime(callback: () => event.start.difference(DateTime.now())),
|
|
||||||
subtitle: Text(Jiffy.parseFromDateTime(event.start).fromNow()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
DebugTile(context).jsonData(event.toJson()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatLongRange() {
|
|
||||||
final start = Jiffy.parseFromDateTime(event.start);
|
|
||||||
final end = Jiffy.parseFromDateTime(event.end);
|
|
||||||
if (event.isAllDay) {
|
|
||||||
final inclusiveEnd = event.end.isAfter(event.start) ? end.subtract(days: 1) : end;
|
|
||||||
final sameAllDay =
|
|
||||||
start.format(pattern: 'yyyy-MM-dd') == inclusiveEnd.format(pattern: 'yyyy-MM-dd');
|
|
||||||
return sameAllDay
|
|
||||||
? '${start.format(pattern: 'dd.MM.yyyy')} · Ganztägig'
|
|
||||||
: '${start.format(pattern: 'dd.MM.yyyy')} – ${inclusiveEnd.format(pattern: 'dd.MM.yyyy')} · Ganztägig';
|
|
||||||
}
|
|
||||||
final sameDay = start.format(pattern: 'yyyy-MM-dd') == end.format(pattern: 'yyyy-MM-dd');
|
|
||||||
if (sameDay) {
|
|
||||||
if (event.start == event.end) {
|
|
||||||
return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')}';
|
|
||||||
}
|
|
||||||
return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')} – ${end.format(pattern: 'HH:mm')}';
|
|
||||||
}
|
|
||||||
return '${start.format(pattern: 'dd.MM.yyyy HH:mm')} – ${end.format(pattern: 'dd.MM.yyyy HH:mm')}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
|
import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
|
||||||
import '../../../widget/placeholder_view.dart';
|
import '../../../widget/placeholder_view.dart';
|
||||||
import 'marianum_dates_view.dart';
|
import 'widgets/event_list_tile.dart';
|
||||||
|
|
||||||
class SearchMarianumDates extends SearchDelegate<MarianumDate?> {
|
class SearchMarianumDates extends SearchDelegate<MarianumDate?> {
|
||||||
final List<MarianumDate> events;
|
final List<MarianumDate> events;
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../extensions/date_time.dart';
|
||||||
|
import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
|
||||||
|
import '../../../../widget/animated_time.dart';
|
||||||
|
import '../../../../widget/centered_leading.dart';
|
||||||
|
import '../../../../widget/debug/debug_tile.dart';
|
||||||
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
|
import '../data/event_formatter.dart';
|
||||||
|
|
||||||
|
void showEventDetailsSheet(BuildContext context, MarianumDate event) {
|
||||||
|
final isUpcoming = !event.start.difference(DateTime.now()).isNegative;
|
||||||
|
showDetailsBottomSheet(
|
||||||
|
context,
|
||||||
|
header: ListTile(
|
||||||
|
leading: const Icon(Icons.event_outlined, size: 32),
|
||||||
|
title: Text(
|
||||||
|
event.title.isEmpty ? '(ohne Titel)' : event.title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: (sheetContext) => [
|
||||||
|
ListTile(
|
||||||
|
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
|
||||||
|
title: Text(EventFormatter.longRange(event)),
|
||||||
|
),
|
||||||
|
if (event.description != null && event.description!.trim().isNotEmpty)
|
||||||
|
ListTile(
|
||||||
|
leading: const CenteredLeading(Icon(Icons.notes_outlined)),
|
||||||
|
title: Text(event.description!.trim()),
|
||||||
|
),
|
||||||
|
if (isUpcoming)
|
||||||
|
ListTile(
|
||||||
|
leading: const CenteredLeading(Icon(Icons.timer_outlined)),
|
||||||
|
title: AnimatedTime(callback: () => event.start.difference(DateTime.now())),
|
||||||
|
subtitle: Text(event.start.formatRelative()),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ListTile(
|
||||||
|
leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)),
|
||||||
|
title: Text(event.start.formatRelative()),
|
||||||
|
),
|
||||||
|
DebugTile(sheetContext).jsonData(event.toJson()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
|
||||||
|
import '../../timetable/custom_events/custom_event_edit_dialog.dart';
|
||||||
|
import '../data/event_formatter.dart';
|
||||||
|
import 'event_details_sheet.dart';
|
||||||
|
|
||||||
|
class MarianumDateRow extends StatelessWidget {
|
||||||
|
final MarianumDate event;
|
||||||
|
const MarianumDateRow({required this.event, super.key});
|
||||||
|
|
||||||
|
String _dayLabel() => event.start.day.toString().padLeft(2, '0');
|
||||||
|
|
||||||
|
String _monthYearLabel() =>
|
||||||
|
'${event.start.month.toString().padLeft(2, '0')}.${event.start.year}';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => showEventDetailsSheet(context, event),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 10, 4, 10),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 44,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_dayLabel(),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_monthYearLabel(),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 1,
|
||||||
|
softWrap: false,
|
||||||
|
overflow: TextOverflow.visible,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: 10,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
event.title.isEmpty ? '(ohne Titel)' : event.title,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
if (event.description != null && event.description!.trim().isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
event.description!.trim(),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
EventFormatter.trailingLabel(event),
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
IconButton(
|
||||||
|
icon: _CalendarPlusIcon(color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
tooltip: 'In Stundenplan übernehmen',
|
||||||
|
onPressed: () => showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => CustomEventEditDialog(
|
||||||
|
initialTitle: event.title,
|
||||||
|
initialDescription: event.description,
|
||||||
|
initialStart: event.start,
|
||||||
|
initialEnd: event.end,
|
||||||
|
),
|
||||||
|
barrierDismissible: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Composite icon: calendar with a small plus badge in the bottom-right.
|
||||||
|
/// Material's bundled icon set has no `calendar_add_on`, so we layer
|
||||||
|
/// `Icons.event_outlined` and `Icons.add` to get the same affordance.
|
||||||
|
class _CalendarPlusIcon extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
const _CalendarPlusIcon({required this.color});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.event_outlined, size: 22, color: color),
|
||||||
|
Positioned(
|
||||||
|
right: -2,
|
||||||
|
bottom: -2,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(1),
|
||||||
|
child: Icon(Icons.add_circle, size: 12, color: color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class MonthHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||||
|
final String label;
|
||||||
|
MonthHeaderDelegate({required this.label});
|
||||||
|
|
||||||
|
static const double _height = 38;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
height: _height,
|
||||||
|
color: theme.colorScheme.surfaceContainer,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get maxExtent => _height;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get minExtent => _height;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRebuild(covariant MonthHeaderDelegate oldDelegate) => oldDelegate.label != label;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:url_launcher/url_launcher.dart';
|
|||||||
|
|
||||||
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
|
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
|
||||||
import '../../../widget/confirm_dialog.dart';
|
import '../../../widget/confirm_dialog.dart';
|
||||||
|
import '../../../widget/info_dialog.dart';
|
||||||
|
|
||||||
class MessageView extends StatefulWidget {
|
class MessageView extends StatefulWidget {
|
||||||
final String basePath;
|
final String basePath;
|
||||||
@@ -26,15 +27,11 @@ class _MessageViewState extends State<MessageView> {
|
|||||||
enableHyperlinkNavigation: true,
|
enableHyperlinkNavigation: true,
|
||||||
onDocumentLoadFailed: (PdfDocumentLoadFailedDetails e) {
|
onDocumentLoadFailed: (PdfDocumentLoadFailedDetails e) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
showDialog(context: context, builder: (context) => AlertDialog(
|
InfoDialog.show(
|
||||||
title: const Text('Fehler beim öffnen'),
|
context,
|
||||||
content: Text("Dokument '${widget.message.name}' konnte nicht geladen werden:\n${e.description}"),
|
"Dokument '${widget.message.name}' konnte nicht geladen werden:\n${e.description}",
|
||||||
actions: [
|
title: 'Fehler beim öffnen',
|
||||||
TextButton(onPressed: () {
|
);
|
||||||
Navigator.of(context).pop();
|
|
||||||
}, child: const Text('Ok'))
|
|
||||||
],
|
|
||||||
));
|
|
||||||
},
|
},
|
||||||
onHyperlinkClicked: (PdfHyperlinkClickedDetails e) {
|
onHyperlinkClicked: (PdfHyperlinkClickedDetails e) {
|
||||||
showDialog(
|
showDialog(
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import '../../../../storage/settings.dart';
|
|||||||
import '../../../../storage/talk_settings.dart';
|
import '../../../../storage/talk_settings.dart';
|
||||||
import '../../../../storage/timetable_settings.dart';
|
import '../../../../storage/timetable_settings.dart';
|
||||||
import '../../../../view/pages/timetable/data/timetable_name_mode.dart';
|
import '../../../../view/pages/timetable/data/timetable_name_mode.dart';
|
||||||
import '../../files/files.dart';
|
import '../../files/data/sort_options.dart';
|
||||||
|
|
||||||
class DefaultSettings {
|
class DefaultSettings {
|
||||||
static Settings get() => Settings(
|
static Settings get() => Settings(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import '../../../../notification/notify_updater.dart';
|
import '../../../../notification/notify_updater.dart';
|
||||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../../widget/centered_leading.dart';
|
import '../../../../widget/centered_leading.dart';
|
||||||
|
import '../../../../widget/info_dialog.dart';
|
||||||
|
|
||||||
class TalkSection extends StatelessWidget {
|
class TalkSection extends StatelessWidget {
|
||||||
const TalkSection({super.key});
|
const TalkSection({super.key});
|
||||||
@@ -51,22 +52,13 @@ class TalkSection extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showInfoDialog(BuildContext context) => showDialog(
|
void _showInfoDialog(BuildContext context) => InfoDialog.show(
|
||||||
context: 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"
|
"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'
|
'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'
|
'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'
|
'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!',
|
'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',
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Zurück')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,22 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/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 '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../../extensions/text.dart';
|
import '../../../../extensions/text.dart';
|
||||||
import '../../../../routing/app_routes.dart';
|
import '../../../../routing/app_routes.dart';
|
||||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
import '../../../../utils/download_manager.dart';
|
import '../../../../utils/download_manager.dart';
|
||||||
import '../../../../widget/async_action_button.dart';
|
import '../../../../widget/confirm_dialog.dart';
|
||||||
import '../../../../widget/loading_spinner.dart';
|
import '../../../../widget/info_dialog.dart';
|
||||||
import '../data/chat_bubble_styles.dart';
|
import '../data/chat_bubble_styles.dart';
|
||||||
import '../data/chat_message.dart';
|
import '../data/chat_message.dart';
|
||||||
import 'answer_reference.dart';
|
import 'answer_reference.dart';
|
||||||
import 'bubble.dart';
|
import 'bubble.dart';
|
||||||
|
import 'chat_bubble_poll.dart';
|
||||||
|
import 'chat_bubble_reactions.dart';
|
||||||
import 'chat_message_options_dialog.dart';
|
import 'chat_message_options_dialog.dart';
|
||||||
import 'poll_options_list.dart';
|
|
||||||
|
|
||||||
class ChatBubble extends StatefulWidget {
|
class ChatBubble extends StatefulWidget {
|
||||||
final BuildContext context;
|
final BuildContext context;
|
||||||
@@ -54,8 +50,8 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
|
|||||||
late ChatMessage message;
|
late ChatMessage message;
|
||||||
DownloadJob? _job;
|
DownloadJob? _job;
|
||||||
|
|
||||||
late Offset _position = const Offset(0, 0);
|
Offset _position = Offset.zero;
|
||||||
late Offset _dragStartPosition = Offset.zero;
|
Offset _dragStartPosition = Offset.zero;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -99,7 +95,7 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
|
|||||||
DownloadManager.instance.clear(job.remotePath);
|
DownloadManager.instance.clear(job.remotePath);
|
||||||
_detachJob();
|
_detachJob();
|
||||||
setState(() {});
|
setState(() {});
|
||||||
showDialog<void>(context: context, builder: (context) => AlertDialog(content: Text(message)));
|
InfoDialog.show(context, message, title: 'Download fehlgeschlagen');
|
||||||
} else if (status is DownloadCancelled) {
|
} else if (status is DownloadCancelled) {
|
||||||
DownloadManager.instance.clear(job.remotePath);
|
DownloadManager.instance.clear(job.remotePath);
|
||||||
_detachJob();
|
_detachJob();
|
||||||
@@ -122,66 +118,69 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _confirmCancel() {
|
void _confirmCancel() {
|
||||||
showDialog<void>(
|
ConfirmDialog(
|
||||||
context: context,
|
title: 'Download abbrechen?',
|
||||||
builder: (dialogContext) => AlertDialog(
|
content: 'Möchtest du den Download abbrechen?',
|
||||||
title: const Text('Download abbrechen?'),
|
confirmButton: 'Ja, Abbrechen',
|
||||||
content: const Text('Möchtest du den Download abbrechen?'),
|
cancelButton: 'Nein',
|
||||||
actions: [
|
onConfirm: () => _job?.cancel(),
|
||||||
TextButton(onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Nein')),
|
).asDialog(context);
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.of(dialogContext).pop();
|
|
||||||
_job?.cancel();
|
|
||||||
},
|
|
||||||
child: const Text('Ja, Abbrechen'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BubbleStyle getStyle() {
|
BubbleStyle _getStyle() {
|
||||||
var styles = ChatBubbleStyles(context);
|
final styles = ChatBubbleStyles(context);
|
||||||
if(widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment) {
|
if (widget.bubbleData.messageType != GetRoomResponseObjectMessageType.comment) {
|
||||||
if(widget.isSender) {
|
|
||||||
return styles.getSelfStyle(false);
|
|
||||||
} else {
|
|
||||||
return styles.getRemoteStyle(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return styles.getSystemStyle();
|
return styles.getSystemStyle();
|
||||||
}
|
}
|
||||||
|
return widget.isSender ? styles.getSelfStyle(false) : styles.getRemoteStyle(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void showOptionsDialog() {
|
void _showOptionsDialog() => showChatMessageOptionsDialog(
|
||||||
showChatMessageOptionsDialog(
|
|
||||||
context,
|
context,
|
||||||
chatData: widget.chatData,
|
chatData: widget.chatData,
|
||||||
bubbleData: widget.bubbleData,
|
bubbleData: widget.bubbleData,
|
||||||
isSender: widget.isSender,
|
isSender: widget.isSender,
|
||||||
onRefetch: widget.refetch,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
message = ChatMessage(originalMessage: widget.bubbleData.message, originalData: widget.bubbleData.messageParameters);
|
message = ChatMessage(originalMessage: widget.bubbleData.message, originalData: widget.bubbleData.messageParameters);
|
||||||
var showActorDisplayName = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment && widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
|
final showActorDisplayName = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment
|
||||||
var showBubbleTime = widget.bubbleData.messageType != GetRoomResponseObjectMessageType.system
|
&& widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
|
||||||
|
final showBubbleTime = widget.bubbleData.messageType != GetRoomResponseObjectMessageType.system
|
||||||
&& widget.bubbleData.messageType != GetRoomResponseObjectMessageType.deletedComment;
|
&& widget.bubbleData.messageType != GetRoomResponseObjectMessageType.deletedComment;
|
||||||
|
|
||||||
var parent = widget.bubbleData.parent;
|
final parent = widget.bubbleData.parent;
|
||||||
var actorText = Text(
|
final actorText = Text(
|
||||||
widget.bubbleData.actorDisplayName,
|
widget.bubbleData.actorDisplayName,
|
||||||
textAlign: TextAlign.start,
|
textAlign: TextAlign.start,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold),
|
style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold),
|
||||||
);
|
);
|
||||||
|
|
||||||
var timeText = Text(
|
final timeText = Text(
|
||||||
Jiffy.parseFromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).format(pattern: 'HH:mm'),
|
DateTime.fromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).formatHm(),
|
||||||
textAlign: TextAlign.end,
|
textAlign: TextAlign.end,
|
||||||
style: TextStyle(color: widget.timeIconColor, fontSize: widget.timeIconSize),
|
style: TextStyle(color: widget.timeIconColor, fontSize: widget.timeIconSize),
|
||||||
);
|
);
|
||||||
@@ -191,191 +190,161 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
|
|||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
textDirection: TextDirection.ltr,
|
textDirection: TextDirection.ltr,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onHorizontalDragStart: (details) {
|
onHorizontalDragStart: (_) => _dragStartPosition = _position,
|
||||||
_dragStartPosition = _position;
|
|
||||||
},
|
|
||||||
onHorizontalDragUpdate: (details) {
|
onHorizontalDragUpdate: (details) {
|
||||||
if(!widget.bubbleData.isReplyable) return;
|
if (!widget.bubbleData.isReplyable) return;
|
||||||
var dx = details.delta.dx - _dragStartPosition.dx;
|
final dx = details.delta.dx - _dragStartPosition.dx;
|
||||||
setState(() {
|
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) {
|
onHorizontalDragEnd: (_) {
|
||||||
var isAction = _position.dx.abs() > 50;
|
final isAction = _position.dx.abs() > 50;
|
||||||
setState(() {
|
setState(() => _position = Offset.zero);
|
||||||
_position = const Offset(0, 0);
|
if (widget.bubbleData.isReplyable && isAction) {
|
||||||
});
|
|
||||||
if(widget.bubbleData.isReplyable && isAction) {
|
|
||||||
context.read<ChatBloc>().setReferenceMessageId(widget.bubbleData.id);
|
context.read<ChatBloc>().setReferenceMessageId(widget.bubbleData.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongPress: showOptionsDialog,
|
onLongPress: _showOptionsDialog,
|
||||||
onDoubleTap: showOptionsDialog,
|
onDoubleTap: _showOptionsDialog,
|
||||||
onTap: () {
|
onTap: _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();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Transform.translate(
|
child: Transform.translate(
|
||||||
offset: _position,
|
offset: _position,
|
||||||
child: Bubble(
|
child: Bubble(
|
||||||
style: getStyle(),
|
style: _getStyle(),
|
||||||
child: Column(
|
child: _BubbleContent(
|
||||||
children: [
|
actorText: actorText,
|
||||||
Container(
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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(
|
constraints: BoxConstraints(
|
||||||
maxWidth: MediaQuery.of(context).size.width * 0.9,
|
maxWidth: MediaQuery.of(context).size.width * 0.9,
|
||||||
minWidth: showActorDisplayName
|
minWidth: showActorDisplayName
|
||||||
? actorText.size.width
|
? actorText.size.width
|
||||||
: timeText.size.width + (widget.isSender ? widget.spacing + widget.timeIconSize : 0) + 3,
|
: timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3,
|
||||||
),
|
),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Visibility(
|
if (showActorDisplayName)
|
||||||
visible: showActorDisplayName,
|
Positioned(top: 0, left: 0, child: actorText),
|
||||||
child: Positioned(
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
child: actorText
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(bottom: showBubbleTime ? 18 : 0, top: showActorDisplayName ? 18 : 0),
|
padding: EdgeInsets.only(
|
||||||
|
bottom: showBubbleTime ? 18 : 0,
|
||||||
|
top: showActorDisplayName ? 18 : 0,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if(parent != null && widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment) ...[
|
if (parent != null && bubbleData.messageType == GetRoomResponseObjectMessageType.comment) ...[
|
||||||
AnswerReference(
|
AnswerReference(
|
||||||
context: context,
|
context: context,
|
||||||
referenceMessage: parent,
|
referenceMessage: parent!,
|
||||||
selfId: widget.selfId,
|
selfId: selfId,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 5),
|
const SizedBox(height: 5),
|
||||||
],
|
],
|
||||||
message.getWidget(),
|
messageWidget,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Visibility(
|
if (showBubbleTime)
|
||||||
visible: showBubbleTime,
|
Positioned(
|
||||||
child: Positioned(
|
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
timeText,
|
timeText,
|
||||||
if(widget.isSender) ...[
|
if (isSender) ...[
|
||||||
SizedBox(width: widget.spacing),
|
SizedBox(width: spacing),
|
||||||
Icon(
|
Icon(
|
||||||
widget.isRead ? Icons.done_all_outlined: Icons.done_outlined,
|
isRead ? Icons.done_all_outlined : Icons.done_outlined,
|
||||||
size: widget.timeIconSize,
|
size: timeIconSize,
|
||||||
color: widget.timeIconColor
|
color: timeIconColor,
|
||||||
)
|
),
|
||||||
]
|
],
|
||||||
],
|
],
|
||||||
)
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_job?.status.value is DownloadInProgress)
|
if (downloadJob?.status.value is DownloadInProgress)
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: () {
|
value: () {
|
||||||
final s = _job!.status.value as DownloadInProgress;
|
final s = downloadJob!.status.value as DownloadInProgress;
|
||||||
return s.percent <= 0 ? null : s.percent / 100;
|
return s.percent <= 0 ? null : s.percent / 100;
|
||||||
}(),
|
}(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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<Widget>((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() ?? [],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void>(
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Widget>((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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emojis;
|
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emojis;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../../../../api/marianumcloud/talk/actions/talk_actions.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 '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../../../../routing/app_routes.dart';
|
import '../../../../routing/app_routes.dart';
|
||||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
|
import '../../../../utils/clipboard_helper.dart';
|
||||||
import '../../../../widget/app_progress_indicator.dart';
|
import '../../../../widget/app_progress_indicator.dart';
|
||||||
import '../../../../widget/async_action_button.dart';
|
import '../../../../widget/async_action_button.dart';
|
||||||
import '../../../../widget/debug/debug_tile.dart';
|
import '../../../../widget/debug/debug_tile.dart';
|
||||||
@@ -69,7 +69,7 @@ Future<void> showChatMessageOptionsDialog(
|
|||||||
leading: const Icon(Icons.copy),
|
leading: const Icon(Icons.copy),
|
||||||
title: const Text('Nachricht kopieren'),
|
title: const Text('Nachricht kopieren'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Clipboard.setData(ClipboardData(text: bubbleData.message));
|
copyToClipboard(parentContext, bubbleData.message);
|
||||||
Navigator.of(dialogCtx).pop();
|
Navigator.of(dialogCtx).pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '../../../../api/marianumcloud/webdav/webdav_api.dart';
|
|||||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../../widget/async_action_button.dart';
|
import '../../../../widget/async_action_button.dart';
|
||||||
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
import '../../../../widget/file_pick.dart';
|
import '../../../../widget/file_pick.dart';
|
||||||
import '../../../../widget/focus_behaviour.dart';
|
import '../../../../widget/focus_behaviour.dart';
|
||||||
import '../../files/files_upload_dialog.dart';
|
import '../../files/files_upload_dialog.dart';
|
||||||
@@ -172,13 +173,15 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
Row(children: <Widget>[
|
Row(children: <Widget>[
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
showDialog(context: context, builder: (dialogCtx) => SimpleDialog(children: [
|
showDetailsBottomSheet(
|
||||||
|
context,
|
||||||
|
children: (sheetCtx) => [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.file_open),
|
leading: const Icon(Icons.file_open),
|
||||||
title: const Text('Aus Dateien auswählen'),
|
title: const Text('Aus Dateien auswählen'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
FilePick.documentPick().then(mediaUpload);
|
FilePick.documentPick().then(mediaUpload);
|
||||||
Navigator.of(dialogCtx).pop();
|
Navigator.of(sheetCtx).pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -188,7 +191,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
FilePick.multipleGalleryPick().then((value) {
|
FilePick.multipleGalleryPick().then((value) {
|
||||||
if (value != null) mediaUpload(value.map((e) => e.path).toList());
|
if (value != null) mediaUpload(value.map((e) => e.path).toList());
|
||||||
});
|
});
|
||||||
Navigator.of(dialogCtx).pop();
|
Navigator.of(sheetCtx).pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -198,10 +201,11 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
FilePick.cameraPick().then((image) {
|
FilePick.cameraPick().then((image) {
|
||||||
if (image != null) mediaUpload([image.path]);
|
if (image != null) mediaUpload([image.path]);
|
||||||
});
|
});
|
||||||
Navigator.of(dialogCtx).pop();
|
Navigator.of(sheetCtx).pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]));
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: Material(
|
child: Material(
|
||||||
elevation: 5,
|
elevation: 5,
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/actions/talk_actions.dart';
|
||||||
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
|
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
|
||||||
import '../../../../api/marianumcloud/talk/room/get_room_response.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.dart';
|
||||||
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart';
|
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart';
|
||||||
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../../model/account_data.dart';
|
import '../../../../model/account_data.dart';
|
||||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
@@ -96,7 +96,7 @@ class _ChatTileState extends State<ChatTile> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
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)}',
|
'${RichObjectStringProcessor.parseToString(widget.data.lastMessage.message.replaceAll("\n", " "), widget.data.lastMessage.messageParameters)}',
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||||
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||||
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||||
@@ -51,7 +51,7 @@ class CustomEventsView extends StatelessWidget {
|
|||||||
title: Text(e.title),
|
title: Text(e.title),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'${e.rrule.isNotEmpty ? "wiederholend, " : ""}'
|
'${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)),
|
leading: CenteredLeading(Icon(e.rrule.isEmpty ? Icons.event_outlined : Icons.event_repeat_outlined)),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
|
|||||||
@@ -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<BoundRegion> expandRegionsForDay(List<TimeRegion> 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 (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<List<Appointment>> inside, List<List<Appointment>> outside})
|
||||||
|
partitionAppointmentsForWeek(
|
||||||
|
List<Appointment> appointments, DateTime weekStart) {
|
||||||
|
final inside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
|
||||||
|
final outside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
|
||||||
|
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<LessonPeriod> 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<double>(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<Appointment> 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<Appointment> 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 = <List<({Appointment apt, int lane})>>[];
|
||||||
|
var current = <({Appointment apt, int lane})>[];
|
||||||
|
var laneEnds = <DateTime>[];
|
||||||
|
|
||||||
|
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 = <DateTime>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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<int>(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;
|
||||||
|
}
|
||||||
@@ -1,21 +1,19 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:jiffy/jiffy.dart';
|
|
||||||
import 'package:rrule/rrule.dart';
|
import 'package:rrule/rrule.dart';
|
||||||
|
|
||||||
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||||
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../../widget/centered_leading.dart';
|
import '../../../../widget/centered_leading.dart';
|
||||||
import '../../../../widget/debug/debug_tile.dart';
|
import '../../../../widget/debug/debug_tile.dart';
|
||||||
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
import '../custom_events/custom_event_edit_dialog.dart';
|
import '../custom_events/custom_event_edit_dialog.dart';
|
||||||
import 'bottom_sheet.dart';
|
|
||||||
import 'delete_custom_event.dart';
|
import 'delete_custom_event.dart';
|
||||||
|
|
||||||
class CustomEventSheet {
|
class CustomEventSheet {
|
||||||
static void show(BuildContext context, CustomTimetableEvent event) {
|
static void show(BuildContext context, CustomTimetableEvent event) {
|
||||||
final timeRange =
|
final timeRange = event.startDate.timeRangeTo(event.endDate);
|
||||||
'${Jiffy.parseFromDateTime(event.startDate).format(pattern: 'HH:mm')} - '
|
|
||||||
'${Jiffy.parseFromDateTime(event.endDate).format(pattern: 'HH:mm')}';
|
|
||||||
|
|
||||||
showAppointmentBottomSheet(
|
showDetailsBottomSheet(
|
||||||
context,
|
context,
|
||||||
header: ListTile(
|
header: ListTile(
|
||||||
leading: const Icon(Icons.event_outlined, size: 32),
|
leading: const Icon(Icons.event_outlined, size: 32),
|
||||||
|
|||||||
@@ -1,38 +1,35 @@
|
|||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:jiffy/jiffy.dart';
|
|
||||||
import 'package:syncfusion_flutter_calendar/calendar.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/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 '../../../../routing/app_routes.dart';
|
||||||
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||||
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||||
import '../../../../widget/debug/debug_tile.dart';
|
import '../../../../widget/debug/debug_tile.dart';
|
||||||
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
import '../../../../widget/unimplemented_dialog.dart';
|
import '../../../../widget/unimplemented_dialog.dart';
|
||||||
import 'bottom_sheet.dart';
|
|
||||||
|
|
||||||
class WebuntisLessonSheet {
|
class WebuntisLessonSheet {
|
||||||
static void show(BuildContext context, TimetableBloc bloc, Appointment appointment, GetTimetableResponseObject lesson) {
|
static void show(BuildContext context, TimetableBloc bloc, Appointment appointment, GetTimetableResponseObject lesson) {
|
||||||
final state = bloc.state.data;
|
final state = bloc.state.data;
|
||||||
if (state == null) return;
|
if (state == null) return;
|
||||||
|
|
||||||
final headerSubject = _resolveSubject(state, lesson.su.firstOrNull?.id);
|
final headerSubject = LessonResolver.resolveSubject(state, lesson.su.firstOrNull?.id);
|
||||||
final headerTitle = _firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']);
|
final headerTitle = firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']);
|
||||||
final headerLongName = headerSubject.longName.isNotEmpty && headerSubject.longName != headerTitle ? headerSubject.longName : '';
|
final headerLongName = headerSubject.longName.isNotEmpty && headerSubject.longName != headerTitle ? headerSubject.longName : '';
|
||||||
|
|
||||||
final timeRange =
|
final timeRange = appointment.startTime.timeRangeTo(appointment.endTime);
|
||||||
'${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - '
|
|
||||||
'${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}';
|
|
||||||
|
|
||||||
showAppointmentBottomSheet(
|
showDetailsBottomSheet(
|
||||||
context,
|
context,
|
||||||
header: ListTile(
|
header: ListTile(
|
||||||
leading: Icon(_iconForCode(lesson.code), size: 32),
|
leading: Icon(LessonFormatter.iconForCode(lesson.code), size: 32),
|
||||||
title: Text(
|
title: Text(
|
||||||
'${_codePrefix(lesson.code)}$headerTitle',
|
'${LessonFormatter.codePrefix(lesson.code)}$headerTitle',
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
subtitle: Text(headerLongName.isNotEmpty
|
subtitle: Text(headerLongName.isNotEmpty
|
||||||
@@ -43,17 +40,17 @@ class WebuntisLessonSheet {
|
|||||||
children: (_) => <Widget>[
|
children: (_) => <Widget>[
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.notifications_active),
|
leading: const Icon(Icons.notifications_active),
|
||||||
title: Text('Status: ${_statusLabel(lesson.code)}'),
|
title: Text('Status: ${LessonFormatter.statusLabel(lesson.code)}'),
|
||||||
),
|
),
|
||||||
if (lesson.su.length > 1)
|
if (lesson.su.length > 1)
|
||||||
_listTile(
|
_listTile(
|
||||||
icon: Icons.book_outlined,
|
icon: Icons.book_outlined,
|
||||||
label: 'Fächer',
|
label: 'Fächer',
|
||||||
entries: lesson.su.map((s) {
|
entries: lesson.su.map((s) {
|
||||||
final resolved = _resolveSubject(state, s.id);
|
final resolved = LessonResolver.resolveSubject(state, s.id);
|
||||||
return _formatLine(
|
return LessonFormatter.formatLine(
|
||||||
_firstNonEmpty([resolved.name, s.name, '?']),
|
firstNonEmpty([resolved.name, s.name, '?']),
|
||||||
longname: _firstNonEmpty([resolved.longName, s.longname, '']),
|
longname: firstNonEmpty([resolved.longName, s.longname, '']),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
@@ -69,7 +66,7 @@ class WebuntisLessonSheet {
|
|||||||
icon: Icons.people,
|
icon: Icons.people,
|
||||||
label: lesson.kl.length == 1 ? 'Klasse' : 'Klassen',
|
label: lesson.kl.length == 1 ? 'Klasse' : 'Klassen',
|
||||||
entries: lesson.kl
|
entries: lesson.kl
|
||||||
.map((k) => _formatLine(
|
.map((k) => LessonFormatter.formatLine(
|
||||||
k.name.isNotEmpty ? k.name : '?',
|
k.name.isNotEmpty ? k.name : '?',
|
||||||
longname: k.longname,
|
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) {
|
static Widget _roomTile(BuildContext context, TimetableState state, GetTimetableResponseObject lesson) {
|
||||||
final trailing = IconButton(
|
final trailing = IconButton(
|
||||||
icon: const Icon(Icons.house_outlined),
|
icon: const Icon(Icons.house_outlined),
|
||||||
@@ -107,11 +93,11 @@ class WebuntisLessonSheet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final entries = lesson.ro.map((r) {
|
final entries = lesson.ro.map((r) {
|
||||||
final resolved = _resolveRoom(state, r.id);
|
final resolved = LessonResolver.resolveRoom(state, r.id);
|
||||||
final name = _firstNonEmpty([resolved.name, r.name, '?']);
|
final name = firstNonEmpty([resolved.name, r.name, '?']);
|
||||||
final longname = _firstNonEmpty([resolved.longName, r.longname, '']);
|
final longname = firstNonEmpty([resolved.longName, r.longname, '']);
|
||||||
final building = resolved.building.trim();
|
final building = resolved.building.trim();
|
||||||
return _formatLine(
|
return LessonFormatter.formatLine(
|
||||||
name,
|
name,
|
||||||
longname: longname,
|
longname: longname,
|
||||||
extra: (building.isNotEmpty && building != '?') ? building : null,
|
extra: (building.isNotEmpty && building != '?') ? building : null,
|
||||||
@@ -144,7 +130,7 @@ class WebuntisLessonSheet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final entries = lesson.te.map((t) {
|
final entries = lesson.te.map((t) {
|
||||||
final base = _formatLine(
|
final base = LessonFormatter.formatLine(
|
||||||
t.name.isNotEmpty ? t.name : '?',
|
t.name.isNotEmpty ? t.name : '?',
|
||||||
longname: t.longname,
|
longname: t.longname,
|
||||||
);
|
);
|
||||||
@@ -206,54 +192,4 @@ class WebuntisLessonSheet {
|
|||||||
subtitle: Text(text),
|
subtitle: Text(text),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _formatLine(String name, {String? longname, String? extra}) {
|
|
||||||
final parts = <String>[
|
|
||||||
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<String> 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Appointment> 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<int>(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<Appointment> 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<Appointment> hidden) {
|
||||||
|
showDetailsBottomSheet(
|
||||||
|
context,
|
||||||
|
children: (sheetCtx) {
|
||||||
|
final tiles = <Widget>[];
|
||||||
|
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 <Appointment>[] : 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
part of '../custom_workweek_calendar.dart';
|
||||||
|
|
||||||
|
class _WeekGrid extends StatelessWidget {
|
||||||
|
final DateTime weekStart;
|
||||||
|
final LessonPeriodSchedule schedule;
|
||||||
|
final List<Appointment> appointments;
|
||||||
|
final List<TimeRegion> timeRegions;
|
||||||
|
final void Function(Appointment) onAppointmentTap;
|
||||||
|
final bool Function(Appointment) isCrossedOut;
|
||||||
|
final void Function(DateTime start, DateTime end)? onCreateEvent;
|
||||||
|
final DateTime today;
|
||||||
|
final ValueListenable<DateTime> 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<Appointment> appointments;
|
||||||
|
final List<TimeRegion> timeRegions;
|
||||||
|
final PeriodLayout layout;
|
||||||
|
final DateTime today;
|
||||||
|
final ValueListenable<DateTime> 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<Appointment> dayAppts) {
|
||||||
|
for (final a in dayAppts) {
|
||||||
|
if (a.endTime.isAfter(start) && a.startTime.isBefore(end)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleLongPress(LongPressStartDetails details, List<Appointment> 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<Appointment> appointments) {
|
||||||
|
final sorted = [...appointments]
|
||||||
|
..sort((a, b) => a.startTime.compareTo(b.startTime));
|
||||||
|
showDetailsBottomSheet(
|
||||||
|
context,
|
||||||
|
children: (sheetContext) {
|
||||||
|
final tiles = <Widget>[];
|
||||||
|
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<DateTime>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../api/errors/error_mapper.dart';
|
import '../api/errors/error_mapper.dart';
|
||||||
import 'app_progress_indicator.dart';
|
import 'app_progress_indicator.dart';
|
||||||
import 'info_dialog.dart';
|
import 'info_dialog.dart';
|
||||||
|
|
||||||
Future<bool> runWithErrorDialog(
|
part 'async_actions/async_action_controller.dart';
|
||||||
BuildContext context,
|
part 'async_actions/async_action_button.dart';
|
||||||
AsyncActionCallback action, {
|
part 'async_actions/async_dialog_action.dart';
|
||||||
AsyncErrorBuilder? errorBuilder,
|
part 'async_actions/async_fab.dart';
|
||||||
}) async {
|
part 'async_actions/async_icon_button.dart';
|
||||||
try {
|
part 'async_actions/async_list_tile.dart';
|
||||||
await action();
|
part 'async_actions/async_mixin.dart';
|
||||||
return true;
|
part 'async_actions/async_text_button.dart';
|
||||||
} 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<void> 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<bool> 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<void> _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<AsyncListTile> createState() => _AsyncListTileState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AsyncListTileState extends State<AsyncListTile> {
|
|
||||||
final AsyncActionController _controller = AsyncActionController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _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<AsyncDialogAction> createState() => _AsyncDialogActionState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AsyncDialogActionState extends State<AsyncDialogAction> {
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
part of '../async_action_button.dart';
|
||||||
|
|
||||||
|
typedef AsyncActionCallback = Future<void> 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<bool> 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<bool> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AsyncDialogAction> createState() => _AsyncDialogActionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AsyncDialogActionState extends State<AsyncDialogAction> {
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<AsyncListTile> createState() => _AsyncListTileState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AsyncListTileState extends State<AsyncListTile> {
|
||||||
|
final AsyncActionController _controller = AsyncActionController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<void> _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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
|
import '../../utils/clipboard_helper.dart';
|
||||||
|
|
||||||
class JsonViewer extends StatelessWidget {
|
class JsonViewer extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
@@ -25,24 +26,20 @@ class JsonViewer extends StatelessWidget {
|
|||||||
static String format(Map<String, dynamic> jsonInput) => _encoder.convert(jsonInput);
|
static String format(Map<String, dynamic> jsonInput) => _encoder.convert(jsonInput);
|
||||||
|
|
||||||
static void asDialog(BuildContext context, Map<String, dynamic> dataMap) {
|
static void asDialog(BuildContext context, Map<String, dynamic> dataMap) {
|
||||||
showDialog(context: context, builder: (context) => AlertDialog(
|
showDialog(context: context, builder: (dialogCtx) => AlertDialog(
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
title: const Row(children: [Icon(Icons.bug_report_outlined), Text('Rohdaten')]),
|
title: const Row(children: [Icon(Icons.bug_report_outlined), Text('Rohdaten')]),
|
||||||
content: Text(JsonViewer.format(dataMap)),
|
content: Text(JsonViewer.format(dataMap)),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () {
|
TextButton(
|
||||||
Clipboard.setData(ClipboardData(text: JsonViewer.format(dataMap))).then((value) {
|
onPressed: () => copyToClipboard(dialogCtx, JsonViewer.format(dataMap), successMessage: 'Formatiertes JSON kopiert'),
|
||||||
if (!context.mounted) return;
|
child: const Text('Kopieren'),
|
||||||
showDialog(context: context, builder: (context) => const AlertDialog(content: Text('Formatiertes JSON wurde erfolgreich in deiner Zwischenlage abgelegt.')));
|
),
|
||||||
});
|
TextButton(
|
||||||
}, child: const Text('Kopieren')),
|
onPressed: () => copyToClipboard(dialogCtx, dataMap.toString(), successMessage: 'Inline JSON kopiert'),
|
||||||
TextButton(onPressed: () {
|
child: const Text('Inline Kopieren'),
|
||||||
Clipboard.setData(ClipboardData(text: dataMap.toString())).then((value) {
|
),
|
||||||
if (!context.mounted) return;
|
TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Schließen'))
|
||||||
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'))
|
|
||||||
],
|
],
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-5
@@ -1,12 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Shows a modal bottom sheet for an appointment, matching the design of the
|
/// Shows a modal bottom sheet for a detail view (appointment, file, lesson,
|
||||||
/// other sheets in the app (file details, file actions, overflow lessons):
|
/// custom event, etc.). All detail sheets in the app share this layout: drag
|
||||||
/// drag handle on top, default theme background, ListTile-style header
|
/// handle on top, default theme background, optional ListTile-style header
|
||||||
/// followed by a divider, scrollable body below.
|
/// followed by a divider, scrollable body below.
|
||||||
void showAppointmentBottomSheet(
|
void showDetailsBottomSheet(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required Widget header,
|
Widget? header,
|
||||||
required List<Widget> Function(BuildContext sheetContext) children,
|
required List<Widget> Function(BuildContext sheetContext) children,
|
||||||
}) {
|
}) {
|
||||||
showModalBottomSheet<void>(
|
showModalBottomSheet<void>(
|
||||||
@@ -21,8 +21,10 @@ void showAppointmentBottomSheet(
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
if (header != null) ...[
|
||||||
header,
|
header,
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
|
],
|
||||||
...children(sheetContext),
|
...children(sheetContext),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
|
import '../utils/clipboard_helper.dart';
|
||||||
|
|
||||||
class InfoDialog {
|
class InfoDialog {
|
||||||
/// Shows a single-text dialog. When [copyable] is true (default for error
|
/// Shows a single-text dialog. When [copyable] is true (default for error
|
||||||
@@ -26,16 +27,7 @@ class InfoDialog {
|
|||||||
actions: [
|
actions: [
|
||||||
if (copyable)
|
if (copyable)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () async {
|
onPressed: () => copyToClipboard(dialogContext, info),
|
||||||
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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.copy_outlined, size: 18),
|
icon: const Icon(Icons.copy_outlined, size: 18),
|
||||||
label: const Text('Kopieren'),
|
label: const Text('Kopieren'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ dependencies:
|
|||||||
enough_icalendar: ^0.17.0
|
enough_icalendar: ^0.17.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
fake_async: ^1.3.1
|
||||||
|
|
||||||
flutter_launcher_icons: ^0.14.3
|
flutter_launcher_icons: ^0.14.3
|
||||||
flutter_native_splash: ^2.4.4
|
flutter_native_splash: ^2.4.4
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<GetSubjectsResponseObject> subjects = const {},
|
||||||
|
Set<GetRoomsResponseObject> 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(''), '?');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 = <int>[];
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<LaidOutAppointment>().toList();
|
||||||
|
final overflow = result.whereType<LaidOutOverflow>().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<LaidOutAppointment>()
|
||||||
|
.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<LaidOutAppointment>()
|
||||||
|
.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<LaidOutOverflow>()
|
||||||
|
.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<AssertionError>()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<void>();
|
||||||
|
final firstCanFinish = Completer<void>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user