custom login implementation, period-based timetable layout with overlap handling, enhanced error dialogs, and unified bottom sheets
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
messages: LoginMessages(
|
||||
loginButton: 'Anmelden',
|
||||
userHint: 'Nutzername',
|
||||
passwordHint: 'Passwort',
|
||||
),
|
||||
final username = _usernameController.text.trim().toLowerCase();
|
||||
final password = _passwordController.text;
|
||||
|
||||
disableCustomPageTransformer: true,
|
||||
try {
|
||||
await AccountData().removeData();
|
||||
await AccountData().setData(username, password);
|
||||
await GetRoom(GetRoomParams(includeStatus: false)).run();
|
||||
if (!mounted) return;
|
||||
context.read<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(
|
||||
padding: const EdgeInsets.only(bottom: 30),
|
||||
child: Center(
|
||||
child: Visibility(
|
||||
visible: displayDisclaimerText,
|
||||
child: const Text(
|
||||
'Dies ist ein Inoffizieller Nextcloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\nKeinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!',
|
||||
textAlign: TextAlign.center,
|
||||
void _showErrorDetails() {
|
||||
final details = _errorDetails;
|
||||
if (details == null) return;
|
||||
showDialog<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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
footer: 'Marianum Fulda - Die persönliche Schule',
|
||||
title: 'Marianum Fulda',
|
||||
|
||||
userType: LoginUserType.name,
|
||||
actions: [
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: details));
|
||||
if (!dialogContext.mounted) return;
|
||||
ScaffoldMessenger.of(dialogContext).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('In Zwischenablage kopiert'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.copy_outlined, size: 18),
|
||||
label: const Text('Kopieren'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Schließen'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
backgroundColor: _marianumRed,
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: IntrinsicHeight(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 40),
|
||||
Image.asset(
|
||||
'assets/logo/icon.png',
|
||||
height: 110,
|
||||
fit: BoxFit.contain,
|
||||
gaplessPlayback: true,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Marianum Fulda',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Stundenplan, Talk & Dateien an einem Ort.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
fontSize: 14,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Card(
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.35),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
color: theme.colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 20),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Anmelden',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Melde dich mit deinen Marianum-Zugangsdaten an.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
enabled: !_loading,
|
||||
validator: _required,
|
||||
autocorrect: false,
|
||||
textInputAction: TextInputAction.next,
|
||||
onFieldSubmitted: (_) => _passwordFocus.requestFocus(),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Nutzername',
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
filled: true,
|
||||
fillColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: theme.colorScheme.primary, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocus,
|
||||
enabled: !_loading,
|
||||
validator: _required,
|
||||
obscureText: true,
|
||||
obscuringCharacter: '•',
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Passwort',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
filled: true,
|
||||
fillColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: theme.colorScheme.primary, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
child: _errorMessage == null
|
||||
? const SizedBox(height: 0, width: double.infinity)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 14),
|
||||
child: Material(
|
||||
color: theme.colorScheme.errorContainer.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: _errorDetails != null ? _showErrorDetails : null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline,
|
||||
size: 20,
|
||||
color: theme.colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
fontSize: 13,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_errorDetails != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.chevron_right,
|
||||
size: 20,
|
||||
color: theme.colorScheme.onErrorContainer
|
||||
.withValues(alpha: 0.7)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: FilledButton(
|
||||
onPressed: _loading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Anmelden'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'Inoffizieller Nextcloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
fontSize: 11,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
||||
child: Text(
|
||||
'Marianum Fulda. Die persönliche Schule.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,51 +11,49 @@ import 'delete_custom_event.dart';
|
||||
|
||||
class CustomEventSheet {
|
||||
static void show(BuildContext context, CustomTimetableEvent event) {
|
||||
final timeRange =
|
||||
'${Jiffy.parseFromDateTime(event.startDate).format(pattern: 'HH:mm')} - '
|
||||
'${Jiffy.parseFromDateTime(event.endDate).format(pattern: 'HH:mm')}';
|
||||
|
||||
showAppointmentBottomSheet(
|
||||
context,
|
||||
header: (_) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(event.title, style: const TextStyle(fontSize: 25, overflow: TextOverflow.ellipsis)),
|
||||
Text(
|
||||
'${Jiffy.parseFromDateTime(event.startDate).format(pattern: 'HH:mm')} - '
|
||||
'${Jiffy.parseFromDateTime(event.endDate).format(pattern: 'HH:mm')}',
|
||||
style: const TextStyle(fontSize: 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
header: ListTile(
|
||||
leading: const Icon(Icons.event_outlined, size: 32),
|
||||
title: Text(event.title, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(timeRange),
|
||||
),
|
||||
body: (sheetCtx) => SliverChildListDelegate([
|
||||
const Divider(),
|
||||
Center(
|
||||
child: Wrap(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => CustomEventEditDialog(existingEvent: event),
|
||||
);
|
||||
},
|
||||
label: const Text('Bearbeiten'),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
showDeleteCustomEventDialog(context, event).future.then((_) {
|
||||
if (!sheetCtx.mounted) return;
|
||||
children: (sheetCtx) => [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Center(
|
||||
child: Wrap(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
});
|
||||
},
|
||||
label: const Text('Löschen'),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => CustomEventEditDialog(existingEvent: event),
|
||||
);
|
||||
},
|
||||
label: const Text('Bearbeiten'),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
showDeleteCustomEventDialog(context, event).future.then((_) {
|
||||
if (!sheetCtx.mounted) return;
|
||||
Navigator.of(sheetCtx).pop();
|
||||
});
|
||||
},
|
||||
label: const Text('Löschen'),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: Text(event.description.isEmpty ? 'Keine Beschreibung' : event.description),
|
||||
@@ -82,7 +80,7 @@ class CustomEventSheet {
|
||||
),
|
||||
),
|
||||
DebugTile(sheetCtx).jsonData(event.toJson()),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,29 +23,24 @@ class WebuntisLessonSheet {
|
||||
final headerTitle = _firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']);
|
||||
final headerLongName = headerSubject.longName.isNotEmpty && headerSubject.longName != headerTitle ? headerSubject.longName : '';
|
||||
|
||||
final timeRange =
|
||||
'${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - '
|
||||
'${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}';
|
||||
|
||||
showAppointmentBottomSheet(
|
||||
context,
|
||||
header: (_) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${_codePrefix(lesson.code)}$headerTitle',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 25),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (headerLongName.isNotEmpty) Text(headerLongName),
|
||||
Text(
|
||||
'${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - '
|
||||
'${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}',
|
||||
style: const TextStyle(fontSize: 15),
|
||||
),
|
||||
],
|
||||
header: ListTile(
|
||||
leading: Icon(_iconForCode(lesson.code), size: 32),
|
||||
title: Text(
|
||||
'${_codePrefix(lesson.code)}$headerTitle',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(headerLongName.isNotEmpty
|
||||
? '$timeRange\n$headerLongName'
|
||||
: timeRange),
|
||||
isThreeLine: headerLongName.isNotEmpty,
|
||||
),
|
||||
body: (_) => SliverChildListDelegate(<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,54 +16,51 @@ class AppointmentTile extends StatelessWidget {
|
||||
final isPast = appointment.endTime.isBefore(DateTime.now());
|
||||
final color = appointment.color.withAlpha(isPast ? 160 : 255);
|
||||
|
||||
final locationLines = (appointment.location ?? '')
|
||||
.split('\n')
|
||||
.where((p) => p.isNotEmpty)
|
||||
.take(2)
|
||||
.toList(growable: false);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(1),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
alignment: Alignment.topLeft,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.rectangle,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(7)),
|
||||
borderRadius: _radius,
|
||||
color: color,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.fitWidth,
|
||||
child: Text(
|
||||
appointment.subject,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
),
|
||||
FittedBox(
|
||||
fit: BoxFit.fitWidth,
|
||||
child: Text(
|
||||
appointment.location?.isNotEmpty == true ? appointment.location! : ' ',
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ScaledLine(
|
||||
text: appointment.subject,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
for (final line in locationLines)
|
||||
_ScaledLine(text: line, fontSize: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (crossedOut)
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(width: 2, color: Colors.red.withAlpha(200)),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(7)),
|
||||
child: ClipRRect(
|
||||
borderRadius: _radius,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(width: 2, color: Colors.red.withAlpha(200)),
|
||||
borderRadius: _radius,
|
||||
),
|
||||
child: CustomPaint(painter: CrossPainter()),
|
||||
),
|
||||
child: CustomPaint(painter: CrossPainter()),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -69,3 +68,35 @@ class AppointmentTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of appointment text. The FittedBox scales **only this line** down
|
||||
/// when the text is wider than the tile, so a long teacher name does not
|
||||
/// shrink the room number above it.
|
||||
class _ScaledLine extends StatelessWidget {
|
||||
final String text;
|
||||
final double fontSize;
|
||||
final FontWeight? fontWeight;
|
||||
|
||||
const _ScaledLine({
|
||||
required this.text,
|
||||
required this.fontSize,
|
||||
this.fontWeight,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: fontSize,
|
||||
fontWeight: fontWeight,
|
||||
height: 1.1,
|
||||
),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,52 +567,66 @@ class _DayColumn extends StatelessWidget {
|
||||
color: isToday ? theme.colorScheme.primary.withAlpha(14) : null,
|
||||
border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)),
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
for (final period in schedule.periods)
|
||||
Positioned(
|
||||
top: _y(period.start.hour, period.start.minute),
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
height: 0.5,
|
||||
color: theme.dividerColor.withAlpha(60),
|
||||
),
|
||||
),
|
||||
for (final region in dayRegions)
|
||||
Positioned(
|
||||
top: _yFromDate(region.start),
|
||||
height:
|
||||
(_yFromDate(region.end) - _yFromDate(region.start)).clamp(0, double.infinity),
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: TimeRegionTile(region: region.region),
|
||||
),
|
||||
for (final apt in dayAppointments)
|
||||
Positioned(
|
||||
top: _yForAppointmentEdge(apt.startTime, isStart: true),
|
||||
height: (_yForAppointmentEdge(apt.endTime, isStart: false) -
|
||||
_yForAppointmentEdge(apt.startTime, isStart: true))
|
||||
.clamp(0, double.infinity),
|
||||
left: 1,
|
||||
right: 1,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => onAppointmentTap(apt),
|
||||
child: AppointmentTile(
|
||||
appointment: apt,
|
||||
crossedOut: isCrossedOut(apt),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
for (final period in schedule.periods)
|
||||
Positioned(
|
||||
top: layout.topOf(period),
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
height: 0.5,
|
||||
color: theme.dividerColor.withAlpha(60),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isToday)
|
||||
ValueListenableBuilder<DateTime>(
|
||||
valueListenable: nowNotifier,
|
||||
builder: (_, now, child) =>
|
||||
_CurrentTimeMarker(now: now, pxPerHour: pxPerHour, theme: theme),
|
||||
),
|
||||
],
|
||||
for (final region in dayRegions)
|
||||
Positioned(
|
||||
top: layout.yOfDateTime(region.start),
|
||||
height: (layout.yOfDateTime(region.end) -
|
||||
layout.yOfDateTime(region.start))
|
||||
.clamp(0, double.infinity),
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: TimeRegionTile(region: region.region),
|
||||
),
|
||||
for (final cell in laidOut)
|
||||
Positioned(
|
||||
top: layout.yOfDateTime(cell.startTime),
|
||||
height: (layout.yOfDateTime(cell.endTime) -
|
||||
layout.yOfDateTime(cell.startTime))
|
||||
.clamp(0, double.infinity),
|
||||
left: cell.lane * width / cell.laneCount,
|
||||
width: width / cell.laneCount,
|
||||
child: switch (cell) {
|
||||
_LaidOutAppointment(:final appointment) => GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => onAppointmentTap(appointment),
|
||||
child: AppointmentTile(
|
||||
appointment: appointment,
|
||||
crossedOut: isCrossedOut(appointment),
|
||||
),
|
||||
),
|
||||
_LaidOutOverflow(:final appointments) => GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () =>
|
||||
_showOverflowSheet(context, appointments),
|
||||
child: _OverflowTile(count: appointments.length),
|
||||
),
|
||||
},
|
||||
),
|
||||
if (isToday)
|
||||
ValueListenableBuilder<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 {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||