merged develop
This commit is contained in:
@@ -21,7 +21,7 @@ class SortOptions {
|
||||
SortOption.name: BetterSortOption(
|
||||
displayName: 'Name',
|
||||
icon: Icons.sort_by_alpha_outlined,
|
||||
compare: (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||
compare: (a, b) => a.lowerName.compareTo(b.lowerName),
|
||||
),
|
||||
SortOption.date: BetterSortOption(
|
||||
displayName: 'Datum',
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.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_response.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';
|
||||
@@ -47,6 +49,22 @@ class _FilesViewState extends State<_FilesView> {
|
||||
late bool currentSortDirection;
|
||||
late final StreamSubscription<String> _invalidationSub;
|
||||
|
||||
// The list builder also runs for loading flips and parent rebuilds; only a
|
||||
// new listing or a changed sort needs another sort pass.
|
||||
Object? _sortedKey;
|
||||
List<CacheableFile> _sortedFiles = const [];
|
||||
|
||||
List<CacheableFile> _sorted(ListFilesResponse listing, bool foldersToTop) {
|
||||
final key = (listing, currentSort, currentSortDirection, foldersToTop);
|
||||
if (key == _sortedKey) return _sortedFiles;
|
||||
_sortedKey = key;
|
||||
return _sortedFiles = listing.sortBy(
|
||||
sortOption: currentSort,
|
||||
foldersToTop: foldersToTop,
|
||||
reversed: currentSortDirection,
|
||||
);
|
||||
}
|
||||
|
||||
// Cache key in FilesBloc's pathString format: '/' for root, otherwise
|
||||
// segments joined without leading/trailing slash.
|
||||
String get _myPathString => widget.path.isEmpty ? '/' : widget.path.join('/');
|
||||
@@ -98,6 +116,11 @@ class _FilesViewState extends State<_FilesView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bloc = context.read<FilesBloc>();
|
||||
// Selected here, not in the LoadableStateConsumer child: that closure runs
|
||||
// during the consumer's build, where this context may not subscribe.
|
||||
final foldersToTop = context.select(
|
||||
(SettingsCubit c) => c.state.fileSettings.sortFoldersToTop,
|
||||
);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'),
|
||||
@@ -153,15 +176,7 @@ class _FilesViewState extends State<_FilesView> {
|
||||
text: 'Der Ordner ist leer',
|
||||
);
|
||||
}
|
||||
final files = listing.sortBy(
|
||||
sortOption: currentSort,
|
||||
foldersToTop: context
|
||||
.watch<SettingsCubit>()
|
||||
.val()
|
||||
.fileSettings
|
||||
.sortFoldersToTop,
|
||||
reversed: currentSortDirection,
|
||||
);
|
||||
final files = _sorted(listing, foldersToTop);
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: files.length,
|
||||
|
||||
@@ -53,15 +53,6 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void showHttpErrorCode(int httpErrorCode) {
|
||||
InfoDialog.show(
|
||||
context,
|
||||
'Error code: $httpErrorCode',
|
||||
title: 'Ein Fehler ist aufgetreten',
|
||||
copyable: true,
|
||||
);
|
||||
}
|
||||
|
||||
void _resetProgress() {
|
||||
_isUploading = false;
|
||||
_overallProgressValue = 0.0;
|
||||
@@ -88,6 +79,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
});
|
||||
|
||||
final webdavClient = await WebdavApi.webdav;
|
||||
if (!mounted) return;
|
||||
|
||||
if (!override) {
|
||||
List<dynamic> result;
|
||||
@@ -157,6 +149,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
if (replaceFiles != true) {
|
||||
setState(() {
|
||||
_resetProgress();
|
||||
@@ -200,18 +193,23 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
}
|
||||
|
||||
final HttpClientResponse uploadTask;
|
||||
final fileIndex = _uploadableFiles.indexOf(file);
|
||||
var lastPercent = -1;
|
||||
try {
|
||||
uploadTask = await webdavClient.putFile(
|
||||
File(filePath),
|
||||
fileStat,
|
||||
PathUri.parse(fullRemotePath),
|
||||
onProgress: (progress) {
|
||||
// Called per 64 KB chunk — thousands of times for a video. Only
|
||||
// rebuild when the visible percentage actually moves.
|
||||
final percent = (progress * 100).floor();
|
||||
if (!mounted || percent == lastPercent) return;
|
||||
lastPercent = percent;
|
||||
setState(() {
|
||||
file._uploadProgress = progress;
|
||||
_overallProgressValue =
|
||||
((progress + _uploadableFiles.indexOf(file)) /
|
||||
_uploadableFiles.length)
|
||||
.toDouble();
|
||||
((progress + fileIndex) / _uploadableFiles.length).toDouble();
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -221,18 +219,21 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
// Stay on the dialog: popping here reported the partial result as a
|
||||
// success to onUploadFinished and closed the error dialog right away.
|
||||
if (uploadTask.statusCode < 200 || uploadTask.statusCode > 299) {
|
||||
setState(_resetProgress);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
showHttpErrorCode(uploadTask.statusCode);
|
||||
} else {
|
||||
uploadetFilePaths.add(fullRemotePath);
|
||||
_showUploadError(
|
||||
'Upload fehlgeschlagen für "$fileName" '
|
||||
'(Fehlercode ${uploadTask.statusCode}).',
|
||||
);
|
||||
return;
|
||||
}
|
||||
uploadetFilePaths.add(fullRemotePath);
|
||||
}
|
||||
|
||||
setState(_resetProgress);
|
||||
if (!mounted) return;
|
||||
setState(_resetProgress);
|
||||
Navigator.of(context).pop();
|
||||
widget.onUploadFinished(uploadetFilePaths);
|
||||
}
|
||||
@@ -252,7 +253,12 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
itemCount: _uploadableFiles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final currentFile = _uploadableFiles[index];
|
||||
currentFile.fileNameController.text = currentFile.fileName;
|
||||
// Only sync when it differs: assigning text resets selection
|
||||
// and notifies the field on every (progress) rebuild.
|
||||
if (currentFile.fileNameController.text !=
|
||||
currentFile.fileName) {
|
||||
currentFile.fileNameController.text = currentFile.fileName;
|
||||
}
|
||||
return ListTile(
|
||||
title: TextField(
|
||||
readOnly: _isUploading,
|
||||
|
||||
@@ -26,6 +26,14 @@ class FilesSearchController extends ChangeNotifier {
|
||||
Object? _serverError;
|
||||
int _serverEpoch = 0;
|
||||
bool _disposed = false;
|
||||
Future<List<CacheableFile>>? _localIndex;
|
||||
|
||||
Future<List<CacheableFile>> _searchLocal(List<String> pathScope) async =>
|
||||
searchLocalCacheIndex(
|
||||
await (_localIndex ??= loadLocalCacheIndex()),
|
||||
_query,
|
||||
pathScope: pathScope,
|
||||
);
|
||||
|
||||
/// Guards against the race where the search delegate is closed (and the
|
||||
/// controller disposed) while a debounced cache scan or server call is
|
||||
@@ -80,7 +88,7 @@ class FilesSearchController extends ChangeNotifier {
|
||||
_serverError = null;
|
||||
_safeNotify();
|
||||
|
||||
final cacheHits = await searchLocalCaches(_query, pathScope: _pathScope);
|
||||
final cacheHits = await _searchLocal(_pathScope);
|
||||
if (epoch != _serverEpoch) return;
|
||||
_cacheResults = cacheHits;
|
||||
_safeNotify();
|
||||
@@ -101,7 +109,7 @@ class FilesSearchController extends ChangeNotifier {
|
||||
_serverError = null;
|
||||
_safeNotify();
|
||||
|
||||
final cacheHits = await searchLocalCaches(_query);
|
||||
final cacheHits = await _searchLocal(const []);
|
||||
if (epoch != _serverEpoch) return;
|
||||
_cacheResults = cacheHits;
|
||||
_safeNotify();
|
||||
|
||||
@@ -104,32 +104,43 @@ class FilesSearchResults extends StatelessWidget {
|
||||
Widget _resultList(BuildContext context, List<CacheableFile> combined) {
|
||||
final groups = _groupByParent(combined);
|
||||
final orderedKeys = groups.keys.toList()..sort();
|
||||
final items = <Widget>[];
|
||||
for (final folder in orderedKeys) {
|
||||
final segments = _segmentsOf(folder);
|
||||
items.add(
|
||||
_FolderHeader(
|
||||
folder: folder,
|
||||
onOpen: () {
|
||||
onResultTap?.call();
|
||||
AppRoutes.openFolder(context, segments);
|
||||
},
|
||||
),
|
||||
);
|
||||
for (final file in groups[folder]!) {
|
||||
items.add(
|
||||
FileElement(
|
||||
file,
|
||||
segments,
|
||||
controller.retry,
|
||||
highlight: controller.query,
|
||||
),
|
||||
// Flat (folder header | file) rows built lazily: results can run into
|
||||
// the hundreds and arrive in several batches per query.
|
||||
final rows = <(String, CacheableFile?)>[
|
||||
for (final folder in orderedKeys) ...[
|
||||
(folder, null),
|
||||
for (final file in groups[folder]!) (folder, file),
|
||||
],
|
||||
];
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, index) {
|
||||
final (folder, file) = rows[index];
|
||||
final segments = _segmentsOf(folder);
|
||||
if (file == null) {
|
||||
return _FolderHeader(
|
||||
key: ValueKey('folder:$folder'),
|
||||
folder: folder,
|
||||
onOpen: () {
|
||||
onResultTap?.call();
|
||||
AppRoutes.openFolder(context, segments);
|
||||
},
|
||||
);
|
||||
}
|
||||
return FileElement(
|
||||
file,
|
||||
segments,
|
||||
controller.retry,
|
||||
key: ValueKey('file:${file.path}'),
|
||||
highlight: controller.query,
|
||||
);
|
||||
}
|
||||
}
|
||||
return ListView(padding: EdgeInsets.zero, children: items);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static final RegExp _edgeSlashes = RegExp(r'^/+|/+$');
|
||||
|
||||
Map<String, List<CacheableFile>> _groupByParent(List<CacheableFile> files) {
|
||||
final map = <String, List<CacheableFile>>{};
|
||||
for (final file in files) {
|
||||
@@ -139,7 +150,7 @@ class FilesSearchResults extends StatelessWidget {
|
||||
}
|
||||
|
||||
String _parentOf(CacheableFile file) {
|
||||
final stripped = file.path.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
final stripped = file.path.replaceAll(_edgeSlashes, '');
|
||||
final segments = stripped.split('/');
|
||||
if (segments.length <= 1) return '/';
|
||||
segments.removeLast();
|
||||
@@ -147,7 +158,7 @@ class FilesSearchResults extends StatelessWidget {
|
||||
}
|
||||
|
||||
List<String> _segmentsOf(String folder) {
|
||||
final stripped = folder.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
final stripped = folder.replaceAll(_edgeSlashes, '');
|
||||
if (stripped.isEmpty) return const [];
|
||||
return stripped.split('/');
|
||||
}
|
||||
@@ -156,7 +167,11 @@ class FilesSearchResults extends StatelessWidget {
|
||||
class _FolderHeader extends StatelessWidget {
|
||||
final String folder;
|
||||
final VoidCallback onOpen;
|
||||
const _FolderHeader({required this.folder, required this.onOpen});
|
||||
const _FolderHeader({
|
||||
required this.folder,
|
||||
required this.onOpen,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -1,49 +1,29 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:localstore/localstore.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../../api/cache_store.dart';
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
|
||||
import '../../../../api/request_cache.dart';
|
||||
|
||||
/// Document key prefix used by `ListFilesCache._documentId`.
|
||||
const String _folderCachePrefix = 'wd-folder-';
|
||||
|
||||
/// Scans every cached folder listing in Localstore and returns files/folders
|
||||
/// whose name contains [query] (case-insensitive).
|
||||
///
|
||||
/// [pathScope] restricts results to entries whose WebDAV path starts with
|
||||
/// the given folder. Pass an empty list (or null) to search globally.
|
||||
///
|
||||
/// [docs] is an injection seam for tests — production callers leave it null
|
||||
/// so the helper reads from the real Localstore.
|
||||
Future<List<CacheableFile>> searchLocalCaches(
|
||||
String query, {
|
||||
List<String>? pathScope,
|
||||
Map<String, dynamic>? docs,
|
||||
}) async {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) return const [];
|
||||
final needle = trimmed.toLowerCase();
|
||||
final scopePrefix = pathScope == null || pathScope.isEmpty
|
||||
? ''
|
||||
: '${pathScope.join('/')}/';
|
||||
|
||||
final raw =
|
||||
docs ??
|
||||
await Localstore.instance.collection(RequestCache.collection).get();
|
||||
if (raw == null || raw.isEmpty) return const [];
|
||||
|
||||
final results = <String, CacheableFile>{};
|
||||
for (final entry in raw.entries) {
|
||||
final docKey = entry.key.split('/').last;
|
||||
if (!docKey.startsWith(_folderCachePrefix)) continue;
|
||||
|
||||
final value = entry.value;
|
||||
if (value is! Map) continue;
|
||||
final json = value['json'];
|
||||
if (json is! String) continue;
|
||||
/// Every file and folder from the cached folder listings, deduplicated by
|
||||
/// path. Built once per search session: reading and parsing all listings on
|
||||
/// each keystroke used to stall typing.
|
||||
Future<List<CacheableFile>> loadLocalCacheIndex() async {
|
||||
final entries = await CacheStore.instance.readAll(prefix: _folderCachePrefix);
|
||||
if (entries.isEmpty) return const [];
|
||||
final payloads = [for (final entry in entries.values) entry.json];
|
||||
return compute(buildLocalCacheIndex, payloads);
|
||||
}
|
||||
|
||||
/// Parses cached `ListFilesResponse` payloads into a deduplicated file list.
|
||||
/// Unparsable payloads are skipped.
|
||||
List<CacheableFile> buildLocalCacheIndex(List<String> payloads) {
|
||||
final byPath = <String, CacheableFile>{};
|
||||
for (final json in payloads) {
|
||||
final ListFilesResponse listing;
|
||||
try {
|
||||
listing = ListFilesResponse.fromJson(
|
||||
@@ -52,14 +32,32 @@ Future<List<CacheableFile>> searchLocalCaches(
|
||||
} on Object {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (final file in listing.files) {
|
||||
if (!file.name.toLowerCase().contains(needle)) continue;
|
||||
if (scopePrefix.isNotEmpty && !file.path.startsWith(scopePrefix)) {
|
||||
continue;
|
||||
}
|
||||
results[file.path] ??= file;
|
||||
byPath[file.path] ??= file;
|
||||
}
|
||||
}
|
||||
return results.values.toList();
|
||||
return byPath.values.toList();
|
||||
}
|
||||
|
||||
/// Files in [index] whose name contains [query] (case-insensitive).
|
||||
///
|
||||
/// [pathScope] restricts results to entries whose WebDAV path starts with
|
||||
/// the given folder. Pass an empty list (or null) to search globally.
|
||||
List<CacheableFile> searchLocalCacheIndex(
|
||||
List<CacheableFile> index,
|
||||
String query, {
|
||||
List<String>? pathScope,
|
||||
}) {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) return const [];
|
||||
final needle = trimmed.toLowerCase();
|
||||
final scopePrefix = pathScope == null || pathScope.isEmpty
|
||||
? ''
|
||||
: '${pathScope.join('/')}/';
|
||||
return [
|
||||
for (final file in index)
|
||||
if (file.lowerName.contains(needle) &&
|
||||
(scopePrefix.isEmpty || file.path.startsWith(scopePrefix)))
|
||||
file,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../../api/marianumconnect/queries/timetable_get_element_week/timetabl
|
||||
import '../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart';
|
||||
import '../../../api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart';
|
||||
import '../../../api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart';
|
||||
import '../../../session/session_manager.dart';
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../storage/timetable_favorites_settings.dart';
|
||||
import '../../../utils/haptics.dart';
|
||||
@@ -31,7 +32,14 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
|
||||
|
||||
// One in-flight/resolved future per type so switching tabs (or rebuilds)
|
||||
// never re-fetches a list that's already loaded.
|
||||
final Map<TimetableElementType, Future<List<_PickerItem>>> _futures = {};
|
||||
// Session-wide (per account): the student list alone has 1000+ entries and
|
||||
// used to be downloaded again every time the picker opened.
|
||||
static final Map<
|
||||
TimetableElementType,
|
||||
({int epoch, DateTime at, Future<List<_PickerItem>> items})
|
||||
>
|
||||
_futures = {};
|
||||
static const Duration _listMaxAge = Duration(minutes: 15);
|
||||
// Memoised combined future for the "Alle" tab; rebuilt on retry.
|
||||
Future<List<_PickerItem>>? _allFuture;
|
||||
|
||||
@@ -47,8 +55,25 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
|
||||
return _allFuture ??= _loadAll();
|
||||
}
|
||||
|
||||
Future<List<_PickerItem>> _loadFor(TimetableElementType type) =>
|
||||
_futures.putIfAbsent(type, () => _fetch(type));
|
||||
Future<List<_PickerItem>> _loadFor(TimetableElementType type) {
|
||||
final epoch = SessionManager().sessionEpoch;
|
||||
final cached = _futures[type];
|
||||
if (cached != null &&
|
||||
cached.epoch == epoch &&
|
||||
DateTime.now().difference(cached.at) < _listMaxAge) {
|
||||
return cached.items;
|
||||
}
|
||||
final items = _fetch(type);
|
||||
_futures[type] = (epoch: epoch, at: DateTime.now(), items: items);
|
||||
// A failed load must not stick for the rest of the session.
|
||||
items.then(
|
||||
(_) {},
|
||||
onError: (Object _) {
|
||||
if (identical(_futures[type]?.items, items)) _futures.remove(type);
|
||||
},
|
||||
);
|
||||
return items;
|
||||
}
|
||||
|
||||
Future<List<_PickerItem>> _loadAll() async {
|
||||
final lists = await Future.wait(TimetableElementType.values.map(_loadFor));
|
||||
@@ -117,20 +142,16 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
|
||||
Haptics.selection();
|
||||
// Hand the selection back to the timetable view, which renders the foreign
|
||||
// plan inline. We do not navigate to a new page.
|
||||
Navigator.of(context).pop((
|
||||
type: item.type,
|
||||
id: item.id,
|
||||
label: item.primary,
|
||||
));
|
||||
Navigator.of(
|
||||
context,
|
||||
).pop((type: item.type, id: item.id, label: item.primary));
|
||||
}
|
||||
|
||||
void _openFavorite(FavoriteTimetableElement favorite) {
|
||||
Haptics.selection();
|
||||
Navigator.of(context).pop((
|
||||
type: favorite.type,
|
||||
id: favorite.id,
|
||||
label: favorite.label,
|
||||
));
|
||||
Navigator.of(
|
||||
context,
|
||||
).pop((type: favorite.type, id: favorite.id, label: favorite.label));
|
||||
}
|
||||
|
||||
void _toggleFavorite(TimetableElementType type, int id, String label) {
|
||||
@@ -325,9 +346,7 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
|
||||
return ListTile(
|
||||
leading: Icon(_iconFor(item.type)),
|
||||
title: Text(item.primary),
|
||||
subtitle: subtitleParts.isEmpty
|
||||
? null
|
||||
: Text(subtitleParts.join(' · ')),
|
||||
subtitle: subtitleParts.isEmpty ? null : Text(subtitleParts.join(' · ')),
|
||||
trailing: IconButton(
|
||||
icon: Icon(isFavorite ? Icons.star : Icons.star_border),
|
||||
tooltip: isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren',
|
||||
|
||||
@@ -214,8 +214,11 @@ class ChatBackgroundSettingsPage extends StatelessWidget {
|
||||
final file = File(AppPaths.chatBackgroundImage);
|
||||
await file.writeAsBytes(bytes);
|
||||
// Same filename across replacements → the decoded image is cached under
|
||||
// an identical key. Evict so the new bytes actually show.
|
||||
await FileImage(file).evict();
|
||||
// an identical key (for "cover" also wrapped in a viewport-sized
|
||||
// ResizeImage). Clearing the cache is fine for this rare action.
|
||||
PaintingBinding.instance.imageCache
|
||||
..clear()
|
||||
..clearLiveImages();
|
||||
final cs = settings.val(write: true).chatBackgroundSettings;
|
||||
cs.imageVersion++;
|
||||
cs.type = ChatBackgroundType.image;
|
||||
|
||||
@@ -71,8 +71,6 @@ class DefaultSettings {
|
||||
fileViewSettings: FileViewSettings(alwaysOpenExternally: Platform.isIOS),
|
||||
notificationSettings: NotificationSettings(enabled: true),
|
||||
devToolsSettings: DevToolsSettings(
|
||||
checkerboardOffscreenLayers: false,
|
||||
checkerboardRasterCacheImages: false,
|
||||
showPerformanceOverlay: false,
|
||||
marianumConnectEndpoint: MarianumConnectEndpoint.live,
|
||||
marianumConnectCustomUrl: '',
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../../../api/marianumconnect/auth/device_token_name.dart';
|
||||
import '../../../api/marianumconnect/auth/token_storage.dart';
|
||||
import '../../../api/marianumconnect/marianumconnect_endpoint.dart';
|
||||
import '../../../api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart';
|
||||
import '../../../background/widget_background_task.dart';
|
||||
import '../../../extensions/date_time.dart';
|
||||
import '../../../model/endpoint_data.dart';
|
||||
import '../../../push/push_registration_store.dart';
|
||||
import '../../../push/push_registration_type.dart';
|
||||
import '../../../session/account_codec.dart';
|
||||
import '../../../session/session_manager.dart';
|
||||
import '../../../state/app/modules/account/bloc/account_bloc.dart';
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../utils/clipboard_helper.dart';
|
||||
import '../../../widget/app_progress_indicator.dart';
|
||||
import '../../../widget/async_action_button.dart';
|
||||
import '../../../widget/centered_leading.dart';
|
||||
import '../../../widget_data/widget_sync.dart';
|
||||
import 'widgets/push_status_sheet.dart';
|
||||
|
||||
typedef _Row = ({String label, String value});
|
||||
typedef _Section = ({String title, List<_Row> rows});
|
||||
|
||||
/// Read-only snapshot of build, endpoint, session, push and widget state for
|
||||
/// bug reports. Secrets (passwords, bearer token) are never shown.
|
||||
class DiagnosticsPage extends StatefulWidget {
|
||||
const DiagnosticsPage({super.key});
|
||||
|
||||
@override
|
||||
State<DiagnosticsPage> createState() => _DiagnosticsPageState();
|
||||
}
|
||||
|
||||
class _DiagnosticsPageState extends State<DiagnosticsPage> {
|
||||
late Future<List<_Section>> _sections = _collect();
|
||||
|
||||
Future<List<_Section>> _collect() async {
|
||||
final settings = context.read<SettingsCubit>().val();
|
||||
final accountStatus = context.read<AccountBloc>().state.status;
|
||||
final session = SessionManager().current;
|
||||
final nextcloud = session?.nextcloud;
|
||||
const tokens = MarianumConnectTokenStorage();
|
||||
final push = PushRegistrationStore();
|
||||
|
||||
Future<String> safe(Future<Object?> Function() read) async {
|
||||
try {
|
||||
final value = await read().timeout(const Duration(seconds: 5));
|
||||
return switch (value) {
|
||||
null => '–',
|
||||
bool() => value ? 'ja' : 'nein',
|
||||
DateTime() => '${value.formatDateTime()} (${value.formatRelative()})',
|
||||
_ => '$value',
|
||||
};
|
||||
} on Object catch (e) {
|
||||
return 'Fehler: $e';
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<_Row>> rows(Map<String, Future<Object?> Function()> reads) =>
|
||||
Future.wait(
|
||||
reads.entries.map(
|
||||
(e) async => (label: e.key, value: await safe(e.value)),
|
||||
),
|
||||
);
|
||||
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
return [
|
||||
(
|
||||
title: 'App',
|
||||
rows: await rows({
|
||||
'Version': () async => '${info.version} (${info.buildNumber})',
|
||||
'Paket': () async => info.packageName,
|
||||
'Build-Modus': () async => kReleaseMode
|
||||
? 'Release'
|
||||
: kProfileMode
|
||||
? 'Profile'
|
||||
: 'Debug',
|
||||
'Plattform': () async =>
|
||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
|
||||
'Gerät': DeviceTokenName.resolve,
|
||||
'Dart': () async => Platform.version.split(' ').first,
|
||||
}),
|
||||
),
|
||||
(
|
||||
title: 'Server',
|
||||
rows: await rows({
|
||||
'Marianum Connect': () async => MarianumConnectEndpoint.current(),
|
||||
'Nextcloud': () async =>
|
||||
'https://${EndpointData().nextcloud().full()}',
|
||||
'Notfall-Nachricht': () async =>
|
||||
settings.devToolsSettings.resolveEmergencyNoticeUrl(),
|
||||
}),
|
||||
),
|
||||
(
|
||||
title: 'Sitzung',
|
||||
rows: await rows({
|
||||
'Status': () async => accountStatus.name,
|
||||
'Benutzer': () async =>
|
||||
session == null ? null : identityOf(session).$2,
|
||||
'Konten': () async => SessionManager().accounts.value.accounts.length,
|
||||
'Demo-Modus': () async => session?.isDemo ?? false,
|
||||
'Login Flow v2': () async => nextcloud?.usesLoginFlow ?? false,
|
||||
'App-Passwort (Dateien)': () async =>
|
||||
nextcloud?.hasAppPassword ?? false,
|
||||
'App-Passwort (Talk)': () async =>
|
||||
nextcloud?.hasAppPasswordTalk ?? false,
|
||||
'Session-Epoche': () async => SessionManager().sessionEpoch,
|
||||
'MC-Token-ID': tokens.readTokenId,
|
||||
'MC-Token läuft ab': tokens.readExpiresAt,
|
||||
}),
|
||||
),
|
||||
(
|
||||
title: 'Push',
|
||||
rows: await rows({
|
||||
'Benachrichtigungen': () async =>
|
||||
settings.notificationSettings.enabled,
|
||||
'FCM-Token': FirebaseMessaging.instance.getToken,
|
||||
for (final type in PushRegistrationType.values) ...{
|
||||
'Registriert (${type.name})': () => push.isRegistered(type),
|
||||
'Letzte Registrierung (${type.name})': () =>
|
||||
push.lastRegistrationAt(type),
|
||||
'Letzter Fehler (${type.name})': () =>
|
||||
push.lastRegistrationError(type),
|
||||
},
|
||||
}),
|
||||
),
|
||||
(
|
||||
title: 'Sonstiges',
|
||||
rows: await rows({
|
||||
'Telemetrie-Geräte-ID': TelemetryDeviceId.resolve,
|
||||
'Widget zuletzt abgerufen': WidgetSync.getFetchedAt,
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
String _asText(List<_Section> sections) => [
|
||||
for (final s in sections) ...[
|
||||
'## ${s.title}',
|
||||
for (final r in s.rows) '${r.label}: ${r.value}',
|
||||
'',
|
||||
],
|
||||
].join('\n');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Diagnose'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Neu laden',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => setState(() {
|
||||
_sections = _collect();
|
||||
}),
|
||||
),
|
||||
FutureBuilder(
|
||||
future: _sections,
|
||||
builder: (context, snapshot) => IconButton(
|
||||
tooltip: 'Alles kopieren',
|
||||
icon: const Icon(Icons.copy_all_outlined),
|
||||
onPressed: snapshot.hasData
|
||||
? () => copyToClipboard(context, _asText(snapshot.data!))
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder(
|
||||
future: _sections,
|
||||
builder: (context, snapshot) {
|
||||
final sections = snapshot.data;
|
||||
if (sections == null) {
|
||||
return const Center(child: AppProgressIndicator.large());
|
||||
}
|
||||
return ListView(
|
||||
children: [
|
||||
for (final section in sections) ...[
|
||||
ListTile(
|
||||
dense: true,
|
||||
title: Text(
|
||||
section.title,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final row in section.rows)
|
||||
ListTile(
|
||||
dense: true,
|
||||
title: Text(row.label),
|
||||
subtitle: SelectableText(row.value),
|
||||
onLongPress: () => copyToClipboard(context, row.value),
|
||||
),
|
||||
if (section.title == 'Push')
|
||||
ListTile(
|
||||
leading: const CenteredLeading(
|
||||
Icon(Icons.notifications_active_outlined),
|
||||
),
|
||||
title: const Text('Push-Status prüfen'),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () => showPushStatusSheet(context),
|
||||
),
|
||||
if (section.title == 'Sonstiges')
|
||||
AsyncListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.widgets_outlined)),
|
||||
title: const Text('Widget jetzt aktualisieren'),
|
||||
closeOnSuccess: false,
|
||||
onPressed: WidgetBackgroundTask.requestImmediateRefresh,
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -212,14 +212,17 @@ class _SchoolAccountState extends State<_SchoolAccount> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
displayName ?? username,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Text(
|
||||
displayName ?? username,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (displayName != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
@@ -304,11 +307,14 @@ class _AccountName extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Text(
|
||||
name,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:filesize/filesize.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../../../api/cache_store.dart';
|
||||
import '../../../../push/chat_thread_store.dart';
|
||||
import '../../../../push/nid_store.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../storage/dev_tools_settings.dart';
|
||||
import '../../../../storage/settings.dart' as model;
|
||||
import '../../../../utils/directory_size.dart';
|
||||
import '../../../../utils/downloads/download_manager.dart';
|
||||
import '../../../../widget/avatar_disk_cache.dart';
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/debug/cache_view.dart';
|
||||
import '../../../../widget/debug/json_viewer.dart';
|
||||
import '../../../../widget/debug/bloc_storage_view.dart';
|
||||
import '../../../../widget/details_bottom_sheet.dart';
|
||||
import '../../../../widget/user_avatar.dart';
|
||||
import '../widgets/endpoint_picker.dart';
|
||||
import '../widgets/settings_checkbox_tile.dart';
|
||||
import '../widgets/settings_dropdown_tile.dart';
|
||||
|
||||
class DevToolsSection extends StatefulWidget {
|
||||
final SettingsCubit settings;
|
||||
@@ -27,9 +38,16 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.monitor_heart_outlined)),
|
||||
title: const Text('Diagnose'),
|
||||
subtitle: const Text('Version, Server, Sitzung, Push'),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () => AppRoutes.openDiagnostics(context),
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.speed_outlined)),
|
||||
title: const Text('Performance overlays'),
|
||||
title: const Text('Debug-Overlays'),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () {
|
||||
showDetailsBottomSheet(
|
||||
@@ -39,35 +57,57 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
||||
bloc: widget.settings,
|
||||
builder: (_, _) {
|
||||
final dev = widget.settings.val().devToolsSettings;
|
||||
DevToolsSettings write() =>
|
||||
widget.settings.val(write: true).devToolsSettings;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SettingsCheckboxTile(
|
||||
icon: Icons.auto_graph_outlined,
|
||||
title: 'Performance graph',
|
||||
title: 'Performance-Graph',
|
||||
value: dev.showPerformanceOverlay,
|
||||
onChanged: (e) => widget.settings
|
||||
.val(write: true)
|
||||
.devToolsSettings
|
||||
.showPerformanceOverlay = e,
|
||||
onChanged: (e) => write().showPerformanceOverlay = e,
|
||||
),
|
||||
SettingsCheckboxTile(
|
||||
icon: Icons.screen_search_desktop_outlined,
|
||||
title: 'Indicate offscreen layers',
|
||||
value: dev.checkerboardOffscreenLayers,
|
||||
onChanged: (e) => widget.settings
|
||||
.val(write: true)
|
||||
.devToolsSettings
|
||||
.checkerboardOffscreenLayers = e,
|
||||
icon: Icons.timer_outlined,
|
||||
title: 'Frame-Statistik',
|
||||
subtitle: 'Build-/Raster-Zeiten und Jank-Zähler',
|
||||
value: dev.showFrameStats,
|
||||
onChanged: (e) => write().showFrameStats = e,
|
||||
),
|
||||
SettingsCheckboxTile(
|
||||
icon: Icons.imagesearch_roller_outlined,
|
||||
title: 'Indicate raster cache images',
|
||||
value: dev.checkerboardRasterCacheImages,
|
||||
onChanged: (e) => widget.settings
|
||||
.val(write: true)
|
||||
.devToolsSettings
|
||||
.checkerboardRasterCacheImages = e,
|
||||
icon: Icons.memory_outlined,
|
||||
title: 'Speicher-Statistik',
|
||||
subtitle: 'Prozess-RAM und Bild-Cache',
|
||||
value: dev.showMemoryStats,
|
||||
onChanged: (e) => write().showMemoryStats = e,
|
||||
),
|
||||
SettingsCheckboxTile(
|
||||
icon: Icons.slow_motion_video_outlined,
|
||||
title: 'Langsame Animationen',
|
||||
subtitle: '5× verlangsamt',
|
||||
value: dev.slowAnimations,
|
||||
onChanged: (e) => write().slowAnimations = e,
|
||||
),
|
||||
SettingsCheckboxTile(
|
||||
icon: Icons.accessibility_new_outlined,
|
||||
title: 'Semantics-Debugger',
|
||||
subtitle: 'Zeigt die Screenreader-Sicht',
|
||||
value: dev.showSemanticsDebugger,
|
||||
onChanged: (e) => write().showSemanticsDebugger = e,
|
||||
),
|
||||
SettingsDropdownTile<double>(
|
||||
icon: Icons.format_size_outlined,
|
||||
title: 'Textgröße',
|
||||
value: dev.textScaleOverride,
|
||||
options: const [0, 0.85, 1, 1.3, 1.6, 2],
|
||||
optionIcon: (e) => e == 0
|
||||
? Icons.phone_android_outlined
|
||||
: Icons.text_fields_outlined,
|
||||
optionLabel: (e) => e == 0
|
||||
? 'System'
|
||||
: '${e == e.roundToDouble() ? e.toInt() : e}×',
|
||||
onChanged: (e) => write().textScaleOverride = e,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -99,98 +139,204 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
||||
.trim();
|
||||
return ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.emergency_outlined)),
|
||||
title: const Text('Notfall-Nachricht (Quelle)'),
|
||||
subtitle: Text(
|
||||
override.isEmpty
|
||||
? 'Standardquelle (im Code hinterlegt)'
|
||||
: override,
|
||||
),
|
||||
title: const Text('Notfall-Nachrichtenquelle'),
|
||||
subtitle: Text(override.isEmpty ? 'Standardquelle' : override),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () => _EmergencyNoticeUrlEditor.show(context, widget.settings),
|
||||
onTap: () =>
|
||||
_EmergencyNoticeUrlEditor.show(context, widget.settings),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.image_outlined)),
|
||||
title: const Text('Thumb-storage'),
|
||||
subtitle: Text(
|
||||
'etwa ${filesize(PaintingBinding.instance.imageCache.currentSizeBytes)}\nLange tippen um zu löschen',
|
||||
const Divider(),
|
||||
_StorageTile(
|
||||
icon: Icons.settings_applications_outlined,
|
||||
title: 'Einstellungen',
|
||||
describe: () async => filesize(
|
||||
utf8.encode(jsonEncode(widget.settings.val().toJson())).length,
|
||||
),
|
||||
onLongPress: () {
|
||||
ConfirmDialog(
|
||||
title: 'Thumbs cache löschen',
|
||||
content: 'Alle zwischengespeicherten Bilder werden gelöscht.',
|
||||
confirmButton: 'Unwiederruflich löschen',
|
||||
onConfirm: () => PaintingBinding.instance.imageCache.clear(),
|
||||
).asDialog(context);
|
||||
},
|
||||
onOpen: (_) async => AppRoutes.openJsonViewer(
|
||||
context,
|
||||
widget.settings.val().toJson(),
|
||||
title: 'Einstellungen',
|
||||
),
|
||||
clearTitle: 'Einstellungen zurücksetzen',
|
||||
clearContent:
|
||||
'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten '
|
||||
'sind nicht betroffen.',
|
||||
onClear: widget.settings.reset,
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(
|
||||
Icon(Icons.settings_applications_outlined),
|
||||
),
|
||||
title: const Text('Settings-storage JSON dump'),
|
||||
subtitle: Text(
|
||||
'etwa ${filesize(widget.settings.val().toJson().toString().length * 8)}\nLange tippen um zu löschen',
|
||||
),
|
||||
onTap: () {
|
||||
JsonViewer.asDialog(context, widget.settings.val().toJson());
|
||||
_StorageTile(
|
||||
icon: Icons.account_tree_outlined,
|
||||
title: 'BLoC-Speicher',
|
||||
describe: () async {
|
||||
final keys = BlocStorageView.keys();
|
||||
return keys == null ? 'nicht auflistbar' : '${keys.length} Einträge';
|
||||
},
|
||||
onLongPress: () {
|
||||
ConfirmDialog(
|
||||
title: 'Einstellungen löschen',
|
||||
content:
|
||||
'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten sind nicht betroffen.',
|
||||
confirmButton: 'Unwiederruflich Löschen',
|
||||
onConfirm: () {
|
||||
context.read<SettingsCubit>().reset();
|
||||
},
|
||||
).asDialog(context);
|
||||
},
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onOpen: AppRoutes.openBlocStorageView,
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.data_object)),
|
||||
title: const Text('Cache-storage JSON dump'),
|
||||
subtitle: FutureBuilder(
|
||||
future: const CacheView().totalSize(),
|
||||
builder: (context, snapshot) => Text(
|
||||
"etwa ${snapshot.hasError
|
||||
? "?"
|
||||
: snapshot.hasData
|
||||
? filesize(snapshot.data)
|
||||
: "..."}\nLange tippen um zu löschen",
|
||||
_StorageTile(
|
||||
icon: Icons.data_object,
|
||||
title: 'Request-Cache',
|
||||
describe: () async => filesize(await CacheStore.instance.totalSize()),
|
||||
onOpen: AppRoutes.openCacheView,
|
||||
),
|
||||
_StorageTile(
|
||||
icon: Icons.image_outlined,
|
||||
title: 'Bild-Speicher (RAM)',
|
||||
describe: () async {
|
||||
final cache = PaintingBinding.instance.imageCache;
|
||||
return '${cache.currentSize} Bilder · '
|
||||
'${filesize(cache.currentSizeBytes)} von '
|
||||
'${filesize(cache.maximumSizeBytes)}';
|
||||
},
|
||||
clearTitle: 'Bild-Speicher leeren',
|
||||
clearContent:
|
||||
'Alle dekodierten Bilder im Arbeitsspeicher werden verworfen.',
|
||||
onClear: () async => PaintingBinding.instance.imageCache.clear(),
|
||||
),
|
||||
_StorageTile(
|
||||
icon: Icons.photo_library_outlined,
|
||||
title: 'Netzwerk-Bilder',
|
||||
describe: () async => filesize(
|
||||
await directorySize(
|
||||
Directory(
|
||||
'${(await getTemporaryDirectory()).path}/${DefaultCacheManager.key}',
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () => AppRoutes.openCacheView(context),
|
||||
onLongPress: () {
|
||||
ConfirmDialog(
|
||||
title: 'App-Cache löschen',
|
||||
content:
|
||||
'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut',
|
||||
confirmButton: 'Unwiederruflich löschen',
|
||||
onConfirm: () =>
|
||||
const CacheView().clear().then((value) => setState(() {})),
|
||||
).asDialog(context);
|
||||
},
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
clearTitle: 'Netzwerk-Bilder löschen',
|
||||
clearContent:
|
||||
'Heruntergeladene Chat- und Vorschaubilder werden gelöscht und bei '
|
||||
'Bedarf neu geladen.',
|
||||
onClear: DefaultCacheManager().emptyCache,
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.data_object)),
|
||||
title: const Text('BLOC-storage state cache'),
|
||||
subtitle: const Text('Lange tippen um zu löschen'),
|
||||
onLongPress: () {
|
||||
ConfirmDialog(
|
||||
title: 'BLOC-Cache löschen',
|
||||
content:
|
||||
'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut',
|
||||
confirmButton: 'Unwiederruflich löschen',
|
||||
onConfirm: () => HydratedBloc.storage.clear(),
|
||||
).asDialog(context);
|
||||
_StorageTile(
|
||||
icon: Icons.account_circle_outlined,
|
||||
title: 'Avatar-Cache',
|
||||
describe: () async =>
|
||||
filesize(await AvatarDiskCache.instance.totalSize()),
|
||||
clearTitle: 'Avatar-Cache löschen',
|
||||
clearContent: 'Profilbilder werden bei Bedarf neu geladen.',
|
||||
onClear: () async {
|
||||
invalidateAvatarCache();
|
||||
await AvatarDiskCache.instance.clear();
|
||||
},
|
||||
),
|
||||
_StorageTile(
|
||||
icon: Icons.notifications_outlined,
|
||||
title: 'Push-Zuordnungen',
|
||||
describe: () async =>
|
||||
'${(await NidStore().all()).length} Benachrichtigungen zugeordnet',
|
||||
clearTitle: 'Push-Zuordnungen löschen',
|
||||
clearContent:
|
||||
'Die Zuordnung angezeigter Benachrichtigungen zu Chats und der '
|
||||
'gruppierte Chat-Verlauf gehen verloren. Bereits angezeigte '
|
||||
'Benachrichtigungen werden danach nicht mehr automatisch entfernt.',
|
||||
onClear: () async {
|
||||
await NidStore().clear();
|
||||
await ChatThreadStore().clearAll();
|
||||
},
|
||||
),
|
||||
_StorageTile(
|
||||
icon: Icons.download_outlined,
|
||||
title: 'Downloads',
|
||||
describe: () async =>
|
||||
filesize(await directorySize(await _downloadsDir())),
|
||||
clearTitle: 'Downloads löschen',
|
||||
clearContent:
|
||||
'Laufende Downloads werden abgebrochen und heruntergeladene '
|
||||
'Dateien gelöscht.',
|
||||
onClear: () async {
|
||||
DownloadManager.instance.clearAll();
|
||||
final dir = await _downloadsDir();
|
||||
if (!dir.existsSync()) return;
|
||||
// Cancelled tasks can still be flushing into the folder, so deleting
|
||||
// it as a whole races them; whatever survives shows up in the size.
|
||||
await for (final entity in dir.list()) {
|
||||
try {
|
||||
await entity.delete(recursive: true);
|
||||
} on FileSystemException {
|
||||
// Retried on the next clear.
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
static Future<Directory> _downloadsDir() async =>
|
||||
Directory('${(await getTemporaryDirectory()).path}/downloads');
|
||||
}
|
||||
|
||||
/// Storage row: tap opens a detail view ([onOpen]), the trailing button clears
|
||||
/// it after confirmation ([onClear]). The subtitle re-evaluates [describe]
|
||||
/// after either.
|
||||
class _StorageTile extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final Future<String> Function() describe;
|
||||
final Future<void> Function(BuildContext context)? onOpen;
|
||||
final String? clearTitle;
|
||||
final String? clearContent;
|
||||
final Future<void> Function()? onClear;
|
||||
|
||||
const _StorageTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.describe,
|
||||
this.onOpen,
|
||||
this.clearTitle,
|
||||
this.clearContent,
|
||||
this.onClear,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_StorageTile> createState() => _StorageTileState();
|
||||
}
|
||||
|
||||
class _StorageTileState extends State<_StorageTile> {
|
||||
late Future<String> _description = widget.describe();
|
||||
|
||||
void _refresh() => setState(() {
|
||||
_description = widget.describe();
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final onOpen = widget.onOpen;
|
||||
final onClear = widget.onClear;
|
||||
return ListTile(
|
||||
leading: CenteredLeading(Icon(widget.icon)),
|
||||
title: Text(widget.title),
|
||||
subtitle: FutureBuilder(
|
||||
future: _description,
|
||||
builder: (context, snapshot) =>
|
||||
Text(snapshot.hasError ? '?' : snapshot.data ?? '…'),
|
||||
),
|
||||
onTap: onOpen == null
|
||||
? null
|
||||
: () async {
|
||||
await onOpen(context);
|
||||
if (mounted) _refresh();
|
||||
},
|
||||
trailing: onClear == null
|
||||
? const Icon(Icons.arrow_right)
|
||||
: IconButton(
|
||||
tooltip: 'Löschen',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () => ConfirmDialog(
|
||||
title: widget.clearTitle ?? '${widget.title} löschen',
|
||||
content: widget.clearContent ?? '',
|
||||
confirmButton: 'Löschen',
|
||||
errorBuilder: (e) => '$e',
|
||||
onConfirmAsync: () async {
|
||||
await onClear();
|
||||
if (mounted) _refresh();
|
||||
},
|
||||
).asDialog(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom-sheet editor for the backend-independent emergency-notice override
|
||||
@@ -202,7 +348,7 @@ class _EmergencyNoticeUrlEditor extends StatefulWidget {
|
||||
static void show(BuildContext context, SettingsCubit settings) {
|
||||
showDetailsBottomSheet(
|
||||
context,
|
||||
header: const ListTile(title: Text('Notfall-Nachricht (Quelle)')),
|
||||
header: const ListTile(title: Text('Notfall-Nachrichtenquelle')),
|
||||
children: (sheetCtx) => [_EmergencyNoticeUrlEditor(settings: settings)],
|
||||
);
|
||||
}
|
||||
@@ -233,8 +379,7 @@ class _EmergencyNoticeUrlEditorState extends State<_EmergencyNoticeUrlEditor> {
|
||||
void _save() {
|
||||
final raw = _controller.text.trim();
|
||||
// Empty is valid: it clears the override and falls back to the default.
|
||||
if (raw.isNotEmpty &&
|
||||
DevToolsSettings.sanitizeCustomUrl(raw) == null) {
|
||||
if (raw.isNotEmpty && DevToolsSettings.sanitizeCustomUrl(raw) == null) {
|
||||
setState(
|
||||
() => _error = DevToolsSettings.allowsHttpCustomEndpoint
|
||||
? 'Ungültige URL (http(s)://host[:port]/...)'
|
||||
@@ -253,19 +398,11 @@ class _EmergencyNoticeUrlEditorState extends State<_EmergencyNoticeUrlEditor> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'Überschreibt die im Code hinterlegte Standardquelle. Leer '
|
||||
'lassen, um die Standardquelle zu verwenden.',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.url,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'https://...',
|
||||
labelText: 'Leer für Standard',
|
||||
errorText: _error,
|
||||
),
|
||||
onChanged: (_) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
|
||||
import '../../../../push/push_registration.dart';
|
||||
import '../../../../push/push_status.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
@@ -104,6 +105,8 @@ class _PushSettingsState extends State<_PushSettings>
|
||||
|
||||
void _onToggle(bool enabled) {
|
||||
widget.settings.val(write: true).notificationSettings.enabled = enabled;
|
||||
// A permission granted below arrives via the resume heartbeat.
|
||||
TelemetryHeartbeat.report(notificationsEnabled: enabled);
|
||||
// Turning off does NOT unregister: the device stays subscribed so silent
|
||||
// sync pushes keep arriving; the message handler and iOS NSE suppress only
|
||||
// the visible notification (via the mirrored flag). Enabling (re-)registers
|
||||
@@ -113,11 +116,10 @@ class _PushSettingsState extends State<_PushSettings>
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
unawaited(() async {
|
||||
try {
|
||||
// Only register when the OS permission isn't explicitly denied —
|
||||
// otherwise NC + proxy would push into the void.
|
||||
if (await PushRegistration.requestOsPermission()) {
|
||||
await PushRegistration().register();
|
||||
} else {
|
||||
final granted = await PushRegistration.requestOsPermission();
|
||||
// Registered regardless: silent sync pushes need no permission.
|
||||
await PushRegistration().register();
|
||||
if (!granted) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
|
||||
@@ -32,18 +32,21 @@ class SettingsDropdownTile<T> extends StatelessWidget {
|
||||
trailing: DropdownButton<T>(
|
||||
value: value,
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
selectedItemBuilder: (context) => options
|
||||
.map(
|
||||
(e) => Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: _optionRow(e),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
items: options
|
||||
.map(
|
||||
(e) => DropdownMenuItem<T>(
|
||||
value: e,
|
||||
enabled: e != value,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(optionIcon(e)),
|
||||
const SizedBox(width: 10),
|
||||
Text(optionLabel(e)),
|
||||
],
|
||||
),
|
||||
child: _optionRow(e),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
@@ -53,4 +56,19 @@ class SettingsDropdownTile<T> extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _optionRow(T e) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(optionIcon(e)),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
optionLabel(e),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,19 +135,24 @@ Future<void> _externalShareFlow(
|
||||
PendingShare share,
|
||||
) async {
|
||||
if (share.hasFiles) {
|
||||
await ensureTalkShareFolder();
|
||||
if (!context.mounted) return;
|
||||
await pushScreen(
|
||||
context,
|
||||
withNavBar: false,
|
||||
screen: FilesUploadDialog(
|
||||
filePaths: share.filePaths,
|
||||
remotePath: talkShareFolder,
|
||||
uniqueNames: true,
|
||||
onUploadFinished: (uploaded) =>
|
||||
_afterExternalFilesUploaded(context, room, uploaded, share),
|
||||
),
|
||||
);
|
||||
ShareIntentListener.instance.beginFlow();
|
||||
try {
|
||||
await ensureTalkShareFolder();
|
||||
if (!context.mounted) return;
|
||||
await pushScreen(
|
||||
context,
|
||||
withNavBar: false,
|
||||
screen: FilesUploadDialog(
|
||||
filePaths: share.filePaths,
|
||||
remotePath: talkShareFolder,
|
||||
uniqueNames: true,
|
||||
onUploadFinished: (uploaded) =>
|
||||
_afterExternalFilesUploaded(context, room, uploaded, share),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
ShareIntentListener.instance.endFlow();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (share.hasText) {
|
||||
@@ -155,17 +160,28 @@ Future<void> _externalShareFlow(
|
||||
}
|
||||
}
|
||||
|
||||
// Called synchronously when the upload dialog pops, so the flow is entered
|
||||
// before the upload step above releases it.
|
||||
Future<void> _afterExternalFilesUploaded(
|
||||
BuildContext context,
|
||||
GetRoomResponseObject room,
|
||||
List<String> uploadedRemotePaths,
|
||||
PendingShare share,
|
||||
) => _runShareFlow(
|
||||
context,
|
||||
action: () =>
|
||||
shareFilesToChat(token: room.token, remoteFilePaths: uploadedRemotePaths),
|
||||
onSuccess: () => _setExternalDraftAndOpenChat(context, room, share),
|
||||
);
|
||||
) async {
|
||||
ShareIntentListener.instance.beginFlow();
|
||||
try {
|
||||
await _runShareFlow(
|
||||
context,
|
||||
action: () => shareFilesToChat(
|
||||
token: room.token,
|
||||
remoteFilePaths: uploadedRemotePaths,
|
||||
),
|
||||
onSuccess: () => _setExternalDraftAndOpenChat(context, room, share),
|
||||
);
|
||||
} finally {
|
||||
ShareIntentListener.instance.endFlow();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared share-flow scaffolding: shows the blocking spinner, runs [action],
|
||||
/// maps failures to an error dialog (popping the spinner first), and invokes
|
||||
|
||||
@@ -206,16 +206,21 @@ Future<void> _externalUploadFlow(
|
||||
List<String> targetPath,
|
||||
PendingShare share,
|
||||
) async {
|
||||
await pushScreen(
|
||||
context,
|
||||
withNavBar: false,
|
||||
screen: FilesUploadDialog(
|
||||
filePaths: share.filePaths,
|
||||
remotePath: targetPath.join('/'),
|
||||
onUploadFinished: (_) =>
|
||||
_afterExternalUploaded(context, targetPath, share),
|
||||
),
|
||||
);
|
||||
ShareIntentListener.instance.beginFlow();
|
||||
try {
|
||||
await pushScreen(
|
||||
context,
|
||||
withNavBar: false,
|
||||
screen: FilesUploadDialog(
|
||||
filePaths: share.filePaths,
|
||||
remotePath: targetPath.join('/'),
|
||||
onUploadFinished: (_) =>
|
||||
_afterExternalUploaded(context, targetPath, share),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
ShareIntentListener.instance.endFlow();
|
||||
}
|
||||
}
|
||||
|
||||
void _afterExternalUploaded(
|
||||
|
||||
@@ -75,6 +75,17 @@ class _ChatListViewState extends State<_ChatListView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bloc = context.read<ChatListBloc>();
|
||||
// Selected here, not in the LoadableStateConsumer child: that closure runs
|
||||
// during the consumer's build, where this context may not subscribe.
|
||||
// Draft keys are part of the selection so the draft marker follows; the
|
||||
// draft text itself is saved without notifying.
|
||||
final (favoritesToTop, unreadToTop, _) = context.select(
|
||||
(SettingsCubit c) => (
|
||||
c.state.talkSettings.sortFavoritesToTop,
|
||||
c.state.talkSettings.sortUnreadToTop,
|
||||
c.state.talkSettings.drafts.keys.join('|'),
|
||||
),
|
||||
);
|
||||
return SplitView.material(
|
||||
placeholder: const SplitViewPlaceholder(),
|
||||
breakpoint: 1000,
|
||||
@@ -129,13 +140,9 @@ class _ChatListViewState extends State<_ChatListView> {
|
||||
final rooms = state.rooms;
|
||||
if (rooms == null) return const SizedBox.shrink();
|
||||
|
||||
final talkSettings = context
|
||||
.watch<SettingsCubit>()
|
||||
.val()
|
||||
.talkSettings;
|
||||
final sorted = rooms.sortBy(
|
||||
favoritesToTop: talkSettings.sortFavoritesToTop,
|
||||
unreadToTop: talkSettings.sortUnreadToTop,
|
||||
favoritesToTop: favoritesToTop,
|
||||
unreadToTop: unreadToTop,
|
||||
);
|
||||
|
||||
if (sorted.isEmpty) {
|
||||
@@ -145,23 +152,25 @@ class _ChatListViewState extends State<_ChatListView> {
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
final drafts = _settings.val().talkSettings.drafts;
|
||||
final indexByToken = {
|
||||
for (var i = 0; i < sorted.length; i++) sorted[i].token: i,
|
||||
};
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
children: sorted.map((room) {
|
||||
final hasDraft = _settings
|
||||
.val()
|
||||
.talkSettings
|
||||
.drafts
|
||||
.containsKey(room.token);
|
||||
// Stable key keeps element identity across re-sorts so the
|
||||
// inner UserAvatar reuses its cached bytes instead of
|
||||
// flashing on every list update.
|
||||
itemCount: sorted.length,
|
||||
// Keeps each tile's state (cached avatar bytes) with its room
|
||||
// when a re-sort moves it to another index.
|
||||
findChildIndexCallback: (key) =>
|
||||
indexByToken[(key as ValueKey<String>).value],
|
||||
itemBuilder: (context, index) {
|
||||
final room = sorted[index];
|
||||
return ChatTile(
|
||||
key: ValueKey(room.token),
|
||||
data: room,
|
||||
hasDraft: hasDraft,
|
||||
hasDraft: drafts.containsKey(room.token),
|
||||
);
|
||||
}).toList(),
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con
|
||||
import '../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../../../state/app/modules/chat/bloc/chat_state.dart';
|
||||
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import '../../../utils/debouncer.dart';
|
||||
import '../../../widget/chat_background.dart';
|
||||
import '../../../widget/clickable_app_bar.dart';
|
||||
import '../../../widget/user_avatar.dart';
|
||||
@@ -118,6 +119,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
_markAsReadFinal();
|
||||
_chatBlocRef?.leaveChat(widget.room.token);
|
||||
_searchTextController.dispose();
|
||||
Debouncer.cancel(_searchDebounceTag);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -150,6 +152,45 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
context.read<ChatBloc>().setToken(widget.room.token);
|
||||
}
|
||||
|
||||
void _refetch({bool renew = false}) => _refresh();
|
||||
|
||||
// The built rows only depend on the chat data and the search state; the list
|
||||
// itself rebuilds far more often (loading flips, keyboard, parent rebuilds),
|
||||
// and re-sorting plus re-creating every bubble each time is wasted work.
|
||||
Object? _itemsKey;
|
||||
List<Widget> _items = const [];
|
||||
|
||||
List<Widget> _itemsFor(ChatState state) {
|
||||
final key = (
|
||||
state.chatResponse,
|
||||
state.isLoadingOlder,
|
||||
state.hasMoreOld,
|
||||
_searchActive,
|
||||
_searchQuery,
|
||||
_activeMatchIndex,
|
||||
widget.room,
|
||||
);
|
||||
if (key == _itemsKey) return _items;
|
||||
_itemsKey = key;
|
||||
final items = _buildMessages(state.chatResponse!).reversed.toList();
|
||||
// reverse:true renders index 0 at the bottom, so the top marker
|
||||
// (spinner / start-of-chat) goes at the end.
|
||||
if (state.isLoadingOlder) {
|
||||
items.add(const _LoadingOlderIndicator());
|
||||
} else if (!state.hasMoreOld) {
|
||||
items.add(
|
||||
ChatBubble(
|
||||
key: const ValueKey('chat-start'),
|
||||
isSender: false,
|
||||
bubbleData: GetChatResponseObject.getTextDummy('Anfang des Chats'),
|
||||
chatData: widget.room,
|
||||
refetch: _refetch,
|
||||
),
|
||||
);
|
||||
}
|
||||
return _items = items;
|
||||
}
|
||||
|
||||
void _enterSearchMode() {
|
||||
setState(() {
|
||||
_searchActive = true;
|
||||
@@ -163,6 +204,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
}
|
||||
|
||||
void _exitSearchMode() {
|
||||
Debouncer.cancel(_searchDebounceTag);
|
||||
setState(() {
|
||||
_searchActive = false;
|
||||
_searchQuery = '';
|
||||
@@ -175,7 +217,22 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
});
|
||||
}
|
||||
|
||||
late final String _searchDebounceTag =
|
||||
'chat-search-${identityHashCode(this)}';
|
||||
|
||||
/// Matching re-scans the whole history and rebuilds every row, so it runs
|
||||
/// once typing pauses instead of per keystroke.
|
||||
void _onSearchChanged(String q) {
|
||||
Debouncer.debounce(
|
||||
_searchDebounceTag,
|
||||
const Duration(milliseconds: 200),
|
||||
() {
|
||||
if (mounted) _applySearch(q);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _applySearch(String q) {
|
||||
final chatResponse = context.read<ChatBloc>().state.data?.chatResponse;
|
||||
setState(() {
|
||||
_searchQuery = q;
|
||||
@@ -268,9 +325,13 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
messages.add(
|
||||
ChatBubble(
|
||||
isSender: false,
|
||||
key: ValueKey(
|
||||
'day-${elementDate.year}-${elementDate.month}-'
|
||||
'${elementDate.day}',
|
||||
),
|
||||
bubbleData: GetChatResponseObject.getDateDummy(element.timestamp),
|
||||
chatData: widget.room,
|
||||
refetch: ({bool renew = false}) => _refresh(),
|
||||
refetch: _refetch,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -286,6 +347,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
|
||||
messages.add(
|
||||
ChatBubble(
|
||||
key: ValueKey(element.id),
|
||||
isSender:
|
||||
element.actorId == widget.selfId &&
|
||||
(element.messageType ==
|
||||
@@ -294,7 +356,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
GetRoomResponseObjectMessageType.deletedComment),
|
||||
bubbleData: element,
|
||||
chatData: widget.room,
|
||||
refetch: ({bool renew = false}) => _refresh(),
|
||||
refetch: _refetch,
|
||||
isRead: element.id <= commonRead,
|
||||
selfId: widget.selfId,
|
||||
highlightQuery: highlightQuery,
|
||||
@@ -317,17 +379,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Swallow the first back gesture while the keyboard is visible so it
|
||||
// dismisses the IME instead of popping the chat — matches platform UX
|
||||
// expectations (e.g. WhatsApp/Telegram) and prevents accidental exits
|
||||
// mid-typing.
|
||||
final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0;
|
||||
return PopScope(
|
||||
canPop: !keyboardOpen,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
return _KeyboardDismissPopScope(
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xffefeae2),
|
||||
appBar: _searchActive
|
||||
@@ -376,25 +428,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
state.currentToken == widget.room.token,
|
||||
enablePullToRefresh: false,
|
||||
child: (state, _) {
|
||||
final items = _buildMessages(
|
||||
state.chatResponse!,
|
||||
).reversed.toList();
|
||||
// reverse:true renders index 0 at the bottom, so the top
|
||||
// marker (spinner / start-of-chat) goes at the end.
|
||||
if (state.isLoadingOlder) {
|
||||
items.add(const _LoadingOlderIndicator());
|
||||
} else if (!state.hasMoreOld) {
|
||||
items.add(
|
||||
ChatBubble(
|
||||
isSender: false,
|
||||
bubbleData: GetChatResponseObject.getTextDummy(
|
||||
'Anfang des Chats',
|
||||
),
|
||||
chatData: widget.room,
|
||||
refetch: ({bool renew = false}) => _refresh(),
|
||||
),
|
||||
);
|
||||
}
|
||||
final items = _itemsFor(state);
|
||||
_itemCount = items.length;
|
||||
return ScrollablePositionedList.builder(
|
||||
reverse: true,
|
||||
@@ -448,3 +482,27 @@ class _LoadingOlderIndicator extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Swallows the first back gesture while the keyboard is visible so it
|
||||
/// dismisses the IME instead of popping the chat — matches platform UX
|
||||
/// expectations (e.g. WhatsApp/Telegram) and prevents accidental exits
|
||||
/// mid-typing. A separate widget so the per-frame inset changes during the
|
||||
/// keyboard animation only rebuild this scope, not the whole chat.
|
||||
class _KeyboardDismissPopScope extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _KeyboardDismissPopScope({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0;
|
||||
return PopScope(
|
||||
canPop: !keyboardOpen,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../../../api/marianumcloud/talk/get_participants/get_participants_cac
|
||||
import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_overview_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/room/notification_level.dart';
|
||||
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import '../../../../widget/app_progress_indicator.dart';
|
||||
import '../../../../widget/async_action_button.dart';
|
||||
@@ -14,6 +15,7 @@ import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/large_profile_picture_view.dart';
|
||||
import '../../../../widget/user_avatar.dart';
|
||||
import '../talk_navigator.dart';
|
||||
import '../widgets/notification_level_sheet.dart';
|
||||
import 'participants_list_view.dart';
|
||||
import 'shared_items_view.dart';
|
||||
|
||||
@@ -29,6 +31,7 @@ class _ChatInfoState extends State<ChatInfo> {
|
||||
GetParticipantsResponse? participants;
|
||||
GetSharedItemsOverviewResponse? _sharesOverview;
|
||||
late bool _isFavorite;
|
||||
late NotificationLevel _notificationLevel;
|
||||
int _avatarVersion = 0;
|
||||
bool _avatarBusy = false;
|
||||
|
||||
@@ -36,6 +39,7 @@ class _ChatInfoState extends State<ChatInfo> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isFavorite = widget.room.isFavorite;
|
||||
_notificationLevel = widget.room.effectiveNotificationLevel;
|
||||
GetParticipantsCache(
|
||||
chatToken: widget.room.token,
|
||||
onUpdate: (GetParticipantsResponse data) {
|
||||
@@ -67,6 +71,16 @@ class _ChatInfoState extends State<ChatInfo> {
|
||||
_refreshList();
|
||||
}
|
||||
|
||||
Future<void> _editNotificationLevel() async {
|
||||
final level = await showNotificationLevelSheet(
|
||||
context,
|
||||
widget.room,
|
||||
current: _notificationLevel,
|
||||
);
|
||||
if (level == null || !mounted) return;
|
||||
setState(() => _notificationLevel = level);
|
||||
}
|
||||
|
||||
Future<void> _confirmLeave() async {
|
||||
final closed = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -243,6 +257,11 @@ class _ChatInfoState extends State<ChatInfo> {
|
||||
title: const Text('Zu Favoriten hinzufügen'),
|
||||
onPressed: _toggleFavorite,
|
||||
),
|
||||
NotificationLevelTile(
|
||||
level: _notificationLevel,
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: _editNotificationLevel,
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
|
||||
@@ -59,6 +59,13 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
with SingleTickerProviderStateMixin, DownloadTrigger<ChatBubble> {
|
||||
late ChatMessage message;
|
||||
|
||||
// The parsed message and its widget are only rebuilt when their inputs
|
||||
// change. The bubble itself rebuilds on every chat emit, swipe frame and
|
||||
// keyboard frame; re-running rich-object parsing, linkify/Markdown and emoji
|
||||
// detection each time is what makes long chats stutter.
|
||||
Object? _messageKey;
|
||||
late Widget _messageWidget;
|
||||
|
||||
Offset _position = Offset.zero;
|
||||
Offset _dragStartPosition = Offset.zero;
|
||||
bool _swipeActionArmed = false;
|
||||
@@ -185,12 +192,34 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
void _updateMessage(BuildContext context) {
|
||||
final style = _messageTextStyle(context);
|
||||
final renderMarkdown =
|
||||
widget.bubbleData.markdown &&
|
||||
widget.bubbleData.messageType ==
|
||||
GetRoomResponseObjectMessageType.comment;
|
||||
final key = (
|
||||
widget.bubbleData,
|
||||
widget.highlightQuery,
|
||||
style,
|
||||
renderMarkdown,
|
||||
);
|
||||
if (key == _messageKey) return;
|
||||
_messageKey = key;
|
||||
message = ChatMessage(
|
||||
originalMessage: widget.bubbleData.message,
|
||||
originalData: widget.bubbleData.messageParameters,
|
||||
);
|
||||
_messageWidget = message.getWidget(
|
||||
highlightQuery: widget.highlightQuery,
|
||||
style: style,
|
||||
renderMarkdown: renderMarkdown,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_updateMessage(context);
|
||||
final showActorDisplayName =
|
||||
_rendersAsCommentBubble &&
|
||||
widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
|
||||
@@ -277,14 +306,7 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
actorText: actorText,
|
||||
actorWidget: actorWidget,
|
||||
timeText: timeText,
|
||||
messageWidget: message.getWidget(
|
||||
highlightQuery: widget.highlightQuery,
|
||||
style: _messageTextStyle(context),
|
||||
renderMarkdown:
|
||||
widget.bubbleData.markdown &&
|
||||
widget.bubbleData.messageType ==
|
||||
GetRoomResponseObjectMessageType.comment,
|
||||
),
|
||||
messageWidget: _messageWidget,
|
||||
parent: parent,
|
||||
bubbleData: widget.bubbleData,
|
||||
isSender: widget.isSender,
|
||||
@@ -350,10 +372,12 @@ class _BubbleContent extends StatelessWidget {
|
||||
Widget build(BuildContext context) => MergeSemantics(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.9,
|
||||
maxWidth: MediaQuery.sizeOf(context).width * 0.9,
|
||||
minWidth: showActorDisplayName
|
||||
? actorText.size.width
|
||||
: timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3,
|
||||
? actorText.measuredWidth(context)
|
||||
: timeText.measuredWidth(context) +
|
||||
(isSender ? spacing + timeIconSize : 0) +
|
||||
3,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
|
||||
@@ -34,7 +34,7 @@ class ChatBubbleReactions extends StatelessWidget {
|
||||
return Transform.translate(
|
||||
offset: const Offset(0, -10),
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
margin: const EdgeInsets.only(left: 15, right: 15),
|
||||
child: Wrap(
|
||||
alignment: isSender ? WrapAlignment.end : WrapAlignment.start,
|
||||
|
||||
@@ -83,12 +83,20 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Called per keystroke, so the text itself is saved silently; only a draft
|
||||
/// appearing or disappearing notifies listeners (the chat list's marker).
|
||||
void _setDraft(String text) {
|
||||
final talkSettings = settings.val(write: true).talkSettings;
|
||||
final drafts = settings.val().talkSettings.drafts;
|
||||
final hadDraft = drafts.containsKey(widget.sendToToken);
|
||||
if (text.isNotEmpty) {
|
||||
talkSettings.drafts[widget.sendToToken] = text;
|
||||
drafts[widget.sendToToken] = text;
|
||||
} else {
|
||||
talkSettings.drafts.removeWhere((key, _) => key == widget.sendToToken);
|
||||
drafts.remove(widget.sendToToken);
|
||||
}
|
||||
if (hadDraft != text.isNotEmpty) {
|
||||
settings.val(write: true);
|
||||
} else {
|
||||
settings.saveSilently();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,17 +284,22 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chatBloc = context.watch<ChatBloc>();
|
||||
final chatState = chatBloc.state.data;
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
// Only the reply reference is rendered from the chat state; loading flags
|
||||
// and paging emits don't need to rebuild the input.
|
||||
final (referenceMessageId, chatResponse) = context.select(
|
||||
(ChatBloc b) => (
|
||||
b.state.data?.referenceMessageId,
|
||||
b.state.data?.chatResponse,
|
||||
),
|
||||
);
|
||||
|
||||
Widget replyBanner = const SizedBox.shrink();
|
||||
if (chatState != null &&
|
||||
chatState.referenceMessageId != null &&
|
||||
chatState.chatResponse != null) {
|
||||
if (referenceMessageId != null && chatResponse != null) {
|
||||
try {
|
||||
final referenceMessage = chatState.chatResponse!
|
||||
.sortByTimestamp()
|
||||
.firstWhere((e) => e.id == chatState.referenceMessageId);
|
||||
final referenceMessage = chatResponse.data.firstWhere(
|
||||
(e) => e.id == referenceMessageId,
|
||||
);
|
||||
replyBanner = Row(
|
||||
children: [
|
||||
Expanded(
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.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/room/notification_level.dart';
|
||||
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../notification/notification_tasks.dart';
|
||||
@@ -22,6 +23,7 @@ import '../../../../widget/details_bottom_sheet.dart';
|
||||
import '../../../../widget/user_avatar.dart';
|
||||
import '../data/talk_markdown.dart';
|
||||
import '../talk_navigator.dart';
|
||||
import 'notification_level_sheet.dart';
|
||||
|
||||
class ChatTile extends StatefulWidget {
|
||||
final GetRoomResponseObject data;
|
||||
@@ -62,15 +64,25 @@ class _ChatTileState extends State<ChatTile> {
|
||||
/// One-line preview of the last message: rich-object placeholders resolved,
|
||||
/// newlines flattened and — for Markdown messages — formatting stripped so
|
||||
/// the list shows readable text rather than raw markers.
|
||||
///
|
||||
/// Memoised per last message: the tile rebuilds on every chat-list emit and
|
||||
/// the Markdown strip is a full parse. Keyed by content, since every refresh
|
||||
/// delivers new message objects.
|
||||
String _lastMessagePreview() {
|
||||
final last = widget.data.lastMessage;
|
||||
final key = (last.id, last.message, last.markdown);
|
||||
if (key == _previewFor) return _preview;
|
||||
final text = RichObjectStringProcessor.parseToString(
|
||||
last.message.replaceAll('\n', ' '),
|
||||
last.messageParameters,
|
||||
);
|
||||
return last.markdown ? markdownToPlainText(text) : text;
|
||||
_previewFor = key;
|
||||
return _preview = last.markdown ? markdownToPlainText(text) : text;
|
||||
}
|
||||
|
||||
Object? _previewFor;
|
||||
String _preview = '';
|
||||
|
||||
Future<void> _setCurrentAsRead() async {
|
||||
final token = widget.data.token;
|
||||
final lastId = widget.data.lastMessage.id;
|
||||
@@ -83,7 +95,11 @@ class _ChatTileState extends State<ChatTile> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chatBloc = context.watch<ChatBloc>();
|
||||
// Only the open token matters here (split-view highlight); watching the
|
||||
// whole bloc rebuilt every tile on each message of the open chat.
|
||||
final currentToken = context.select(
|
||||
(ChatBloc b) => b.state.data?.currentToken,
|
||||
);
|
||||
final isGroup =
|
||||
widget.data.type != GetRoomResponseObjectConversationType.oneToOne;
|
||||
final circleAvatar = UserAvatar(
|
||||
@@ -94,7 +110,7 @@ class _ChatTileState extends State<ChatTile> {
|
||||
return ListTile(
|
||||
style: ListTileStyle.list,
|
||||
tileColor:
|
||||
chatBloc.state.data?.currentToken == widget.data.token &&
|
||||
currentToken == widget.data.token &&
|
||||
TalkNavigator.isSecondaryVisible(context)
|
||||
? Theme.of(context).primaryColor.withAlpha(100)
|
||||
: null,
|
||||
@@ -147,6 +163,15 @@ class _ChatTileState extends State<ChatTile> {
|
||||
semanticLabel: A11yLabels.draft,
|
||||
),
|
||||
],
|
||||
if (widget.data.isMuted) ...[
|
||||
const SizedBox(width: 5),
|
||||
Icon(
|
||||
NotificationLevel.neverNotify.icon,
|
||||
size: 15,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
semanticLabel: A11yLabels.muted,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
@@ -234,6 +259,17 @@ class _ChatTileState extends State<ChatTile> {
|
||||
if (mounted) _refreshList();
|
||||
},
|
||||
),
|
||||
NotificationLevelTile(
|
||||
level: widget.data.effectiveNotificationLevel,
|
||||
onTap: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
showNotificationLevelSheet(
|
||||
context,
|
||||
widget.data,
|
||||
current: widget.data.effectiveNotificationLevel,
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline),
|
||||
title: const Text('Talk-Chat verlassen'),
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/room/notification_level.dart';
|
||||
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/details_bottom_sheet.dart';
|
||||
|
||||
extension NotificationLevelPresentation on NotificationLevel {
|
||||
// defaultLevel never reaches the UI (see effectiveNotificationLevel), but
|
||||
// the switch has to stay exhaustive.
|
||||
({String label, String description, IconData icon}) get _presentation =>
|
||||
switch (this) {
|
||||
NotificationLevel.alwaysNotify || NotificationLevel.defaultLevel => (
|
||||
label: 'Alle Nachrichten',
|
||||
description: 'Bei jeder neuen Nachricht',
|
||||
icon: Icons.notifications_outlined,
|
||||
),
|
||||
NotificationLevel.notifyOnMention => (
|
||||
label: 'Nur Erwähnungen',
|
||||
description: 'Nur wenn du erwähnt wirst',
|
||||
icon: Icons.alternate_email,
|
||||
),
|
||||
NotificationLevel.neverNotify => (
|
||||
label: 'Aus',
|
||||
description: 'Keine Benachrichtigungen',
|
||||
icon: Icons.notifications_off_outlined,
|
||||
),
|
||||
};
|
||||
|
||||
String get label => _presentation.label;
|
||||
String get description => _presentation.description;
|
||||
IconData get icon => _presentation.icon;
|
||||
}
|
||||
|
||||
/// Lets the user pick the room's Talk notification level. Nextcloud applies
|
||||
/// it server-side, so no push for the room is sent at all when muted.
|
||||
/// Returns the new level, or null when nothing changed.
|
||||
Future<NotificationLevel?> showNotificationLevelSheet(
|
||||
BuildContext context,
|
||||
GetRoomResponseObject room, {
|
||||
required NotificationLevel current,
|
||||
}) async {
|
||||
final bloc = context.read<ChatListBloc>();
|
||||
NotificationLevel? selected;
|
||||
await showDetailsBottomSheet(
|
||||
context,
|
||||
header: ListTile(
|
||||
leading: const Icon(Icons.notifications_outlined),
|
||||
title: const Text('Benachrichtigungen'),
|
||||
subtitle: Text(room.displayName, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
children: (_) => [
|
||||
for (final level in selectableNotificationLevels)
|
||||
AsyncListTile(
|
||||
leading: Icon(
|
||||
level == current
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
),
|
||||
title: Text(level.label),
|
||||
subtitle: Text(level.description),
|
||||
onPressed: () async {
|
||||
if (level == current) return;
|
||||
await bloc.setNotificationLevel(room.token, level);
|
||||
selected = level;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
return selected;
|
||||
}
|
||||
|
||||
class NotificationLevelTile extends StatelessWidget {
|
||||
final NotificationLevel level;
|
||||
final VoidCallback onTap;
|
||||
final Widget? trailing;
|
||||
|
||||
const NotificationLevelTile({
|
||||
required this.level,
|
||||
required this.onTap,
|
||||
this.trailing,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListTile(
|
||||
leading: Icon(level.icon),
|
||||
title: const Text('Benachrichtigungen'),
|
||||
subtitle: Text(level.label),
|
||||
trailing: trailing,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:rrule/rrule.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../utils/recurrence_occurrences.dart';
|
||||
import 'arbitrary_appointment.dart';
|
||||
import 'calendar_layout.dart';
|
||||
import 'lesson_period_schedule.dart';
|
||||
@@ -103,7 +103,6 @@ partitionAppointmentsForWeek(
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
final parsed = RecurrenceRule.fromString(rule);
|
||||
final anchorUtc = a.startTime.toUtc();
|
||||
final duration = a.endTime.difference(a.startTime);
|
||||
// Day-keyed set of exception dates so occurrences scheduled for one
|
||||
@@ -112,9 +111,12 @@ partitionAppointmentsForWeek(
|
||||
final exceptionDayKeys = (a.recurrenceExceptionDates ?? const <DateTime>[])
|
||||
.map((d) => '${d.year}-${d.month}-${d.day}')
|
||||
.toSet();
|
||||
for (final occUtc in parsed.getInstances(start: anchorUtc)) {
|
||||
if (!occUtc.isBefore(weekEndUtc)) break;
|
||||
if (occUtc.isBefore(weekStartUtc)) continue;
|
||||
for (final occUtc in RecurrenceOccurrences.between(
|
||||
rule,
|
||||
anchorUtc,
|
||||
weekStartUtc,
|
||||
weekEndUtc,
|
||||
)) {
|
||||
final occLocal = occUtc.toLocal();
|
||||
if (exceptionDayKeys.contains(
|
||||
'${occLocal.year}-${occLocal.month}-${occLocal.day}',
|
||||
|
||||
@@ -73,5 +73,6 @@ class LessonMerger {
|
||||
substitutionText: source.substitutionText,
|
||||
lessonText: source.lessonText,
|
||||
infoText: source.infoText,
|
||||
classIds: source.classIds,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||
import 'lesson_labels.dart';
|
||||
|
||||
/// Derives a human-readable title from the Marianum-Connect `lessonType` for
|
||||
/// lessons that have no subject of their own (Aufsicht, Sprechstunde, …).
|
||||
@@ -10,10 +11,20 @@ class LessonTypeLabel {
|
||||
|
||||
static String forEntry(McTimetableEntry lesson) {
|
||||
final base = baseLabel(lesson.lessonType);
|
||||
if (base == fallback) {
|
||||
final text = entryText(lesson);
|
||||
if (text != null) return text;
|
||||
}
|
||||
final room = (lesson.rooms.firstOrNull ?? '').trim();
|
||||
return room.isEmpty ? base : '$room $base';
|
||||
}
|
||||
|
||||
/// Webuntis-Freitext eines fachlosen Eintrags („Klassenfahrt 8a“), der
|
||||
/// aussagekräftiger ist als das generische „Event“.
|
||||
static String? entryText(McTimetableEntry lesson) =>
|
||||
collapseWhitespace(lesson.substitutionText) ??
|
||||
collapseWhitespace(lesson.lessonText);
|
||||
|
||||
/// Returns just the type wording without the room — useful when the caller
|
||||
/// renders the room separately (e.g. as a subtitle line).
|
||||
static String baseLabel(String lessonType) {
|
||||
|
||||
@@ -213,10 +213,12 @@ class TimetableAppointmentFactory {
|
||||
return LessonColor.forStatus(status);
|
||||
}
|
||||
|
||||
McSubject? _findSubject(String? subjectShort) {
|
||||
if (subjectShort == null) return null;
|
||||
return subjects.where((s) => s.shortName == subjectShort).firstOrNull;
|
||||
}
|
||||
late final Map<String, McSubject> _subjectsByShort = {
|
||||
for (final s in subjects.reversed) s.shortName: s,
|
||||
};
|
||||
|
||||
McSubject? _findSubject(String? subjectShort) =>
|
||||
subjectShort == null ? null : _subjectsByShort[subjectShort];
|
||||
|
||||
String _subjectName(String? subjectShort, McTimetableEntry lesson) {
|
||||
if (subjectShort != null) {
|
||||
@@ -240,12 +242,14 @@ class TimetableAppointmentFactory {
|
||||
String _locationLabel(McTimetableEntry lesson) {
|
||||
final roomName =
|
||||
collapseWhitespace(lesson.rooms.firstOrNull) ?? 'Unbekannt';
|
||||
// Klassenlose Einträge (Aufsichten etc.) fallen auf den Lehrer zurück.
|
||||
final secondLine =
|
||||
(showClassInsteadOfTeacher ? lesson.classLabel : null) ??
|
||||
_teacherLabel(lesson.teachers.firstOrNull) ??
|
||||
'Unbekannt';
|
||||
return '$roomName\n$secondLine';
|
||||
if (showClassInsteadOfTeacher) {
|
||||
// Klassenlose Einträge (Aufsichten etc.) zeigen nur den Raum — die
|
||||
// Lehrkraft wäre hier die Plan-Inhaberin selbst.
|
||||
final classLabel = lesson.classLabel;
|
||||
return classLabel == null ? roomName : '$roomName\n$classLabel';
|
||||
}
|
||||
final teacher = _teacherLabel(lesson.teachers.firstOrNull) ?? 'Unbekannt';
|
||||
return '$roomName\n$teacher';
|
||||
}
|
||||
|
||||
/// Backend serves teachers with their full display name ("Stefan Müller"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||
import '../data/arbitrary_appointment.dart';
|
||||
import 'custom_event_sheet.dart';
|
||||
@@ -12,6 +13,8 @@ class AppointmentDetailsDispatcher {
|
||||
TimetableState? state,
|
||||
Appointment appointment, {
|
||||
bool canEditSubjectColor = false,
|
||||
bool teacherPlan = false,
|
||||
void Function(TimetableElementRef element)? onOpenElement,
|
||||
}) {
|
||||
final id = appointment.id;
|
||||
if (id is! ArbitraryAppointment) return;
|
||||
@@ -23,6 +26,8 @@ class AppointmentDetailsDispatcher {
|
||||
appointment,
|
||||
entry,
|
||||
canEditSubjectColor: canEditSubjectColor,
|
||||
teacherPlan: teacherPlan,
|
||||
onOpenElement: onOpenElement,
|
||||
),
|
||||
custom: (event) => CustomEventSheet.show(context, event),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../extensions/text.dart';
|
||||
@@ -10,6 +11,7 @@ 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 '../data/lesson_labels.dart';
|
||||
import '../data/lesson_type_label.dart';
|
||||
import '../subject_colors/subject_color_picker.dart';
|
||||
|
||||
@@ -20,6 +22,8 @@ class LessonSheet {
|
||||
Appointment appointment,
|
||||
McTimetableEntry lesson, {
|
||||
bool canEditSubjectColor = false,
|
||||
bool teacherPlan = false,
|
||||
void Function(TimetableElementRef element)? onOpenElement,
|
||||
}) {
|
||||
if (state == null) return;
|
||||
|
||||
@@ -71,7 +75,7 @@ class LessonSheet {
|
||||
)
|
||||
: null,
|
||||
),
|
||||
children: (_) => <Widget>[
|
||||
children: (sheetContext) => <Widget>[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.notifications_active),
|
||||
title: Text('Status: ${_statusLabel(lesson.status)}'),
|
||||
@@ -87,13 +91,14 @@ class LessonSheet {
|
||||
.toList(),
|
||||
),
|
||||
_roomTile(context, lesson),
|
||||
_teacherTile(context, lesson),
|
||||
if (lesson.classNames.isNotEmpty)
|
||||
_listTile(
|
||||
icon: Icons.people,
|
||||
label: lesson.classNames.length == 1 ? 'Klasse' : 'Klassen',
|
||||
entries: lesson.classNames.map(_line).toList(),
|
||||
),
|
||||
..._peopleTiles(
|
||||
context,
|
||||
lesson,
|
||||
opener: onOpenElement == null
|
||||
? null
|
||||
: _ElementOpener(sheetContext, onOpenElement),
|
||||
teacherPlan: teacherPlan,
|
||||
),
|
||||
..._optionalTextTiles(lesson),
|
||||
DebugTile(context).jsonData(lesson.toJson()),
|
||||
],
|
||||
@@ -127,7 +132,59 @@ class LessonSheet {
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _teacherTile(BuildContext context, McTimetableEntry lesson) {
|
||||
// Aus Lehrersicht ist die Klasse die relevante Angabe, die Lehrkraft ist
|
||||
// meist die Plan-Inhaberin selbst — daher dort Klasse zuerst.
|
||||
static List<Widget> _peopleTiles(
|
||||
BuildContext context,
|
||||
McTimetableEntry lesson, {
|
||||
required _ElementOpener? opener,
|
||||
required bool teacherPlan,
|
||||
}) {
|
||||
final classTile = _classTile(lesson, opener);
|
||||
final teacherTile = _teacherTile(
|
||||
context,
|
||||
lesson,
|
||||
opener,
|
||||
teacherPlan: teacherPlan,
|
||||
);
|
||||
return teacherPlan ? [?classTile, teacherTile] : [teacherTile, ?classTile];
|
||||
}
|
||||
|
||||
static Widget? _classTile(McTimetableEntry lesson, _ElementOpener? opener) {
|
||||
final names = lesson.classNames;
|
||||
if (names.isEmpty) return null;
|
||||
const tooltip = 'Stundenplan der Klasse öffnen';
|
||||
final ids = lesson.classIds?.length == names.length
|
||||
? lesson.classIds
|
||||
: null;
|
||||
TimetableElementRef? element(int i) => ids == null
|
||||
? null
|
||||
: (type: TimetableElementType.schoolClass, id: ids[i], label: names[i]);
|
||||
|
||||
return _listTile(
|
||||
icon: Icons.people,
|
||||
label: names.length == 1 ? 'Klasse' : 'Klassen',
|
||||
entries: names.map(_line).toList(),
|
||||
trailing: names.length == 1
|
||||
? opener?.button(tooltip: tooltip, element: element(0))
|
||||
: null,
|
||||
wrapRow: (i, row) => _openable(opener, row, tooltip, element(i)),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _openable(
|
||||
_ElementOpener? opener,
|
||||
Widget label,
|
||||
String tooltip,
|
||||
TimetableElementRef? element,
|
||||
) => opener?.row(label: label, tooltip: tooltip, element: element) ?? label;
|
||||
|
||||
static Widget _teacherTile(
|
||||
BuildContext context,
|
||||
McTimetableEntry lesson,
|
||||
_ElementOpener? opener, {
|
||||
required bool teacherPlan,
|
||||
}) {
|
||||
if (lesson.teachers.isEmpty) {
|
||||
return const ListTile(
|
||||
leading: Icon(Icons.person),
|
||||
@@ -145,12 +202,17 @@ class LessonSheet {
|
||||
}
|
||||
|
||||
final label = teachers.length == 1 ? 'Lehrkraft' : 'Lehrkräfte';
|
||||
const tooltip = 'Stundenplan der Lehrkraft öffnen';
|
||||
|
||||
// Einzelne, reguläre Lehrkraft kompakt in der Titelzeile.
|
||||
if (teachers.length == 1 && teachers.first.isPlain) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.person),
|
||||
title: Text('$label: ${teachers.first.after}'),
|
||||
// Im Lehrerplan ist das die Plan-Inhaberin selbst.
|
||||
trailing: teacherPlan
|
||||
? null
|
||||
: opener?.button(tooltip: tooltip, element: teachers.first.target),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,7 +221,10 @@ class LessonSheet {
|
||||
title: Text(label),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [for (final t in teachers) t.buildRow(context)],
|
||||
children: [
|
||||
for (final t in teachers)
|
||||
_openable(opener, t.buildRow(context), tooltip, t.target),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -206,6 +271,7 @@ class LessonSheet {
|
||||
required String label,
|
||||
required List<String> entries,
|
||||
Widget? trailing,
|
||||
Widget Function(int index, Widget row)? wrapRow,
|
||||
}) {
|
||||
if (entries.length == 1) {
|
||||
return ListTile(
|
||||
@@ -219,17 +285,23 @@ class LessonSheet {
|
||||
title: Text(label),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: entries.map<Widget>(Text.new).toList(),
|
||||
children: [
|
||||
for (final (i, entry) in entries.indexed)
|
||||
wrapRow?.call(i, Text(entry)) ?? Text(entry),
|
||||
],
|
||||
),
|
||||
trailing: trailing,
|
||||
);
|
||||
}
|
||||
|
||||
static List<Widget> _optionalTextTiles(McTimetableEntry lesson) {
|
||||
final sameText =
|
||||
collapseWhitespace(lesson.lessonText) ==
|
||||
collapseWhitespace(lesson.substitutionText);
|
||||
return <Widget?>[
|
||||
_textTile(Icons.info_outline, 'Info', lesson.infoText),
|
||||
_textTile(Icons.swap_horiz, 'Vertretungstext', lesson.substitutionText),
|
||||
_textTile(Icons.subject, 'Stundentext', lesson.lessonText),
|
||||
if (!sameText) _textTile(Icons.subject, 'Stundentext', lesson.lessonText),
|
||||
_textTile(
|
||||
Icons.category_outlined,
|
||||
'Stundentyp',
|
||||
@@ -329,7 +401,15 @@ class _TeacherDisplay {
|
||||
/// Aktuelle Lehrkraft. Leer bei ersatzlosem Entfall.
|
||||
final String after;
|
||||
|
||||
const _TeacherDisplay._({this.before = '', this.after = ''});
|
||||
/// Lehrkraft, deren Stundenplan sich öffnen lässt: die aktuelle, bei
|
||||
/// ersatzlosem Entfall die ursprüngliche. Null, wenn beide unbekannt sind.
|
||||
final TimetableElementRef? target;
|
||||
|
||||
const _TeacherDisplay._({
|
||||
this.before = '',
|
||||
this.after = '',
|
||||
this.target,
|
||||
});
|
||||
|
||||
bool get isPlain => before.isEmpty;
|
||||
|
||||
@@ -341,14 +421,32 @@ class _TeacherDisplay {
|
||||
t.originalShortName ?? '',
|
||||
t.originalDisplayName ?? '',
|
||||
);
|
||||
final target = _target(t, currentKnown: current.isNotEmpty);
|
||||
|
||||
// Kein (abweichendes) Original → reguläre Lehrkraft.
|
||||
if (original.isEmpty || original == current) {
|
||||
return _TeacherDisplay._(after: current.isEmpty ? '?' : current);
|
||||
return _TeacherDisplay._(
|
||||
after: current.isEmpty ? '?' : current,
|
||||
target: target,
|
||||
);
|
||||
}
|
||||
// Original vorhanden → ersetzte/entfallende Lehrkraft vorn, aktuelle
|
||||
// Lehrkraft (falls vorhanden) als Ersatz dahinter.
|
||||
return _TeacherDisplay._(before: original, after: current);
|
||||
return _TeacherDisplay._(
|
||||
before: original,
|
||||
after: current,
|
||||
target: target,
|
||||
);
|
||||
}
|
||||
|
||||
static TimetableElementRef? _target(
|
||||
McTimetableTeacher t, {
|
||||
required bool currentKnown,
|
||||
}) {
|
||||
final id = currentKnown ? t.id : t.originalId;
|
||||
if (id == null) return null;
|
||||
final label = currentKnown ? t.displayName : t.originalDisplayName;
|
||||
return (type: TimetableElementType.teacher, id: id, label: label ?? '');
|
||||
}
|
||||
|
||||
/// Eine Bullet-Zeile je Lehrkraft: ersetzte Person durchgestrichen, bei
|
||||
@@ -400,3 +498,58 @@ class _TeacherDisplay {
|
||||
return short;
|
||||
}
|
||||
}
|
||||
|
||||
/// Baut die Bedienelemente, die aus dem Sheet heraus einen fremden
|
||||
/// Stundenplan öffnen. Ohne Element-Id (älterer Server) entfallen sie.
|
||||
class _ElementOpener {
|
||||
static const IconData icon = Icons.calendar_view_week_outlined;
|
||||
|
||||
final BuildContext sheetContext;
|
||||
final void Function(TimetableElementRef element) onOpen;
|
||||
|
||||
const _ElementOpener(this.sheetContext, this.onOpen);
|
||||
|
||||
void _open(TimetableElementRef element) {
|
||||
Navigator.of(sheetContext).pop();
|
||||
onOpen(element);
|
||||
}
|
||||
|
||||
/// Trailing-Button für einzeilige Tiles, analog zum Raumplan-Button.
|
||||
Widget? button({
|
||||
required String tooltip,
|
||||
required TimetableElementRef? element,
|
||||
}) {
|
||||
if (element == null) return null;
|
||||
return IconButton(
|
||||
icon: const Icon(icon),
|
||||
tooltip: tooltip,
|
||||
onPressed: () => _open(element),
|
||||
);
|
||||
}
|
||||
|
||||
/// Kompakte, vollständig antippbare Zeile für Aufzählungen — ein
|
||||
/// 48px-Button pro Eintrag würde die Liste auseinanderziehen.
|
||||
Widget row({
|
||||
required Widget label,
|
||||
required String tooltip,
|
||||
required TimetableElementRef? element,
|
||||
}) {
|
||||
if (element == null) return label;
|
||||
final muted = Theme.of(sheetContext).colorScheme.onSurfaceVariant;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: tooltip,
|
||||
child: InkWell(
|
||||
onTap: () => _open(element),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: label),
|
||||
const SizedBox(width: 8),
|
||||
Icon(icon, size: 18, color: muted),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@ class _TimetableState extends State<Timetable> {
|
||||
if (guardDemoAction(context)) return;
|
||||
final ref = await AppRoutes.openElementPicker(context);
|
||||
if (!mounted || ref == null) return;
|
||||
_openElement(ref);
|
||||
}
|
||||
|
||||
void _openElement(TimetableElementRef ref) {
|
||||
setState(() => _selected = ref);
|
||||
}
|
||||
|
||||
@@ -190,6 +194,8 @@ class _TimetableState extends State<Timetable> {
|
||||
state,
|
||||
apt,
|
||||
canEditSubjectColor: policy.canEditSubjectColors,
|
||||
teacherPlan: policy.showClassInsteadOfTeacher,
|
||||
onOpenElement: policy.canOpenForeign ? _openElement : null,
|
||||
),
|
||||
onCreateEvent: policy.canManageCustomEvents
|
||||
? _onCreateEventAt
|
||||
@@ -244,11 +250,12 @@ class _ViewingBanner extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isFavorite = context
|
||||
.watch<SettingsCubit>()
|
||||
.val()
|
||||
.timetableFavoritesSettings
|
||||
.isFavorite(element.type, element.id);
|
||||
final isFavorite = context.select(
|
||||
(SettingsCubit c) => c.state.timetableFavoritesSettings.isFavorite(
|
||||
element.type,
|
||||
element.id,
|
||||
),
|
||||
);
|
||||
|
||||
final onColor = theme.colorScheme.onSecondaryContainer;
|
||||
// Compact icon button: ~32px square, no extra padding, so the banner stays
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
@@ -11,16 +12,19 @@ class AppointmentTile extends StatelessWidget {
|
||||
final Appointment appointment;
|
||||
final bool crossedOut;
|
||||
|
||||
/// Ticking clock; only the tile background listens, so a lesson fades to
|
||||
/// the "past" look without rebuilding the text layout.
|
||||
final ValueListenable<DateTime> now;
|
||||
|
||||
const AppointmentTile({
|
||||
super.key,
|
||||
required this.appointment,
|
||||
required this.now,
|
||||
this.crossedOut = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isPast = appointment.endTime.isBefore(DateTime.now());
|
||||
final color = appointment.color.withAlpha(isPast ? 160 : 255);
|
||||
final isCustom = appointment.id is CustomAppointment;
|
||||
final description = appointment.location ?? '';
|
||||
|
||||
@@ -29,13 +33,19 @@ class AppointmentTile extends StatelessWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
alignment: Alignment.topLeft,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.rectangle,
|
||||
borderRadius: _radius,
|
||||
color: color,
|
||||
child: ValueListenableBuilder<DateTime>(
|
||||
valueListenable: now,
|
||||
builder: (context, now, child) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
alignment: Alignment.topLeft,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.rectangle,
|
||||
borderRadius: _radius,
|
||||
color: appointment.color.withAlpha(
|
||||
appointment.endTime.isBefore(now) ? 160 : 255,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
child: _TileContent(
|
||||
title: appointment.subject,
|
||||
@@ -100,8 +110,10 @@ class _TileContent extends StatelessWidget {
|
||||
// entirely; the coloured rectangle is enough.
|
||||
if (available < titleLineHeight) return const SizedBox.shrink();
|
||||
|
||||
final remaining =
|
||||
(available - titleLineHeight).clamp(0.0, double.infinity);
|
||||
final remaining = (available - titleLineHeight).clamp(
|
||||
0.0,
|
||||
double.infinity,
|
||||
);
|
||||
final bodyLineCapacity = (remaining / bodyLineHeight).floor();
|
||||
|
||||
if (isCustom) {
|
||||
@@ -187,7 +199,9 @@ class _AdaptiveTitle extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
textScaler: textScaler,
|
||||
)..layout();
|
||||
if (probe.width > constraints.maxWidth) {
|
||||
final overflows = probe.width > constraints.maxWidth;
|
||||
probe.dispose();
|
||||
if (overflows) {
|
||||
return Text(
|
||||
text,
|
||||
style: baseStyle.copyWith(fontSize: minFontSize),
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
part of '../custom_workweek_calendar.dart';
|
||||
|
||||
class _OutsideHoursStrip extends StatelessWidget {
|
||||
final DateTime weekStart;
|
||||
final List<Appointment> appointments;
|
||||
final List<List<Appointment>> outside;
|
||||
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.outside,
|
||||
required this.rulerWidth,
|
||||
required this.onAppointmentTap,
|
||||
required this.isCrossedOut,
|
||||
@@ -18,10 +16,6 @@ class _OutsideHoursStrip extends StatelessWidget {
|
||||
|
||||
@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);
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
part of '../custom_workweek_calendar.dart';
|
||||
|
||||
/// Per-week derived data, computed once per appointment/region set instead of
|
||||
/// on every rebuild of a week page.
|
||||
class _WeekData {
|
||||
final List<List<Appointment>> inside;
|
||||
final List<List<Appointment>> outside;
|
||||
final List<List<BoundRegion>> regions;
|
||||
|
||||
_WeekData._(this.inside, this.outside, this.regions);
|
||||
|
||||
factory _WeekData.compute(
|
||||
DateTime weekStart,
|
||||
List<Appointment> appointments,
|
||||
List<TimeRegion> timeRegions,
|
||||
) {
|
||||
final partitioned = partitionAppointmentsForWeek(appointments, weekStart);
|
||||
return _WeekData._(partitioned.inside, partitioned.outside, [
|
||||
for (var d = 0; d < 5; d++)
|
||||
expandRegionsForDay(timeRegions, weekStart.addDays(d)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _WeekGrid extends StatelessWidget {
|
||||
final DateTime weekStart;
|
||||
final LessonPeriodSchedule schedule;
|
||||
final List<Appointment> appointments;
|
||||
final List<TimeRegion> timeRegions;
|
||||
final _WeekData data;
|
||||
final void Function(Appointment) onAppointmentTap;
|
||||
final bool Function(Appointment) isCrossedOut;
|
||||
final void Function(DateTime start, DateTime end)? onCreateEvent;
|
||||
@@ -16,8 +37,7 @@ class _WeekGrid extends StatelessWidget {
|
||||
const _WeekGrid({
|
||||
required this.weekStart,
|
||||
required this.schedule,
|
||||
required this.appointments,
|
||||
required this.timeRegions,
|
||||
required this.data,
|
||||
required this.onAppointmentTap,
|
||||
required this.isCrossedOut,
|
||||
required this.onCreateEvent,
|
||||
@@ -29,8 +49,6 @@ class _WeekGrid extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final partitioned = partitionAppointmentsForWeek(appointments, weekStart);
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -48,8 +66,8 @@ class _WeekGrid extends StatelessWidget {
|
||||
child: _DayColumn(
|
||||
date: weekStart.addDays(d),
|
||||
schedule: schedule,
|
||||
appointments: partitioned.inside[d],
|
||||
timeRegions: timeRegions,
|
||||
appointments: data.inside[d],
|
||||
regions: data.regions[d],
|
||||
layout: layout,
|
||||
today: today,
|
||||
nowNotifier: nowNotifier,
|
||||
@@ -195,7 +213,7 @@ class _DayColumn extends StatelessWidget {
|
||||
final DateTime date;
|
||||
final LessonPeriodSchedule schedule;
|
||||
final List<Appointment> appointments;
|
||||
final List<TimeRegion> timeRegions;
|
||||
final List<BoundRegion> regions;
|
||||
final PeriodLayout layout;
|
||||
final DateTime today;
|
||||
final ValueListenable<DateTime> nowNotifier;
|
||||
@@ -207,7 +225,7 @@ class _DayColumn extends StatelessWidget {
|
||||
required this.date,
|
||||
required this.schedule,
|
||||
required this.appointments,
|
||||
required this.timeRegions,
|
||||
required this.regions,
|
||||
required this.layout,
|
||||
required this.today,
|
||||
required this.nowNotifier,
|
||||
@@ -280,12 +298,10 @@ class _DayColumn extends StatelessWidget {
|
||||
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);
|
||||
final isTablet = MediaQuery.sizeOf(context).shortestSide >= 600;
|
||||
final laidOut = assignLanes(appointments, maxLanes: isTablet ? 3 : 2);
|
||||
|
||||
final dayName = DateFormat(
|
||||
'EEEE',
|
||||
@@ -304,8 +320,7 @@ class _DayColumn extends StatelessWidget {
|
||||
sortKey: OrdinalSortKey(date.weekday.toDouble()),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onLongPressStart: (details) =>
|
||||
_handleLongPress(details, dayAppointments),
|
||||
onLongPressStart: (details) => _handleLongPress(details, appointments),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: isToday ? theme.colorScheme.primary.withAlpha(14) : null,
|
||||
@@ -342,7 +357,7 @@ class _DayColumn extends StatelessWidget {
|
||||
color: theme.dividerColor.withAlpha(60),
|
||||
),
|
||||
),
|
||||
for (final region in dayRegions)
|
||||
for (final region in regions)
|
||||
Positioned(
|
||||
top: layout.yOfDateTime(region.start),
|
||||
height:
|
||||
@@ -379,6 +394,7 @@ class _DayColumn extends StatelessWidget {
|
||||
onTap: () => onAppointmentTap(appointment),
|
||||
child: AppointmentTile(
|
||||
appointment: appointment,
|
||||
now: nowNotifier,
|
||||
crossedOut: isCrossedOut(appointment),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -61,29 +61,41 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||
static const double _rulerWidth = 36;
|
||||
|
||||
late PageController _pageController;
|
||||
late int _currentWeekIndex;
|
||||
// Drives header + all-day strip. Kept out of setState so a page change
|
||||
// mid-swipe doesn't rebuild the PageView and every tile in it.
|
||||
late final ValueNotifier<int> _visibleWeekIndex;
|
||||
late int _reportedWeekIndex;
|
||||
late DateTime _firstMonday;
|
||||
late int _totalWeeks;
|
||||
late Timer _ticker;
|
||||
late ValueNotifier<DateTime> _nowNotifier;
|
||||
DateTime _today = _dateOnly(DateTime.now());
|
||||
DateTime _today = DateTime.now().dateOnly;
|
||||
|
||||
final Map<int, _WeekData> _weekData = {};
|
||||
// Returning the identical widget instance lets Flutter skip rebuilding a
|
||||
// week page when the host rebuilds for unrelated reasons (e.g. the bloc
|
||||
// recording the new week after a swipe).
|
||||
final Map<int, _WeekGrid> _gridCache = {};
|
||||
Object? _cacheKey;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_firstMonday = _mondayOf(widget.minDate);
|
||||
final lastMonday = _mondayOf(widget.maxDate);
|
||||
_firstMonday = widget.minDate.mondayOfWeek;
|
||||
final lastMonday = widget.maxDate.mondayOfWeek;
|
||||
_totalWeeks = lastMonday.difference(_firstMonday).inDays ~/ 7 + 1;
|
||||
_currentWeekIndex =
|
||||
_mondayOf(widget.initialDate).difference(_firstMonday).inDays ~/ 7;
|
||||
_pageController = PageController(initialPage: _currentWeekIndex);
|
||||
final initialIndex =
|
||||
widget.initialDate.mondayOfWeek.difference(_firstMonday).inDays ~/ 7;
|
||||
_visibleWeekIndex = ValueNotifier<int>(initialIndex);
|
||||
_reportedWeekIndex = initialIndex;
|
||||
_pageController = PageController(initialPage: initialIndex);
|
||||
_nowNotifier = ValueNotifier<DateTime>(DateTime.now());
|
||||
|
||||
_ticker = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
if (!mounted) return;
|
||||
final now = DateTime.now();
|
||||
_nowNotifier.value = now;
|
||||
final newToday = _dateOnly(now);
|
||||
final newToday = now.dateOnly;
|
||||
if (newToday != _today) setState(() => _today = newToday);
|
||||
});
|
||||
}
|
||||
@@ -99,11 +111,11 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||
// the initial mount with the conservative fallback). Recompute the
|
||||
// range and snap the controller to the page that still represents the
|
||||
// currently visible week so the user doesn't get yanked around.
|
||||
final newFirstMonday = _mondayOf(widget.minDate);
|
||||
final newLastMonday = _mondayOf(widget.maxDate);
|
||||
final newFirstMonday = widget.minDate.mondayOfWeek;
|
||||
final newLastMonday = widget.maxDate.mondayOfWeek;
|
||||
final newTotalWeeks =
|
||||
newLastMonday.difference(newFirstMonday).inDays ~/ 7 + 1;
|
||||
final visibleWeekStart = _firstMonday.addDays(_currentWeekIndex * 7);
|
||||
final visibleWeekStart = _firstMonday.addDays(_visibleWeekIndex.value * 7);
|
||||
final newIndex = visibleWeekStart.difference(newFirstMonday).inDays ~/ 7;
|
||||
final clampedIndex = newIndex.clamp(0, newTotalWeeks - 1);
|
||||
final oldController = _pageController;
|
||||
@@ -112,8 +124,9 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||
setState(() {
|
||||
_firstMonday = newFirstMonday;
|
||||
_totalWeeks = newTotalWeeks;
|
||||
_currentWeekIndex = clampedIndex;
|
||||
});
|
||||
_visibleWeekIndex.value = clampedIndex;
|
||||
_reportedWeekIndex = clampedIndex;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -121,13 +134,12 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||
_pageController.dispose();
|
||||
_ticker.cancel();
|
||||
_nowNotifier.dispose();
|
||||
_visibleWeekIndex.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
static DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day);
|
||||
|
||||
void jumpToDate(DateTime date) {
|
||||
final target = _mondayOf(date).difference(_firstMonday).inDays ~/ 7;
|
||||
final target = date.mondayOfWeek.difference(_firstMonday).inDays ~/ 7;
|
||||
if (target < 0 || target >= _totalWeeks) return;
|
||||
_pageController.animateToPage(
|
||||
target,
|
||||
@@ -136,16 +148,79 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime _mondayOf(DateTime d) {
|
||||
final monday = d.subtractDays(d.weekday - 1);
|
||||
return DateTime(monday.year, monday.month, monday.day);
|
||||
/// Drops both per-week caches when any input they were built from changed.
|
||||
void _syncCaches() {
|
||||
final key = (
|
||||
widget.appointments,
|
||||
widget.timeRegions,
|
||||
widget.schedule,
|
||||
widget.onCreateEvent == null,
|
||||
_today,
|
||||
_firstMonday,
|
||||
);
|
||||
if (key == _cacheKey) return;
|
||||
_cacheKey = key;
|
||||
_weekData.clear();
|
||||
_gridCache.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final visibleWeekStart = _firstMonday.addDays(_currentWeekIndex * 7);
|
||||
_WeekData _weekDataFor(int weekIndex) {
|
||||
return _weekData.putIfAbsent(
|
||||
weekIndex,
|
||||
() => _WeekData.compute(
|
||||
_firstMonday.addDays(weekIndex * 7),
|
||||
widget.appointments,
|
||||
widget.timeRegions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Tells the host about the new week only once the swipe has settled, so
|
||||
/// the resulting bloc emit + rebuild never lands inside the page animation.
|
||||
bool _onPageScroll(ScrollNotification notification) {
|
||||
if (notification is! ScrollEndNotification || notification.depth != 0) {
|
||||
return false;
|
||||
}
|
||||
final index = _visibleWeekIndex.value;
|
||||
if (index == _reportedWeekIndex) return false;
|
||||
_reportedWeekIndex = index;
|
||||
final weekStart = _firstMonday.addDays(index * 7);
|
||||
widget.onWeekChanged(weekStart, weekStart.addDays(4));
|
||||
return false;
|
||||
}
|
||||
|
||||
void _onAppointmentTap(Appointment appointment) =>
|
||||
widget.onAppointmentTap(appointment);
|
||||
|
||||
bool _isCrossedOut(Appointment appointment) =>
|
||||
widget.isCrossedOut(appointment);
|
||||
|
||||
void _onCreateEvent(DateTime start, DateTime end) =>
|
||||
widget.onCreateEvent?.call(start, end);
|
||||
|
||||
_WeekGrid _weekGrid(int weekIndex, PeriodLayout layout) {
|
||||
final cached = _gridCache[weekIndex];
|
||||
if (cached != null && cached.layout.lessonHeight == layout.lessonHeight) {
|
||||
return cached;
|
||||
}
|
||||
// Only the pages next to the visible one are ever alive.
|
||||
_gridCache.removeWhere((i, _) => (i - weekIndex).abs() > 2);
|
||||
return _gridCache[weekIndex] = _WeekGrid(
|
||||
weekStart: _firstMonday.addDays(weekIndex * 7),
|
||||
schedule: widget.schedule,
|
||||
data: _weekDataFor(weekIndex),
|
||||
onAppointmentTap: _onAppointmentTap,
|
||||
isCrossedOut: _isCrossedOut,
|
||||
onCreateEvent: widget.onCreateEvent == null ? null : _onCreateEvent,
|
||||
today: _today,
|
||||
nowNotifier: _nowNotifier,
|
||||
rulerWidth: _rulerWidth,
|
||||
layout: layout,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeekHeader(int weekIndex) {
|
||||
final visibleWeekStart = _firstMonday.addDays(weekIndex * 7);
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
@@ -190,15 +265,29 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
child: _OutsideHoursStrip(
|
||||
key: ValueKey(visibleWeekStart),
|
||||
weekStart: visibleWeekStart,
|
||||
appointments: widget.appointments,
|
||||
outside: _weekDataFor(weekIndex).outside,
|
||||
rulerWidth: _rulerWidth,
|
||||
onAppointmentTap: widget.onAppointmentTap,
|
||||
isCrossedOut: widget.isCrossedOut,
|
||||
onAppointmentTap: _onAppointmentTap,
|
||||
isCrossedOut: _isCrossedOut,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
_syncCaches();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ValueListenableBuilder<int>(
|
||||
valueListenable: _visibleWeekIndex,
|
||||
builder: (_, weekIndex, _) => _buildWeekHeader(weekIndex),
|
||||
),
|
||||
Container(height: 0.5, color: theme.dividerColor.withAlpha(110)),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
@@ -225,30 +314,15 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: SizedBox(
|
||||
height: gridHeight,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: _totalWeeks,
|
||||
onPageChanged: (index) {
|
||||
setState(() => _currentWeekIndex = index);
|
||||
final weekStart = _firstMonday.addDays(index * 7);
|
||||
widget.onWeekChanged(weekStart, weekStart.addDays(4));
|
||||
},
|
||||
itemBuilder: (_, weekIndex) {
|
||||
final weekStart = _firstMonday.addDays(weekIndex * 7);
|
||||
return _WeekGrid(
|
||||
weekStart: weekStart,
|
||||
schedule: widget.schedule,
|
||||
appointments: widget.appointments,
|
||||
timeRegions: widget.timeRegions,
|
||||
onAppointmentTap: widget.onAppointmentTap,
|
||||
isCrossedOut: widget.isCrossedOut,
|
||||
onCreateEvent: widget.onCreateEvent,
|
||||
today: _today,
|
||||
nowNotifier: _nowNotifier,
|
||||
rulerWidth: _rulerWidth,
|
||||
layout: layout,
|
||||
);
|
||||
},
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: _onPageScroll,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: _totalWeeks,
|
||||
onPageChanged: (index) => _visibleWeekIndex.value = index,
|
||||
itemBuilder: (_, weekIndex) =>
|
||||
_weekGrid(weekIndex, layout),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -7,10 +7,10 @@ import '../../../../extensions/date_time.dart';
|
||||
import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||
import '../../../../storage/timetable_settings.dart';
|
||||
import '../data/arbitrary_appointment.dart';
|
||||
import '../data/lesson_period_schedule.dart';
|
||||
import '../data/timetable_appointment_factory.dart';
|
||||
import '../data/timetable_name_mode.dart';
|
||||
import 'custom_workweek_calendar.dart';
|
||||
import 'special_regions_builder.dart';
|
||||
|
||||
@@ -51,9 +51,17 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
GlobalKey<CustomWorkWeekCalendarState>();
|
||||
|
||||
List<Appointment>? _cachedAppointments;
|
||||
// TimetableSettings and List define no `==`, so record equality degrades to
|
||||
// the same identity checks the cache always used.
|
||||
(int, TimetableSettings, List<CustomTimetableEvent>, bool)? _cacheKey;
|
||||
// Settings are keyed by the values the factory reads: the settings object is
|
||||
// re-created on every settings write, so its identity would miss the cache
|
||||
// for unrelated changes. The event list has no `==` and compares by identity.
|
||||
(int, bool, TimetableNameMode, List<CustomTimetableEvent>, bool)? _cacheKey;
|
||||
|
||||
// Stable identities let the calendar reuse its per-week pages across
|
||||
// rebuilds; rebuilding these every frame would invalidate that cache.
|
||||
LessonPeriodSchedule? _schedule;
|
||||
Object? _scheduleKey;
|
||||
List<TimeRegion>? _regions;
|
||||
Object? _regionsKey;
|
||||
|
||||
DateTime _initialDisplayDate() => DateTime.now().addDays(2);
|
||||
|
||||
@@ -64,13 +72,16 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
}
|
||||
|
||||
List<Appointment> _appointments(TimetableState state) {
|
||||
final timetableSettings = context
|
||||
.watch<SettingsCubit>()
|
||||
.val()
|
||||
.timetableSettings;
|
||||
final (connectDoubleLessons, nameMode) = context.select(
|
||||
(SettingsCubit c) => (
|
||||
c.state.timetableSettings.connectDoubleLessons,
|
||||
c.state.timetableSettings.timetableNameMode,
|
||||
),
|
||||
);
|
||||
final key = (
|
||||
state.dataVersion,
|
||||
timetableSettings,
|
||||
connectDoubleLessons,
|
||||
nameMode,
|
||||
widget.customEvents,
|
||||
widget.showClassInsteadOfTeacher,
|
||||
);
|
||||
@@ -84,13 +95,43 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
customEvents: widget.customEvents,
|
||||
subjects: state.subjects?.result ?? const [],
|
||||
holidays: state.schoolHolidays?.result ?? const [],
|
||||
settings: timetableSettings,
|
||||
settings: context.read<SettingsCubit>().val().timetableSettings,
|
||||
now: DateTime.now(),
|
||||
showClassInsteadOfTeacher: widget.showClassInsteadOfTeacher,
|
||||
).build();
|
||||
}
|
||||
|
||||
bool _isCrossedOut(Appointment appointment) {
|
||||
LessonPeriodSchedule _scheduleFor(TimetableState state) {
|
||||
if (_schedule != null && identical(_scheduleKey, state.timegrid)) {
|
||||
return _schedule!;
|
||||
}
|
||||
_scheduleKey = state.timegrid;
|
||||
return _schedule = LessonPeriodSchedule.fromState(state);
|
||||
}
|
||||
|
||||
List<TimeRegion> _regionsFor(
|
||||
TimetableState state,
|
||||
LessonPeriodSchedule schedule,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final key = (
|
||||
state.schoolHolidays,
|
||||
schedule,
|
||||
theme.colorScheme,
|
||||
theme.disabledColor,
|
||||
DateTime.now().dateOnly,
|
||||
);
|
||||
if (_regions != null && key == _regionsKey) return _regions!;
|
||||
_regionsKey = key;
|
||||
return _regions = SpecialRegionsBuilder(
|
||||
holidays: state.schoolHolidays!,
|
||||
schedule: schedule,
|
||||
colorScheme: theme.colorScheme,
|
||||
disabledColor: theme.disabledColor,
|
||||
).build();
|
||||
}
|
||||
|
||||
static bool _isCrossedOut(Appointment appointment) {
|
||||
final id = appointment.id;
|
||||
if (id is LessonAppointment) return id.entry.status == 'CANCELLED';
|
||||
return false;
|
||||
@@ -104,14 +145,9 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
// transient null can never crash the build.
|
||||
if (state.schoolHolidays == null) return const SizedBox.shrink();
|
||||
|
||||
final schedule = LessonPeriodSchedule.fromState(state);
|
||||
final schedule = _scheduleFor(state);
|
||||
final appointments = _appointments(state);
|
||||
final regions = SpecialRegionsBuilder(
|
||||
holidays: state.schoolHolidays!,
|
||||
schedule: schedule,
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
disabledColor: Theme.of(context).disabledColor,
|
||||
).build();
|
||||
final regions = _regionsFor(state, schedule);
|
||||
|
||||
final capabilities = context.watch<CapabilitiesCubit>();
|
||||
final (minDate, maxDate) = _scrollBounds(
|
||||
@@ -171,8 +207,8 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
? state.accessibleEndDate!
|
||||
: baseMax)
|
||||
: baseMax;
|
||||
final today = _startOfDay(DateTime.now());
|
||||
final todayMonday = _mondayOf(today);
|
||||
final today = DateTime.now().dateOnly;
|
||||
final todayMonday = today.mondayOfWeek;
|
||||
final currentWeekEnd = todayMonday.addDays(DateTime.daysPerWeek - 1);
|
||||
final capMin = pastDays == null
|
||||
? null
|
||||
@@ -202,11 +238,6 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
return (mondayMin, finalMax);
|
||||
}
|
||||
|
||||
static DateTime _mondayOf(DateTime d) =>
|
||||
_startOfDay(d.subtractDays(d.weekday - 1));
|
||||
|
||||
static DateTime _startOfDay(DateTime d) => DateTime(d.year, d.month, d.day);
|
||||
|
||||
static DateTime _earlier(DateTime a, DateTime b) => a.isBefore(b) ? a : b;
|
||||
|
||||
static DateTime _later(DateTime a, DateTime b) => a.isAfter(b) ? a : b;
|
||||
|
||||
Reference in New Issue
Block a user