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