merged develop

This commit is contained in:
2026-09-27 14:58:07 +02:00
188 changed files with 7474 additions and 2558 deletions
+1 -1
View File
@@ -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',
+24 -9
View File
@@ -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,
+26 -20
View File
@@ -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(
+27 -18
View File
@@ -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(),
},
);
},
),
+90 -32
View File
@@ -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(
+37 -13
View File
@@ -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,
+24 -11
View File
@@ -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(
+39 -3
View File
@@ -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),
],
),
),
);
}
}
+12 -5
View File
@@ -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;