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.
# 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"
+22 -8
View File
@@ -159,6 +159,15 @@ class _MainState extends State<Main> {
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<Main> {
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<void> _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) {
+1 -9
View File
@@ -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<void> removeData({BuildContext? context}) async {
Future<void> removeData() async {
_populated = Completer();
if (context != null) {
context.read<AccountBloc>().setStatus(AccountStatus.loggedOut);
}
_username = null;
_password = null;
await _secureStorage.delete(key: _usernameField);
@@ -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,
@@ -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'),
),
],
+342 -74
View File
@@ -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<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 {
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<String> _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<AccountBloc>().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<void> _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;
});
final username = _usernameController.text.trim().toLowerCase();
final password = _passwordController.text;
try {
await AccountData().removeData();
await AccountData().setData(username, password);
await GetRoom(GetRoomParams(includeStatus: false)).run();
if (!mounted) return;
context.read<AccountBloc>().setStatus(AccountStatus.loggedIn);
} catch (e) {
log(e.toString());
await AccountData().removeData();
if (!mounted) return;
// 401 from the probe means the credentials were wrong; everything else
// (no network, server down, TLS errors, …) gets the generic mapped
// message so the user knows it isn't their typo.
final isWrongCredentials = e is AuthException && e.statusCode == 401;
setState(() {
_errorMessage = isWrongCredentials
? 'Benutzername oder Passwort falsch.'
: errorToUserMessage(e);
_errorDetails = errorToTechnicalDetails(e);
});
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _showErrorDetails() {
final details = _errorDetails;
if (details == null) return;
showDialog<void>(
context: context,
builder: (dialogContext) {
final theme = Theme.of(dialogContext);
return AlertDialog(
icon: Icon(Icons.error_outline, color: theme.colorScheme.error),
title: const Text('Fehlerdetails'),
content: SingleChildScrollView(
child: SelectableText(
details,
style: theme.textTheme.bodySmall,
),
),
messages: LoginMessages(
loginButton: 'Anmelden',
userHint: 'Nutzername',
passwordHint: 'Passwort',
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),
),
disableCustomPageTransformer: true,
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,
),
),
),
),
footer: 'Marianum Fulda - Die persönliche Schule',
title: 'Marianum Fulda',
userType: LoginUserType.name,
);
},
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) {
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_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<void> _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<bool>(
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<AccountBloc>().setStatus(AccountStatus.loggedOut);
}
}
@@ -115,7 +115,7 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
Navigator.of(context).pop();
}).catchError((Object error) {
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
/// 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;
@@ -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<GetTimetableResponseObject> _mergeAdjacentLessons(
List<GetTimetableResponseObject> input, {
@@ -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<Widget> Function(BuildContext sheetContext) children,
}) {
showModalBottomSheet<void>(
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;
}
@@ -11,24 +11,21 @@ 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(
children: (sheetCtx) => [
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Center(
child: Wrap(
children: [
TextButton.icon(
@@ -55,7 +52,8 @@ class CustomEventSheet {
],
),
),
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()),
]),
],
);
}
}
@@ -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(
header: ListTile(
leading: Icon(_iconForCode(lesson.code), size: 32),
title: Text(
'${_codePrefix(lesson.code)}$headerTitle',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 25),
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold),
),
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>[
const Divider(),
children: (_) => <Widget>[
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),
@@ -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,58 +16,87 @@ 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,
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
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),
),
_ScaledLine(
text: appointment.subject,
fontSize: 15,
fontWeight: FontWeight.w500,
),
for (final line in locationLines)
_ScaledLine(text: line, fontSize: 10),
],
),
),
),
),
if (crossedOut)
Positioned.fill(
child: ClipRRect(
borderRadius: _radius,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(width: 2, color: Colors.red.withAlpha(200)),
borderRadius: const BorderRadius.all(Radius.circular(7)),
borderRadius: _radius,
),
child: CustomPaint(painter: CrossPainter()),
),
),
),
],
),
);
}
}
/// 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(
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<CustomWorkWeekCalendar> {
today: _today,
nowNotifier: _nowNotifier,
rulerWidth: _rulerWidth,
pxPerHour: pxPerHour,
layout: layout,
);
},
),
@@ -271,7 +283,7 @@ class _WeekGrid extends StatelessWidget {
final DateTime today;
final ValueListenable<DateTime> 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<Appointment> appointments;
final List<TimeRegion> timeRegions;
final double pxPerHour;
final _PeriodLayout layout;
final DateTime today;
final ValueListenable<DateTime> 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<Appointment> 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<Appointment> 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<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
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,12 +567,15 @@ 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(
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
return Stack(
clipBehavior: Clip.none,
children: [
for (final period in schedule.periods)
Positioned(
top: _y(period.start.hour, period.start.minute),
top: layout.topOf(period),
left: 0,
right: 0,
child: Container(
@@ -581,37 +585,48 @@ class _DayColumn extends StatelessWidget {
),
for (final region in dayRegions)
Positioned(
top: _yFromDate(region.start),
height:
(_yFromDate(region.end) - _yFromDate(region.start)).clamp(0, double.infinity),
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 apt in dayAppointments)
for (final cell in laidOut)
Positioned(
top: _yForAppointmentEdge(apt.startTime, isStart: true),
height: (_yForAppointmentEdge(apt.endTime, isStart: false) -
_yForAppointmentEdge(apt.startTime, isStart: true))
top: layout.yOfDateTime(cell.startTime),
height: (layout.yOfDateTime(cell.endTime) -
layout.yOfDateTime(cell.startTime))
.clamp(0, double.infinity),
left: 1,
right: 1,
child: GestureDetector(
left: cell.lane * width / cell.laneCount,
width: width / cell.laneCount,
child: switch (cell) {
_LaidOutAppointment(:final appointment) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onAppointmentTap(apt),
onTap: () => onAppointmentTap(appointment),
child: AppointmentTile(
appointment: apt,
crossedOut: isCrossedOut(apt),
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, pxPerHour: pxPerHour, theme: theme),
_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<List<Appointment>> _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<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) {
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;
}
}
+1 -1
View File
@@ -123,7 +123,7 @@ class _FileViewerState extends State<FileViewer> {
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;
}
+46 -5
View File
@@ -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<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
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