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

This commit is contained in:
2026-05-06 20:42:09 +02:00
parent 50d2941e52
commit 86d12884fc
32 changed files with 1038 additions and 377 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+1 -1
View File
@@ -54,7 +54,7 @@ flutter_native_splash:
# 640 pixels in diameter. # 640 pixels in diameter.
# App icon without an icon background: This should be 1152×1152 pixels, and fit within a circle # App icon without an icon background: This should be 1152×1152 pixels, and fit within a circle
# 768 pixels in diameter. # 768 pixels in diameter.
image: assets/logo/icon-android12.png image: assets/logo/icon/icon-android12.png
# Splash screen background color. # Splash screen background color.
color: "#993333" color: "#993333"
+22 -8
View File
@@ -159,6 +159,15 @@ class _MainState extends State<Main> {
themeMode: settings.appTheme, themeMode: settings.appTheme,
theme: LightAppTheme.theme, theme: LightAppTheme.theme,
darkTheme: DarkAppTheme.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( home: LoaderOverlay(
child: Breaker( child: Breaker(
breaker: BreakerArea.global, breaker: BreakerArea.global,
@@ -203,14 +212,16 @@ class _MainState extends State<Main> {
case AccountStatus.loggedOut: case AccountStatus.loggedOut:
return const Login(); return const Login();
case AccountStatus.undefined: case AccountStatus.undefined:
return const Scaffold( return Scaffold(
body: Center( backgroundColor: LightAppTheme.marianumRed,
body: const Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
AppProgressIndicator.large(), AppProgressIndicator.large(color: Colors.white),
SizedBox(height: 16), SizedBox(height: 16),
Text('Konto wird geladen…'), Text('Konto wird geladen…',
style: TextStyle(color: Colors.white)),
], ],
), ),
), ),
@@ -234,16 +245,19 @@ Future<void> _wipeUserState({
required BreakerBloc breakerBloc, required BreakerBloc breakerBloc,
}) async { }) async {
try { try {
final prefs = await SharedPreferences.getInstance(); // Reset user-data blocs whose tree is no longer mounted after the
await prefs.clear(); // home swap. We do NOT touch SettingsCubit here — its outer BlocBuilder
PaintingBinding.instance.imageCache.clear(); // wraps MaterialApp, so emit'ing a fresh state would tear down the
await settingsCubit.reset(); // freshly-mounted Login tree and leave the user with a blank screen
// (the MaterialApp.builder backdrop) until the next interaction.
await Future.wait([ await Future.wait([
timetableBloc.reset(), timetableBloc.reset(),
chatListBloc.reset(), chatListBloc.reset(),
chatBloc.reset(), chatBloc.reset(),
breakerBloc.reset(), breakerBloc.reset(),
]); ]);
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
await HydratedBloc.storage.clear(); await HydratedBloc.storage.clear();
await const CacheView().clear(); await const CacheView().clear();
} catch (e, s) { } catch (e, s) {
+1 -9
View File
@@ -3,14 +3,9 @@ import 'dart:convert';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:firebase_messaging/firebase_messaging.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:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.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 { class AccountData {
static const _usernameField = 'username'; static const _usernameField = 'username';
static const _passwordField = 'password'; static const _passwordField = 'password';
@@ -54,11 +49,8 @@ class AccountData {
if (!_populated.isCompleted) _populated.complete(); if (!_populated.isCompleted) _populated.complete();
} }
Future<void> removeData({BuildContext? context}) async { Future<void> removeData() async {
_populated = Completer(); _populated = Completer();
if (context != null) {
context.read<AccountBloc>().setStatus(AccountStatus.loggedOut);
}
_username = null; _username = null;
_password = null; _password = null;
await _secureStorage.delete(key: _usernameField); await _secureStorage.delete(key: _usernameField);
@@ -55,7 +55,7 @@ class LoadableStateErrorBar extends StatelessWidget {
if (technicalDetails != null && technicalDetails!.isNotEmpty) technicalDetails!, if (technicalDetails != null && technicalDetails!.isNotEmpty) technicalDetails!,
].join('\n\n'); ].join('\n\n');
if (body.isEmpty) return; if (body.isEmpty) return;
InfoDialog.show(context, body); InfoDialog.show(context, body, copyable: true, title: 'Fehlerdetails');
}, },
child: Container( child: Container(
height: 20, height: 20,
@@ -58,7 +58,7 @@ class LoadableStateErrorScreen extends StatelessWidget {
if (technicalDetails != null) ...[ if (technicalDetails != null) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
TextButton( TextButton(
onPressed: () => InfoDialog.show(context, technicalDetails!), onPressed: () => InfoDialog.show(context, technicalDetails!, copyable: true, title: 'Fehlerdetails'),
child: const Text('Details anzeigen'), child: const Text('Details anzeigen'),
), ),
], ],
+340 -72
View File
@@ -1,15 +1,17 @@
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '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.dart';
import '../../api/marianumcloud/talk/room/get_room_params.dart'; import '../../api/marianumcloud/talk/room/get_room_params.dart';
import '../../model/account_data.dart'; import '../../model/account_data.dart';
import '../../state/app/modules/account/bloc/account_bloc.dart'; import '../../state/app/modules/account/bloc/account_bloc.dart';
import '../../state/app/modules/account/bloc/account_state.dart'; import '../../state/app/modules/account/bloc/account_state.dart';
import '../../theming/light_app_theme.dart';
class Login extends StatefulWidget { class Login extends StatefulWidget {
const Login({super.key}); const Login({super.key});
@@ -19,88 +21,354 @@ class Login extends StatefulWidget {
} }
class _LoginState extends State<Login> { class _LoginState extends State<Login> {
bool displayDisclaimerText = true; static const _marianumRed = LightAppTheme.marianumRed;
String? _checkInput(String? value) => (value ?? '').isEmpty ? 'Eingabe erforderlich' : null; final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _passwordFocus = FocusNode();
Future<String?> _login(LoginData data) async { bool _loading = false;
await AccountData().removeData(); String? _errorMessage;
String? _errorDetails;
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<String> _resetPassword(String name) => Future.delayed(Duration.zero).then((_) => 'Diese Funktion steht nicht zur Verfügung!');
@override @override
Widget build(BuildContext context) => FlutterLogin( void didChangeDependencies() {
logo: Image.asset('assets/logo/icon.png').image, super.didChangeDependencies();
precacheImage(const AssetImage('assets/logo/icon.png'), context);
}
userValidator: _checkInput, @override
passwordValidator: _checkInput, void dispose() {
onSubmitAnimationCompleted: () => context.read<AccountBloc>().setStatus(AccountStatus.loggedIn), _usernameController.dispose();
_passwordController.dispose();
_passwordFocus.dispose();
super.dispose();
}
onLogin: _login, String? _required(String? value) => (value ?? '').trim().isEmpty ? 'Eingabe erforderlich' : null;
onSignup: null,
onRecoverPassword: _resetPassword, Future<void> _submit() async {
hideForgotPasswordButton: true, if (_loading) return;
if (!(_formKey.currentState?.validate() ?? false)) return;
theme: LoginTheme( setState(() {
primaryColor: Theme.of(context).primaryColor, _loading = true;
accentColor: Colors.white, _errorMessage = null;
errorColor: Theme.of(context).primaryColor, _errorDetails = null;
footerBottomPadding: 10, });
textFieldStyle: const TextStyle(
fontWeight: FontWeight.w500
),
cardTheme: const CardTheme(
elevation: 10,
),
),
messages: LoginMessages( final username = _usernameController.text.trim().toLowerCase();
loginButton: 'Anmelden', final password = _passwordController.text;
userHint: 'Nutzername',
passwordHint: 'Passwort',
),
disableCustomPageTransformer: true, try {
await AccountData().removeData();
await AccountData().setData(username, password);
await GetRoom(GetRoomParams(includeStatus: false)).run();
if (!mounted) return;
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);
}
}
headerWidget: Padding( void _showErrorDetails() {
padding: const EdgeInsets.only(bottom: 30), final details = _errorDetails;
child: Center( if (details == null) return;
child: Visibility( showDialog<void>(
visible: displayDisclaimerText, context: context,
child: const Text( builder: (dialogContext) {
'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!', final theme = Theme.of(dialogContext);
textAlign: TextAlign.center, 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 {
footer: 'Marianum Fulda - Die persönliche Schule', await Clipboard.setData(ClipboardData(text: details));
title: 'Marianum Fulda', if (!dialogContext.mounted) return;
ScaffoldMessenger.of(dialogContext).showSnackBar(
userType: LoginUserType.name, 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,
),
),
),
],
),
),
),
),
),
),
);
}
} }
+1 -1
View File
@@ -127,7 +127,7 @@ class _OverhangState extends State<Overhang> {
}, },
onError: (error) { onError: (error) {
if (!context.mounted) return; if (!context.mounted) return;
InfoDialog.show(context, error.toString()); InfoDialog.show(context, error.toString(), copyable: true, title: 'Fehler');
}, },
); );
}, },
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../model/account_data.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/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/confirm_dialog.dart';
@@ -15,21 +18,23 @@ class AccountSection extends StatelessWidget {
onTap: () => _showLogoutDialog(context), onTap: () => _showLogoutDialog(context),
); );
void _showLogoutDialog(BuildContext context) { Future<void> _showLogoutDialog(BuildContext context) async {
showDialog( // 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<bool>(
context: context, context: context,
builder: (dialogContext) => ConfirmDialog( builder: (dialogContext) => ConfirmDialog(
title: 'Abmelden?', title: 'Abmelden?',
content: 'Möchtest du dich wirklich abmelden?', content: 'Möchtest du dich wirklich abmelden?',
confirmButton: 'Abmelden', confirmButton: 'Abmelden',
// Cleanup of caches, hydrated bloc storage and bloc in-memory state is onConfirmAsync: AccountData().removeData,
// 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),
), ),
); );
if (confirmed != true || !context.mounted) return;
context.read<AccountBloc>().setStatus(AccountStatus.loggedOut);
} }
} }
@@ -115,7 +115,7 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
Navigator.of(context).pop(); Navigator.of(context).pop();
}).catchError((Object error) { }).catchError((Object error) {
if (!mounted) return; if (!mounted) return;
InfoDialog.show(context, error.toString()); InfoDialog.show(context, error.toString(), copyable: true, title: 'Fehler');
}); });
} }
@@ -6,3 +6,11 @@ const double kCalendarViewHeaderHeight = 60;
/// Minimum pixels per hour. Below this, the grid scrolls vertically rather /// Minimum pixels per hour. Below this, the grid scrolls vertically rather
/// than compressing further. /// than compressing further.
const double kCalendarMinPxPerHour = 56; 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;
@@ -72,8 +72,8 @@ class TimetableAppointmentFactory {
id: CustomAppointment(event), id: CustomAppointment(event),
startTime: event.startDate, startTime: event.startDate,
endTime: event.endDate, endTime: event.endDate,
location: event.description, location: _collapseWhitespace(event.description),
subject: event.title, subject: _collapseWhitespace(event.title) ?? event.title,
recurrenceRule: event.rrule, recurrenceRule: event.rrule,
color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name), color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name),
startTimeZone: '', startTimeZone: '',
@@ -83,19 +83,38 @@ class TimetableAppointmentFactory {
String _subjectName(GetTimetableResponseObject lesson) { String _subjectName(GetTimetableResponseObject lesson) {
final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id); final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id);
if (subject == null) return 'Unbekannt'; if (subject == null) return 'Unbekannt';
return switch (settings.timetableNameMode) { final name = switch (settings.timetableNameMode) {
TimetableNameMode.name => subject.name, TimetableNameMode.name => subject.name,
TimetableNameMode.longName => subject.longName, TimetableNameMode.longName => subject.longName,
TimetableNameMode.alternateName => subject.alternateName, TimetableNameMode.alternateName => subject.alternateName,
}; };
return _collapseWhitespace(name) ?? 'Unbekannt';
} }
String _locationLabel(GetTimetableResponseObject lesson) { String _locationLabel(GetTimetableResponseObject lesson) {
final roomName = rooms.result.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)?.name ?? 'Unbekannt'; final roomName = _collapseWhitespace(
final teacherName = lesson.te.firstOrNull?.longname ?? 'Unbekannt'; rooms.result.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)?.name) ??
'Unbekannt';
final teacherName = _collapseWhitespace(lesson.te.firstOrNull?.longname) ?? 'Unbekannt';
return '$roomName\n$teacherName'; 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. // Pure: returns a new list, does not mutate input.
static List<GetTimetableResponseObject> _mergeAdjacentLessons( static List<GetTimetableResponseObject> _mergeAdjacentLessons(
List<GetTimetableResponseObject> input, { List<GetTimetableResponseObject> input, {
@@ -1,51 +1,32 @@
import 'package:flutter/material.dart'; 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( void showAppointmentBottomSheet(
BuildContext context, { BuildContext context, {
required Widget Function(BuildContext context) header, required Widget header,
required SliverChildListDelegate Function(BuildContext context) body, required List<Widget> Function(BuildContext sheetContext) children,
}) { }) {
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
showDragHandle: true,
useSafeArea: true, useSafeArea: true,
backgroundColor: Theme.of(context).colorScheme.surface, builder: (sheetContext) => SafeArea(
builder: (sheetContext) => DraggableScrollableSheet( child: SingleChildScrollView(
expand: false, padding: const EdgeInsets.only(bottom: 16),
initialChildSize: 0.4, child: Column(
minChildSize: 0.2, mainAxisSize: MainAxisSize.min,
maxChildSize: 0.7, crossAxisAlignment: CrossAxisAlignment.stretch,
snap: true, children: [
snapSizes: const [0.4], header,
builder: (_, scrollController) => CustomScrollView( const Divider(height: 1),
controller: scrollController, ...children(sheetContext),
slivers: [ ],
SliverPersistentHeader( ),
pinned: true,
delegate: _StickyHeader(child: header(sheetContext)),
),
SliverList(delegate: body(sheetContext)),
],
), ),
), ),
); );
} }
class _StickyHeader extends SliverPersistentHeaderDelegate {
_StickyHeader({required this.child});
final Widget child;
@override
double get minExtent => 100;
@override
double get maxExtent => 100;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => Material(
color: Theme.of(context).colorScheme.surface,
child: SizedBox.expand(child: child),
);
@override
bool shouldRebuild(covariant _StickyHeader oldDelegate) => oldDelegate.child != child;
}
@@ -11,51 +11,49 @@ import 'delete_custom_event.dart';
class CustomEventSheet { class CustomEventSheet {
static void show(BuildContext context, CustomTimetableEvent event) { 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( showAppointmentBottomSheet(
context, context,
header: (_) => Center( header: ListTile(
child: Column( leading: const Icon(Icons.event_outlined, size: 32),
mainAxisSize: MainAxisSize.min, title: Text(event.title, style: const TextStyle(fontWeight: FontWeight.bold)),
children: [ subtitle: Text(timeRange),
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),
),
],
),
), ),
body: (sheetCtx) => SliverChildListDelegate([ children: (sheetCtx) => [
const Divider(), Padding(
Center( padding: const EdgeInsets.symmetric(vertical: 4),
child: Wrap( child: Center(
children: [ child: Wrap(
TextButton.icon( children: [
onPressed: () { TextButton.icon(
Navigator.of(sheetCtx).pop(); onPressed: () {
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(); Navigator.of(sheetCtx).pop();
}); showDialog(
}, context: context,
label: const Text('Löschen'), builder: (_) => CustomEventEditDialog(existingEvent: event),
icon: const Icon(Icons.delete_outline), );
), },
], 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( ListTile(
leading: const Icon(Icons.info_outline), leading: const Icon(Icons.info_outline),
title: Text(event.description.isEmpty ? 'Keine Beschreibung' : event.description), title: Text(event.description.isEmpty ? 'Keine Beschreibung' : event.description),
@@ -82,7 +80,7 @@ class CustomEventSheet {
), ),
), ),
DebugTile(sheetCtx).jsonData(event.toJson()), DebugTile(sheetCtx).jsonData(event.toJson()),
]), ],
); );
} }
} }
@@ -23,29 +23,24 @@ class WebuntisLessonSheet {
final headerTitle = _firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']); final headerTitle = _firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']);
final headerLongName = headerSubject.longName.isNotEmpty && headerSubject.longName != headerTitle ? headerSubject.longName : ''; final headerLongName = headerSubject.longName.isNotEmpty && headerSubject.longName != headerTitle ? headerSubject.longName : '';
final timeRange =
'${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - '
'${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}';
showAppointmentBottomSheet( showAppointmentBottomSheet(
context, context,
header: (_) => Center( header: ListTile(
child: Column( leading: Icon(_iconForCode(lesson.code), size: 32),
mainAxisSize: MainAxisSize.min, title: Text(
children: [ '${_codePrefix(lesson.code)}$headerTitle',
Text( style: const TextStyle(fontWeight: FontWeight.bold),
'${_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),
),
],
), ),
subtitle: Text(headerLongName.isNotEmpty
? '$timeRange\n$headerLongName'
: timeRange),
isThreeLine: headerLongName.isNotEmpty,
), ),
body: (_) => SliverChildListDelegate(<Widget>[ children: (_) => <Widget>[
const Divider(),
ListTile( ListTile(
leading: const Icon(Icons.notifications_active), leading: const Icon(Icons.notifications_active),
title: Text('Status: ${_statusLabel(lesson.code)}'), title: Text('Status: ${_statusLabel(lesson.code)}'),
@@ -82,10 +77,21 @@ class WebuntisLessonSheet {
), ),
..._optionalTextTiles(lesson), ..._optionalTextTiles(lesson),
DebugTile(context).jsonData(lesson.toJson()), 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) { static Widget _roomTile(BuildContext context, TimetableState state, GetTimetableResponseObject lesson) {
final trailing = IconButton( final trailing = IconButton(
icon: const Icon(Icons.house_outlined), icon: const Icon(Icons.house_outlined),
@@ -193,7 +199,7 @@ class WebuntisLessonSheet {
static Widget? _textTile(IconData icon, String label, String? value) { static Widget? _textTile(IconData icon, String label, String? value) {
final text = (value ?? '').trim(); final text = (value ?? '').trim();
if (text.isEmpty) return null; if (text.isEmpty || text == '-') return null;
return ListTile( return ListTile(
leading: Icon(icon), leading: Icon(icon),
title: Text(label), title: Text(label),
@@ -4,6 +4,8 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
import 'cross_painter.dart'; import 'cross_painter.dart';
class AppointmentTile extends StatelessWidget { class AppointmentTile extends StatelessWidget {
static const _radius = BorderRadius.all(Radius.circular(7));
final Appointment appointment; final Appointment appointment;
final bool crossedOut; final bool crossedOut;
@@ -14,54 +16,51 @@ class AppointmentTile extends StatelessWidget {
final isPast = appointment.endTime.isBefore(DateTime.now()); final isPast = appointment.endTime.isBefore(DateTime.now());
final color = appointment.color.withAlpha(isPast ? 160 : 255); 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( return Padding(
padding: const EdgeInsets.all(1), padding: const EdgeInsets.all(1),
child: Stack( child: Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
child: Container( child: Container(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.rectangle, shape: BoxShape.rectangle,
borderRadius: const BorderRadius.all(Radius.circular(7)), borderRadius: _radius,
color: color, color: color,
), ),
child: SingleChildScrollView( child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [ children: [
FittedBox( _ScaledLine(
fit: BoxFit.fitWidth, text: appointment.subject,
child: Text( fontSize: 15,
appointment.subject, fontWeight: FontWeight.w500,
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500), ),
maxLines: 1, for (final line in locationLines)
softWrap: false, _ScaledLine(text: line, fontSize: 10),
), ],
),
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),
),
),
],
),
), ),
), ),
), ),
if (crossedOut) if (crossedOut)
Positioned.fill( Positioned.fill(
child: DecoratedBox( child: ClipRRect(
decoration: BoxDecoration( borderRadius: _radius,
border: Border.all(width: 2, color: Colors.red.withAlpha(200)), child: DecoratedBox(
borderRadius: const BorderRadius.all(Radius.circular(7)), 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,
),
);
}
@@ -132,11 +132,23 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
Expanded( Expanded(
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final hours = kCalendarEndHour - kCalendarStartHour; final periods = widget.schedule.periods;
final fitPxPerHour = constraints.maxHeight / hours; final lessonCount =
final pxPerHour = periods.where((p) => !p.isBreak).length;
fitPxPerHour < kCalendarMinPxPerHour ? kCalendarMinPxPerHour : fitPxPerHour; final breakCount = periods.length - lessonCount;
final gridHeight = pxPerHour * hours; 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( return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
@@ -163,7 +175,7 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
today: _today, today: _today,
nowNotifier: _nowNotifier, nowNotifier: _nowNotifier,
rulerWidth: _rulerWidth, rulerWidth: _rulerWidth,
pxPerHour: pxPerHour, layout: layout,
); );
}, },
), ),
@@ -271,7 +283,7 @@ class _WeekGrid extends StatelessWidget {
final DateTime today; final DateTime today;
final ValueListenable<DateTime> nowNotifier; final ValueListenable<DateTime> nowNotifier;
final double rulerWidth; final double rulerWidth;
final double pxPerHour; final _PeriodLayout layout;
const _WeekGrid({ const _WeekGrid({
required this.weekStart, required this.weekStart,
@@ -284,7 +296,7 @@ class _WeekGrid extends StatelessWidget {
required this.today, required this.today,
required this.nowNotifier, required this.nowNotifier,
required this.rulerWidth, required this.rulerWidth,
required this.pxPerHour, required this.layout,
}); });
@override @override
@@ -296,7 +308,7 @@ class _WeekGrid extends StatelessWidget {
children: [ children: [
_PeriodRuler( _PeriodRuler(
schedule: schedule, schedule: schedule,
pxPerHour: pxPerHour, layout: layout,
width: rulerWidth, width: rulerWidth,
), ),
for (var d = 0; d < 5; d++) for (var d = 0; d < 5; d++)
@@ -306,7 +318,7 @@ class _WeekGrid extends StatelessWidget {
schedule: schedule, schedule: schedule,
appointments: perDay[d], appointments: perDay[d],
timeRegions: timeRegions, timeRegions: timeRegions,
pxPerHour: pxPerHour, layout: layout,
today: today, today: today,
nowNotifier: nowNotifier, nowNotifier: nowNotifier,
onAppointmentTap: onAppointmentTap, onAppointmentTap: onAppointmentTap,
@@ -321,18 +333,15 @@ class _WeekGrid extends StatelessWidget {
class _PeriodRuler extends StatelessWidget { class _PeriodRuler extends StatelessWidget {
final LessonPeriodSchedule schedule; final LessonPeriodSchedule schedule;
final double pxPerHour; final _PeriodLayout layout;
final double width; final double width;
const _PeriodRuler({ const _PeriodRuler({
required this.schedule, required this.schedule,
required this.pxPerHour, required this.layout,
required this.width, required this.width,
}); });
double _y(TimeOfDay t) =>
(t.hour + t.minute / 60 - kCalendarStartHour) * pxPerHour;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -343,8 +352,8 @@ class _PeriodRuler extends StatelessWidget {
children: [ children: [
for (final period in schedule.periods) for (final period in schedule.periods)
Positioned( Positioned(
top: _y(period.start), top: layout.topOf(period),
height: (_y(period.end) - _y(period.start)).clamp(0, double.infinity), height: layout.heightOf(period),
left: 0, left: 0,
right: 0, right: 0,
child: _PeriodLabel(period: period, theme: theme), child: _PeriodLabel(period: period, theme: theme),
@@ -450,7 +459,7 @@ class _DayColumn extends StatelessWidget {
final LessonPeriodSchedule schedule; final LessonPeriodSchedule schedule;
final List<Appointment> appointments; final List<Appointment> appointments;
final List<TimeRegion> timeRegions; final List<TimeRegion> timeRegions;
final double pxPerHour; final _PeriodLayout layout;
final DateTime today; final DateTime today;
final ValueListenable<DateTime> nowNotifier; final ValueListenable<DateTime> nowNotifier;
final void Function(Appointment) onAppointmentTap; final void Function(Appointment) onAppointmentTap;
@@ -462,7 +471,7 @@ class _DayColumn extends StatelessWidget {
required this.schedule, required this.schedule,
required this.appointments, required this.appointments,
required this.timeRegions, required this.timeRegions,
required this.pxPerHour, required this.layout,
required this.today, required this.today,
required this.nowNotifier, required this.nowNotifier,
required this.onAppointmentTap, required this.onAppointmentTap,
@@ -470,66 +479,6 @@ class _DayColumn extends StatelessWidget {
required this.onCreateEvent, 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<Appointment> dayAppts) { bool _overlapsExistingAppointment(DateTime start, DateTime end, List<Appointment> dayAppts) {
for (final a in dayAppts) { for (final a in dayAppts) {
if (a.endTime.isAfter(start) && a.startTime.isBefore(end)) return true; if (a.endTime.isAfter(start) && a.startTime.isBefore(end)) return true;
@@ -539,7 +488,7 @@ class _DayColumn extends StatelessWidget {
void _handleLongPress(LongPressStartDetails details, List<Appointment> dayAppts) { void _handleLongPress(LongPressStartDetails details, List<Appointment> dayAppts) {
if (onCreateEvent == null) return; if (onCreateEvent == null) return;
final period = _periodAt(details.localPosition.dy); final period = layout.periodAtY(details.localPosition.dy);
if (period == null) return; if (period == null) return;
final start = DateTime(date.year, date.month, date.day, period.start.hour, period.start.minute); 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); onCreateEvent!(start, end);
} }
void _showOverflowSheet(BuildContext context, List<Appointment> appointments) {
final sorted = [...appointments]
..sort((a, b) => a.startTime.compareTo(b.startTime));
showModalBottomSheet<void>(
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -558,6 +557,8 @@ class _DayColumn extends StatelessWidget {
final dayRegions = _expandRegionsForDay(timeRegions, date); final dayRegions = _expandRegionsForDay(timeRegions, date);
final isToday = _isSameDay(date, today); final isToday = _isSameDay(date, today);
final laidOut = _assignLanes(dayAppointments);
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onLongPressStart: (details) => _handleLongPress(details, dayAppointments), onLongPressStart: (details) => _handleLongPress(details, dayAppointments),
@@ -566,52 +567,66 @@ class _DayColumn extends StatelessWidget {
color: isToday ? theme.colorScheme.primary.withAlpha(14) : null, color: isToday ? theme.colorScheme.primary.withAlpha(14) : null,
border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)), border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)),
), ),
child: Stack( child: LayoutBuilder(
clipBehavior: Clip.none, builder: (context, constraints) {
children: [ final width = constraints.maxWidth;
for (final period in schedule.periods) return Stack(
Positioned( clipBehavior: Clip.none,
top: _y(period.start.hour, period.start.minute), children: [
left: 0, for (final period in schedule.periods)
right: 0, Positioned(
child: Container( top: layout.topOf(period),
height: 0.5, left: 0,
color: theme.dividerColor.withAlpha(60), right: 0,
), child: Container(
), height: 0.5,
for (final region in dayRegions) color: theme.dividerColor.withAlpha(60),
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),
), ),
), for (final region in dayRegions)
), Positioned(
if (isToday) top: layout.yOfDateTime(region.start),
ValueListenableBuilder<DateTime>( height: (layout.yOfDateTime(region.end) -
valueListenable: nowNotifier, layout.yOfDateTime(region.start))
builder: (_, now, child) => .clamp(0, double.infinity),
_CurrentTimeMarker(now: now, pxPerHour: pxPerHour, theme: theme), 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),
),
],
);
},
), ),
), ),
); );
@@ -620,20 +635,27 @@ class _DayColumn extends StatelessWidget {
class _CurrentTimeMarker extends StatelessWidget { class _CurrentTimeMarker extends StatelessWidget {
final DateTime now; final DateTime now;
final double pxPerHour; final _PeriodLayout layout;
final ThemeData theme; final ThemeData theme;
const _CurrentTimeMarker({ const _CurrentTimeMarker({
required this.now, required this.now,
required this.pxPerHour, required this.layout,
required this.theme, required this.theme,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final y = (now.hour + now.minute / 60 + now.second / 3600 - kCalendarStartHour) * pxPerHour; final periods = layout.periods;
final maxY = (kCalendarEndHour - kCalendarStartHour) * pxPerHour; if (periods.isEmpty) return const SizedBox.shrink();
if (y < 0 || y > maxY) 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( return AnimatedPositioned(
duration: const Duration(milliseconds: 400), duration: const Duration(milliseconds: 400),
@@ -759,3 +781,278 @@ List<List<Appointment>> _expandAppointmentsForWeek(
} }
return perDay; 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<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 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<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);
}
/// 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<Appointment> 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 = <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 <= _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;
}
+3 -1
View File
@@ -15,7 +15,9 @@ Future<bool> runWithErrorDialog(
} catch (e) { } catch (e) {
if (!context.mounted) return false; if (!context.mounted) return false;
final message = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e); 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; return false;
} }
} }
+1 -1
View File
@@ -123,7 +123,7 @@ class _FileViewerState extends State<FileViewer> {
if (saved != null) InfoDialog.show(context, 'Datei gespeichert.'); if (saved != null) InfoDialog.show(context, 'Datei gespeichert.');
} on Object catch (e) { } on Object catch (e) {
if (!context.mounted) return; if (!context.mounted) return;
InfoDialog.show(context, 'Speichern fehlgeschlagen: $e'); InfoDialog.show(context, 'Speichern fehlgeschlagen: $e', copyable: true, title: 'Fehler');
} }
break; break;
} }
+46 -5
View File
@@ -1,10 +1,51 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class InfoDialog { class InfoDialog {
static void show(BuildContext context, String info) { /// Shows a single-text dialog. When [copyable] is true (default for error
showDialog(context: context, builder: (context) => AlertDialog( /// details surfaces), the dialog body is selectable and a "Kopieren" action
content: Text(info), /// places it on the clipboard with a SnackBar confirmation.
contentPadding: const EdgeInsets.all(20), static void show(
)); BuildContext context,
String info, {
bool copyable = false,
String? title,
}) {
showDialog<void>(
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'),
),
],
);
},
);
} }
} }
+1 -2
View File
@@ -37,8 +37,6 @@ dependencies:
intl: ^0.20.2 intl: ^0.20.2
flutter_linkify: ^6.0.0 flutter_linkify: ^6.0.0
flutter_local_notifications: ^21.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_split_view: ^0.1.2
flutter_svg: ^2.0.10 flutter_svg: ^2.0.10
freezed_annotation: ^3.1.0 freezed_annotation: ^3.1.0
@@ -74,6 +72,7 @@ dependencies:
dev_dependencies: dev_dependencies:
flutter_launcher_icons: ^0.14.3 flutter_launcher_icons: ^0.14.3
flutter_native_splash: ^2.4.4
build_runner: ^2.10.5 build_runner: ^2.10.5
freezed: ^3.2.4 freezed: ^3.2.4