diff --git a/assets/background/chat.png b/assets/background/chat.png index a9f5760..0566458 100644 Binary files a/assets/background/chat.png and b/assets/background/chat.png differ diff --git a/assets/img/raumplan.png b/assets/img/raumplan.png index 4993888..275e3fe 100644 Binary files a/assets/img/raumplan.png and b/assets/img/raumplan.png differ diff --git a/assets/logo/icon-android12.png b/assets/logo/icon-android12.png deleted file mode 100644 index 51e192d..0000000 Binary files a/assets/logo/icon-android12.png and /dev/null differ diff --git a/assets/logo/icon.png b/assets/logo/icon.png index dc89db9..f541898 100644 Binary files a/assets/logo/icon.png and b/assets/logo/icon.png differ diff --git a/assets/logo/icon/1024.png b/assets/logo/icon/1024.png index 197c7af..9095987 100644 Binary files a/assets/logo/icon/1024.png and b/assets/logo/icon/1024.png differ diff --git a/assets/logo/icon/adaptive_back.png b/assets/logo/icon/adaptive_back.png deleted file mode 100644 index 7126e69..0000000 Binary files a/assets/logo/icon/adaptive_back.png and /dev/null differ diff --git a/assets/logo/icon/adaptive_fore.png b/assets/logo/icon/adaptive_fore.png deleted file mode 100644 index 7eea023..0000000 Binary files a/assets/logo/icon/adaptive_fore.png and /dev/null differ diff --git a/assets/logo/icon/appIcon.png b/assets/logo/icon/appIcon.png index 5f53154..dcfb12a 100644 Binary files a/assets/logo/icon/appIcon.png and b/assets/logo/icon/appIcon.png differ diff --git a/assets/logo/icon/ic_launcher.png b/assets/logo/icon/ic_launcher.png index 42d8c9a..f5ffbd4 100644 Binary files a/assets/logo/icon/ic_launcher.png and b/assets/logo/icon/ic_launcher.png differ diff --git a/assets/logo/icon/ic_launcher_adaptive_back.png b/assets/logo/icon/ic_launcher_adaptive_back.png index 7126e69..a467285 100644 Binary files a/assets/logo/icon/ic_launcher_adaptive_back.png and b/assets/logo/icon/ic_launcher_adaptive_back.png differ diff --git a/assets/logo/icon/ic_launcher_adaptive_fore.png b/assets/logo/icon/ic_launcher_adaptive_fore.png index 7eea023..3f96112 100644 Binary files a/assets/logo/icon/ic_launcher_adaptive_fore.png and b/assets/logo/icon/ic_launcher_adaptive_fore.png differ diff --git a/assets/logo/icon/icon-android12.png b/assets/logo/icon/icon-android12.png new file mode 100644 index 0000000..e625664 Binary files /dev/null and b/assets/logo/icon/icon-android12.png differ diff --git a/flutter_native_splash.yaml b/flutter_native_splash.yaml index 37bb48d..703f49a 100644 --- a/flutter_native_splash.yaml +++ b/flutter_native_splash.yaml @@ -54,7 +54,7 @@ flutter_native_splash: # 640 pixels in diameter. # App icon without an icon background: This should be 1152×1152 pixels, and fit within a circle # 768 pixels in diameter. - image: assets/logo/icon-android12.png + image: assets/logo/icon/icon-android12.png # Splash screen background color. color: "#993333" diff --git a/lib/main.dart b/lib/main.dart index 3ce1dad..e392be5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -159,6 +159,15 @@ class _MainState extends State
{ themeMode: settings.appTheme, theme: LightAppTheme.theme, darkTheme: DarkAppTheme.theme, + // Brand-colored backdrop behind every route. During the logout + // home-swap and route pop animations the framework can briefly + // expose the layer below the topmost Scaffold; without this + // the dark Material default shows through and the user sees a + // black flash. + builder: (context, child) => ColoredBox( + color: LightAppTheme.marianumRed, + child: child ?? const SizedBox.shrink(), + ), home: LoaderOverlay( child: Breaker( breaker: BreakerArea.global, @@ -203,14 +212,16 @@ class _MainState extends State
{ case AccountStatus.loggedOut: return const Login(); case AccountStatus.undefined: - return const Scaffold( - body: Center( + return Scaffold( + backgroundColor: LightAppTheme.marianumRed, + body: const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - AppProgressIndicator.large(), + AppProgressIndicator.large(color: Colors.white), SizedBox(height: 16), - Text('Konto wird geladen…'), + Text('Konto wird geladen…', + style: TextStyle(color: Colors.white)), ], ), ), @@ -234,16 +245,19 @@ Future _wipeUserState({ required BreakerBloc breakerBloc, }) async { try { - final prefs = await SharedPreferences.getInstance(); - await prefs.clear(); - PaintingBinding.instance.imageCache.clear(); - await settingsCubit.reset(); + // Reset user-data blocs whose tree is no longer mounted after the + // home swap. We do NOT touch SettingsCubit here — its outer BlocBuilder + // wraps MaterialApp, so emit'ing a fresh state would tear down the + // freshly-mounted Login tree and leave the user with a blank screen + // (the MaterialApp.builder backdrop) until the next interaction. await Future.wait([ timetableBloc.reset(), chatListBloc.reset(), chatBloc.reset(), breakerBloc.reset(), ]); + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); await HydratedBloc.storage.clear(); await const CacheView().clear(); } catch (e, s) { diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart index 712301a..35680da 100644 --- a/lib/model/account_data.dart +++ b/lib/model/account_data.dart @@ -3,14 +3,9 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../state/app/modules/account/bloc/account_bloc.dart'; -import '../state/app/modules/account/bloc/account_state.dart'; - class AccountData { static const _usernameField = 'username'; static const _passwordField = 'password'; @@ -54,11 +49,8 @@ class AccountData { if (!_populated.isCompleted) _populated.complete(); } - Future removeData({BuildContext? context}) async { + Future removeData() async { _populated = Completer(); - if (context != null) { - context.read().setStatus(AccountStatus.loggedOut); - } _username = null; _password = null; await _secureStorage.delete(key: _usernameField); diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart index 03916ca..0bae6ec 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart @@ -55,7 +55,7 @@ class LoadableStateErrorBar extends StatelessWidget { if (technicalDetails != null && technicalDetails!.isNotEmpty) technicalDetails!, ].join('\n\n'); if (body.isEmpty) return; - InfoDialog.show(context, body); + InfoDialog.show(context, body, copyable: true, title: 'Fehlerdetails'); }, child: Container( height: 20, diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart index e56eb29..dac7d3c 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart @@ -58,7 +58,7 @@ class LoadableStateErrorScreen extends StatelessWidget { if (technicalDetails != null) ...[ const SizedBox(height: 4), TextButton( - onPressed: () => InfoDialog.show(context, technicalDetails!), + onPressed: () => InfoDialog.show(context, technicalDetails!, copyable: true, title: 'Fehlerdetails'), child: const Text('Details anzeigen'), ), ], diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index a2d22cc..87aed0d 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -1,15 +1,17 @@ - import 'dart:developer'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_login/flutter_login.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'; class Login extends StatefulWidget { const Login({super.key}); @@ -19,88 +21,354 @@ class Login extends StatefulWidget { } class _LoginState extends State { - bool displayDisclaimerText = true; + static const _marianumRed = LightAppTheme.marianumRed; - String? _checkInput(String? value) => (value ?? '').isEmpty ? 'Eingabe erforderlich' : null; + final _formKey = GlobalKey(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _passwordFocus = FocusNode(); - Future _login(LoginData data) async { - await AccountData().removeData(); - - try { - await AccountData().setData(data.name.toLowerCase(), data.password); - await GetRoom( - GetRoomParams( - includeStatus: false, - ), - ).run().then((value) async { - await AccountData().setData(data.name.toLowerCase(), data.password); - setState(() { - displayDisclaimerText = false; - }); - }); - } catch(e) { - await AccountData().removeData(); - log(e.toString()); - return 'Benutzername oder Password falsch! (${e.toString()})'; - } - - await Future.delayed(const Duration(seconds: 1)); - return null; - } - - Future _resetPassword(String name) => Future.delayed(Duration.zero).then((_) => 'Diese Funktion steht nicht zur Verfügung!'); + bool _loading = false; + String? _errorMessage; + String? _errorDetails; @override - Widget build(BuildContext context) => FlutterLogin( - logo: Image.asset('assets/logo/icon.png').image, + void didChangeDependencies() { + super.didChangeDependencies(); + precacheImage(const AssetImage('assets/logo/icon.png'), context); + } - userValidator: _checkInput, - passwordValidator: _checkInput, - onSubmitAnimationCompleted: () => context.read().setStatus(AccountStatus.loggedIn), + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + _passwordFocus.dispose(); + super.dispose(); + } - onLogin: _login, - onSignup: null, + String? _required(String? value) => (value ?? '').trim().isEmpty ? 'Eingabe erforderlich' : null; - onRecoverPassword: _resetPassword, - hideForgotPasswordButton: true, + Future _submit() async { + if (_loading) return; + if (!(_formKey.currentState?.validate() ?? false)) return; - theme: LoginTheme( - primaryColor: Theme.of(context).primaryColor, - accentColor: Colors.white, - errorColor: Theme.of(context).primaryColor, - footerBottomPadding: 10, - textFieldStyle: const TextStyle( - fontWeight: FontWeight.w500 - ), - cardTheme: const CardTheme( - elevation: 10, - ), - ), + setState(() { + _loading = true; + _errorMessage = null; + _errorDetails = null; + }); - messages: LoginMessages( - loginButton: 'Anmelden', - userHint: 'Nutzername', - passwordHint: 'Passwort', - ), + final username = _usernameController.text.trim().toLowerCase(); + final password = _passwordController.text; - disableCustomPageTransformer: true, + try { + await AccountData().removeData(); + await AccountData().setData(username, password); + await GetRoom(GetRoomParams(includeStatus: false)).run(); + if (!mounted) return; + context.read().setStatus(AccountStatus.loggedIn); + } catch (e) { + log(e.toString()); + await AccountData().removeData(); + if (!mounted) return; + // 401 from the probe means the credentials were wrong; everything else + // (no network, server down, TLS errors, …) gets the generic mapped + // message so the user knows it isn't their typo. + final isWrongCredentials = e is AuthException && e.statusCode == 401; + setState(() { + _errorMessage = isWrongCredentials + ? 'Benutzername oder Passwort falsch.' + : errorToUserMessage(e); + _errorDetails = errorToTechnicalDetails(e); + }); + } finally { + if (mounted) setState(() => _loading = false); + } + } - headerWidget: Padding( - padding: const EdgeInsets.only(bottom: 30), - child: Center( - child: Visibility( - visible: displayDisclaimerText, - child: const Text( - 'Dies ist ein Inoffizieller Nextcloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\nKeinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!', - textAlign: TextAlign.center, + void _showErrorDetails() { + final details = _errorDetails; + if (details == null) return; + showDialog( + context: context, + builder: (dialogContext) { + final theme = Theme.of(dialogContext); + return AlertDialog( + icon: Icon(Icons.error_outline, color: theme.colorScheme.error), + title: const Text('Fehlerdetails'), + content: SingleChildScrollView( + child: SelectableText( + details, + style: theme.textTheme.bodySmall, ), ), - ), - ), - - footer: 'Marianum Fulda - Die persönliche Schule', - title: 'Marianum Fulda', - - userType: LoginUserType.name, + 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( + backgroundColor: _marianumRed, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Column( + children: [ + const SizedBox(height: 40), + Image.asset( + 'assets/logo/icon.png', + height: 110, + fit: BoxFit.contain, + gaplessPlayback: true, + ), + const SizedBox(height: 20), + const Text( + 'Marianum Fulda', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + const SizedBox(height: 6), + Text( + 'Stundenplan, Talk & Dateien an einem Ort.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.85), + fontSize: 14, + height: 1.3, + ), + ), + const SizedBox(height: 28), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Card( + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.35), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + color: theme.colorScheme.surface, + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Anmelden', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + 'Melde dich mit deinen Marianum-Zugangsdaten an.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + TextFormField( + controller: _usernameController, + enabled: !_loading, + validator: _required, + autocorrect: false, + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => _passwordFocus.requestFocus(), + decoration: InputDecoration( + labelText: 'Nutzername', + prefixIcon: const Icon(Icons.person_outline), + filled: true, + fillColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.primary, width: 1.5), + ), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordController, + focusNode: _passwordFocus, + enabled: !_loading, + validator: _required, + obscureText: true, + obscuringCharacter: '•', + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.visiblePassword, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + decoration: InputDecoration( + labelText: 'Passwort', + prefixIcon: const Icon(Icons.lock_outline), + filled: true, + fillColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.primary, width: 1.5), + ), + ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + child: _errorMessage == null + ? const SizedBox(height: 0, width: double.infinity) + : Padding( + padding: const EdgeInsets.only(top: 14), + child: Material( + color: theme.colorScheme.errorContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: _errorDetails != null ? _showErrorDetails : null, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(Icons.error_outline, + size: 20, + color: theme.colorScheme.onErrorContainer), + const SizedBox(width: 10), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle( + color: theme.colorScheme.onErrorContainer, + fontSize: 13, + height: 1.3, + ), + ), + ), + if (_errorDetails != null) ...[ + const SizedBox(width: 8), + Icon(Icons.chevron_right, + size: 20, + color: theme.colorScheme.onErrorContainer + .withValues(alpha: 0.7)), + ], + ], + ), + ), + ), + ), + ), + ), + const SizedBox(height: 20), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _loading ? null : _submit, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + child: _loading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white, + ), + ) + : const Text('Anmelden'), + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 18), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + 'Inoffizieller Nextcloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.75), + fontSize: 11, + height: 1.4, + ), + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8), + child: Text( + 'Marianum Fulda. Die persönliche Schule.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 12, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } } diff --git a/lib/view/pages/overhang.dart b/lib/view/pages/overhang.dart index 2489fa4..01b4c50 100644 --- a/lib/view/pages/overhang.dart +++ b/lib/view/pages/overhang.dart @@ -127,7 +127,7 @@ class _OverhangState extends State { }, onError: (error) { if (!context.mounted) return; - InfoDialog.show(context, error.toString()); + InfoDialog.show(context, error.toString(), copyable: true, title: 'Fehler'); }, ); }, diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index d4b8128..a6190cc 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.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 '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; @@ -15,21 +18,23 @@ class AccountSection extends StatelessWidget { onTap: () => _showLogoutDialog(context), ); - void _showLogoutDialog(BuildContext context) { - showDialog( + Future _showLogoutDialog(BuildContext context) async { + // Sequential logout flow: dialog wipes secure storage, dialog closes + // (single Navigator.pop), then we flip the AccountBloc state. The bloc + // listener in main.dart pops the Settings route and runs the in-memory + // wipe. Triggering setStatus from inside removeData (the previous + // approach) raced AsyncDialogAction's pop(true) against popUntil(isFirst) + // and could leave the navigator in an inconsistent state. + final confirmed = await showDialog( context: context, builder: (dialogContext) => ConfirmDialog( title: 'Abmelden?', content: 'Möchtest du dich wirklich abmelden?', confirmButton: 'Abmelden', - // Cleanup of caches, hydrated bloc storage and bloc in-memory state is - // handled by the AccountBloc listener in main.dart on the loggedOut - // transition. Doing the cleanup *before* setting loggedOut caused - // rebuilds in the still-mounted App tree (TimetableBloc/ChatListBloc - // emitting empty states) which raced with the home-route swap and - // produced a black screen. - onConfirmAsync: () => AccountData().removeData(context: context), + onConfirmAsync: AccountData().removeData, ), ); + if (confirmed != true || !context.mounted) return; + context.read().setStatus(AccountStatus.loggedOut); } } diff --git a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart index caa6e62..a85b652 100644 --- a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart +++ b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart @@ -115,7 +115,7 @@ class _CustomEventEditDialogState extends State { Navigator.of(context).pop(); }).catchError((Object error) { if (!mounted) return; - InfoDialog.show(context, error.toString()); + InfoDialog.show(context, error.toString(), copyable: true, title: 'Fehler'); }); } diff --git a/lib/view/pages/timetable/data/calendar_layout.dart b/lib/view/pages/timetable/data/calendar_layout.dart index d24729b..b026edd 100644 --- a/lib/view/pages/timetable/data/calendar_layout.dart +++ b/lib/view/pages/timetable/data/calendar_layout.dart @@ -6,3 +6,11 @@ const double kCalendarViewHeaderHeight = 60; /// Minimum pixels per hour. Below this, the grid scrolls vertically rather /// than compressing further. const double kCalendarMinPxPerHour = 56; + +/// Minimum height of a lesson block in the period-based layout. The grid +/// scrolls vertically once lessons would otherwise be smaller than this. +const double kLessonBlockMinHeight = 50; + +/// Fixed height of a break block in the period-based layout. Independent of +/// the actual break duration; breaks are rendered as a compact indicator. +const double kBreakBlockHeight = 28; diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index 29cebf2..985dfd7 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -72,8 +72,8 @@ class TimetableAppointmentFactory { id: CustomAppointment(event), startTime: event.startDate, endTime: event.endDate, - location: event.description, - subject: event.title, + location: _collapseWhitespace(event.description), + subject: _collapseWhitespace(event.title) ?? event.title, recurrenceRule: event.rrule, color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name), startTimeZone: '', @@ -83,19 +83,38 @@ class TimetableAppointmentFactory { String _subjectName(GetTimetableResponseObject lesson) { final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id); if (subject == null) return 'Unbekannt'; - return switch (settings.timetableNameMode) { + final name = switch (settings.timetableNameMode) { TimetableNameMode.name => subject.name, TimetableNameMode.longName => subject.longName, TimetableNameMode.alternateName => subject.alternateName, }; + return _collapseWhitespace(name) ?? 'Unbekannt'; } String _locationLabel(GetTimetableResponseObject lesson) { - final roomName = rooms.result.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)?.name ?? 'Unbekannt'; - final teacherName = lesson.te.firstOrNull?.longname ?? 'Unbekannt'; + final roomName = _collapseWhitespace( + rooms.result.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)?.name) ?? + 'Unbekannt'; + final teacherName = _collapseWhitespace(lesson.te.firstOrNull?.longname) ?? 'Unbekannt'; return '$roomName\n$teacherName'; } + /// Collapses any line-break or whitespace run to a single space and trims. + /// Returns null when input is null or fully whitespace. Webuntis sometimes + /// returns multi-line room names like "A30\n4" — this normalizes those so + /// the tile renders the room on a single line. + static String? _collapseWhitespace(String? s) { + if (s == null) return null; + final cleaned = s + .replaceAll('\r\n', ' ') + .replaceAll('\n', ' ') + .replaceAll('\r', ' ') + .replaceAll('\t', ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + return cleaned.isEmpty ? null : cleaned; + } + // Pure: returns a new list, does not mutate input. static List _mergeAdjacentLessons( List input, { diff --git a/lib/view/pages/timetable/details/bottom_sheet.dart b/lib/view/pages/timetable/details/bottom_sheet.dart index d834a09..c0066b1 100644 --- a/lib/view/pages/timetable/details/bottom_sheet.dart +++ b/lib/view/pages/timetable/details/bottom_sheet.dart @@ -1,51 +1,32 @@ import 'package:flutter/material.dart'; +/// Shows a modal bottom sheet for an appointment, matching the design of the +/// other sheets in the app (file details, file actions, overflow lessons): +/// drag handle on top, default theme background, ListTile-style header +/// followed by a divider, scrollable body below. void showAppointmentBottomSheet( BuildContext context, { - required Widget Function(BuildContext context) header, - required SliverChildListDelegate Function(BuildContext context) body, + required Widget header, + required List Function(BuildContext sheetContext) children, }) { showModalBottomSheet( context: context, isScrollControlled: true, + showDragHandle: true, useSafeArea: true, - backgroundColor: Theme.of(context).colorScheme.surface, - builder: (sheetContext) => DraggableScrollableSheet( - expand: false, - initialChildSize: 0.4, - minChildSize: 0.2, - maxChildSize: 0.7, - snap: true, - snapSizes: const [0.4], - builder: (_, scrollController) => CustomScrollView( - controller: scrollController, - slivers: [ - SliverPersistentHeader( - pinned: true, - delegate: _StickyHeader(child: header(sheetContext)), - ), - SliverList(delegate: body(sheetContext)), - ], + builder: (sheetContext) => SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + header, + const Divider(height: 1), + ...children(sheetContext), + ], + ), ), ), ); } - -class _StickyHeader extends SliverPersistentHeaderDelegate { - _StickyHeader({required this.child}); - final Widget child; - - @override - double get minExtent => 100; - @override - double get maxExtent => 100; - - @override - Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => Material( - color: Theme.of(context).colorScheme.surface, - child: SizedBox.expand(child: child), - ); - - @override - bool shouldRebuild(covariant _StickyHeader oldDelegate) => oldDelegate.child != child; -} diff --git a/lib/view/pages/timetable/details/custom_event_sheet.dart b/lib/view/pages/timetable/details/custom_event_sheet.dart index ce52d8c..1a66504 100644 --- a/lib/view/pages/timetable/details/custom_event_sheet.dart +++ b/lib/view/pages/timetable/details/custom_event_sheet.dart @@ -11,51 +11,49 @@ import 'delete_custom_event.dart'; class CustomEventSheet { static void show(BuildContext context, CustomTimetableEvent event) { + final timeRange = + '${Jiffy.parseFromDateTime(event.startDate).format(pattern: 'HH:mm')} - ' + '${Jiffy.parseFromDateTime(event.endDate).format(pattern: 'HH:mm')}'; + showAppointmentBottomSheet( context, - header: (_) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(event.title, style: const TextStyle(fontSize: 25, overflow: TextOverflow.ellipsis)), - Text( - '${Jiffy.parseFromDateTime(event.startDate).format(pattern: 'HH:mm')} - ' - '${Jiffy.parseFromDateTime(event.endDate).format(pattern: 'HH:mm')}', - style: const TextStyle(fontSize: 15), - ), - ], - ), + header: ListTile( + leading: const Icon(Icons.event_outlined, size: 32), + title: Text(event.title, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(timeRange), ), - body: (sheetCtx) => SliverChildListDelegate([ - const Divider(), - Center( - child: Wrap( - children: [ - TextButton.icon( - onPressed: () { - Navigator.of(sheetCtx).pop(); - showDialog( - context: context, - builder: (_) => CustomEventEditDialog(existingEvent: event), - ); - }, - label: const Text('Bearbeiten'), - icon: const Icon(Icons.edit_outlined), - ), - TextButton.icon( - onPressed: () { - showDeleteCustomEventDialog(context, event).future.then((_) { - if (!sheetCtx.mounted) return; + children: (sheetCtx) => [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Center( + child: Wrap( + children: [ + TextButton.icon( + onPressed: () { Navigator.of(sheetCtx).pop(); - }); - }, - label: const Text('Löschen'), - icon: const Icon(Icons.delete_outline), - ), - ], + showDialog( + context: context, + builder: (_) => CustomEventEditDialog(existingEvent: event), + ); + }, + label: const Text('Bearbeiten'), + icon: const Icon(Icons.edit_outlined), + ), + TextButton.icon( + onPressed: () { + showDeleteCustomEventDialog(context, event).future.then((_) { + if (!sheetCtx.mounted) return; + Navigator.of(sheetCtx).pop(); + }); + }, + label: const Text('Löschen'), + icon: const Icon(Icons.delete_outline), + ), + ], + ), ), ), - const Divider(), + const Divider(height: 1), ListTile( leading: const Icon(Icons.info_outline), title: Text(event.description.isEmpty ? 'Keine Beschreibung' : event.description), @@ -82,7 +80,7 @@ class CustomEventSheet { ), ), DebugTile(sheetCtx).jsonData(event.toJson()), - ]), + ], ); } } diff --git a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart index 7b88c05..2ada2ae 100644 --- a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart +++ b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart @@ -23,29 +23,24 @@ class WebuntisLessonSheet { final headerTitle = _firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']); final headerLongName = headerSubject.longName.isNotEmpty && headerSubject.longName != headerTitle ? headerSubject.longName : ''; + final timeRange = + '${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - ' + '${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}'; + showAppointmentBottomSheet( context, - header: (_) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${_codePrefix(lesson.code)}$headerTitle', - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 25), - overflow: TextOverflow.ellipsis, - ), - if (headerLongName.isNotEmpty) Text(headerLongName), - Text( - '${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - ' - '${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}', - style: const TextStyle(fontSize: 15), - ), - ], + header: ListTile( + leading: Icon(_iconForCode(lesson.code), size: 32), + title: Text( + '${_codePrefix(lesson.code)}$headerTitle', + style: const TextStyle(fontWeight: FontWeight.bold), ), + subtitle: Text(headerLongName.isNotEmpty + ? '$timeRange\n$headerLongName' + : timeRange), + isThreeLine: headerLongName.isNotEmpty, ), - body: (_) => SliverChildListDelegate([ - const Divider(), + children: (_) => [ ListTile( leading: const Icon(Icons.notifications_active), title: Text('Status: ${_statusLabel(lesson.code)}'), @@ -82,10 +77,21 @@ class WebuntisLessonSheet { ), ..._optionalTextTiles(lesson), DebugTile(context).jsonData(lesson.toJson()), - ]), + ], ); } + static IconData _iconForCode(String? code) { + switch (code) { + case 'cancelled': + return Icons.event_busy_outlined; + case 'irregular': + return Icons.swap_horiz; + default: + return Icons.school_outlined; + } + } + static Widget _roomTile(BuildContext context, TimetableState state, GetTimetableResponseObject lesson) { final trailing = IconButton( icon: const Icon(Icons.house_outlined), @@ -193,7 +199,7 @@ class WebuntisLessonSheet { static Widget? _textTile(IconData icon, String label, String? value) { final text = (value ?? '').trim(); - if (text.isEmpty) return null; + if (text.isEmpty || text == '-') return null; return ListTile( leading: Icon(icon), title: Text(label), diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart index d185f5c..180a6ac 100644 --- a/lib/view/pages/timetable/widgets/appointment_tile.dart +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -4,6 +4,8 @@ import 'package:syncfusion_flutter_calendar/calendar.dart'; import 'cross_painter.dart'; class AppointmentTile extends StatelessWidget { + static const _radius = BorderRadius.all(Radius.circular(7)); + final Appointment appointment; final bool crossedOut; @@ -14,54 +16,51 @@ class AppointmentTile extends StatelessWidget { final isPast = appointment.endTime.isBefore(DateTime.now()); final color = appointment.color.withAlpha(isPast ? 160 : 255); + final locationLines = (appointment.location ?? '') + .split('\n') + .where((p) => p.isNotEmpty) + .take(2) + .toList(growable: false); + return Padding( padding: const EdgeInsets.all(1), child: Stack( children: [ Positioned.fill( child: Container( - padding: const EdgeInsets.all(4), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), alignment: Alignment.topLeft, decoration: BoxDecoration( shape: BoxShape.rectangle, - borderRadius: const BorderRadius.all(Radius.circular(7)), + borderRadius: _radius, color: color, ), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FittedBox( - fit: BoxFit.fitWidth, - child: Text( - appointment.subject, - style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500), - maxLines: 1, - softWrap: false, - ), - ), - FittedBox( - fit: BoxFit.fitWidth, - child: Text( - appointment.location?.isNotEmpty == true ? appointment.location! : ' ', - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white, fontSize: 10), - ), - ), - ], - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + _ScaledLine( + text: appointment.subject, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + for (final line in locationLines) + _ScaledLine(text: line, fontSize: 10), + ], ), ), ), if (crossedOut) Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - border: Border.all(width: 2, color: Colors.red.withAlpha(200)), - borderRadius: const BorderRadius.all(Radius.circular(7)), + child: ClipRRect( + borderRadius: _radius, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(width: 2, color: Colors.red.withAlpha(200)), + borderRadius: _radius, + ), + child: CustomPaint(painter: CrossPainter()), ), - child: CustomPaint(painter: CrossPainter()), ), ), ], @@ -69,3 +68,35 @@ class AppointmentTile extends StatelessWidget { ); } } + +/// One row of appointment text. The FittedBox scales **only this line** down +/// when the text is wider than the tile, so a long teacher name does not +/// shrink the room number above it. +class _ScaledLine extends StatelessWidget { + final String text; + final double fontSize; + final FontWeight? fontWeight; + + const _ScaledLine({ + required this.text, + required this.fontSize, + this.fontWeight, + }); + + @override + Widget build(BuildContext context) => FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + text, + style: TextStyle( + color: Colors.white, + fontSize: fontSize, + fontWeight: fontWeight, + height: 1.1, + ), + maxLines: 1, + softWrap: false, + ), + ); +} diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 85ce55e..08a1384 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -132,11 +132,23 @@ class CustomWorkWeekCalendarState extends State { Expanded( child: LayoutBuilder( builder: (context, constraints) { - final hours = kCalendarEndHour - kCalendarStartHour; - final fitPxPerHour = constraints.maxHeight / hours; - final pxPerHour = - fitPxPerHour < kCalendarMinPxPerHour ? kCalendarMinPxPerHour : fitPxPerHour; - final gridHeight = pxPerHour * hours; + final periods = widget.schedule.periods; + final lessonCount = + periods.where((p) => !p.isBreak).length; + final breakCount = periods.length - lessonCount; + final available = + constraints.maxHeight - breakCount * kBreakBlockHeight; + final fitLessonH = + lessonCount > 0 ? available / lessonCount : kLessonBlockMinHeight; + final lessonH = fitLessonH < kLessonBlockMinHeight + ? kLessonBlockMinHeight + : fitLessonH; + final layout = _PeriodLayout( + periods: periods, + lessonHeight: lessonH, + breakHeight: kBreakBlockHeight, + ); + final gridHeight = layout.totalHeight; return SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), @@ -163,7 +175,7 @@ class CustomWorkWeekCalendarState extends State { today: _today, nowNotifier: _nowNotifier, rulerWidth: _rulerWidth, - pxPerHour: pxPerHour, + layout: layout, ); }, ), @@ -271,7 +283,7 @@ class _WeekGrid extends StatelessWidget { final DateTime today; final ValueListenable nowNotifier; final double rulerWidth; - final double pxPerHour; + final _PeriodLayout layout; const _WeekGrid({ required this.weekStart, @@ -284,7 +296,7 @@ class _WeekGrid extends StatelessWidget { required this.today, required this.nowNotifier, required this.rulerWidth, - required this.pxPerHour, + required this.layout, }); @override @@ -296,7 +308,7 @@ class _WeekGrid extends StatelessWidget { children: [ _PeriodRuler( schedule: schedule, - pxPerHour: pxPerHour, + layout: layout, width: rulerWidth, ), for (var d = 0; d < 5; d++) @@ -306,7 +318,7 @@ class _WeekGrid extends StatelessWidget { schedule: schedule, appointments: perDay[d], timeRegions: timeRegions, - pxPerHour: pxPerHour, + layout: layout, today: today, nowNotifier: nowNotifier, onAppointmentTap: onAppointmentTap, @@ -321,18 +333,15 @@ class _WeekGrid extends StatelessWidget { class _PeriodRuler extends StatelessWidget { final LessonPeriodSchedule schedule; - final double pxPerHour; + final _PeriodLayout layout; final double width; const _PeriodRuler({ required this.schedule, - required this.pxPerHour, + required this.layout, required this.width, }); - double _y(TimeOfDay t) => - (t.hour + t.minute / 60 - kCalendarStartHour) * pxPerHour; - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -343,8 +352,8 @@ class _PeriodRuler extends StatelessWidget { children: [ for (final period in schedule.periods) Positioned( - top: _y(period.start), - height: (_y(period.end) - _y(period.start)).clamp(0, double.infinity), + top: layout.topOf(period), + height: layout.heightOf(period), left: 0, right: 0, child: _PeriodLabel(period: period, theme: theme), @@ -450,7 +459,7 @@ class _DayColumn extends StatelessWidget { final LessonPeriodSchedule schedule; final List appointments; final List timeRegions; - final double pxPerHour; + final _PeriodLayout layout; final DateTime today; final ValueListenable nowNotifier; final void Function(Appointment) onAppointmentTap; @@ -462,7 +471,7 @@ class _DayColumn extends StatelessWidget { required this.schedule, required this.appointments, required this.timeRegions, - required this.pxPerHour, + required this.layout, required this.today, required this.nowNotifier, required this.onAppointmentTap, @@ -470,66 +479,6 @@ class _DayColumn extends StatelessWidget { required this.onCreateEvent, }); - double _y(int hour, int minute) => - (hour + minute / 60 - kCalendarStartHour) * pxPerHour; - - double _yFromDate(DateTime t) => _y(t.hour, t.minute); - - /// Snaps an appointment edge to the nearest period boundary if the gap is small, - /// so small inter-period transitions (Wechselzeiten) appear as part of the lesson visually. - double _yForAppointmentEdge(DateTime t, {required bool isStart}) { - final tMin = t.hour * 60 + t.minute; - for (final period in schedule.periods) { - if (period.isBreak) continue; - final pStart = period.start.hour * 60 + period.start.minute; - final pEnd = period.end.hour * 60 + period.end.minute; - if (isStart) { - final delta = tMin - pStart; - if (delta >= 0 && delta < 5) { - return _y(period.start.hour, period.start.minute); - } - } else { - final delta = pEnd - tMin; - if (delta >= 0 && delta < 5) { - // Snap to the next non-break period's start when the gap is short - // (Wechselzeit). Skips into a break never extends the lesson. - final idx = schedule.periods.indexOf(period); - if (idx + 1 < schedule.periods.length) { - final next = schedule.periods[idx + 1]; - if (!next.isBreak) { - final nextStart = next.start.hour * 60 + next.start.minute; - if (nextStart - pEnd < 10) { - return _y(next.start.hour, next.start.minute); - } - } - } - } - } - } - return _yFromDate(t); - } - - /// Returns the lesson period (non-break) that the given y-offset falls into, - /// or the next upcoming non-break period if y falls inside a break or before - /// the first period. Returns null if y is past the last period of the day. - LessonPeriod? _periodAt(double y) { - final hoursDecimal = y / pxPerHour + kCalendarStartHour; - final tappedMinutes = (hoursDecimal * 60).round(); - - LessonPeriod? upcoming; - for (final p in schedule.periods) { - if (p.isBreak) continue; - final pStart = p.start.hour * 60 + p.start.minute; - final pEnd = p.end.hour * 60 + p.end.minute; - if (tappedMinutes >= pStart && tappedMinutes < pEnd) return p; - if (tappedMinutes < pStart) { - upcoming = p; - break; - } - } - return upcoming; - } - bool _overlapsExistingAppointment(DateTime start, DateTime end, List dayAppts) { for (final a in dayAppts) { if (a.endTime.isAfter(start) && a.startTime.isBefore(end)) return true; @@ -539,7 +488,7 @@ class _DayColumn extends StatelessWidget { void _handleLongPress(LongPressStartDetails details, List dayAppts) { if (onCreateEvent == null) return; - final period = _periodAt(details.localPosition.dy); + 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); @@ -550,6 +499,56 @@ class _DayColumn extends StatelessWidget { onCreateEvent!(start, end); } + void _showOverflowSheet(BuildContext context, List appointments) { + final sorted = [...appointments] + ..sort((a, b) => a.startTime.compareTo(b.startTime)); + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: sorted.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (_, i) { + final apt = sorted[i]; + return ListTile( + leading: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: apt.color, + borderRadius: BorderRadius.circular(3), + ), + ), + title: Text( + apt.subject, + style: isCrossedOut(apt) + ? const TextStyle(decoration: TextDecoration.lineThrough) + : null, + ), + subtitle: Text(_overflowSubtitle(apt)), + onTap: () { + Navigator.of(sheetContext).pop(); + onAppointmentTap(apt); + }, + ); + }, + ), + ), + ); + } + + static String _overflowSubtitle(Appointment apt) { + final time = '${_formatHm(apt.startTime)}–${_formatHm(apt.endTime)}'; + final loc = apt.location?.replaceAll('\n', ' · '); + return loc != null && loc.isNotEmpty ? '$time · $loc' : time; + } + + static String _formatHm(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -558,6 +557,8 @@ class _DayColumn extends StatelessWidget { final dayRegions = _expandRegionsForDay(timeRegions, date); final isToday = _isSameDay(date, today); + final laidOut = _assignLanes(dayAppointments); + return GestureDetector( behavior: HitTestBehavior.translucent, onLongPressStart: (details) => _handleLongPress(details, dayAppointments), @@ -566,52 +567,66 @@ class _DayColumn extends StatelessWidget { color: isToday ? theme.colorScheme.primary.withAlpha(14) : null, border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)), ), - child: Stack( - clipBehavior: Clip.none, - children: [ - for (final period in schedule.periods) - Positioned( - top: _y(period.start.hour, period.start.minute), - left: 0, - right: 0, - child: Container( - height: 0.5, - color: theme.dividerColor.withAlpha(60), - ), - ), - for (final region in dayRegions) - Positioned( - top: _yFromDate(region.start), - height: - (_yFromDate(region.end) - _yFromDate(region.start)).clamp(0, double.infinity), - left: 0, - right: 0, - child: TimeRegionTile(region: region.region), - ), - for (final apt in dayAppointments) - Positioned( - top: _yForAppointmentEdge(apt.startTime, isStart: true), - height: (_yForAppointmentEdge(apt.endTime, isStart: false) - - _yForAppointmentEdge(apt.startTime, isStart: true)) - .clamp(0, double.infinity), - left: 1, - right: 1, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onAppointmentTap(apt), - child: AppointmentTile( - appointment: apt, - crossedOut: isCrossedOut(apt), + 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), + ), ), - ), - ), - if (isToday) - ValueListenableBuilder( - valueListenable: nowNotifier, - builder: (_, now, child) => - _CurrentTimeMarker(now: now, pxPerHour: pxPerHour, theme: theme), - ), - ], + for (final region in dayRegions) + Positioned( + top: layout.yOfDateTime(region.start), + height: (layout.yOfDateTime(region.end) - + layout.yOfDateTime(region.start)) + .clamp(0, double.infinity), + left: 0, + right: 0, + child: TimeRegionTile(region: region.region), + ), + for (final cell in laidOut) + Positioned( + top: layout.yOfDateTime(cell.startTime), + height: (layout.yOfDateTime(cell.endTime) - + layout.yOfDateTime(cell.startTime)) + .clamp(0, double.infinity), + left: cell.lane * width / cell.laneCount, + width: width / cell.laneCount, + child: switch (cell) { + _LaidOutAppointment(:final appointment) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onAppointmentTap(appointment), + child: AppointmentTile( + appointment: appointment, + crossedOut: isCrossedOut(appointment), + ), + ), + _LaidOutOverflow(:final appointments) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => + _showOverflowSheet(context, appointments), + child: _OverflowTile(count: appointments.length), + ), + }, + ), + if (isToday) + ValueListenableBuilder( + valueListenable: nowNotifier, + builder: (_, now, child) => + _CurrentTimeMarker(now: now, layout: layout, theme: theme), + ), + ], + ); + }, ), ), ); @@ -620,20 +635,27 @@ class _DayColumn extends StatelessWidget { class _CurrentTimeMarker extends StatelessWidget { final DateTime now; - final double pxPerHour; + final _PeriodLayout layout; final ThemeData theme; const _CurrentTimeMarker({ required this.now, - required this.pxPerHour, + required this.layout, required this.theme, }); @override Widget build(BuildContext context) { - final y = (now.hour + now.minute / 60 + now.second / 3600 - kCalendarStartHour) * pxPerHour; - final maxY = (kCalendarEndHour - kCalendarStartHour) * pxPerHour; - if (y < 0 || y > maxY) return const SizedBox.shrink(); + 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), @@ -759,3 +781,278 @@ List> _expandAppointmentsForWeek( } return perDay; } + +/// Maps lesson periods to vertical screen positions. Every non-break period +/// gets the same fixed height (`lessonHeight`), every break gets `breakHeight`. +/// Short transition gaps (Wechselzeiten) between periods are not represented +/// at all — periods are rendered back-to-back, so a 5-minute gap simply +/// disappears visually. +class _PeriodLayout { + final List periods; + final double lessonHeight; + final double breakHeight; + + const _PeriodLayout({ + required this.periods, + required this.lessonHeight, + required this.breakHeight, + }); + + double _h(LessonPeriod p) => p.isBreak ? breakHeight : lessonHeight; + + double get totalHeight => + periods.fold(0, (sum, p) => sum + _h(p)); + + double topOf(LessonPeriod period) { + var y = 0.0; + for (final p in periods) { + if (identical(p, period)) return y; + y += _h(p); + } + return y; + } + + double heightOf(LessonPeriod period) => _h(period); + + /// Vertical offset for a given time of day. Times inside a period are mapped + /// proportionally; times that fall into a transition gap are clipped to the + /// end of the preceding period. Times before the first / after the last + /// period clip to 0 / [totalHeight]. + double yOf(TimeOfDay t) { + final tMin = t.hour * 60 + t.minute; + var y = 0.0; + for (final p in periods) { + final pStart = p.start.hour * 60 + p.start.minute; + final pEnd = p.end.hour * 60 + p.end.minute; + final h = _h(p); + if (tMin < pStart) return y; + if (tMin <= pEnd) { + final span = pEnd - pStart; + final ratio = span > 0 ? (tMin - pStart) / span : 0.0; + return y + ratio * h; + } + y += h; + } + return y; + } + + double yOfDateTime(DateTime t) => + yOf(TimeOfDay(hour: t.hour, minute: t.minute)); + + /// 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; + } +} + +/// Maximum number of cells shown side by side in a single time slot. When a +/// cluster needs more lanes than this, the first appointment (by start time) +/// keeps lane 0 and the rest are collapsed into a single "+N" overflow cell +/// in lane 1. +const int _kMaxVisibleCells = 2; + +class _OverflowTile extends StatelessWidget { + final int count; + const _OverflowTile({required this.count}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + const radius = BorderRadius.all(Radius.circular(7)); + + return Padding( + padding: const EdgeInsets.all(1), + child: Stack( + children: [ + // Card peeking out at the bottom — visual hint that more cards lie + // underneath the visible one. + Positioned( + top: 4, + left: 2, + right: 2, + bottom: 0, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: radius, + color: scheme.secondaryContainer.withAlpha(120), + ), + ), + ), + // Front card with the "+N" indicator. + Positioned( + top: 0, + left: 0, + right: 0, + bottom: 4, + child: Container( + decoration: BoxDecoration( + borderRadius: radius, + color: scheme.secondaryContainer, + ), + alignment: Alignment.center, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.unfold_more_rounded, + size: 18, + color: scheme.onSecondaryContainer, + ), + Text( + '+$count', + style: theme.textTheme.titleSmall?.copyWith( + color: scheme.onSecondaryContainer, + fontWeight: FontWeight.w700, + height: 1.0, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +/// One cell rendered in the day column — either a regular appointment or an +/// overflow placeholder representing several hidden appointments. +sealed class _LaidOutCell { + int get lane; + int get laneCount; + DateTime get startTime; + DateTime get endTime; +} + +class _LaidOutAppointment extends _LaidOutCell { + final Appointment appointment; + @override + final int lane; + @override + final int laneCount; + _LaidOutAppointment(this.appointment, this.lane, this.laneCount); + + @override + DateTime get startTime => appointment.startTime; + @override + DateTime get endTime => appointment.endTime; +} + +class _LaidOutOverflow extends _LaidOutCell { + final List appointments; + @override + final int lane; + @override + final int laneCount; + @override + final DateTime startTime; + @override + final DateTime endTime; + _LaidOutOverflow(this.appointments, this.lane, this.laneCount, this.startTime, this.endTime); +} + +/// Assigns each appointment a lane index using a greedy sweep, then collapses +/// clusters that exceed [_kMaxVisibleCells] into 1 visible appointment + 1 +/// overflow cell side by side. +/// +/// Greedy sweep: +/// 1. Sort by `startTime` ascending, `endTime` descending on ties. +/// 2. Walk the list, placing each appointment in the lowest-index lane that +/// is free at its `startTime`. When no lane is free, open a new one. +/// 3. A cluster ends as soon as every active lane's end is at or before the +/// next appointment's start. +List<_LaidOutCell> _assignLanes(List appts) { + if (appts.isEmpty) return const <_LaidOutCell>[]; + + final sorted = [...appts]..sort((a, b) { + final c = a.startTime.compareTo(b.startTime); + if (c != 0) return c; + return b.endTime.compareTo(a.endTime); + }); + + // Phase 1: greedy lane assignment, grouped by cluster. + final clusters = >[]; + var current = <({Appointment apt, int lane})>[]; + var laneEnds = []; + + for (final apt in sorted) { + final allFree = + laneEnds.isNotEmpty && laneEnds.every((end) => !end.isAfter(apt.startTime)); + if (allFree) { + clusters.add(current); + current = <({Appointment apt, int lane})>[]; + laneEnds = []; + } + + var laneIdx = -1; + for (var i = 0; i < laneEnds.length; i++) { + if (!laneEnds[i].isAfter(apt.startTime)) { + laneIdx = i; + break; + } + } + if (laneIdx == -1) { + laneIdx = laneEnds.length; + laneEnds.add(apt.endTime); + } else { + laneEnds[laneIdx] = apt.endTime; + } + + current.add((apt: apt, lane: laneIdx)); + } + if (current.isNotEmpty) clusters.add(current); + + // Phase 2: emit cells per cluster, collapsing if too wide. + final result = <_LaidOutCell>[]; + for (final cluster in clusters) { + final laneCount = + cluster.fold(0, (m, e) => e.lane + 1 > m ? e.lane + 1 : m); + + if (laneCount <= _kMaxVisibleCells) { + for (final entry in cluster) { + result.add(_LaidOutAppointment(entry.apt, entry.lane, laneCount)); + } + } else { + // 3+ parallel appointments: keep the earliest, collapse the rest. + final byStart = [...cluster.map((e) => e.apt)] + ..sort((a, b) => a.startTime.compareTo(b.startTime)); + result.add(_LaidOutAppointment(byStart[0], 0, _kMaxVisibleCells)); + + final overflow = byStart.sublist(1); + 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, _kMaxVisibleCells - 1, _kMaxVisibleCells, earliest, latest)); + } + } + return result; +} diff --git a/lib/widget/async_action_button.dart b/lib/widget/async_action_button.dart index 4a5e941..1044e82 100644 --- a/lib/widget/async_action_button.dart +++ b/lib/widget/async_action_button.dart @@ -15,7 +15,9 @@ Future runWithErrorDialog( } catch (e) { if (!context.mounted) return false; final message = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e); - InfoDialog.show(context, message); + 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; } } diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index 06ce484..4f4e1a2 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -123,7 +123,7 @@ class _FileViewerState extends State { if (saved != null) InfoDialog.show(context, 'Datei gespeichert.'); } on Object catch (e) { if (!context.mounted) return; - InfoDialog.show(context, 'Speichern fehlgeschlagen: $e'); + InfoDialog.show(context, 'Speichern fehlgeschlagen: $e', copyable: true, title: 'Fehler'); } break; } diff --git a/lib/widget/info_dialog.dart b/lib/widget/info_dialog.dart index 001b2ed..59be35c 100644 --- a/lib/widget/info_dialog.dart +++ b/lib/widget/info_dialog.dart @@ -1,10 +1,51 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; class InfoDialog { - static void show(BuildContext context, String info) { - showDialog(context: context, builder: (context) => AlertDialog( - content: Text(info), - contentPadding: const EdgeInsets.all(20), - )); + /// Shows a single-text dialog. When [copyable] is true (default for error + /// details surfaces), the dialog body is selectable and a "Kopieren" action + /// places it on the clipboard with a SnackBar confirmation. + static void show( + BuildContext context, + String info, { + bool copyable = false, + String? title, + }) { + showDialog( + context: context, + builder: (dialogContext) { + final theme = Theme.of(dialogContext); + return AlertDialog( + title: title != null ? Text(title) : null, + content: SingleChildScrollView( + child: copyable + ? SelectableText(info, style: theme.textTheme.bodyMedium) + : Text(info, style: theme.textTheme.bodyMedium), + ), + contentPadding: const EdgeInsets.fromLTRB(20, 20, 20, 12), + 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), + ), + ); + }, + icon: const Icon(Icons.copy_outlined, size: 18), + label: const Text('Kopieren'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Schließen'), + ), + ], + ); + }, + ); } } diff --git a/pubspec.yaml b/pubspec.yaml index 1798def..92eb3e2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,8 +37,6 @@ dependencies: intl: ^0.20.2 flutter_linkify: ^6.0.0 flutter_local_notifications: ^21.0.0 - flutter_login: ^6.0.0 - flutter_native_splash: ^2.4.4 flutter_split_view: ^0.1.2 flutter_svg: ^2.0.10 freezed_annotation: ^3.1.0 @@ -74,6 +72,7 @@ dependencies: dev_dependencies: flutter_launcher_icons: ^0.14.3 + flutter_native_splash: ^2.4.4 build_runner: ^2.10.5 freezed: ^3.2.4