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

This commit is contained in:
2026-05-08 19:05:16 +02:00
parent 3b1b0d0c19
commit c62a14645a
68 changed files with 4633 additions and 3141 deletions
+77
View File
@@ -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 '../../../../extensions/date_time.dart';
import '../../../api_response.dart';
import '../room/get_room_response.dart';
@@ -63,7 +63,7 @@ class GetChatResponseObject {
static GetChatResponseObject getDateDummy(int timestamp) {
var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
return getTextDummy(Jiffy.parseFromDateTime(elementDate).format(pattern: 'dd.MM.yyyy'));
return getTextDummy(elementDate.formatDate());
}
static GetChatResponseObject getTextDummy(String text) => GetChatResponseObject(
@@ -1,7 +1,7 @@
import 'package:jiffy/jiffy.dart';
import 'package:json_annotation/json_annotation.dart';
import '../../../../../view/pages/files/files.dart';
import '../../../../../view/pages/files/data/sort_options.dart';
import '../../../../api_response.dart';
import 'cacheable_file.dart';
@@ -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(' ');
}
}
+21
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
extension IsSameDay on DateTime {
bool isSameDay(DateTime other) => year == other.year && month == other.month && day == other.day;
@@ -18,3 +19,23 @@ extension IsSameDay on DateTime {
bool isSameOrAfter(DateTime other) => isSameDateTime(other) || isAfter(other);
}
/// Formatting helpers backed by Jiffy. Centralises the patterns that previously
/// were repeated as `Jiffy.parseFromDateTime(dt).format(pattern: '...')`.
extension DateTimeFormatting on DateTime {
String formatHm() => Jiffy.parseFromDateTime(this).format(pattern: 'HH:mm');
String formatDate() => Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM.yyyy');
String formatDateTime() => Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM.yyyy HH:mm');
String formatDateShort() => Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM.');
String formatDateShortHm() => Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM. HH:mm');
String formatMonthYear() => Jiffy.parseFromDateTime(this).format(pattern: 'MMMM yyyy');
String formatRelative() => Jiffy.parseFromDateTime(this).fromNow();
String timeRangeTo(DateTime end) => '${formatHm()} - ${end.formatHm()}';
}
+8
View File
@@ -10,3 +10,11 @@ extension TextExt on Text {
return textPainter.size;
}
}
/// Returns the first non-empty (after trim) entry, or '' if none match.
String firstNonEmpty(List<String?> values) {
for (final v in values) {
if (v != null && v.trim().isNotEmpty) return v;
}
return '';
}
+1 -1
View File
@@ -29,7 +29,7 @@ import '../widget/user_avatar.dart';
///
/// Every full-page push in modules should go through one of these methods.
/// Dialogs (`showDialog`), bottom sheets (`showStickyFlexibleBottomSheet`,
/// `showAppointmentBottomSheet`), and `Navigator.pop` for closing those
/// `showDetailsBottomSheet`), and `Navigator.pop` for closing those
/// remain unchanged and live at the call sites.
class AppRoutes {
AppRoutes._();
@@ -3,8 +3,8 @@ import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:jiffy/jiffy.dart';
import '../../../../../extensions/date_time.dart';
import 'loadable_state_event.dart';
import 'loadable_state_state.dart';
@@ -73,7 +73,7 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState>
String connectionText({int? lastUpdated}) => connectivityStatusKnown()
? isConnected()
? 'Verbindung fehlgeschlagen'
: 'Offline${lastUpdated == null ? '' : ' - Stand von ${Jiffy.parseFromMillisecondsSinceEpoch(lastUpdated).fromNow()}'}'
: 'Offline${lastUpdated == null ? '' : ' - Stand von ${DateTime.fromMillisecondsSinceEpoch(lastUpdated).formatRelative()}'}'
: 'Unbekannte Fehlerursache';
@override
+1 -1
View File
@@ -1,6 +1,6 @@
import 'package:json_annotation/json_annotation.dart';
import '../../view/pages/files/files.dart';
import '../view/pages/files/data/sort_options.dart';
part 'file_settings.g.dart';
+19
View File
@@ -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
View File
@@ -1,17 +1,12 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../api/errors/auth_exception.dart';
import '../../api/errors/error_mapper.dart';
import '../../api/marianumcloud/talk/room/get_room.dart';
import '../../api/marianumcloud/talk/room/get_room_params.dart';
import '../../model/account_data.dart';
import '../../state/app/modules/account/bloc/account_bloc.dart';
import '../../state/app/modules/account/bloc/account_state.dart';
import '../../theming/light_app_theme.dart';
import 'login_controller.dart';
import 'widgets/login_branding.dart';
import 'widgets/login_card.dart';
class Login extends StatefulWidget {
const Login({super.key});
@@ -23,14 +18,7 @@ class Login extends StatefulWidget {
class _LoginState extends State<Login> {
static const _marianumRed = LightAppTheme.marianumRed;
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _passwordFocus = FocusNode();
bool _loading = false;
String? _errorMessage;
String? _errorDetails;
final LoginController _controller = LoginController();
@override
void didChangeDependencies() {
@@ -40,95 +28,16 @@ class _LoginState extends State<Login> {
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
_passwordFocus.dispose();
_controller.dispose();
super.dispose();
}
String? _required(String? value) =>
(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;
void _onLoginSuccess() {
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
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
Widget build(BuildContext context) => Scaffold(
backgroundColor: _marianumRed,
body: SafeArea(
child: LayoutBuilder(
@@ -143,268 +52,13 @@ class _LoginState extends State<Login> {
child: IntrinsicHeight(
child: Column(
children: [
const SizedBox(height: 40),
Image.asset(
'assets/logo/icon.png',
height: 110,
fit: BoxFit.contain,
gaplessPlayback: true,
),
const SizedBox(height: 20),
const Text(
'Marianum Fulda',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
),
),
const SizedBox(height: 6),
Text(
'Stundenplan, Talk & Dateien an einem Ort.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.85),
fontSize: 14,
height: 1.3,
),
),
const LoginHeader(),
const SizedBox(height: 28),
Card(
elevation: 8,
shadowColor: Colors.black.withValues(alpha: 0.35),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
color: theme.colorScheme.surface,
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Anmelden',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Text(
'Melde dich mit deinen Marianum-Zugangsdaten an.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
TextFormField(
controller: _usernameController,
enabled: !_loading,
validator: _required,
autocorrect: false,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) =>
_passwordFocus.requestFocus(),
decoration: InputDecoration(
labelText: 'Nutzername',
prefixIcon: const Icon(
Icons.person_outline,
),
filled: true,
fillColor: theme
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.4),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 1.5,
),
),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordController,
focusNode: _passwordFocus,
enabled: !_loading,
validator: _required,
obscureText: true,
obscuringCharacter: '',
autocorrect: false,
enableSuggestions: false,
keyboardType: TextInputType.visiblePassword,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
decoration: InputDecoration(
labelText: 'Passwort',
prefixIcon: const Icon(Icons.lock_outline),
filled: true,
fillColor: theme
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.4),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 1.5,
),
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
child: _errorMessage == null
? const SizedBox(
height: 0,
width: double.infinity,
)
: Padding(
padding: const EdgeInsets.only(
top: 14,
),
child: Material(
color: theme
.colorScheme
.errorContainer
.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(
12,
),
child: InkWell(
onTap: _errorDetails != null
? _showErrorDetails
: null,
borderRadius:
BorderRadius.circular(12),
child: Padding(
padding:
const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Row(
children: [
Icon(
Icons.error_outline,
size: 20,
color: theme
.colorScheme
.onErrorContainer,
),
const SizedBox(width: 10),
Expanded(
child: Text(
_errorMessage!,
style: TextStyle(
color: theme
.colorScheme
.onErrorContainer,
fontSize: 13,
height: 1.3,
),
),
),
if (_errorDetails !=
null) ...[
const SizedBox(width: 8),
Icon(
Icons.chevron_right,
size: 20,
color: theme
.colorScheme
.onErrorContainer
.withValues(
alpha: 0.7,
),
),
],
],
),
),
),
),
),
),
const SizedBox(height: 20),
SizedBox(
height: 50,
child: FilledButton(
onPressed: _loading ? null : _submit,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
child: _loading
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: const Text('Anmelden'),
),
),
],
),
),
),
),
LoginCard(controller: _controller, onSuccess: _onLoginSuccess),
const SizedBox(height: 18),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'Inoffizieller Nextcloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.75),
fontSize: 11,
height: 1.4,
),
),
),
const LoginDisclaimer(),
const Spacer(),
Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: Text(
'Marianum Fulda. Die persönliche Schule.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
),
const LoginFooter(),
],
),
),
@@ -414,5 +68,4 @@ class _LoginState extends State<Login> {
),
),
);
}
}
+55
View File
@@ -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,
),
),
);
}
+157
View File
@@ -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
View File
@@ -2,12 +2,8 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nextcloud/nextcloud.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import '../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
import '../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
@@ -15,49 +11,13 @@ import '../../../state/app/modules/files/bloc/files_bloc.dart';
import '../../../state/app/modules/files/bloc/files_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../utils/cache_invalidation_bus.dart';
import '../../../utils/file_clipboard.dart';
import '../../../widget/async_action_button.dart';
import '../../../widget/file_pick.dart';
import '../../../widget/placeholder_view.dart';
import 'data/sort_options.dart';
import 'files_upload_dialog.dart';
import 'widgets/add_file_menu.dart';
import 'widgets/clipboard_banner.dart';
import 'widgets/file_element.dart';
class BetterSortOption {
String displayName;
int Function(CacheableFile, CacheableFile) compare;
IconData icon;
BetterSortOption({required this.displayName, required this.icon, required this.compare});
}
enum SortOption { name, date, size }
class SortOptions {
static Map<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]!;
}
import 'widgets/files_sort_actions.dart';
class Files extends StatelessWidget {
final List<String> path;
@@ -89,6 +49,10 @@ class _FilesViewState extends State<_FilesView> {
// segments joined without leading/trailing slash.
String get _myPathString => widget.path.isEmpty ? '/' : widget.path.join('/');
// Relative folder path matching the WebDAV format used by `CacheableFile.path`
// (no leading slash; trailing slash for non-root). Empty string means root.
String get _currentFolderPath => widget.path.isEmpty ? '' : '${widget.path.join('/')}/';
@override
void initState() {
super.initState();
@@ -110,7 +74,7 @@ class _FilesViewState extends State<_FilesView> {
super.dispose();
}
Future<void> mediaUpload(List<String>? paths) async {
Future<void> _mediaUpload(List<String>? paths) async {
if (paths == null) return;
final bloc = context.read<FilesBloc>();
unawaited(pushScreen(
@@ -131,47 +95,16 @@ class _FilesViewState extends State<_FilesView> {
appBar: AppBar(
title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'),
actions: [
PopupMenuButton<bool>(
icon: Icon(currentSortDirection ? Icons.text_rotate_up : Icons.text_rotation_down),
itemBuilder: (context) => [true, false]
.map((e) => PopupMenuItem<bool>(
value: e,
enabled: e != currentSortDirection,
child: Row(
children: [
Icon(
e ? Icons.text_rotate_up : Icons.text_rotation_down,
color: Theme.of(context).colorScheme.onSurface,
),
const SizedBox(width: 15),
Text(e ? 'Aufsteigend' : 'Absteigend'),
],
),
))
.toList(),
onSelected: (e) {
FilesSortActions(
currentSort: currentSort,
ascending: currentSortDirection,
onDirectionChanged: (e) {
setState(() {
currentSortDirection = e;
settings.val(write: true).fileSettings.ascending = e;
});
},
),
PopupMenuButton<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) {
onSortChanged: (e) {
setState(() {
currentSort = e;
settings.val(write: true).fileSettings.sortBy = e;
@@ -183,12 +116,12 @@ class _FilesViewState extends State<_FilesView> {
floatingActionButton: FloatingActionButton(
heroTag: 'uploadFile',
backgroundColor: Theme.of(context).primaryColor,
onPressed: () => _showAddDialog(context, bloc),
onPressed: () => showAddFileSheet(context, bloc: bloc, onPickedFiles: _mediaUpload),
child: const Icon(Icons.add),
),
body: Column(
children: [
_ClipboardBanner(currentFolder: _currentFolderPath, onPasteDone: bloc.refresh),
ClipboardBanner(currentFolder: _currentFolderPath, onPasteDone: bloc.refresh),
Expanded(
child: LoadableStateConsumer<FilesBloc, FilesState>(
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:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:jiffy/jiffy.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../../extensions/date_time.dart';
import '../../../../utils/clipboard_helper.dart';
import '../../../../widget/details_bottom_sheet.dart';
/// Shows a modal bottom sheet with technical metadata about a single file or
/// folder: full path, MIME type, size, timestamps, ETag.
Future<void> showFileDetailsSheet(BuildContext context, CacheableFile file) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
void showFileDetailsSheet(BuildContext context, CacheableFile file) {
showDetailsBottomSheet(
context,
header: ListTile(
leading: Icon(file.isDirectory ? Icons.folder : Icons.description_outlined, size: 32),
title: Text(file.name, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(file.isDirectory ? 'Ordner' : (file.mimeType ?? '')),
),
const Divider(),
children: (_) => [
_DetailRow(label: 'Pfad', value: file.path, copyable: true),
if (!file.isDirectory) _DetailRow(label: 'Größe', value: filesize(file.size)),
if (file.modifiedAt != null)
_DetailRow(
label: 'Geändert',
value: '${Jiffy.parseFromDateTime(file.modifiedAt!).format(pattern: 'dd.MM.yyyy HH:mm')} '
'(${Jiffy.parseFromDateTime(file.modifiedAt!).fromNow()})',
value: '${file.modifiedAt!.formatDateTime()} (${file.modifiedAt!.formatRelative()})',
),
if (file.createdAt != null)
_DetailRow(
label: 'Erstellt',
value: Jiffy.parseFromDateTime(file.createdAt!).format(pattern: 'dd.MM.yyyy HH:mm'),
),
_DetailRow(label: 'Erstellt', value: file.createdAt!.formatDateTime()),
if (file.eTag != null) _DetailRow(label: 'ETag', value: file.eTag!, copyable: true),
],
),
),
),
);
}
@@ -54,7 +39,7 @@ class _DetailRow extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -67,12 +52,7 @@ class _DetailRow extends StatelessWidget {
IconButton(
tooltip: 'Kopieren',
icon: const Icon(Icons.copy, size: 18),
onPressed: () {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('In Zwischenablage kopiert')),
);
},
onPressed: () => copyToClipboard(context, value),
),
],
),
@@ -1,10 +1,10 @@
import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:nextcloud/nextcloud.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../../extensions/date_time.dart';
import '../../../../model/endpoint_data.dart';
import '../../../../routing/app_routes.dart';
import '../../../../utils/download_manager.dart';
@@ -135,9 +135,10 @@ class _FileElementState extends State<FileElement> {
],
);
}
final modified = widget.file.modifiedAt ?? DateTime.now();
return widget.file.isDirectory
? Text('geändert ${Jiffy.parseFromDateTime(widget.file.modifiedAt ?? DateTime.now()).fromNow()}')
: Text('${filesize(widget.file.size)}, ${Jiffy.parseFromDateTime(widget.file.modifiedAt ?? DateTime.now()).fromNow()}');
? Text('geändert ${modified.formatRelative()}')
: Text('${filesize(widget.file.size)}, ${modified.formatRelative()}');
}
void _onTap() {
@@ -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:mmHH:mm" for same-day,
/// "dd.MM. HH:mmdd.MM. HH:mm" otherwise, or "Ganztägig" for all-day events.
static String trailingLabel(MarianumDate event) {
if (event.isAllDay) return 'Ganztägig';
if (event.start.isSameDay(event.end)) {
if (event.start == event.end) return event.start.formatHm();
return '${event.start.formatHm()}${event.end.formatHm()}';
}
return '${event.start.formatDateShortHm()}${event.end.formatDateShortHm()}';
}
/// Verbose date+time line shown in the details sheet. Drops the trailing
/// time when the event is all-day, and de-duplicates same-day endpoints.
static String longRange(MarianumDate event) {
if (event.isAllDay) {
final inclusiveEnd = event.end.isAfter(event.start)
? event.end.subtract(const Duration(days: 1))
: event.end;
return event.start.isSameDay(inclusiveEnd)
? '${event.start.formatDate()} · Ganztägig'
: '${event.start.formatDate()} ${inclusiveEnd.formatDate()} · Ganztägig';
}
if (event.start.isSameDay(event.end)) {
if (event.start == event.end) {
return '${event.start.formatDate()} · ${event.start.formatHm()}';
}
return '${event.start.formatDate()} · ${event.start.formatHm()} ${event.end.formatHm()}';
}
return '${event.start.formatDateTime()} ${event.end.formatDateTime()}';
}
}
@@ -1,18 +1,16 @@
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import '../../../extensions/date_time.dart';
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart';
import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_event.dart';
import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
import '../../../widget/animated_time.dart';
import '../../../widget/centered_leading.dart';
import '../../../widget/debug/debug_tile.dart';
import '../../../widget/placeholder_view.dart';
import '../timetable/custom_events/custom_event_edit_dialog.dart';
import 'search_marianum_dates.dart';
import 'widgets/event_list_tile.dart';
import 'widgets/month_section_header.dart';
class MarianumDatesView extends StatelessWidget {
const MarianumDatesView({super.key});
@@ -27,7 +25,7 @@ class MarianumDatesView extends StatelessWidget {
final keys = byMonth.keys.toList()..sort();
return keys.map((key) {
final first = byMonth[key]!.first.start;
final label = Jiffy.parseFromDateTime(first).format(pattern: 'MMMM yyyy').toUpperCase();
final label = first.formatMonthYear().toUpperCase();
return _MonthGroup(key: key, label: label, events: byMonth[key]!);
}).toList();
}
@@ -110,239 +108,3 @@ class _MonthGroup {
final List<MarianumDate> events;
_MonthGroup({required this.key, required this.label, required this.events});
}
class MonthHeaderDelegate extends SliverPersistentHeaderDelegate {
final String label;
MonthHeaderDelegate({required this.label});
static const double _height = 38;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
final theme = Theme.of(context);
return Container(
height: _height,
color: theme.colorScheme.surfaceContainer,
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
child: Text(
label,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
),
);
}
@override
double get maxExtent => _height;
@override
double get minExtent => _height;
@override
bool shouldRebuild(covariant MonthHeaderDelegate oldDelegate) => oldDelegate.label != label;
}
/// Composite icon: calendar with a small plus badge in the bottom-right.
/// Material's bundled icon set has no `calendar_add_on`, so we layer
/// `Icons.event_outlined` and `Icons.add` to get the same affordance.
class _CalendarPlusIcon extends StatelessWidget {
final Color color;
const _CalendarPlusIcon({required this.color});
@override
Widget build(BuildContext context) => SizedBox(
width: 22,
height: 22,
child: Stack(
clipBehavior: Clip.none,
children: [
Icon(Icons.event_outlined, size: 22, color: color),
Positioned(
right: -2,
bottom: -2,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
),
padding: const EdgeInsets.all(1),
child: Icon(Icons.add_circle, size: 12, color: color),
),
),
],
),
);
}
class MarianumDateRow extends StatelessWidget {
final MarianumDate event;
const MarianumDateRow({required this.event, super.key});
String _dayLabel() => event.start.day.toString().padLeft(2, '0');
String _monthYearLabel() =>
'${event.start.month.toString().padLeft(2, '0')}.${event.start.year}';
String _trailingLabel() {
final start = Jiffy.parseFromDateTime(event.start);
final end = Jiffy.parseFromDateTime(event.end);
if (event.isAllDay) return 'Ganztägig';
final sameDay = start.format(pattern: 'yyyy-MM-dd') == end.format(pattern: 'yyyy-MM-dd');
if (sameDay) {
if (event.start == event.end) return start.format(pattern: 'HH:mm');
return '${start.format(pattern: 'HH:mm')}${end.format(pattern: 'HH:mm')}';
}
return '${start.format(pattern: 'dd.MM. HH:mm')}${end.format(pattern: 'dd.MM. HH:mm')}';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: () => _showDetails(context),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 4, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 44,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_dayLabel(),
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
height: 1.1,
),
),
Text(
_monthYearLabel(),
textAlign: TextAlign.center,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.visible,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 10,
height: 1.1,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
event.title.isEmpty ? '(ohne Titel)' : event.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface),
),
if (event.description != null && event.description!.trim().isNotEmpty) ...[
const SizedBox(height: 2),
Text(
event.description!.trim(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
const SizedBox(width: 8),
Text(
_trailingLabel(),
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 4),
IconButton(
icon: _CalendarPlusIcon(color: theme.colorScheme.onSurfaceVariant),
tooltip: 'In Stundenplan übernehmen',
onPressed: () => showDialog(
context: context,
builder: (_) => CustomEventEditDialog(
initialTitle: event.title,
initialDescription: event.description,
initialStart: event.start,
initialEnd: event.end,
),
barrierDismissible: false,
),
),
],
),
),
);
}
void _showDetails(BuildContext context) {
showDialog(
context: context,
builder: (context) => SimpleDialog(
title: Text(event.title.isEmpty ? '(ohne Titel)' : event.title),
children: [
ListTile(
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
title: Text(_formatLongRange()),
),
if (event.description != null && event.description!.trim().isNotEmpty)
ListTile(
leading: const CenteredLeading(Icon(Icons.notes_outlined)),
title: Text(event.description!.trim()),
),
Visibility(
visible: !event.start.difference(DateTime.now()).isNegative,
replacement: ListTile(
leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)),
title: Text(Jiffy.parseFromDateTime(event.start).fromNow()),
),
child: ListTile(
leading: const CenteredLeading(Icon(Icons.timer_outlined)),
title: AnimatedTime(callback: () => event.start.difference(DateTime.now())),
subtitle: Text(Jiffy.parseFromDateTime(event.start).fromNow()),
),
),
DebugTile(context).jsonData(event.toJson()),
],
),
);
}
String _formatLongRange() {
final start = Jiffy.parseFromDateTime(event.start);
final end = Jiffy.parseFromDateTime(event.end);
if (event.isAllDay) {
final inclusiveEnd = event.end.isAfter(event.start) ? end.subtract(days: 1) : end;
final sameAllDay =
start.format(pattern: 'yyyy-MM-dd') == inclusiveEnd.format(pattern: 'yyyy-MM-dd');
return sameAllDay
? '${start.format(pattern: 'dd.MM.yyyy')} · Ganztägig'
: '${start.format(pattern: 'dd.MM.yyyy')} ${inclusiveEnd.format(pattern: 'dd.MM.yyyy')} · Ganztägig';
}
final sameDay = start.format(pattern: 'yyyy-MM-dd') == end.format(pattern: 'yyyy-MM-dd');
if (sameDay) {
if (event.start == event.end) {
return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')}';
}
return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')} ${end.format(pattern: 'HH:mm')}';
}
return '${start.format(pattern: 'dd.MM.yyyy HH:mm')} ${end.format(pattern: 'dd.MM.yyyy HH:mm')}';
}
}
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
import '../../../widget/placeholder_view.dart';
import 'marianum_dates_view.dart';
import 'widgets/event_list_tile.dart';
class SearchMarianumDates extends SearchDelegate<MarianumDate?> {
final List<MarianumDate> events;
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import '../../../../extensions/date_time.dart';
import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
import '../../../../widget/animated_time.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../data/event_formatter.dart';
void showEventDetailsSheet(BuildContext context, MarianumDate event) {
final isUpcoming = !event.start.difference(DateTime.now()).isNegative;
showDetailsBottomSheet(
context,
header: ListTile(
leading: const Icon(Icons.event_outlined, size: 32),
title: Text(
event.title.isEmpty ? '(ohne Titel)' : event.title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
children: (sheetContext) => [
ListTile(
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
title: Text(EventFormatter.longRange(event)),
),
if (event.description != null && event.description!.trim().isNotEmpty)
ListTile(
leading: const CenteredLeading(Icon(Icons.notes_outlined)),
title: Text(event.description!.trim()),
),
if (isUpcoming)
ListTile(
leading: const CenteredLeading(Icon(Icons.timer_outlined)),
title: AnimatedTime(callback: () => event.start.difference(DateTime.now())),
subtitle: Text(event.start.formatRelative()),
)
else
ListTile(
leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)),
title: Text(event.start.formatRelative()),
),
DebugTile(sheetContext).jsonData(event.toJson()),
],
);
}
@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
import '../../timetable/custom_events/custom_event_edit_dialog.dart';
import '../data/event_formatter.dart';
import 'event_details_sheet.dart';
class MarianumDateRow extends StatelessWidget {
final MarianumDate event;
const MarianumDateRow({required this.event, super.key});
String _dayLabel() => event.start.day.toString().padLeft(2, '0');
String _monthYearLabel() =>
'${event.start.month.toString().padLeft(2, '0')}.${event.start.year}';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: () => showEventDetailsSheet(context, event),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 4, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 44,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_dayLabel(),
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
height: 1.1,
),
),
Text(
_monthYearLabel(),
textAlign: TextAlign.center,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.visible,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 10,
height: 1.1,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
event.title.isEmpty ? '(ohne Titel)' : event.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface),
),
if (event.description != null && event.description!.trim().isNotEmpty) ...[
const SizedBox(height: 2),
Text(
event.description!.trim(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
const SizedBox(width: 8),
Text(
EventFormatter.trailingLabel(event),
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 4),
IconButton(
icon: _CalendarPlusIcon(color: theme.colorScheme.onSurfaceVariant),
tooltip: 'In Stundenplan übernehmen',
onPressed: () => showDialog(
context: context,
builder: (_) => CustomEventEditDialog(
initialTitle: event.title,
initialDescription: event.description,
initialStart: event.start,
initialEnd: event.end,
),
barrierDismissible: false,
),
),
],
),
),
);
}
}
/// Composite icon: calendar with a small plus badge in the bottom-right.
/// Material's bundled icon set has no `calendar_add_on`, so we layer
/// `Icons.event_outlined` and `Icons.add` to get the same affordance.
class _CalendarPlusIcon extends StatelessWidget {
final Color color;
const _CalendarPlusIcon({required this.color});
@override
Widget build(BuildContext context) => SizedBox(
width: 22,
height: 22,
child: Stack(
clipBehavior: Clip.none,
children: [
Icon(Icons.event_outlined, size: 22, color: color),
Positioned(
right: -2,
bottom: -2,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
),
padding: const EdgeInsets.all(1),
child: Icon(Icons.add_circle, size: 12, color: color),
),
),
],
),
);
}
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class MonthHeaderDelegate extends SliverPersistentHeaderDelegate {
final String label;
MonthHeaderDelegate({required this.label});
static const double _height = 38;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
final theme = Theme.of(context);
return Container(
height: _height,
color: theme.colorScheme.surfaceContainer,
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
child: Text(
label,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
),
);
}
@override
double get maxExtent => _height;
@override
double get minExtent => _height;
@override
bool shouldRebuild(covariant MonthHeaderDelegate oldDelegate) => oldDelegate.label != label;
}
@@ -4,6 +4,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
import '../../../widget/confirm_dialog.dart';
import '../../../widget/info_dialog.dart';
class MessageView extends StatefulWidget {
final String basePath;
@@ -26,15 +27,11 @@ class _MessageViewState extends State<MessageView> {
enableHyperlinkNavigation: true,
onDocumentLoadFailed: (PdfDocumentLoadFailedDetails e) {
Navigator.of(context).pop();
showDialog(context: context, builder: (context) => AlertDialog(
title: const Text('Fehler beim öffnen'),
content: Text("Dokument '${widget.message.name}' konnte nicht geladen werden:\n${e.description}"),
actions: [
TextButton(onPressed: () {
Navigator.of(context).pop();
}, child: const Text('Ok'))
],
));
InfoDialog.show(
context,
"Dokument '${widget.message.name}' konnte nicht geladen werden:\n${e.description}",
title: 'Fehler beim öffnen',
);
},
onHyperlinkClicked: (PdfHyperlinkClickedDetails e) {
showDialog(
@@ -13,7 +13,7 @@ import '../../../../storage/settings.dart';
import '../../../../storage/talk_settings.dart';
import '../../../../storage/timetable_settings.dart';
import '../../../../view/pages/timetable/data/timetable_name_mode.dart';
import '../../files/files.dart';
import '../../files/data/sort_options.dart';
class DefaultSettings {
static Settings get() => Settings(
@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../notification/notify_updater.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/info_dialog.dart';
class TalkSection extends StatelessWidget {
const TalkSection({super.key});
@@ -51,22 +52,13 @@ class TalkSection extends StatelessWidget {
);
}
void _showInfoDialog(BuildContext context) => showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Info über Push'),
content: const SingleChildScrollView(
child: Text(
void _showInfoDialog(BuildContext context) => InfoDialog.show(
context,
"Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n"
'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n'
'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n'
'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n'
'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!',
),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Zurück')),
],
),
title: 'Info über Push',
);
}
+150 -181
View File
@@ -1,26 +1,22 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:jiffy/jiffy.dart';
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message.dart';
import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart';
import '../../../../api/marianumcloud/talk/get_poll/get_poll_state.dart';
import '../../../../api/marianumcloud/talk/react_message/react_message.dart';
import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../extensions/date_time.dart';
import '../../../../extensions/text.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../utils/download_manager.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/loading_spinner.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/info_dialog.dart';
import '../data/chat_bubble_styles.dart';
import '../data/chat_message.dart';
import 'answer_reference.dart';
import 'bubble.dart';
import 'chat_bubble_poll.dart';
import 'chat_bubble_reactions.dart';
import 'chat_message_options_dialog.dart';
import 'poll_options_list.dart';
class ChatBubble extends StatefulWidget {
final BuildContext context;
@@ -54,8 +50,8 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
late ChatMessage message;
DownloadJob? _job;
late Offset _position = const Offset(0, 0);
late Offset _dragStartPosition = Offset.zero;
Offset _position = Offset.zero;
Offset _dragStartPosition = Offset.zero;
@override
void initState() {
@@ -99,7 +95,7 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
DownloadManager.instance.clear(job.remotePath);
_detachJob();
setState(() {});
showDialog<void>(context: context, builder: (context) => AlertDialog(content: Text(message)));
InfoDialog.show(context, message, title: 'Download fehlgeschlagen');
} else if (status is DownloadCancelled) {
DownloadManager.instance.clear(job.remotePath);
_detachJob();
@@ -122,66 +118,69 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
}
void _confirmCancel() {
showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Download abbrechen?'),
content: const Text('Möchtest du den Download abbrechen?'),
actions: [
TextButton(onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Nein')),
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop();
_job?.cancel();
},
child: const Text('Ja, Abbrechen'),
),
],
),
);
ConfirmDialog(
title: 'Download abbrechen?',
content: 'Möchtest du den Download abbrechen?',
confirmButton: 'Ja, Abbrechen',
cancelButton: 'Nein',
onConfirm: () => _job?.cancel(),
).asDialog(context);
}
BubbleStyle getStyle() {
var styles = ChatBubbleStyles(context);
if(widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment) {
if(widget.isSender) {
return styles.getSelfStyle(false);
} else {
return styles.getRemoteStyle(false);
}
} else {
BubbleStyle _getStyle() {
final styles = ChatBubbleStyles(context);
if (widget.bubbleData.messageType != GetRoomResponseObjectMessageType.comment) {
return styles.getSystemStyle();
}
return widget.isSender ? styles.getSelfStyle(false) : styles.getRemoteStyle(false);
}
void showOptionsDialog() {
showChatMessageOptionsDialog(
void _showOptionsDialog() => showChatMessageOptionsDialog(
context,
chatData: widget.chatData,
bubbleData: widget.bubbleData,
isSender: widget.isSender,
onRefetch: widget.refetch,
);
}
void _onTap() {
final obj = message.originalData?['object'];
if (obj?.type == RichObjectStringObjectType.talkPoll) {
showChatBubblePollDialog(
context,
chatToken: widget.chatData.token,
messageToken: widget.bubbleData.token,
pollId: int.parse(obj!.id),
pollName: obj.name,
);
return;
}
if (message.file == null) return;
if (_job?.status.value is DownloadInProgress) {
_confirmCancel();
} else {
_startFileDownload();
}
}
@override
Widget build(BuildContext context) {
message = ChatMessage(originalMessage: widget.bubbleData.message, originalData: widget.bubbleData.messageParameters);
var showActorDisplayName = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment && widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
var showBubbleTime = widget.bubbleData.messageType != GetRoomResponseObjectMessageType.system
final showActorDisplayName = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment
&& widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
final showBubbleTime = widget.bubbleData.messageType != GetRoomResponseObjectMessageType.system
&& widget.bubbleData.messageType != GetRoomResponseObjectMessageType.deletedComment;
var parent = widget.bubbleData.parent;
var actorText = Text(
final parent = widget.bubbleData.parent;
final actorText = Text(
widget.bubbleData.actorDisplayName,
textAlign: TextAlign.start,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold),
);
var timeText = Text(
Jiffy.parseFromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).format(pattern: 'HH:mm'),
final timeText = Text(
DateTime.fromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).formatHm(),
textAlign: TextAlign.end,
style: TextStyle(color: widget.timeIconColor, fontSize: widget.timeIconSize),
);
@@ -191,191 +190,161 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
mainAxisAlignment: MainAxisAlignment.end,
textDirection: TextDirection.ltr,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
GestureDetector(
onHorizontalDragStart: (details) {
_dragStartPosition = _position;
},
onHorizontalDragStart: (_) => _dragStartPosition = _position,
onHorizontalDragUpdate: (details) {
if(!widget.bubbleData.isReplyable) return;
var dx = details.delta.dx - _dragStartPosition.dx;
if (!widget.bubbleData.isReplyable) return;
final dx = details.delta.dx - _dragStartPosition.dx;
setState(() {
_position = (_position.dx + dx).abs() > 60 ? Offset(_position.dx, 0) : Offset(_position.dx + dx, 0);
_position = (_position.dx + dx).abs() > 60
? Offset(_position.dx, 0)
: Offset(_position.dx + dx, 0);
});
},
onHorizontalDragEnd: (DragEndDetails details) {
var isAction = _position.dx.abs() > 50;
setState(() {
_position = const Offset(0, 0);
});
if(widget.bubbleData.isReplyable && isAction) {
onHorizontalDragEnd: (_) {
final isAction = _position.dx.abs() > 50;
setState(() => _position = Offset.zero);
if (widget.bubbleData.isReplyable && isAction) {
context.read<ChatBloc>().setReferenceMessageId(widget.bubbleData.id);
}
},
onLongPress: showOptionsDialog,
onDoubleTap: showOptionsDialog,
onTap: () {
if(message.originalData?['object']?.type == RichObjectStringObjectType.talkPoll) {
var pollId = int.parse(message.originalData!['object']!.id);
var pollState = GetPollState(token: widget.bubbleData.token, pollId: pollId).run();
showDialog(context: context, builder: (context) => AlertDialog(
title: Text(message.originalData!['object']!.name, overflow: TextOverflow.ellipsis),
content: FutureBuilder(
future: pollState,
builder: (context, snapshot) {
if(snapshot.connectionState == ConnectionState.waiting) return const Column(mainAxisSize: MainAxisSize.min, children: [LoadingSpinner()]);
var pollData = snapshot.data!.data;
return SingleChildScrollView(
child: PollOptionsList(
pollData: pollData,
chatToken: widget.chatData.token,
),
);
}
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Zurück')
),
],
));
}
if (message.file == null) return;
if (_job?.status.value is DownloadInProgress) {
_confirmCancel();
} else {
_startFileDownload();
}
},
onLongPress: _showOptionsDialog,
onDoubleTap: _showOptionsDialog,
onTap: _onTap,
child: Transform.translate(
offset: _position,
child: Bubble(
style: getStyle(),
child: Column(
children: [
Container(
style: _getStyle(),
child: _BubbleContent(
actorText: actorText,
timeText: timeText,
messageWidget: message.getWidget(),
parent: parent,
bubbleData: widget.bubbleData,
isSender: widget.isSender,
isRead: widget.isRead,
selfId: widget.selfId,
spacing: widget.spacing,
timeIconSize: widget.timeIconSize,
timeIconColor: widget.timeIconColor,
showActorDisplayName: showActorDisplayName,
showBubbleTime: showBubbleTime,
downloadJob: _job,
),
),
),
),
ChatBubbleReactions(
bubbleData: widget.bubbleData,
chatData: widget.chatData,
isSender: widget.isSender,
onChanged: widget.refetch,
),
],
);
}
}
/// Stack inside the bubble: actor name (top-left, optional), message body
/// (centre), timestamp + read marker (bottom-right, optional), and a
/// download progress bar overlaid at the bottom while a job is running.
class _BubbleContent extends StatelessWidget {
final Text actorText;
final Text timeText;
final Widget messageWidget;
final GetChatResponseObject? parent;
final GetChatResponseObject bubbleData;
final bool isSender;
final bool isRead;
final String? selfId;
final double spacing;
final double timeIconSize;
final Color timeIconColor;
final bool showActorDisplayName;
final bool showBubbleTime;
final DownloadJob? downloadJob;
const _BubbleContent({
required this.actorText,
required this.timeText,
required this.messageWidget,
required this.parent,
required this.bubbleData,
required this.isSender,
required this.isRead,
required this.selfId,
required this.spacing,
required this.timeIconSize,
required this.timeIconColor,
required this.showActorDisplayName,
required this.showBubbleTime,
required this.downloadJob,
});
@override
Widget build(BuildContext context) => Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.9,
minWidth: showActorDisplayName
? actorText.size.width
: timeText.size.width + (widget.isSender ? widget.spacing + widget.timeIconSize : 0) + 3,
: timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3,
),
child: Stack(
children: [
Visibility(
visible: showActorDisplayName,
child: Positioned(
top: 0,
left: 0,
child: actorText
),
),
if (showActorDisplayName)
Positioned(top: 0, left: 0, child: actorText),
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if(parent != null && widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment) ...[
if (parent != null && bubbleData.messageType == GetRoomResponseObjectMessageType.comment) ...[
AnswerReference(
context: context,
referenceMessage: parent,
selfId: widget.selfId,
referenceMessage: parent!,
selfId: selfId,
),
const SizedBox(height: 5),
],
message.getWidget(),
messageWidget,
],
),
),
Visibility(
visible: showBubbleTime,
child: Positioned(
if (showBubbleTime)
Positioned(
bottom: 0,
right: 0,
child: Row(
children: [
timeText,
if(widget.isSender) ...[
SizedBox(width: widget.spacing),
if (isSender) ...[
SizedBox(width: spacing),
Icon(
widget.isRead ? Icons.done_all_outlined: Icons.done_outlined,
size: widget.timeIconSize,
color: widget.timeIconColor
)
]
isRead ? Icons.done_all_outlined : Icons.done_outlined,
size: timeIconSize,
color: timeIconColor,
),
],
],
)
),
),
if (_job?.status.value is DownloadInProgress)
if (downloadJob?.status.value is DownloadInProgress)
Positioned(
bottom: 0,
right: 0,
left: 0,
child: LinearProgressIndicator(
value: () {
final s = _job!.status.value as DownloadInProgress;
final s = downloadJob!.status.value as DownloadInProgress;
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:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/marianumcloud/talk/actions/talk_actions.dart';
@@ -11,6 +10,7 @@ import '../../../../api/marianumcloud/talk/react_message/react_message_params.da
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../utils/clipboard_helper.dart';
import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/debug/debug_tile.dart';
@@ -69,7 +69,7 @@ Future<void> showChatMessageOptionsDialog(
leading: const Icon(Icons.copy),
title: const Text('Nachricht kopieren'),
onTap: () {
Clipboard.setData(ClipboardData(text: bubbleData.message));
copyToClipboard(parentContext, bubbleData.message);
Navigator.of(dialogCtx).pop();
},
),
@@ -14,6 +14,7 @@ import '../../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/file_pick.dart';
import '../../../../widget/focus_behaviour.dart';
import '../../files/files_upload_dialog.dart';
@@ -172,13 +173,15 @@ class _ChatTextfieldState extends State<ChatTextfield> {
Row(children: <Widget>[
GestureDetector(
onTap: () {
showDialog(context: context, builder: (dialogCtx) => SimpleDialog(children: [
showDetailsBottomSheet(
context,
children: (sheetCtx) => [
ListTile(
leading: const Icon(Icons.file_open),
title: const Text('Aus Dateien auswählen'),
onTap: () {
FilePick.documentPick().then(mediaUpload);
Navigator.of(dialogCtx).pop();
Navigator.of(sheetCtx).pop();
},
),
ListTile(
@@ -188,7 +191,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
FilePick.multipleGalleryPick().then((value) {
if (value != null) mediaUpload(value.map((e) => e.path).toList());
});
Navigator.of(dialogCtx).pop();
Navigator.of(sheetCtx).pop();
},
),
ListTile(
@@ -198,10 +201,11 @@ class _ChatTextfieldState extends State<ChatTextfield> {
FilePick.cameraPick().then((image) {
if (image != null) mediaUpload([image.path]);
});
Navigator.of(dialogCtx).pop();
Navigator.of(sheetCtx).pop();
},
),
]));
],
);
},
child: Material(
elevation: 5,
+2 -2
View File
@@ -2,13 +2,13 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:jiffy/jiffy.dart';
import '../../../../api/marianumcloud/talk/actions/talk_actions.dart';
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart';
import '../../../../extensions/date_time.dart';
import '../../../../model/account_data.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
@@ -96,7 +96,7 @@ class _ChatTileState extends State<ChatTile> {
],
),
subtitle: Text(
'${Jiffy.parseFromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).fromNow()}: '
'${DateTime.fromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).formatRelative()}: '
'${RichObjectStringProcessor.parseToString(widget.data.lastMessage.message.replaceAll("\n", " "), widget.data.lastMessage.messageParameters)}',
overflow: TextOverflow.ellipsis,
),
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import '../../../../extensions/date_time.dart';
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
@@ -51,7 +51,7 @@ class CustomEventsView extends StatelessWidget {
title: Text(e.title),
subtitle: Text(
'${e.rrule.isNotEmpty ? "wiederholend, " : ""}'
'beginnend ${Jiffy.parseFromDateTime(e.startDate).fromNow()}',
'beginnend ${e.startDate.formatRelative()}',
),
leading: CenteredLeading(Icon(e.rrule.isEmpty ? Icons.event_outlined : Icons.event_repeat_outlined)),
trailing: Row(
@@ -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:jiffy/jiffy.dart';
import 'package:rrule/rrule.dart';
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../extensions/date_time.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../custom_events/custom_event_edit_dialog.dart';
import 'bottom_sheet.dart';
import 'delete_custom_event.dart';
class CustomEventSheet {
static void show(BuildContext context, CustomTimetableEvent event) {
final timeRange =
'${Jiffy.parseFromDateTime(event.startDate).format(pattern: 'HH:mm')} - '
'${Jiffy.parseFromDateTime(event.endDate).format(pattern: 'HH:mm')}';
final timeRange = event.startDate.timeRangeTo(event.endDate);
showAppointmentBottomSheet(
showDetailsBottomSheet(
context,
header: ListTile(
leading: const Icon(Icons.event_outlined, size: 32),
@@ -1,38 +1,35 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart';
import '../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart';
import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart';
import '../../../../api/webuntis/services/lesson_resolver.dart';
import '../../../../extensions/date_time.dart';
import '../../../../extensions/text.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/unimplemented_dialog.dart';
import 'bottom_sheet.dart';
class WebuntisLessonSheet {
static void show(BuildContext context, TimetableBloc bloc, Appointment appointment, GetTimetableResponseObject lesson) {
final state = bloc.state.data;
if (state == null) return;
final headerSubject = _resolveSubject(state, lesson.su.firstOrNull?.id);
final headerTitle = _firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']);
final headerSubject = LessonResolver.resolveSubject(state, lesson.su.firstOrNull?.id);
final headerTitle = firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']);
final headerLongName = headerSubject.longName.isNotEmpty && headerSubject.longName != headerTitle ? headerSubject.longName : '';
final timeRange =
'${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - '
'${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}';
final timeRange = appointment.startTime.timeRangeTo(appointment.endTime);
showAppointmentBottomSheet(
showDetailsBottomSheet(
context,
header: ListTile(
leading: Icon(_iconForCode(lesson.code), size: 32),
leading: Icon(LessonFormatter.iconForCode(lesson.code), size: 32),
title: Text(
'${_codePrefix(lesson.code)}$headerTitle',
'${LessonFormatter.codePrefix(lesson.code)}$headerTitle',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(headerLongName.isNotEmpty
@@ -43,17 +40,17 @@ class WebuntisLessonSheet {
children: (_) => <Widget>[
ListTile(
leading: const Icon(Icons.notifications_active),
title: Text('Status: ${_statusLabel(lesson.code)}'),
title: Text('Status: ${LessonFormatter.statusLabel(lesson.code)}'),
),
if (lesson.su.length > 1)
_listTile(
icon: Icons.book_outlined,
label: 'Fächer',
entries: lesson.su.map((s) {
final resolved = _resolveSubject(state, s.id);
return _formatLine(
_firstNonEmpty([resolved.name, s.name, '?']),
longname: _firstNonEmpty([resolved.longName, s.longname, '']),
final resolved = LessonResolver.resolveSubject(state, s.id);
return LessonFormatter.formatLine(
firstNonEmpty([resolved.name, s.name, '?']),
longname: firstNonEmpty([resolved.longName, s.longname, '']),
);
}).toList(),
),
@@ -69,7 +66,7 @@ class WebuntisLessonSheet {
icon: Icons.people,
label: lesson.kl.length == 1 ? 'Klasse' : 'Klassen',
entries: lesson.kl
.map((k) => _formatLine(
.map((k) => LessonFormatter.formatLine(
k.name.isNotEmpty ? k.name : '?',
longname: k.longname,
))
@@ -81,17 +78,6 @@ class WebuntisLessonSheet {
);
}
static IconData _iconForCode(String? code) {
switch (code) {
case 'cancelled':
return Icons.event_busy_outlined;
case 'irregular':
return Icons.swap_horiz;
default:
return Icons.school_outlined;
}
}
static Widget _roomTile(BuildContext context, TimetableState state, GetTimetableResponseObject lesson) {
final trailing = IconButton(
icon: const Icon(Icons.house_outlined),
@@ -107,11 +93,11 @@ class WebuntisLessonSheet {
}
final entries = lesson.ro.map((r) {
final resolved = _resolveRoom(state, r.id);
final name = _firstNonEmpty([resolved.name, r.name, '?']);
final longname = _firstNonEmpty([resolved.longName, r.longname, '']);
final resolved = LessonResolver.resolveRoom(state, r.id);
final name = firstNonEmpty([resolved.name, r.name, '?']);
final longname = firstNonEmpty([resolved.longName, r.longname, '']);
final building = resolved.building.trim();
return _formatLine(
return LessonFormatter.formatLine(
name,
longname: longname,
extra: (building.isNotEmpty && building != '?') ? building : null,
@@ -144,7 +130,7 @@ class WebuntisLessonSheet {
}
final entries = lesson.te.map((t) {
final base = _formatLine(
final base = LessonFormatter.formatLine(
t.name.isNotEmpty ? t.name : '?',
longname: t.longname,
);
@@ -206,54 +192,4 @@ class WebuntisLessonSheet {
subtitle: Text(text),
);
}
static String _formatLine(String name, {String? longname, String? extra}) {
final parts = <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
+14 -537
View File
@@ -1,543 +1,20 @@
/// Family of async-aware buttons + helpers. Implementation is split across
/// `async_actions/` for readability; everything still lives in this single
/// library so private widgets like `_AsyncMixin` and `_InlineErrorWrapper`
/// can stay private and shared.
library;
import 'package:flutter/material.dart';
import '../api/errors/error_mapper.dart';
import 'app_progress_indicator.dart';
import 'info_dialog.dart';
Future<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;
}
}
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),
),
],
),
],
);
},
);
}
part 'async_actions/async_action_controller.dart';
part 'async_actions/async_action_button.dart';
part 'async_actions/async_dialog_action.dart';
part 'async_actions/async_fab.dart';
part 'async_actions/async_icon_button.dart';
part 'async_actions/async_list_tile.dart';
part 'async_actions/async_mixin.dart';
part 'async_actions/async_text_button.dart';
@@ -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),
),
],
),
],
);
},
);
}
+48
View File
@@ -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),
),
),
],
);
},
);
}
+109
View File
@@ -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),
);
},
);
}
+12 -15
View File
@@ -1,7 +1,8 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../utils/clipboard_helper.dart';
class JsonViewer extends StatelessWidget {
final String title;
@@ -25,24 +26,20 @@ class JsonViewer extends StatelessWidget {
static String format(Map<String, dynamic> jsonInput) => _encoder.convert(jsonInput);
static void asDialog(BuildContext context, Map<String, dynamic> dataMap) {
showDialog(context: context, builder: (context) => AlertDialog(
showDialog(context: context, builder: (dialogCtx) => AlertDialog(
scrollable: true,
title: const Row(children: [Icon(Icons.bug_report_outlined), Text('Rohdaten')]),
content: Text(JsonViewer.format(dataMap)),
actions: [
TextButton(onPressed: () {
Clipboard.setData(ClipboardData(text: JsonViewer.format(dataMap))).then((value) {
if (!context.mounted) return;
showDialog(context: context, builder: (context) => const AlertDialog(content: Text('Formatiertes JSON wurde erfolgreich in deiner Zwischenlage abgelegt.')));
});
}, child: const Text('Kopieren')),
TextButton(onPressed: () {
Clipboard.setData(ClipboardData(text: dataMap.toString())).then((value) {
if (!context.mounted) return;
showDialog(context: context, builder: (context) => const AlertDialog(content: Text('Unformatiertes JSON wurde erfolgreich in deiner Zwischenablage abgelegt.')));
});
}, child: const Text('Inline Kopieren')),
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Schließen'))
TextButton(
onPressed: () => copyToClipboard(dialogCtx, JsonViewer.format(dataMap), successMessage: 'Formatiertes JSON kopiert'),
child: const Text('Kopieren'),
),
TextButton(
onPressed: () => copyToClipboard(dialogCtx, dataMap.toString(), successMessage: 'Inline JSON kopiert'),
child: const Text('Inline Kopieren'),
),
TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Schließen'))
],
));
}
@@ -1,12 +1,12 @@
import 'package:flutter/material.dart';
/// Shows a modal bottom sheet for an appointment, matching the design of the
/// other sheets in the app (file details, file actions, overflow lessons):
/// drag handle on top, default theme background, ListTile-style header
/// Shows a modal bottom sheet for a detail view (appointment, file, lesson,
/// custom event, etc.). All detail sheets in the app share this layout: drag
/// handle on top, default theme background, optional ListTile-style header
/// followed by a divider, scrollable body below.
void showAppointmentBottomSheet(
void showDetailsBottomSheet(
BuildContext context, {
required Widget header,
Widget? header,
required List<Widget> Function(BuildContext sheetContext) children,
}) {
showModalBottomSheet<void>(
@@ -21,8 +21,10 @@ void showAppointmentBottomSheet(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (header != null) ...[
header,
const Divider(height: 1),
],
...children(sheetContext),
],
),
+3 -11
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/clipboard_helper.dart';
class InfoDialog {
/// Shows a single-text dialog. When [copyable] is true (default for error
@@ -26,16 +27,7 @@ class InfoDialog {
actions: [
if (copyable)
TextButton.icon(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: info));
if (!dialogContext.mounted) return;
ScaffoldMessenger.of(dialogContext).showSnackBar(
const SnackBar(
content: Text('In Zwischenablage kopiert'),
duration: Duration(seconds: 2),
),
);
},
onPressed: () => copyToClipboard(dialogContext, info),
icon: const Icon(Icons.copy_outlined, size: 18),
label: const Text('Kopieren'),
),
+4
View File
@@ -71,6 +71,10 @@ dependencies:
enough_icalendar: ^0.17.0
dev_dependencies:
flutter_test:
sdk: flutter
fake_async: ^1.3.1
flutter_launcher_icons: ^0.14.3
flutter_native_splash: ^2.4.4
+105
View File
@@ -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',
);
});
});
}
+104
View File
@@ -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(''), '?');
});
});
}
+61
View File
@@ -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');
});
});
}
+112
View File
@@ -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));
});
});
});
}
+97
View File
@@ -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);
});
});
}
+108
View File
@@ -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:0010: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:0009.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 MonFri 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);
});
});
}