refactored broad range of the application, split files, modularized calendar and file views, centralized bottom sheets and clipboard handling, and implemented unit test coverage

This commit is contained in:
2026-05-08 19:05:16 +02:00
parent 3b1b0d0c19
commit c62a14645a
68 changed files with 4633 additions and 3141 deletions
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
enum SortOption { name, date, size }
class BetterSortOption {
final String displayName;
final int Function(CacheableFile, CacheableFile) compare;
final IconData icon;
BetterSortOption({required this.displayName, required this.icon, required this.compare});
}
class SortOptions {
static final Map<SortOption, BetterSortOption> options = {
SortOption.name: BetterSortOption(
displayName: 'Name',
icon: Icons.sort_by_alpha_outlined,
compare: (a, b) => a.name.compareTo(b.name),
),
SortOption.date: BetterSortOption(
displayName: 'Datum',
icon: Icons.history_outlined,
compare: (a, b) => a.modifiedAt!.compareTo(b.modifiedAt!),
),
SortOption.size: BetterSortOption(
displayName: 'Größe',
icon: Icons.sd_card_outlined,
compare: (a, b) {
if (a.isDirectory || b.isDirectory) return a.isDirectory ? 1 : 0;
if (a.size == null) return 0;
if (b.size == null) return 1;
return a.size!.compareTo(b.size!);
},
),
};
static BetterSortOption getOption(SortOption option) => options[option]!;
}
+16 -296
View File
@@ -2,12 +2,8 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nextcloud/nextcloud.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import '../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
import '../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
@@ -15,49 +11,13 @@ import '../../../state/app/modules/files/bloc/files_bloc.dart';
import '../../../state/app/modules/files/bloc/files_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../utils/cache_invalidation_bus.dart';
import '../../../utils/file_clipboard.dart';
import '../../../widget/async_action_button.dart';
import '../../../widget/file_pick.dart';
import '../../../widget/placeholder_view.dart';
import 'data/sort_options.dart';
import 'files_upload_dialog.dart';
import 'widgets/add_file_menu.dart';
import 'widgets/clipboard_banner.dart';
import 'widgets/file_element.dart';
class BetterSortOption {
String displayName;
int Function(CacheableFile, CacheableFile) compare;
IconData icon;
BetterSortOption({required this.displayName, required this.icon, required this.compare});
}
enum SortOption { name, date, size }
class SortOptions {
static Map<SortOption, BetterSortOption> options = {
SortOption.name: BetterSortOption(
displayName: 'Name',
icon: Icons.sort_by_alpha_outlined,
compare: (a, b) => a.name.compareTo(b.name),
),
SortOption.date: BetterSortOption(
displayName: 'Datum',
icon: Icons.history_outlined,
compare: (a, b) => a.modifiedAt!.compareTo(b.modifiedAt!),
),
SortOption.size: BetterSortOption(
displayName: 'Größe',
icon: Icons.sd_card_outlined,
compare: (a, b) {
if (a.isDirectory || b.isDirectory) return a.isDirectory ? 1 : 0;
if (a.size == null) return 0;
if (b.size == null) return 1;
return a.size!.compareTo(b.size!);
},
),
};
static BetterSortOption getOption(SortOption option) => options[option]!;
}
import 'widgets/files_sort_actions.dart';
class Files extends StatelessWidget {
final List<String> path;
@@ -89,6 +49,10 @@ class _FilesViewState extends State<_FilesView> {
// segments joined without leading/trailing slash.
String get _myPathString => widget.path.isEmpty ? '/' : widget.path.join('/');
// Relative folder path matching the WebDAV format used by `CacheableFile.path`
// (no leading slash; trailing slash for non-root). Empty string means root.
String get _currentFolderPath => widget.path.isEmpty ? '' : '${widget.path.join('/')}/';
@override
void initState() {
super.initState();
@@ -110,7 +74,7 @@ class _FilesViewState extends State<_FilesView> {
super.dispose();
}
Future<void> mediaUpload(List<String>? paths) async {
Future<void> _mediaUpload(List<String>? paths) async {
if (paths == null) return;
final bloc = context.read<FilesBloc>();
unawaited(pushScreen(
@@ -131,47 +95,16 @@ class _FilesViewState extends State<_FilesView> {
appBar: AppBar(
title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'),
actions: [
PopupMenuButton<bool>(
icon: Icon(currentSortDirection ? Icons.text_rotate_up : Icons.text_rotation_down),
itemBuilder: (context) => [true, false]
.map((e) => PopupMenuItem<bool>(
value: e,
enabled: e != currentSortDirection,
child: Row(
children: [
Icon(
e ? Icons.text_rotate_up : Icons.text_rotation_down,
color: Theme.of(context).colorScheme.onSurface,
),
const SizedBox(width: 15),
Text(e ? 'Aufsteigend' : 'Absteigend'),
],
),
))
.toList(),
onSelected: (e) {
FilesSortActions(
currentSort: currentSort,
ascending: currentSortDirection,
onDirectionChanged: (e) {
setState(() {
currentSortDirection = e;
settings.val(write: true).fileSettings.ascending = e;
});
},
),
PopupMenuButton<SortOption>(
icon: const Icon(Icons.sort),
itemBuilder: (context) => SortOptions.options.keys
.map((key) => PopupMenuItem<SortOption>(
value: key,
enabled: key != currentSort,
child: Row(
children: [
Icon(SortOptions.getOption(key).icon, color: Theme.of(context).colorScheme.onSurface),
const SizedBox(width: 15),
Text(SortOptions.getOption(key).displayName),
],
),
))
.toList(),
onSelected: (e) {
onSortChanged: (e) {
setState(() {
currentSort = e;
settings.val(write: true).fileSettings.sortBy = e;
@@ -183,12 +116,12 @@ class _FilesViewState extends State<_FilesView> {
floatingActionButton: FloatingActionButton(
heroTag: 'uploadFile',
backgroundColor: Theme.of(context).primaryColor,
onPressed: () => _showAddDialog(context, bloc),
onPressed: () => showAddFileSheet(context, bloc: bloc, onPickedFiles: _mediaUpload),
child: const Icon(Icons.add),
),
body: Column(
children: [
_ClipboardBanner(currentFolder: _currentFolderPath, onPasteDone: bloc.refresh),
ClipboardBanner(currentFolder: _currentFolderPath, onPasteDone: bloc.refresh),
Expanded(
child: LoadableStateConsumer<FilesBloc, FilesState>(
isReady: (state) => state.listing != null,
@@ -214,217 +147,4 @@ class _FilesViewState extends State<_FilesView> {
),
);
}
// Relative folder path matching the WebDAV format used by `CacheableFile.path`
// (no leading slash; trailing slash for non-root). Empty string means root.
String get _currentFolderPath => widget.path.isEmpty ? '' : '${widget.path.join('/')}/';
void _showAddDialog(BuildContext context, FilesBloc bloc) {
showDialog(
context: context,
builder: (dialogCtx) => SimpleDialog(children: [
ListTile(
leading: const Icon(Icons.create_new_folder_outlined),
title: const Text('Ordner erstellen'),
onTap: () {
Navigator.of(dialogCtx).pop();
_showCreateFolderDialog(context, bloc);
},
),
ListTile(
leading: const Icon(Icons.upload_file),
title: const Text('Aus Dateien hochladen'),
onTap: () {
FilePick.documentPick().then(mediaUpload);
Navigator.of(dialogCtx).pop();
},
),
ListTile(
leading: const Icon(Icons.add_a_photo_outlined),
title: const Text('Aus Galerie hochladen'),
onTap: () {
FilePick.multipleGalleryPick().then((value) {
if (value != null) mediaUpload(value.map((e) => e.path).toList());
});
Navigator.of(dialogCtx).pop();
},
),
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: const Text('Foto aufnehmen'),
onTap: () {
FilePick.cameraPick().then((image) {
if (image != null) mediaUpload([image.path]);
});
Navigator.of(dialogCtx).pop();
},
),
]),
);
}
void _showCreateFolderDialog(BuildContext context, FilesBloc bloc) {
final inputController = TextEditingController();
showDialog(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Neuer Ordner'),
content: TextField(
controller: inputController,
decoration: const InputDecoration(labelText: 'Name'),
autofocus: true,
),
actions: [
AsyncDialogAction(
confirmLabel: 'Ordner erstellen',
onConfirm: () async {
if (inputController.text.trim().isEmpty) {
throw Exception('Bitte einen Namen eingeben.');
}
await bloc.createFolder(inputController.text.trim());
},
),
],
),
);
}
}
class _ClipboardBanner extends StatefulWidget {
const _ClipboardBanner({required this.currentFolder, required this.onPasteDone});
final String currentFolder;
final void Function() onPasteDone;
@override
State<_ClipboardBanner> createState() => _ClipboardBannerState();
}
class _ClipboardBannerState extends State<_ClipboardBanner> {
bool _busy = false;
// All paths here are relative to the WebDAV root (matching `CacheableFile.path`).
// Root is the empty string ''. Folders end with '/'.
String _normalised(String path) {
final stripped = path.replaceAll(RegExp(r'^/+|/+$'), '');
return stripped.isEmpty ? '' : '$stripped/';
}
String _joinPath(String folder, String name, {required bool isDirectory}) =>
isDirectory ? '$folder$name/' : '$folder$name';
// Disabled when:
// - clipboard is empty
// - we'd be pasting a folder into itself or one of its descendants
// - every entry already lives in the current folder (paste would be a no-op)
bool get _canPaste {
final cb = FileClipboard.instance;
if (cb.isEmpty) return false;
final dst = _normalised(widget.currentFolder);
var atLeastOneActionable = false;
for (final f in cb.files) {
if (f.isDirectory) {
final src = _normalised(f.path);
if (dst == src || dst.startsWith(src)) return false;
}
final destination = _joinPath(widget.currentFolder, f.name, isDirectory: f.isDirectory);
if (destination != f.path) atLeastOneActionable = true;
}
return atLeastOneActionable;
}
// Cache key format used by ListFilesCache (matches FilesBloc's pathString:
// relative, no leading or trailing slash; root is '/').
String _parentCacheKey(String relativePath) {
final stripped = relativePath.replaceAll(RegExp(r'^/+|/+$'), '');
if (!stripped.contains('/')) return '/';
final parts = stripped.split('/')..removeLast();
return parts.isEmpty ? '/' : parts.join('/');
}
Future<void> _paste() async {
final cb = FileClipboard.instance;
if (_busy || !_canPaste) return;
setState(() => _busy = true);
final operation = cb.operation;
final errors = <String>[];
final invalidatedSourceFolders = <String>{};
try {
final webdav = await WebdavApi.webdav;
for (final file in cb.files) {
final destination = _joinPath(widget.currentFolder, file.name, isDirectory: file.isDirectory);
if (destination == file.path) continue;
try {
if (operation == FileClipboardOperation.cut) {
await webdav.move(PathUri.parse(file.path), PathUri.parse(destination));
invalidatedSourceFolders.add(_parentCacheKey(file.path));
} else {
await webdav.copy(PathUri.parse(file.path), PathUri.parse(destination));
}
} on Object catch (e) {
errors.add('${file.name}: $e');
}
}
// After cut, the source folders no longer contain the moved files. Drop
// their cached listings so the next visit fetches fresh data instead of
// briefly showing the moved file as still present.
for (final folder in invalidatedSourceFolders) {
await ListFilesCache.invalidate(folder);
}
if (operation == FileClipboardOperation.cut) cb.clear();
widget.onPasteDone();
} finally {
if (mounted) setState(() => _busy = false);
}
if (errors.isNotEmpty && mounted) {
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Einfügen teilweise fehlgeschlagen'),
content: SingleChildScrollView(child: Text(errors.join('\n\n'))),
actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('OK'))],
),
);
}
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: FileClipboard.instance,
builder: (context, _) {
final cb = FileClipboard.instance;
if (cb.isEmpty) return const SizedBox.shrink();
final cut = cb.operation == FileClipboardOperation.cut;
final count = cb.files.length;
final label = count == 1 ? '"${cb.files.first.name}"' : '$count Elemente';
return Material(
color: Theme.of(context).colorScheme.secondaryContainer,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Icon(cut ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
cut ? '$label verschieben' : '$label kopieren',
overflow: TextOverflow.ellipsis,
),
),
TextButton(
onPressed: _busy || !_canPaste ? null : _paste,
child: _busy
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Hier einfügen'),
),
IconButton(
tooltip: 'Verwerfen',
icon: const Icon(Icons.close, size: 20),
onPressed: _busy ? null : cb.clear,
),
],
),
),
);
},
);
}
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import '../../../../state/app/modules/files/bloc/files_bloc.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/file_pick.dart';
/// Opens the "Element hinzufügen" sheet (create folder, upload, take photo, …).
/// [onPickedFiles] receives selected/captured file paths (gallery, file picker
/// or camera) and is responsible for kicking off the upload flow.
void showAddFileSheet(
BuildContext context, {
required FilesBloc bloc,
required Future<void> Function(List<String>? paths) onPickedFiles,
}) {
showDetailsBottomSheet(
context,
children: (sheetCtx) => [
ListTile(
leading: const Icon(Icons.create_new_folder_outlined),
title: const Text('Ordner erstellen'),
onTap: () {
Navigator.of(sheetCtx).pop();
_showCreateFolderDialog(context, bloc);
},
),
ListTile(
leading: const Icon(Icons.upload_file),
title: const Text('Aus Dateien hochladen'),
onTap: () {
FilePick.documentPick().then(onPickedFiles);
Navigator.of(sheetCtx).pop();
},
),
ListTile(
leading: const Icon(Icons.add_a_photo_outlined),
title: const Text('Aus Galerie hochladen'),
onTap: () {
FilePick.multipleGalleryPick().then((value) {
if (value != null) onPickedFiles(value.map((e) => e.path).toList());
});
Navigator.of(sheetCtx).pop();
},
),
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: const Text('Foto aufnehmen'),
onTap: () {
FilePick.cameraPick().then((image) {
if (image != null) onPickedFiles([image.path]);
});
Navigator.of(sheetCtx).pop();
},
),
],
);
}
void _showCreateFolderDialog(BuildContext context, FilesBloc bloc) {
final inputController = TextEditingController();
showDialog(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Neuer Ordner'),
content: TextField(
controller: inputController,
decoration: const InputDecoration(labelText: 'Name'),
autofocus: true,
),
actions: [
AsyncDialogAction(
confirmLabel: 'Ordner erstellen',
onConfirm: () async {
if (inputController.text.trim().isEmpty) {
throw Exception('Bitte einen Namen eingeben.');
}
await bloc.createFolder(inputController.text.trim());
},
),
],
),
);
}
@@ -0,0 +1,152 @@
import 'package:flutter/material.dart';
import 'package:nextcloud/nextcloud.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
import '../../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../../utils/file_clipboard.dart';
import '../../../../widget/info_dialog.dart';
/// Banner that appears at the top of a Files folder while there is something
/// in the file clipboard. Shows the cut/copy state and offers a "Hier
/// einfügen" button.
class ClipboardBanner extends StatefulWidget {
final String currentFolder;
final VoidCallback onPasteDone;
const ClipboardBanner({
required this.currentFolder,
required this.onPasteDone,
super.key,
});
@override
State<ClipboardBanner> createState() => _ClipboardBannerState();
}
class _ClipboardBannerState extends State<ClipboardBanner> {
bool _busy = false;
// All paths here are relative to the WebDAV root (matching `CacheableFile.path`).
// Root is the empty string ''. Folders end with '/'.
String _normalised(String path) {
final stripped = path.replaceAll(RegExp(r'^/+|/+$'), '');
return stripped.isEmpty ? '' : '$stripped/';
}
String _joinPath(String folder, String name, {required bool isDirectory}) =>
isDirectory ? '$folder$name/' : '$folder$name';
// Disabled when:
// - clipboard is empty
// - we'd be pasting a folder into itself or one of its descendants
// - every entry already lives in the current folder (paste would be a no-op)
bool get _canPaste {
final cb = FileClipboard.instance;
if (cb.isEmpty) return false;
final dst = _normalised(widget.currentFolder);
var atLeastOneActionable = false;
for (final f in cb.files) {
if (f.isDirectory) {
final src = _normalised(f.path);
if (dst == src || dst.startsWith(src)) return false;
}
final destination = _joinPath(widget.currentFolder, f.name, isDirectory: f.isDirectory);
if (destination != f.path) atLeastOneActionable = true;
}
return atLeastOneActionable;
}
// Cache key format used by ListFilesCache (matches FilesBloc's pathString:
// relative, no leading or trailing slash; root is '/').
String _parentCacheKey(String relativePath) {
final stripped = relativePath.replaceAll(RegExp(r'^/+|/+$'), '');
if (!stripped.contains('/')) return '/';
final parts = stripped.split('/')..removeLast();
return parts.isEmpty ? '/' : parts.join('/');
}
Future<void> _paste() async {
final cb = FileClipboard.instance;
if (_busy || !_canPaste) return;
setState(() => _busy = true);
final operation = cb.operation;
final errors = <String>[];
final invalidatedSourceFolders = <String>{};
try {
final webdav = await WebdavApi.webdav;
for (final file in cb.files) {
final destination = _joinPath(widget.currentFolder, file.name, isDirectory: file.isDirectory);
if (destination == file.path) continue;
try {
if (operation == FileClipboardOperation.cut) {
await webdav.move(PathUri.parse(file.path), PathUri.parse(destination));
invalidatedSourceFolders.add(_parentCacheKey(file.path));
} else {
await webdav.copy(PathUri.parse(file.path), PathUri.parse(destination));
}
} on Object catch (e) {
errors.add('${file.name}: $e');
}
}
// After cut, the source folders no longer contain the moved files. Drop
// their cached listings so the next visit fetches fresh data instead of
// briefly showing the moved file as still present.
for (final folder in invalidatedSourceFolders) {
await ListFilesCache.invalidate(folder);
}
if (operation == FileClipboardOperation.cut) cb.clear();
widget.onPasteDone();
} finally {
if (mounted) setState(() => _busy = false);
}
if (errors.isNotEmpty && mounted) {
InfoDialog.show(
context,
errors.join('\n\n'),
copyable: true,
title: 'Einfügen teilweise fehlgeschlagen',
);
}
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: FileClipboard.instance,
builder: (context, _) {
final cb = FileClipboard.instance;
if (cb.isEmpty) return const SizedBox.shrink();
final cut = cb.operation == FileClipboardOperation.cut;
final count = cb.files.length;
final label = count == 1 ? '"${cb.files.first.name}"' : '$count Elemente';
return Material(
color: Theme.of(context).colorScheme.secondaryContainer,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Icon(cut ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
cut ? '$label verschieben' : '$label kopieren',
overflow: TextOverflow.ellipsis,
),
),
TextButton(
onPressed: _busy || !_canPaste ? null : _paste,
child: _busy
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Hier einfügen'),
),
IconButton(
tooltip: 'Verwerfen',
icon: const Icon(Icons.close, size: 20),
onPressed: _busy ? null : cb.clear,
),
],
),
),
);
},
);
}
@@ -1,48 +1,33 @@
import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:jiffy/jiffy.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../../extensions/date_time.dart';
import '../../../../utils/clipboard_helper.dart';
import '../../../../widget/details_bottom_sheet.dart';
/// Shows a modal bottom sheet with technical metadata about a single file or
/// folder: full path, MIME type, size, timestamps, ETag.
Future<void> showFileDetailsSheet(BuildContext context, CacheableFile file) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(file.isDirectory ? Icons.folder : Icons.description_outlined, size: 32),
title: Text(file.name, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(file.isDirectory ? 'Ordner' : (file.mimeType ?? '')),
),
const Divider(),
_DetailRow(label: 'Pfad', value: file.path, copyable: true),
if (!file.isDirectory) _DetailRow(label: 'Größe', value: filesize(file.size)),
if (file.modifiedAt != null)
_DetailRow(
label: 'Geändert',
value: '${Jiffy.parseFromDateTime(file.modifiedAt!).format(pattern: 'dd.MM.yyyy HH:mm')} '
'(${Jiffy.parseFromDateTime(file.modifiedAt!).fromNow()})',
),
if (file.createdAt != null)
_DetailRow(
label: 'Erstellt',
value: Jiffy.parseFromDateTime(file.createdAt!).format(pattern: 'dd.MM.yyyy HH:mm'),
),
if (file.eTag != null) _DetailRow(label: 'ETag', value: file.eTag!, copyable: true),
],
),
),
void showFileDetailsSheet(BuildContext context, CacheableFile file) {
showDetailsBottomSheet(
context,
header: ListTile(
leading: Icon(file.isDirectory ? Icons.folder : Icons.description_outlined, size: 32),
title: Text(file.name, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(file.isDirectory ? 'Ordner' : (file.mimeType ?? '')),
),
children: (_) => [
_DetailRow(label: 'Pfad', value: file.path, copyable: true),
if (!file.isDirectory) _DetailRow(label: 'Größe', value: filesize(file.size)),
if (file.modifiedAt != null)
_DetailRow(
label: 'Geändert',
value: '${file.modifiedAt!.formatDateTime()} (${file.modifiedAt!.formatRelative()})',
),
if (file.createdAt != null)
_DetailRow(label: 'Erstellt', value: file.createdAt!.formatDateTime()),
if (file.eTag != null) _DetailRow(label: 'ETag', value: file.eTag!, copyable: true),
],
);
}
@@ -54,7 +39,7 @@ class _DetailRow extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -67,12 +52,7 @@ class _DetailRow extends StatelessWidget {
IconButton(
tooltip: 'Kopieren',
icon: const Icon(Icons.copy, size: 18),
onPressed: () {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('In Zwischenablage kopiert')),
);
},
onPressed: () => copyToClipboard(context, value),
),
],
),
@@ -1,10 +1,10 @@
import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:nextcloud/nextcloud.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../../extensions/date_time.dart';
import '../../../../model/endpoint_data.dart';
import '../../../../routing/app_routes.dart';
import '../../../../utils/download_manager.dart';
@@ -135,9 +135,10 @@ class _FileElementState extends State<FileElement> {
],
);
}
final modified = widget.file.modifiedAt ?? DateTime.now();
return widget.file.isDirectory
? Text('geändert ${Jiffy.parseFromDateTime(widget.file.modifiedAt ?? DateTime.now()).fromNow()}')
: Text('${filesize(widget.file.size)}, ${Jiffy.parseFromDateTime(widget.file.modifiedAt ?? DateTime.now()).fromNow()}');
? Text('geändert ${modified.formatRelative()}')
: Text('${filesize(widget.file.size)}, ${modified.formatRelative()}');
}
void _onTap() {
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import '../data/sort_options.dart';
/// AppBar action buttons for sort direction (asc/desc) and sort field
/// (name/date/size). Pure UI owners pass current values + selection
/// callbacks.
class FilesSortActions extends StatelessWidget {
final SortOption currentSort;
final bool ascending;
final ValueChanged<bool> onDirectionChanged;
final ValueChanged<SortOption> onSortChanged;
const FilesSortActions({
required this.currentSort,
required this.ascending,
required this.onDirectionChanged,
required this.onSortChanged,
super.key,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
PopupMenuButton<bool>(
icon: Icon(ascending ? Icons.text_rotate_up : Icons.text_rotation_down),
itemBuilder: (context) => [true, false]
.map((e) => PopupMenuItem<bool>(
value: e,
enabled: e != ascending,
child: Row(
children: [
Icon(e ? Icons.text_rotate_up : Icons.text_rotation_down,
color: theme.colorScheme.onSurface),
const SizedBox(width: 15),
Text(e ? 'Aufsteigend' : 'Absteigend'),
],
),
))
.toList(),
onSelected: onDirectionChanged,
),
PopupMenuButton<SortOption>(
icon: const Icon(Icons.sort),
itemBuilder: (context) => SortOptions.options.keys
.map((key) => PopupMenuItem<SortOption>(
value: key,
enabled: key != currentSort,
child: Row(
children: [
Icon(SortOptions.getOption(key).icon, color: theme.colorScheme.onSurface),
const SizedBox(width: 15),
Text(SortOptions.getOption(key).displayName),
],
),
))
.toList(),
onSelected: onSortChanged,
),
],
);
}
}
@@ -0,0 +1,38 @@
import '../../../../extensions/date_time.dart';
import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
/// Pure formatting helpers for `MarianumDate` events. Held outside the view
/// so the view can stay focused on layout and these helpers remain
/// unit-testable.
class EventFormatter {
/// Compact trailing label shown in the list row: "HH:mmHH:mm" for same-day,
/// "dd.MM. HH:mmdd.MM. HH:mm" otherwise, or "Ganztägig" for all-day events.
static String trailingLabel(MarianumDate event) {
if (event.isAllDay) return 'Ganztägig';
if (event.start.isSameDay(event.end)) {
if (event.start == event.end) return event.start.formatHm();
return '${event.start.formatHm()}${event.end.formatHm()}';
}
return '${event.start.formatDateShortHm()}${event.end.formatDateShortHm()}';
}
/// Verbose date+time line shown in the details sheet. Drops the trailing
/// time when the event is all-day, and de-duplicates same-day endpoints.
static String longRange(MarianumDate event) {
if (event.isAllDay) {
final inclusiveEnd = event.end.isAfter(event.start)
? event.end.subtract(const Duration(days: 1))
: event.end;
return event.start.isSameDay(inclusiveEnd)
? '${event.start.formatDate()} · Ganztägig'
: '${event.start.formatDate()} ${inclusiveEnd.formatDate()} · Ganztägig';
}
if (event.start.isSameDay(event.end)) {
if (event.start == event.end) {
return '${event.start.formatDate()} · ${event.start.formatHm()}';
}
return '${event.start.formatDate()} · ${event.start.formatHm()} ${event.end.formatHm()}';
}
return '${event.start.formatDateTime()} ${event.end.formatDateTime()}';
}
}
@@ -1,18 +1,16 @@
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import '../../../extensions/date_time.dart';
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart';
import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_event.dart';
import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
import '../../../widget/animated_time.dart';
import '../../../widget/centered_leading.dart';
import '../../../widget/debug/debug_tile.dart';
import '../../../widget/placeholder_view.dart';
import '../timetable/custom_events/custom_event_edit_dialog.dart';
import 'search_marianum_dates.dart';
import 'widgets/event_list_tile.dart';
import 'widgets/month_section_header.dart';
class MarianumDatesView extends StatelessWidget {
const MarianumDatesView({super.key});
@@ -27,7 +25,7 @@ class MarianumDatesView extends StatelessWidget {
final keys = byMonth.keys.toList()..sort();
return keys.map((key) {
final first = byMonth[key]!.first.start;
final label = Jiffy.parseFromDateTime(first).format(pattern: 'MMMM yyyy').toUpperCase();
final label = first.formatMonthYear().toUpperCase();
return _MonthGroup(key: key, label: label, events: byMonth[key]!);
}).toList();
}
@@ -110,239 +108,3 @@ class _MonthGroup {
final List<MarianumDate> events;
_MonthGroup({required this.key, required this.label, required this.events});
}
class MonthHeaderDelegate extends SliverPersistentHeaderDelegate {
final String label;
MonthHeaderDelegate({required this.label});
static const double _height = 38;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
final theme = Theme.of(context);
return Container(
height: _height,
color: theme.colorScheme.surfaceContainer,
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
child: Text(
label,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
),
);
}
@override
double get maxExtent => _height;
@override
double get minExtent => _height;
@override
bool shouldRebuild(covariant MonthHeaderDelegate oldDelegate) => oldDelegate.label != label;
}
/// Composite icon: calendar with a small plus badge in the bottom-right.
/// Material's bundled icon set has no `calendar_add_on`, so we layer
/// `Icons.event_outlined` and `Icons.add` to get the same affordance.
class _CalendarPlusIcon extends StatelessWidget {
final Color color;
const _CalendarPlusIcon({required this.color});
@override
Widget build(BuildContext context) => SizedBox(
width: 22,
height: 22,
child: Stack(
clipBehavior: Clip.none,
children: [
Icon(Icons.event_outlined, size: 22, color: color),
Positioned(
right: -2,
bottom: -2,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
),
padding: const EdgeInsets.all(1),
child: Icon(Icons.add_circle, size: 12, color: color),
),
),
],
),
);
}
class MarianumDateRow extends StatelessWidget {
final MarianumDate event;
const MarianumDateRow({required this.event, super.key});
String _dayLabel() => event.start.day.toString().padLeft(2, '0');
String _monthYearLabel() =>
'${event.start.month.toString().padLeft(2, '0')}.${event.start.year}';
String _trailingLabel() {
final start = Jiffy.parseFromDateTime(event.start);
final end = Jiffy.parseFromDateTime(event.end);
if (event.isAllDay) return 'Ganztägig';
final sameDay = start.format(pattern: 'yyyy-MM-dd') == end.format(pattern: 'yyyy-MM-dd');
if (sameDay) {
if (event.start == event.end) return start.format(pattern: 'HH:mm');
return '${start.format(pattern: 'HH:mm')}${end.format(pattern: 'HH:mm')}';
}
return '${start.format(pattern: 'dd.MM. HH:mm')}${end.format(pattern: 'dd.MM. HH:mm')}';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: () => _showDetails(context),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 4, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 44,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_dayLabel(),
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
height: 1.1,
),
),
Text(
_monthYearLabel(),
textAlign: TextAlign.center,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.visible,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 10,
height: 1.1,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
event.title.isEmpty ? '(ohne Titel)' : event.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface),
),
if (event.description != null && event.description!.trim().isNotEmpty) ...[
const SizedBox(height: 2),
Text(
event.description!.trim(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
const SizedBox(width: 8),
Text(
_trailingLabel(),
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 4),
IconButton(
icon: _CalendarPlusIcon(color: theme.colorScheme.onSurfaceVariant),
tooltip: 'In Stundenplan übernehmen',
onPressed: () => showDialog(
context: context,
builder: (_) => CustomEventEditDialog(
initialTitle: event.title,
initialDescription: event.description,
initialStart: event.start,
initialEnd: event.end,
),
barrierDismissible: false,
),
),
],
),
),
);
}
void _showDetails(BuildContext context) {
showDialog(
context: context,
builder: (context) => SimpleDialog(
title: Text(event.title.isEmpty ? '(ohne Titel)' : event.title),
children: [
ListTile(
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
title: Text(_formatLongRange()),
),
if (event.description != null && event.description!.trim().isNotEmpty)
ListTile(
leading: const CenteredLeading(Icon(Icons.notes_outlined)),
title: Text(event.description!.trim()),
),
Visibility(
visible: !event.start.difference(DateTime.now()).isNegative,
replacement: ListTile(
leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)),
title: Text(Jiffy.parseFromDateTime(event.start).fromNow()),
),
child: ListTile(
leading: const CenteredLeading(Icon(Icons.timer_outlined)),
title: AnimatedTime(callback: () => event.start.difference(DateTime.now())),
subtitle: Text(Jiffy.parseFromDateTime(event.start).fromNow()),
),
),
DebugTile(context).jsonData(event.toJson()),
],
),
);
}
String _formatLongRange() {
final start = Jiffy.parseFromDateTime(event.start);
final end = Jiffy.parseFromDateTime(event.end);
if (event.isAllDay) {
final inclusiveEnd = event.end.isAfter(event.start) ? end.subtract(days: 1) : end;
final sameAllDay =
start.format(pattern: 'yyyy-MM-dd') == inclusiveEnd.format(pattern: 'yyyy-MM-dd');
return sameAllDay
? '${start.format(pattern: 'dd.MM.yyyy')} · Ganztägig'
: '${start.format(pattern: 'dd.MM.yyyy')} ${inclusiveEnd.format(pattern: 'dd.MM.yyyy')} · Ganztägig';
}
final sameDay = start.format(pattern: 'yyyy-MM-dd') == end.format(pattern: 'yyyy-MM-dd');
if (sameDay) {
if (event.start == event.end) {
return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')}';
}
return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')} ${end.format(pattern: 'HH:mm')}';
}
return '${start.format(pattern: 'dd.MM.yyyy HH:mm')} ${end.format(pattern: 'dd.MM.yyyy HH:mm')}';
}
}
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
import '../../../widget/placeholder_view.dart';
import 'marianum_dates_view.dart';
import 'widgets/event_list_tile.dart';
class SearchMarianumDates extends SearchDelegate<MarianumDate?> {
final List<MarianumDate> events;
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import '../../../../extensions/date_time.dart';
import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
import '../../../../widget/animated_time.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../data/event_formatter.dart';
void showEventDetailsSheet(BuildContext context, MarianumDate event) {
final isUpcoming = !event.start.difference(DateTime.now()).isNegative;
showDetailsBottomSheet(
context,
header: ListTile(
leading: const Icon(Icons.event_outlined, size: 32),
title: Text(
event.title.isEmpty ? '(ohne Titel)' : event.title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
children: (sheetContext) => [
ListTile(
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
title: Text(EventFormatter.longRange(event)),
),
if (event.description != null && event.description!.trim().isNotEmpty)
ListTile(
leading: const CenteredLeading(Icon(Icons.notes_outlined)),
title: Text(event.description!.trim()),
),
if (isUpcoming)
ListTile(
leading: const CenteredLeading(Icon(Icons.timer_outlined)),
title: AnimatedTime(callback: () => event.start.difference(DateTime.now())),
subtitle: Text(event.start.formatRelative()),
)
else
ListTile(
leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)),
title: Text(event.start.formatRelative()),
),
DebugTile(sheetContext).jsonData(event.toJson()),
],
);
}
@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
import '../../timetable/custom_events/custom_event_edit_dialog.dart';
import '../data/event_formatter.dart';
import 'event_details_sheet.dart';
class MarianumDateRow extends StatelessWidget {
final MarianumDate event;
const MarianumDateRow({required this.event, super.key});
String _dayLabel() => event.start.day.toString().padLeft(2, '0');
String _monthYearLabel() =>
'${event.start.month.toString().padLeft(2, '0')}.${event.start.year}';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: () => showEventDetailsSheet(context, event),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 4, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 44,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_dayLabel(),
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
height: 1.1,
),
),
Text(
_monthYearLabel(),
textAlign: TextAlign.center,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.visible,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 10,
height: 1.1,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
event.title.isEmpty ? '(ohne Titel)' : event.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface),
),
if (event.description != null && event.description!.trim().isNotEmpty) ...[
const SizedBox(height: 2),
Text(
event.description!.trim(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
const SizedBox(width: 8),
Text(
EventFormatter.trailingLabel(event),
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 4),
IconButton(
icon: _CalendarPlusIcon(color: theme.colorScheme.onSurfaceVariant),
tooltip: 'In Stundenplan übernehmen',
onPressed: () => showDialog(
context: context,
builder: (_) => CustomEventEditDialog(
initialTitle: event.title,
initialDescription: event.description,
initialStart: event.start,
initialEnd: event.end,
),
barrierDismissible: false,
),
),
],
),
),
);
}
}
/// Composite icon: calendar with a small plus badge in the bottom-right.
/// Material's bundled icon set has no `calendar_add_on`, so we layer
/// `Icons.event_outlined` and `Icons.add` to get the same affordance.
class _CalendarPlusIcon extends StatelessWidget {
final Color color;
const _CalendarPlusIcon({required this.color});
@override
Widget build(BuildContext context) => SizedBox(
width: 22,
height: 22,
child: Stack(
clipBehavior: Clip.none,
children: [
Icon(Icons.event_outlined, size: 22, color: color),
Positioned(
right: -2,
bottom: -2,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
),
padding: const EdgeInsets.all(1),
child: Icon(Icons.add_circle, size: 12, color: color),
),
),
],
),
);
}
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class MonthHeaderDelegate extends SliverPersistentHeaderDelegate {
final String label;
MonthHeaderDelegate({required this.label});
static const double _height = 38;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
final theme = Theme.of(context);
return Container(
height: _height,
color: theme.colorScheme.surfaceContainer,
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
child: Text(
label,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
),
);
}
@override
double get maxExtent => _height;
@override
double get minExtent => _height;
@override
bool shouldRebuild(covariant MonthHeaderDelegate oldDelegate) => oldDelegate.label != label;
}
@@ -4,6 +4,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
import '../../../widget/confirm_dialog.dart';
import '../../../widget/info_dialog.dart';
class MessageView extends StatefulWidget {
final String basePath;
@@ -26,15 +27,11 @@ class _MessageViewState extends State<MessageView> {
enableHyperlinkNavigation: true,
onDocumentLoadFailed: (PdfDocumentLoadFailedDetails e) {
Navigator.of(context).pop();
showDialog(context: context, builder: (context) => AlertDialog(
title: const Text('Fehler beim öffnen'),
content: Text("Dokument '${widget.message.name}' konnte nicht geladen werden:\n${e.description}"),
actions: [
TextButton(onPressed: () {
Navigator.of(context).pop();
}, child: const Text('Ok'))
],
));
InfoDialog.show(
context,
"Dokument '${widget.message.name}' konnte nicht geladen werden:\n${e.description}",
title: 'Fehler beim öffnen',
);
},
onHyperlinkClicked: (PdfHyperlinkClickedDetails e) {
showDialog(
@@ -13,7 +13,7 @@ import '../../../../storage/settings.dart';
import '../../../../storage/talk_settings.dart';
import '../../../../storage/timetable_settings.dart';
import '../../../../view/pages/timetable/data/timetable_name_mode.dart';
import '../../files/files.dart';
import '../../files/data/sort_options.dart';
class DefaultSettings {
static Settings get() => Settings(
@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../notification/notify_updater.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/info_dialog.dart';
class TalkSection extends StatelessWidget {
const TalkSection({super.key});
@@ -51,22 +52,13 @@ class TalkSection extends StatelessWidget {
);
}
void _showInfoDialog(BuildContext context) => showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Info über Push'),
content: const SingleChildScrollView(
child: Text(
"Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n"
'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n'
'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n'
'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n'
'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!',
),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Zurück')),
],
),
void _showInfoDialog(BuildContext context) => InfoDialog.show(
context,
"Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n"
'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n'
'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n'
'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n'
'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!',
title: 'Info über Push',
);
}
+190 -221
View File
@@ -1,26 +1,22 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:jiffy/jiffy.dart';
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message.dart';
import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart';
import '../../../../api/marianumcloud/talk/get_poll/get_poll_state.dart';
import '../../../../api/marianumcloud/talk/react_message/react_message.dart';
import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../extensions/date_time.dart';
import '../../../../extensions/text.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../utils/download_manager.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/loading_spinner.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/info_dialog.dart';
import '../data/chat_bubble_styles.dart';
import '../data/chat_message.dart';
import 'answer_reference.dart';
import 'bubble.dart';
import 'chat_bubble_poll.dart';
import 'chat_bubble_reactions.dart';
import 'chat_message_options_dialog.dart';
import 'poll_options_list.dart';
class ChatBubble extends StatefulWidget {
final BuildContext context;
@@ -54,8 +50,8 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
late ChatMessage message;
DownloadJob? _job;
late Offset _position = const Offset(0, 0);
late Offset _dragStartPosition = Offset.zero;
Offset _position = Offset.zero;
Offset _dragStartPosition = Offset.zero;
@override
void initState() {
@@ -99,7 +95,7 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
DownloadManager.instance.clear(job.remotePath);
_detachJob();
setState(() {});
showDialog<void>(context: context, builder: (context) => AlertDialog(content: Text(message)));
InfoDialog.show(context, message, title: 'Download fehlgeschlagen');
} else if (status is DownloadCancelled) {
DownloadManager.instance.clear(job.remotePath);
_detachJob();
@@ -122,66 +118,69 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
}
void _confirmCancel() {
showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Download abbrechen?'),
content: const Text('Möchtest du den Download abbrechen?'),
actions: [
TextButton(onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Nein')),
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop();
_job?.cancel();
},
child: const Text('Ja, Abbrechen'),
),
],
),
);
ConfirmDialog(
title: 'Download abbrechen?',
content: 'Möchtest du den Download abbrechen?',
confirmButton: 'Ja, Abbrechen',
cancelButton: 'Nein',
onConfirm: () => _job?.cancel(),
).asDialog(context);
}
BubbleStyle getStyle() {
var styles = ChatBubbleStyles(context);
if(widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment) {
if(widget.isSender) {
return styles.getSelfStyle(false);
} else {
return styles.getRemoteStyle(false);
}
} else {
BubbleStyle _getStyle() {
final styles = ChatBubbleStyles(context);
if (widget.bubbleData.messageType != GetRoomResponseObjectMessageType.comment) {
return styles.getSystemStyle();
}
return widget.isSender ? styles.getSelfStyle(false) : styles.getRemoteStyle(false);
}
void showOptionsDialog() {
showChatMessageOptionsDialog(
context,
chatData: widget.chatData,
bubbleData: widget.bubbleData,
isSender: widget.isSender,
onRefetch: widget.refetch,
);
}
void _showOptionsDialog() => showChatMessageOptionsDialog(
context,
chatData: widget.chatData,
bubbleData: widget.bubbleData,
isSender: widget.isSender,
onRefetch: widget.refetch,
);
void _onTap() {
final obj = message.originalData?['object'];
if (obj?.type == RichObjectStringObjectType.talkPoll) {
showChatBubblePollDialog(
context,
chatToken: widget.chatData.token,
messageToken: widget.bubbleData.token,
pollId: int.parse(obj!.id),
pollName: obj.name,
);
return;
}
if (message.file == null) return;
if (_job?.status.value is DownloadInProgress) {
_confirmCancel();
} else {
_startFileDownload();
}
}
@override
Widget build(BuildContext context) {
message = ChatMessage(originalMessage: widget.bubbleData.message, originalData: widget.bubbleData.messageParameters);
var showActorDisplayName = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment && widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
var showBubbleTime = widget.bubbleData.messageType != GetRoomResponseObjectMessageType.system
&& widget.bubbleData.messageType != GetRoomResponseObjectMessageType.deletedComment;
final showActorDisplayName = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment
&& widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
final showBubbleTime = widget.bubbleData.messageType != GetRoomResponseObjectMessageType.system
&& widget.bubbleData.messageType != GetRoomResponseObjectMessageType.deletedComment;
var parent = widget.bubbleData.parent;
var actorText = Text(
final parent = widget.bubbleData.parent;
final actorText = Text(
widget.bubbleData.actorDisplayName,
textAlign: TextAlign.start,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold),
);
var timeText = Text(
Jiffy.parseFromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).format(pattern: 'HH:mm'),
final timeText = Text(
DateTime.fromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).formatHm(),
textAlign: TextAlign.end,
style: TextStyle(color: widget.timeIconColor, fontSize: widget.timeIconSize),
);
@@ -191,191 +190,161 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
mainAxisAlignment: MainAxisAlignment.end,
textDirection: TextDirection.ltr,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
GestureDetector(
onHorizontalDragStart: (details) {
_dragStartPosition = _position;
},
onHorizontalDragStart: (_) => _dragStartPosition = _position,
onHorizontalDragUpdate: (details) {
if(!widget.bubbleData.isReplyable) return;
var dx = details.delta.dx - _dragStartPosition.dx;
if (!widget.bubbleData.isReplyable) return;
final dx = details.delta.dx - _dragStartPosition.dx;
setState(() {
_position = (_position.dx + dx).abs() > 60 ? Offset(_position.dx, 0) : Offset(_position.dx + dx, 0);
_position = (_position.dx + dx).abs() > 60
? Offset(_position.dx, 0)
: Offset(_position.dx + dx, 0);
});
},
onHorizontalDragEnd: (DragEndDetails details) {
var isAction = _position.dx.abs() > 50;
setState(() {
_position = const Offset(0, 0);
});
if(widget.bubbleData.isReplyable && isAction) {
onHorizontalDragEnd: (_) {
final isAction = _position.dx.abs() > 50;
setState(() => _position = Offset.zero);
if (widget.bubbleData.isReplyable && isAction) {
context.read<ChatBloc>().setReferenceMessageId(widget.bubbleData.id);
}
},
onLongPress: showOptionsDialog,
onDoubleTap: showOptionsDialog,
onTap: () {
if(message.originalData?['object']?.type == RichObjectStringObjectType.talkPoll) {
var pollId = int.parse(message.originalData!['object']!.id);
var pollState = GetPollState(token: widget.bubbleData.token, pollId: pollId).run();
showDialog(context: context, builder: (context) => AlertDialog(
title: Text(message.originalData!['object']!.name, overflow: TextOverflow.ellipsis),
content: FutureBuilder(
future: pollState,
builder: (context, snapshot) {
if(snapshot.connectionState == ConnectionState.waiting) return const Column(mainAxisSize: MainAxisSize.min, children: [LoadingSpinner()]);
var pollData = snapshot.data!.data;
return SingleChildScrollView(
child: PollOptionsList(
pollData: pollData,
chatToken: widget.chatData.token,
),
);
}
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Zurück')
),
],
));
}
if (message.file == null) return;
if (_job?.status.value is DownloadInProgress) {
_confirmCancel();
} else {
_startFileDownload();
}
},
onLongPress: _showOptionsDialog,
onDoubleTap: _showOptionsDialog,
onTap: _onTap,
child: Transform.translate(
offset: _position,
child: Bubble(
style: getStyle(),
child: Column(
children: [
Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.9,
minWidth: showActorDisplayName
? actorText.size.width
: timeText.size.width + (widget.isSender ? widget.spacing + widget.timeIconSize : 0) + 3,
),
child: Stack(
children: [
Visibility(
visible: showActorDisplayName,
child: Positioned(
top: 0,
left: 0,
child: actorText
),
),
Padding(
padding: EdgeInsets.only(bottom: showBubbleTime ? 18 : 0, top: showActorDisplayName ? 18 : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if(parent != null && widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment) ...[
AnswerReference(
context: context,
referenceMessage: parent,
selfId: widget.selfId,
),
const SizedBox(height: 5),
],
message.getWidget(),
],
),
),
Visibility(
visible: showBubbleTime,
child: Positioned(
bottom: 0,
right: 0,
child: Row(
children: [
timeText,
if(widget.isSender) ...[
SizedBox(width: widget.spacing),
Icon(
widget.isRead ? Icons.done_all_outlined: Icons.done_outlined,
size: widget.timeIconSize,
color: widget.timeIconColor
)
]
],
)
),
),
if (_job?.status.value is DownloadInProgress)
Positioned(
bottom: 0,
right: 0,
left: 0,
child: LinearProgressIndicator(
value: () {
final s = _job!.status.value as DownloadInProgress;
return s.percent <= 0 ? null : s.percent / 100;
}(),
),
),
],
),
),
],
style: _getStyle(),
child: _BubbleContent(
actorText: actorText,
timeText: timeText,
messageWidget: message.getWidget(),
parent: parent,
bubbleData: widget.bubbleData,
isSender: widget.isSender,
isRead: widget.isRead,
selfId: widget.selfId,
spacing: widget.spacing,
timeIconSize: widget.timeIconSize,
timeIconColor: widget.timeIconColor,
showActorDisplayName: showActorDisplayName,
showBubbleTime: showBubbleTime,
downloadJob: _job,
),
),
),
),
Visibility(
visible: widget.bubbleData.reactions != null,
child: Transform.translate(
offset: const Offset(0, -10),
child: Container(
width: MediaQuery.of(context).size.width,
margin: const EdgeInsets.only(left: 15, right: 15),
child: Wrap(
alignment: widget.isSender ? WrapAlignment.end : WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.start,
children: widget.bubbleData.reactions?.entries.map<Widget>((e) {
var hasSelfReacted = widget.bubbleData.reactionsSelf?.contains(e.key) ?? false;
return Container(
margin: const EdgeInsets.only(right: 2.5, left: 2.5),
child: ActionChip(
label: Text('${e.key} ${e.value}'),
visualDensity: const VisualDensity(vertical: VisualDensity.minimumDensity, horizontal: VisualDensity.minimumDensity),
padding: EdgeInsets.zero,
backgroundColor: hasSelfReacted ? Theme.of(context).primaryColor : null,
onPressed: () {
runWithErrorDialog(context, () async {
if (hasSelfReacted) {
await DeleteReactMessage(
chatToken: widget.chatData.token,
messageId: widget.bubbleData.id,
params: DeleteReactMessageParams(e.key),
).run();
} else {
await ReactMessage(
chatToken: widget.chatData.token,
messageId: widget.bubbleData.id,
params: ReactMessageParams(e.key),
).run();
}
widget.refetch(renew: true);
});
},
),
);
}).toList() ?? [],
),
),
),
ChatBubbleReactions(
bubbleData: widget.bubbleData,
chatData: widget.chatData,
isSender: widget.isSender,
onChanged: widget.refetch,
),
],
);
}
}
/// Stack inside the bubble: actor name (top-left, optional), message body
/// (centre), timestamp + read marker (bottom-right, optional), and a
/// download progress bar overlaid at the bottom while a job is running.
class _BubbleContent extends StatelessWidget {
final Text actorText;
final Text timeText;
final Widget messageWidget;
final GetChatResponseObject? parent;
final GetChatResponseObject bubbleData;
final bool isSender;
final bool isRead;
final String? selfId;
final double spacing;
final double timeIconSize;
final Color timeIconColor;
final bool showActorDisplayName;
final bool showBubbleTime;
final DownloadJob? downloadJob;
const _BubbleContent({
required this.actorText,
required this.timeText,
required this.messageWidget,
required this.parent,
required this.bubbleData,
required this.isSender,
required this.isRead,
required this.selfId,
required this.spacing,
required this.timeIconSize,
required this.timeIconColor,
required this.showActorDisplayName,
required this.showBubbleTime,
required this.downloadJob,
});
@override
Widget build(BuildContext context) => Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.9,
minWidth: showActorDisplayName
? actorText.size.width
: timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3,
),
child: Stack(
children: [
if (showActorDisplayName)
Positioned(top: 0, left: 0, child: actorText),
Padding(
padding: EdgeInsets.only(
bottom: showBubbleTime ? 18 : 0,
top: showActorDisplayName ? 18 : 0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (parent != null && bubbleData.messageType == GetRoomResponseObjectMessageType.comment) ...[
AnswerReference(
context: context,
referenceMessage: parent!,
selfId: selfId,
),
const SizedBox(height: 5),
],
messageWidget,
],
),
),
if (showBubbleTime)
Positioned(
bottom: 0,
right: 0,
child: Row(
children: [
timeText,
if (isSender) ...[
SizedBox(width: spacing),
Icon(
isRead ? Icons.done_all_outlined : Icons.done_outlined,
size: timeIconSize,
color: timeIconColor,
),
],
],
),
),
if (downloadJob?.status.value is DownloadInProgress)
Positioned(
bottom: 0,
right: 0,
left: 0,
child: LinearProgressIndicator(
value: () {
final s = downloadJob!.status.value as DownloadInProgress;
return s.percent <= 0 ? null : s.percent / 100;
}(),
),
),
],
),
);
}
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import '../../../../api/marianumcloud/talk/get_poll/get_poll_state.dart';
import '../../../../widget/loading_spinner.dart';
import 'poll_options_list.dart';
/// Opens the poll dialog that lets a user vote on a Talk poll attached to
/// a message. Loads the poll state lazily and renders the option list.
void showChatBubblePollDialog(
BuildContext context, {
required String chatToken,
required String messageToken,
required int pollId,
required String pollName,
}) {
final pollState = GetPollState(token: messageToken, pollId: pollId).run();
showDialog<void>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: Text(pollName, overflow: TextOverflow.ellipsis),
content: FutureBuilder(
future: pollState,
builder: (_, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Column(mainAxisSize: MainAxisSize.min, children: [LoadingSpinner()]);
}
final pollData = snapshot.data!.data;
return SingleChildScrollView(
child: PollOptionsList(
pollData: pollData,
chatToken: chatToken,
),
);
},
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(),
child: const Text('Zurück'),
),
],
),
);
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message.dart';
import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart';
import '../../../../api/marianumcloud/talk/react_message/react_message.dart';
import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../widget/async_action_button.dart';
/// Reactions wrap shown beneath a chat bubble. Tapping a reaction toggles
/// the user's own reaction via the Talk API and notifies via [onChanged].
class ChatBubbleReactions extends StatelessWidget {
final GetChatResponseObject bubbleData;
final GetRoomResponseObject chatData;
final bool isSender;
final void Function({bool renew}) onChanged;
const ChatBubbleReactions({
required this.bubbleData,
required this.chatData,
required this.isSender,
required this.onChanged,
super.key,
});
@override
Widget build(BuildContext context) {
final reactions = bubbleData.reactions;
if (reactions == null) return const SizedBox.shrink();
return Transform.translate(
offset: const Offset(0, -10),
child: Container(
width: MediaQuery.of(context).size.width,
margin: const EdgeInsets.only(left: 15, right: 15),
child: Wrap(
alignment: isSender ? WrapAlignment.end : WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.start,
children: reactions.entries.map<Widget>((e) {
final hasSelfReacted = bubbleData.reactionsSelf?.contains(e.key) ?? false;
return Container(
margin: const EdgeInsets.only(right: 2.5, left: 2.5),
child: ActionChip(
label: Text('${e.key} ${e.value}'),
visualDensity: const VisualDensity(vertical: VisualDensity.minimumDensity, horizontal: VisualDensity.minimumDensity),
padding: EdgeInsets.zero,
backgroundColor: hasSelfReacted ? Theme.of(context).primaryColor : null,
onPressed: () {
runWithErrorDialog(context, () async {
if (hasSelfReacted) {
await DeleteReactMessage(
chatToken: chatData.token,
messageId: bubbleData.id,
params: DeleteReactMessageParams(e.key),
).run();
} else {
await ReactMessage(
chatToken: chatData.token,
messageId: bubbleData.id,
params: ReactMessageParams(e.key),
).run();
}
onChanged(renew: true);
});
},
),
);
}).toList(),
),
),
);
}
}
@@ -1,7 +1,6 @@
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emojis;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/marianumcloud/talk/actions/talk_actions.dart';
@@ -11,6 +10,7 @@ import '../../../../api/marianumcloud/talk/react_message/react_message_params.da
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../utils/clipboard_helper.dart';
import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/debug/debug_tile.dart';
@@ -69,7 +69,7 @@ Future<void> showChatMessageOptionsDialog(
leading: const Icon(Icons.copy),
title: const Text('Nachricht kopieren'),
onTap: () {
Clipboard.setData(ClipboardData(text: bubbleData.message));
copyToClipboard(parentContext, bubbleData.message);
Navigator.of(dialogCtx).pop();
},
),
+34 -30
View File
@@ -14,6 +14,7 @@ import '../../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/file_pick.dart';
import '../../../../widget/focus_behaviour.dart';
import '../../files/files_upload_dialog.dart';
@@ -172,36 +173,39 @@ class _ChatTextfieldState extends State<ChatTextfield> {
Row(children: <Widget>[
GestureDetector(
onTap: () {
showDialog(context: context, builder: (dialogCtx) => SimpleDialog(children: [
ListTile(
leading: const Icon(Icons.file_open),
title: const Text('Aus Dateien auswählen'),
onTap: () {
FilePick.documentPick().then(mediaUpload);
Navigator.of(dialogCtx).pop();
},
),
ListTile(
leading: const Icon(Icons.image),
title: const Text('Aus Galerie auswählen'),
onTap: () {
FilePick.multipleGalleryPick().then((value) {
if (value != null) mediaUpload(value.map((e) => e.path).toList());
});
Navigator.of(dialogCtx).pop();
},
),
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: const Text('Foto aufnehmen'),
onTap: () {
FilePick.cameraPick().then((image) {
if (image != null) mediaUpload([image.path]);
});
Navigator.of(dialogCtx).pop();
},
),
]));
showDetailsBottomSheet(
context,
children: (sheetCtx) => [
ListTile(
leading: const Icon(Icons.file_open),
title: const Text('Aus Dateien auswählen'),
onTap: () {
FilePick.documentPick().then(mediaUpload);
Navigator.of(sheetCtx).pop();
},
),
ListTile(
leading: const Icon(Icons.image),
title: const Text('Aus Galerie auswählen'),
onTap: () {
FilePick.multipleGalleryPick().then((value) {
if (value != null) mediaUpload(value.map((e) => e.path).toList());
});
Navigator.of(sheetCtx).pop();
},
),
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: const Text('Foto aufnehmen'),
onTap: () {
FilePick.cameraPick().then((image) {
if (image != null) mediaUpload([image.path]);
});
Navigator.of(sheetCtx).pop();
},
),
],
);
},
child: Material(
elevation: 5,
+2 -2
View File
@@ -2,13 +2,13 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:jiffy/jiffy.dart';
import '../../../../api/marianumcloud/talk/actions/talk_actions.dart';
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart';
import '../../../../extensions/date_time.dart';
import '../../../../model/account_data.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
@@ -96,7 +96,7 @@ class _ChatTileState extends State<ChatTile> {
],
),
subtitle: Text(
'${Jiffy.parseFromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).fromNow()}: '
'${DateTime.fromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).formatRelative()}: '
'${RichObjectStringProcessor.parseToString(widget.data.lastMessage.message.replaceAll("\n", " "), widget.data.lastMessage.messageParameters)}',
overflow: TextOverflow.ellipsis,
),
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import '../../../../extensions/date_time.dart';
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
@@ -51,7 +51,7 @@ class CustomEventsView extends StatelessWidget {
title: Text(e.title),
subtitle: Text(
'${e.rrule.isNotEmpty ? "wiederholend, " : ""}'
'beginnend ${Jiffy.parseFromDateTime(e.startDate).fromNow()}',
'beginnend ${e.startDate.formatRelative()}',
),
leading: CenteredLeading(Icon(e.rrule.isEmpty ? Icons.event_outlined : Icons.event_repeat_outlined)),
trailing: Row(
@@ -0,0 +1,356 @@
import 'package:rrule/rrule.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../extensions/date_time.dart';
import 'arbitrary_appointment.dart';
import 'calendar_layout.dart';
import 'lesson_period_schedule.dart';
/// Either explicitly marked as all-day, or so long it's effectively a full
/// day from the user's perspective. We compare in minutes (not hours) because
/// `Duration.inHours` truncates: a 9h 30min event would otherwise count as 9.
bool isAllDayLike(Appointment a) =>
a.isAllDay || a.endTime.difference(a.startTime).inMinutes >= 8 * 60;
/// True when the appointment doesn't fit into the school-hours grid:
/// all-day, fully before the grid start, fully after the grid end, engulfing
/// the grid entirely, or lasting 10+ hours (treated as a de-facto all-day
/// event the source system happens to represent with explicit times).
bool isOutsideSchoolHours(Appointment a) {
if (isAllDayLike(a)) return true;
final schoolStart = (kCalendarStartHour * 60).round();
final schoolEnd = (kCalendarEndHour * 60).round();
final startMin = a.startTime.hour * 60 + a.startTime.minute;
final endMin = a.endTime.hour * 60 + a.endTime.minute;
if (endMin <= schoolStart) return true;
if (startMin >= schoolEnd) return true;
if (startMin <= schoolStart && endMin >= schoolEnd) return true;
return false;
}
int dayIndex(DateTime t, DateTime weekStart) =>
DateTime(t.year, t.month, t.day).difference(weekStart).inDays;
class BoundRegion {
final TimeRegion region;
final DateTime start;
final DateTime end;
BoundRegion({required this.region, required this.start, required this.end});
}
List<BoundRegion> expandRegionsForDay(List<TimeRegion> regions, DateTime day) {
final result = <BoundRegion>[];
final dayStart = DateTime(day.year, day.month, day.day);
for (final region in regions) {
final isRecurringDaily = region.recurrenceRule != null &&
region.recurrenceRule!.toUpperCase().contains('FREQ=DAILY');
if (isRecurringDaily) {
final start = dayStart.add(Duration(
hours: region.startTime.hour,
minutes: region.startTime.minute,
));
final end = dayStart.add(Duration(
hours: region.endTime.hour,
minutes: region.endTime.minute,
));
result.add(BoundRegion(region: region, start: start, end: end));
} else if (region.startTime.isSameDay(day)) {
result.add(BoundRegion(
region: region,
start: region.startTime,
end: region.endTime,
));
}
}
return result;
}
/// Expands the given list of appointments across the visible 5-day work week
/// (resolving RRULE recurrences) and splits each day's events into two
/// buckets: those that fit within the school-hours grid (`inside`) and those
/// that don't (`outside` — all-day events and events that start before
/// [kCalendarStartHour] or end after [kCalendarEndHour]). The outside bucket
/// is rendered as chips above the grid.
({List<List<Appointment>> inside, List<List<Appointment>> outside})
partitionAppointmentsForWeek(
List<Appointment> appointments, DateTime weekStart) {
final inside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
final outside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
final weekEnd = weekStart.add(const Duration(days: 5));
final weekStartUtc = weekStart.toUtc();
final weekEndUtc = weekEnd.toUtc();
void place(int idx, Appointment a) {
if (isOutsideSchoolHours(a)) {
outside[idx].add(a);
} else {
inside[idx].add(a);
}
}
for (final a in appointments) {
final rule = a.recurrenceRule;
if (rule == null || rule.isEmpty) {
final idx = dayIndex(a.startTime, weekStart);
if (idx >= 0 && idx < 5) place(idx, a);
continue;
}
try {
final parsed = RecurrenceRule.fromString(rule);
final anchorUtc = a.startTime.toUtc();
final duration = a.endTime.difference(a.startTime);
for (final occUtc in parsed.getInstances(start: anchorUtc)) {
if (!occUtc.isBefore(weekEndUtc)) break;
if (occUtc.isBefore(weekStartUtc)) continue;
final occLocal = occUtc.toLocal();
final idx = DateTime(occLocal.year, occLocal.month, occLocal.day)
.difference(weekStart)
.inDays;
if (idx < 0 || idx >= 5) continue;
final newStart = DateTime(occLocal.year, occLocal.month, occLocal.day,
a.startTime.hour, a.startTime.minute);
place(
idx,
Appointment(
id: a.id,
startTime: newStart,
endTime: newStart.add(duration),
subject: a.subject,
color: a.color,
location: a.location,
notes: a.notes,
isAllDay: a.isAllDay,
),
);
}
} catch (_) {
final idx = dayIndex(a.startTime, weekStart);
if (idx >= 0 && idx < 5) place(idx, a);
}
}
return (inside: inside, outside: outside);
}
/// 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 yOfDateTime(DateTime t) {
final tMin = t.hour * 60 + t.minute + t.second / 60.0;
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;
}
/// 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;
}
}
/// 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);
}
/// Horizontal ordering rank for parallel appointments. Lower = further left.
/// User-owned custom events sit on the leftmost lane, cancelled lessons after
/// them, every other lesson last. Only used as a tiebreaker — the greedy lane
/// assignment still has to honor actual time-overlap constraints, so events
/// that start later can't jump left of events that started earlier and are
/// still occupying that lane.
int _appointmentPriority(Appointment a) {
final id = a.id;
if (id is CustomAppointment) return 0;
if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 1;
return 2;
}
/// Assigns each appointment a lane index using a greedy sweep, then collapses
/// clusters that exceed [maxLanes] into `(maxLanes - 1)` visible appointments
/// + one trailing overflow cell.
///
/// Greedy sweep:
/// 1. Sort by `startTime` ascending, then [_appointmentPriority] (custom →
/// cancelled → other) so parallel events land in the requested left-to-
/// right order, then `endTime` descending as a final tiebreaker.
/// 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, {required int maxLanes}) {
assert(maxLanes >= 2, 'maxLanes must reserve at least one slot for overflow');
if (appts.isEmpty) return const <LaidOutCell>[];
final sorted = [...appts]..sort((a, b) {
final c = a.startTime.compareTo(b.startTime);
if (c != 0) return c;
final p = _appointmentPriority(a).compareTo(_appointmentPriority(b));
if (p != 0) return p;
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 <= maxLanes) {
for (final entry in cluster) {
result.add(LaidOutAppointment(entry.apt, entry.lane, laneCount));
}
} else {
// Too many parallel appointments: keep the highest-priority
// (maxLanes - 1) and collapse the rest into a single overflow cell in
// the trailing lane. Sorting by priority first means custom and
// cancelled lessons stay visible when the cluster has to be trimmed,
// matching the requested left-to-right order in the visible lanes.
final visibleCount = maxLanes - 1;
final byPriority = [...cluster.map((e) => e.apt)]
..sort((a, b) {
final p = _appointmentPriority(a).compareTo(_appointmentPriority(b));
if (p != 0) return p;
return a.startTime.compareTo(b.startTime);
});
for (var i = 0; i < visibleCount; i++) {
result.add(LaidOutAppointment(byPriority[i], i, maxLanes));
}
final overflow = byPriority.sublist(visibleCount);
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, maxLanes - 1, maxLanes, earliest, latest));
}
}
return result;
}
@@ -1,32 +0,0 @@
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 header,
required List<Widget> Function(BuildContext sheetContext) children,
}) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
useSafeArea: true,
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),
],
),
),
),
);
}
@@ -1,21 +1,19 @@
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:rrule/rrule.dart';
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../extensions/date_time.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../custom_events/custom_event_edit_dialog.dart';
import 'bottom_sheet.dart';
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')}';
final timeRange = event.startDate.timeRangeTo(event.endDate);
showAppointmentBottomSheet(
showDetailsBottomSheet(
context,
header: ListTile(
leading: const Icon(Icons.event_outlined, size: 32),
@@ -1,38 +1,35 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart';
import '../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart';
import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart';
import '../../../../api/webuntis/services/lesson_resolver.dart';
import '../../../../extensions/date_time.dart';
import '../../../../extensions/text.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/unimplemented_dialog.dart';
import 'bottom_sheet.dart';
class WebuntisLessonSheet {
static void show(BuildContext context, TimetableBloc bloc, Appointment appointment, GetTimetableResponseObject lesson) {
final state = bloc.state.data;
if (state == null) return;
final headerSubject = _resolveSubject(state, lesson.su.firstOrNull?.id);
final headerTitle = _firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']);
final headerSubject = LessonResolver.resolveSubject(state, lesson.su.firstOrNull?.id);
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')}';
final timeRange = appointment.startTime.timeRangeTo(appointment.endTime);
showAppointmentBottomSheet(
showDetailsBottomSheet(
context,
header: ListTile(
leading: Icon(_iconForCode(lesson.code), size: 32),
leading: Icon(LessonFormatter.iconForCode(lesson.code), size: 32),
title: Text(
'${_codePrefix(lesson.code)}$headerTitle',
'${LessonFormatter.codePrefix(lesson.code)}$headerTitle',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(headerLongName.isNotEmpty
@@ -43,17 +40,17 @@ class WebuntisLessonSheet {
children: (_) => <Widget>[
ListTile(
leading: const Icon(Icons.notifications_active),
title: Text('Status: ${_statusLabel(lesson.code)}'),
title: Text('Status: ${LessonFormatter.statusLabel(lesson.code)}'),
),
if (lesson.su.length > 1)
_listTile(
icon: Icons.book_outlined,
label: 'Fächer',
entries: lesson.su.map((s) {
final resolved = _resolveSubject(state, s.id);
return _formatLine(
_firstNonEmpty([resolved.name, s.name, '?']),
longname: _firstNonEmpty([resolved.longName, s.longname, '']),
final resolved = LessonResolver.resolveSubject(state, s.id);
return LessonFormatter.formatLine(
firstNonEmpty([resolved.name, s.name, '?']),
longname: firstNonEmpty([resolved.longName, s.longname, '']),
);
}).toList(),
),
@@ -69,7 +66,7 @@ class WebuntisLessonSheet {
icon: Icons.people,
label: lesson.kl.length == 1 ? 'Klasse' : 'Klassen',
entries: lesson.kl
.map((k) => _formatLine(
.map((k) => LessonFormatter.formatLine(
k.name.isNotEmpty ? k.name : '?',
longname: k.longname,
))
@@ -81,17 +78,6 @@ class WebuntisLessonSheet {
);
}
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),
@@ -107,11 +93,11 @@ class WebuntisLessonSheet {
}
final entries = lesson.ro.map((r) {
final resolved = _resolveRoom(state, r.id);
final name = _firstNonEmpty([resolved.name, r.name, '?']);
final longname = _firstNonEmpty([resolved.longName, r.longname, '']);
final resolved = LessonResolver.resolveRoom(state, r.id);
final name = firstNonEmpty([resolved.name, r.name, '?']);
final longname = firstNonEmpty([resolved.longName, r.longname, '']);
final building = resolved.building.trim();
return _formatLine(
return LessonFormatter.formatLine(
name,
longname: longname,
extra: (building.isNotEmpty && building != '?') ? building : null,
@@ -144,7 +130,7 @@ class WebuntisLessonSheet {
}
final entries = lesson.te.map((t) {
final base = _formatLine(
final base = LessonFormatter.formatLine(
t.name.isNotEmpty ? t.name : '?',
longname: t.longname,
);
@@ -206,54 +192,4 @@ class WebuntisLessonSheet {
subtitle: Text(text),
);
}
static String _formatLine(String name, {String? longname, String? extra}) {
final parts = <String>[
if (name.isNotEmpty) name else '?',
];
final ln = (longname ?? '').trim();
if (ln.isNotEmpty && ln != name) parts.add('($ln)');
final ex = (extra ?? '').trim();
if (ex.isNotEmpty) parts.add('· $ex');
return parts.join(' ');
}
static String _firstNonEmpty(List<String> values) {
for (final v in values) {
if (v.trim().isNotEmpty) return v;
}
return '';
}
static String _statusLabel(String? code) {
switch (code) {
case null:
case '':
return 'Regulär';
case 'cancelled':
return 'Entfällt';
case 'irregular':
return 'Geändert';
default:
return code;
}
}
static String _codePrefix(String? code) {
if (code == 'cancelled') return 'Entfällt: ';
if (code == 'irregular') return 'Änderung: ';
return code ?? '';
}
static GetSubjectsResponseObject _resolveSubject(TimetableState state, int? id) {
final fallback = GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true);
if (id == null) return fallback;
return state.subjects?.result.firstWhereOrNull((s) => s.id == id) ?? fallback;
}
static GetRoomsResponseObject _resolveRoom(TimetableState state, int? id) {
final fallback = GetRoomsResponseObject(0, '?', 'Unbekannt', true, '');
if (id == null) return fallback;
return state.rooms?.result.firstWhereOrNull((r) => r.id == id) ?? fallback;
}
}
@@ -0,0 +1,84 @@
part of '../custom_workweek_calendar.dart';
class _DayHeaderStrip extends StatelessWidget {
final DateTime weekStart;
final DateTime today;
final double rulerWidth;
const _DayHeaderStrip({
super.key,
required this.weekStart,
required this.today,
required this.rulerWidth,
});
@override
Widget build(BuildContext context) => Row(
children: [
SizedBox(width: rulerWidth),
for (var d = 0; d < 5; d++)
Expanded(
child: _DayHeaderCell(
date: weekStart.add(Duration(days: d)),
today: today,
),
),
],
);
}
class _DayHeaderCell extends StatelessWidget {
final DateTime date;
final DateTime today;
const _DayHeaderCell({required this.date, required this.today});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isToday = date.isSameDay(today);
final dayName = DateFormat('EE', Localizations.localeOf(context).toString()).format(date).toUpperCase();
final accent = theme.colorScheme.primary;
final onAccent = theme.colorScheme.onPrimary;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
dayName,
style: theme.textTheme.labelSmall?.copyWith(
color: isToday ? accent : theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
height: 1.1,
),
),
const SizedBox(height: 2),
AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isToday ? accent : Colors.transparent,
),
alignment: Alignment.center,
child: Text(
'${date.day}',
style: theme.textTheme.titleSmall?.copyWith(
color: isToday ? onAccent : theme.colorScheme.onSurface,
fontWeight: isToday ? FontWeight.bold : FontWeight.normal,
height: 1.0,
),
),
),
],
),
);
}
}
@@ -0,0 +1,271 @@
part of '../custom_workweek_calendar.dart';
class _OutsideHoursStrip extends StatelessWidget {
static const int _maxVisibleChips = 2;
static const double _chipHeight = 22;
static const double _chipSpacing = 3;
static const double _verticalPadding = 3;
final DateTime weekStart;
final List<Appointment> appointments;
final double rulerWidth;
final void Function(Appointment) onAppointmentTap;
final bool Function(Appointment) isCrossedOut;
const _OutsideHoursStrip({
super.key,
required this.weekStart,
required this.appointments,
required this.rulerWidth,
required this.onAppointmentTap,
required this.isCrossedOut,
});
@override
Widget build(BuildContext context) {
final outside = partitionAppointmentsForWeek(appointments, weekStart).outside;
if (outside.every((day) => day.isEmpty)) return const SizedBox.shrink();
final theme = Theme.of(context);
final maxChipsPerDay = outside
.map((day) => day.length > _maxVisibleChips ? _maxVisibleChips : day.length)
.fold<int>(0, (m, c) => c > m ? c : m);
final stripHeight = _verticalPadding * 2 +
maxChipsPerDay * _chipHeight +
(maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * _chipSpacing : 0);
return Container(
color: theme.colorScheme.surfaceContainerLowest,
padding: const EdgeInsets.symmetric(vertical: _verticalPadding),
child: SizedBox(
height: stripHeight - _verticalPadding * 2,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: rulerWidth),
for (var d = 0; d < 5; d++)
Expanded(
child: _OutsideDayColumn(
appointments: outside[d],
maxVisible: _maxVisibleChips,
chipHeight: _chipHeight,
chipSpacing: _chipSpacing,
onAppointmentTap: onAppointmentTap,
isCrossedOut: isCrossedOut,
),
),
],
),
),
);
}
}
class _OutsideDayColumn extends StatelessWidget {
final List<Appointment> appointments;
final int maxVisible;
final double chipHeight;
final double chipSpacing;
final void Function(Appointment) onAppointmentTap;
final bool Function(Appointment) isCrossedOut;
const _OutsideDayColumn({
required this.appointments,
required this.maxVisible,
required this.chipHeight,
required this.chipSpacing,
required this.onAppointmentTap,
required this.isCrossedOut,
});
void _showOverflow(BuildContext context, List<Appointment> hidden) {
showDetailsBottomSheet(
context,
children: (sheetCtx) {
final tiles = <Widget>[];
for (var i = 0; i < hidden.length; i++) {
if (i > 0) tiles.add(const Divider(height: 1));
final apt = hidden[i];
tiles.add(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(_subtitleFor(apt)),
onTap: () {
Navigator.of(sheetCtx).pop();
onAppointmentTap(apt);
},
));
}
return tiles;
},
);
}
static String _subtitleFor(Appointment a) {
if (isAllDayLike(a)) return 'Ganztägig';
return '${_hm(a.startTime)}${_hm(a.endTime)}';
}
static String _hm(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
@override
Widget build(BuildContext context) {
if (appointments.isEmpty) return const SizedBox.shrink();
final sorted = [...appointments]
..sort((a, b) {
final aLike = isAllDayLike(a);
final bLike = isAllDayLike(b);
if (aLike && !bLike) return -1;
if (!aLike && bLike) return 1;
return a.startTime.compareTo(b.startTime);
});
final visible = sorted.length <= maxVisible
? sorted
: sorted.take(maxVisible - 1).toList();
final overflow =
sorted.length <= maxVisible ? const <Appointment>[] : sorted.skip(maxVisible - 1).toList();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < visible.length; i++) ...[
if (i > 0) SizedBox(height: chipSpacing),
SizedBox(
height: chipHeight,
child: _OutsideChip(
appointment: visible[i],
onTap: () => onAppointmentTap(visible[i]),
),
),
],
if (overflow.isNotEmpty) ...[
SizedBox(height: chipSpacing),
SizedBox(
height: chipHeight,
child: _OutsideOverflowChip(
count: overflow.length,
onTap: () => _showOverflow(context, overflow),
),
),
],
],
),
);
}
}
class _OutsideChip extends StatelessWidget {
final Appointment appointment;
final VoidCallback onTap;
const _OutsideChip({required this.appointment, required this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final allDay = isAllDayLike(appointment);
final timeLabel = allDay
? null
: '${appointment.startTime.hour.toString().padLeft(2, '0')}:${appointment.startTime.minute.toString().padLeft(2, '0')}';
// Past chips fade further, future/ongoing ones get a more saturated tint
// so the strip no longer reads as one uniform grey block.
final isPast = appointment.endTime.isBefore(DateTime.now());
final backgroundAlpha = isPast ? 38 : 120;
final subjectColor = isPast
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.onSurface;
final subjectWeight = isPast ? FontWeight.w400 : FontWeight.w600;
return Material(
color: appointment.color.withAlpha(backgroundAlpha),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(7)),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: Text(
appointment.subject,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: theme.textTheme.labelSmall?.copyWith(
color: subjectColor,
fontWeight: subjectWeight,
),
),
),
if (timeLabel != null) ...[
const SizedBox(width: 4),
Flexible(
child: Text(
timeLabel,
maxLines: 1,
overflow: TextOverflow.fade,
softWrap: false,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 10,
),
),
),
],
],
),
),
),
);
}
}
class _OutsideOverflowChip extends StatelessWidget {
final int count;
final VoidCallback onTap;
const _OutsideOverflowChip({required this.count, required this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.secondaryContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Center(
child: Text(
'+$count weitere',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
),
),
);
}
}
@@ -0,0 +1,489 @@
part of '../custom_workweek_calendar.dart';
class _WeekGrid extends StatelessWidget {
final DateTime weekStart;
final LessonPeriodSchedule schedule;
final List<Appointment> appointments;
final List<TimeRegion> timeRegions;
final void Function(Appointment) onAppointmentTap;
final bool Function(Appointment) isCrossedOut;
final void Function(DateTime start, DateTime end)? onCreateEvent;
final DateTime today;
final ValueListenable<DateTime> nowNotifier;
final double rulerWidth;
final PeriodLayout layout;
const _WeekGrid({
required this.weekStart,
required this.schedule,
required this.appointments,
required this.timeRegions,
required this.onAppointmentTap,
required this.isCrossedOut,
required this.onCreateEvent,
required this.today,
required this.nowNotifier,
required this.rulerWidth,
required this.layout,
});
@override
Widget build(BuildContext context) {
final partitioned = partitionAppointmentsForWeek(appointments, weekStart);
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PeriodRuler(
schedule: schedule,
layout: layout,
width: rulerWidth,
),
for (var d = 0; d < 5; d++)
Expanded(
child: _DayColumn(
date: weekStart.add(Duration(days: d)),
schedule: schedule,
appointments: partitioned.inside[d],
timeRegions: timeRegions,
layout: layout,
today: today,
nowNotifier: nowNotifier,
onAppointmentTap: onAppointmentTap,
isCrossedOut: isCrossedOut,
onCreateEvent: onCreateEvent,
),
),
],
);
}
}
class _PeriodRuler extends StatelessWidget {
final LessonPeriodSchedule schedule;
final PeriodLayout layout;
final double width;
const _PeriodRuler({
required this.schedule,
required this.layout,
required this.width,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: width,
child: Stack(
clipBehavior: Clip.none,
children: [
for (final period in schedule.periods)
Positioned(
top: layout.topOf(period),
height: layout.heightOf(period),
left: 0,
right: 0,
child: _PeriodLabel(period: period, theme: theme),
),
],
),
);
}
}
class _PeriodLabel extends StatelessWidget {
final LessonPeriod period;
final ThemeData theme;
const _PeriodLabel({required this.period, required this.theme});
@override
Widget build(BuildContext context) {
final dividerColor = theme.dividerColor.withAlpha(110);
final secondaryTextColor = theme.colorScheme.onSurfaceVariant;
if (period.isBreak) {
return Container(
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: dividerColor, width: 0.5),
bottom: BorderSide(color: dividerColor, width: 0.5),
),
),
alignment: Alignment.center,
child: Icon(Icons.coffee_outlined, size: 12, color: secondaryTextColor.withAlpha(180)),
);
}
final timeStyle = theme.textTheme.labelSmall?.copyWith(
color: secondaryTextColor.withAlpha(140),
height: 1.0,
fontSize: 9,
);
const tightTextHeight = TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
);
return LayoutBuilder(
builder: (context, constraints) {
final showTimes = constraints.maxHeight >= 38;
return DecoratedBox(
decoration: BoxDecoration(
border: Border(top: BorderSide(color: dividerColor, width: 0.5)),
),
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
if (showTimes)
Positioned(
top: 3,
left: 0,
right: 0,
child: Text(
_format(period.start),
style: timeStyle,
textAlign: TextAlign.center,
textHeightBehavior: tightTextHeight,
),
),
Text(
period.name,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w500,
height: 1.0,
),
textAlign: TextAlign.center,
textHeightBehavior: tightTextHeight,
),
if (showTimes)
Positioned(
bottom: 3,
left: 0,
right: 0,
child: Text(
_format(period.end),
style: timeStyle,
textAlign: TextAlign.center,
textHeightBehavior: tightTextHeight,
),
),
],
),
);
},
);
}
static String _format(TimeOfDay t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
}
class _DayColumn extends StatelessWidget {
final DateTime date;
final LessonPeriodSchedule schedule;
final List<Appointment> appointments;
final List<TimeRegion> timeRegions;
final PeriodLayout layout;
final DateTime today;
final ValueListenable<DateTime> nowNotifier;
final void Function(Appointment) onAppointmentTap;
final bool Function(Appointment) isCrossedOut;
final void Function(DateTime start, DateTime end)? onCreateEvent;
const _DayColumn({
required this.date,
required this.schedule,
required this.appointments,
required this.timeRegions,
required this.layout,
required this.today,
required this.nowNotifier,
required this.onAppointmentTap,
required this.isCrossedOut,
required this.onCreateEvent,
});
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;
}
return false;
}
void _handleLongPress(LongPressStartDetails details, List<Appointment> dayAppts) {
if (onCreateEvent == null) return;
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);
final end = DateTime(date.year, date.month, date.day, period.end.hour, period.end.minute);
if (_overlapsExistingAppointment(start, end, dayAppts)) return;
HapticFeedback.mediumImpact();
onCreateEvent!(start, end);
}
void _showOverflowSheet(BuildContext context, List<Appointment> appointments) {
final sorted = [...appointments]
..sort((a, b) => a.startTime.compareTo(b.startTime));
showDetailsBottomSheet(
context,
children: (sheetContext) {
final tiles = <Widget>[];
for (var i = 0; i < sorted.length; i++) {
if (i > 0) tiles.add(const Divider(height: 1));
final apt = sorted[i];
tiles.add(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);
},
));
}
return tiles;
},
);
}
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);
final dayAppointments = appointments;
final dayRegions = expandRegionsForDay(timeRegions, date);
final isToday = date.isSameDay(today);
final isTablet = MediaQuery.of(context).size.shortestSide >= 600;
final laidOut = assignLanes(dayAppointments, maxLanes: isTablet ? 3 : 2);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onLongPressStart: (details) => _handleLongPress(details, dayAppointments),
child: DecoratedBox(
decoration: BoxDecoration(
color: isToday ? theme.colorScheme.primary.withAlpha(14) : null,
border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)),
),
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),
),
),
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),
),
],
);
},
),
),
);
}
}
class _CurrentTimeMarker extends StatelessWidget {
final DateTime now;
final PeriodLayout layout;
final ThemeData theme;
const _CurrentTimeMarker({
required this.now,
required this.layout,
required this.theme,
});
@override
Widget build(BuildContext context) {
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),
curve: Curves.easeInOut,
top: y - 1,
left: 0,
right: 0,
child: IgnorePointer(
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
height: 2,
color: theme.colorScheme.primary,
),
Positioned(
top: -3,
left: -4,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
),
),
],
),
),
);
}
}
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,
),
),
],
),
),
),
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff