improved performance and battery usage on older devices

This commit is contained in:
2026-09-25 23:58:08 +02:00
parent ed8e52f475
commit 56a497985b
70 changed files with 1631 additions and 621 deletions
+185
View File
@@ -0,0 +1,185 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
/// One cached response: the raw JSON payload and when it was stored.
class CacheEntry {
final String json;
final int lastUpdate;
const CacheEntry({required this.json, required this.lastUpdate});
}
/// File-per-key store behind [RequestCache] and friends.
///
/// Replaces `localstore` for response caching: localstore reads and decodes
/// the entire collection synchronously on first access, keeps every document
/// in memory for the whole session and JSON-encodes payloads a second time.
/// Here every access is async and touches exactly one file, and the payload is
/// stored verbatim behind a one-line header (`<lastUpdate>\n<json>`).
class CacheStore {
CacheStore._();
static final CacheStore instance = CacheStore._();
/// Payloads above this size are decoded on a background isolate.
static const int isolateDecodeThreshold = 64 * 1024;
Future<Directory>? _dir;
final Map<String, Future<void>> _writes = {};
Future<Directory> _directory() => _dir ??= () async {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/request_cache');
await dir.create(recursive: true);
return dir;
}();
Future<File> _file(String key) async =>
File('${(await _directory()).path}/${_safeName(key)}');
/// Keys are internal ids (`nc-chat-<token>`, `wd-folder-<md5>`); only path
/// separators would be a problem in a file name.
static String _safeName(String key) => key.replaceAll(RegExp(r'[/\\]'), '_');
Future<CacheEntry?> read(String key) async {
try {
await _writes[key];
return parse(await (await _file(key)).readAsString());
} on Object {
// Missing (PathNotFoundException) or unreadable: a cache miss.
return null;
}
}
/// All entries whose key starts with [prefix], read concurrently.
Future<Map<String, CacheEntry>> readAll({String prefix = ''}) async {
final keys = await this.keys(prefix: prefix);
final entries = await Future.wait(keys.map(read));
return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]};
}
Future<void> write(String key, String json) {
final previous = _writes[key] ?? Future<void>.value();
late final Future<void> current;
current = previous.then((_) => _write(key, json)).whenComplete(() {
if (identical(_writes[key], current)) _writes.remove(key);
});
return _writes[key] = current;
}
Future<void> _write(String key, String json) async {
try {
final file = await _file(key);
// Write-then-rename so a reader (or the widget background isolate)
// never sees a half-written file.
final tmp = File('${file.path}.tmp');
await tmp.writeAsString(
'${DateTime.now().millisecondsSinceEpoch}\n$json',
flush: true,
);
await tmp.rename(file.path);
} on Object catch (e) {
debugPrint('CacheStore.write($key) failed: $e');
}
}
Future<void> delete(String key) async {
try {
await _writes[key];
await (await _file(key)).delete();
} on Object {
// A missing or locked file is as good as deleted for a cache.
}
}
Future<void> clear() async {
try {
final dir = await _directory();
if (dir.existsSync()) await dir.delete(recursive: true);
} on Object catch (e) {
debugPrint('CacheStore.clear failed: $e');
}
_dir = null;
}
/// All stored keys, optionally filtered by [prefix].
Future<List<String>> keys({String prefix = ''}) async {
try {
final dir = await _directory();
return [
await for (final entity in dir.list())
if (entity is File && !entity.path.endsWith('.tmp'))
if (entity.uri.pathSegments.last case final name
when name.startsWith(prefix))
name,
];
} on Object {
return const [];
}
}
/// Removes entries not written for [maxAge], judged by file mtime so nothing
/// has to be read or decoded.
Future<void> deleteOlderThan(Duration maxAge) async {
try {
final dir = await _directory();
final cutoff = DateTime.now().subtract(maxAge);
await for (final entity in dir.list()) {
if (entity is! File) continue;
// ignore: avoid_slow_async_io
final modified = (await entity.stat()).modified;
if (modified.isBefore(cutoff)) await entity.delete();
}
} on Object catch (e) {
debugPrint('CacheStore.deleteOlderThan failed: $e');
}
}
/// Total size of all entries in bytes.
Future<int> totalSize() async {
try {
final dir = await _directory();
var sum = 0;
await for (final entity in dir.list()) {
if (entity is File) sum += await entity.length();
}
return sum;
} on Object {
return 0;
}
}
/// Parses the stored file format; public for tests.
static CacheEntry? parse(String raw) {
final newline = raw.indexOf('\n');
if (newline < 0) return null;
final lastUpdate = int.tryParse(raw.substring(0, newline));
if (lastUpdate == null) return null;
return CacheEntry(json: raw.substring(newline + 1), lastUpdate: lastUpdate);
}
/// Decodes a cached payload, off the UI isolate when it is large.
static Future<Map<String, dynamic>> decode(String json) =>
json.length > isolateDecodeThreshold
? compute(_decodeMap, json)
: Future.value(_decodeMap(json));
static Map<String, dynamic> _decodeMap(String json) =>
jsonDecode(json) as Map<String, dynamic>;
/// Deletes the directory the old localstore-based cache lived in. It is
/// never read again and would otherwise linger with up to 200 days of data.
static Future<void> deleteLegacyLocalstoreCache() async {
try {
final docs = await getApplicationDocumentsDirectory();
final legacy = Directory('${docs.path}/MarianumMobile');
if (legacy.existsSync()) await legacy.delete(recursive: true);
} on Object catch (e) {
debugPrint('Legacy cache cleanup failed: $e');
}
}
}
@@ -17,7 +17,7 @@ class GetChatCache extends SimpleCache<GetChatResponse> {
lookIntoFuture: GetChatParamsSwitch.off, lookIntoFuture: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.on, setReadMarker: GetChatParamsSwitch.on,
// Small initial page; also the per-chat offline snapshot written to // Small initial page; also the per-chat offline snapshot written to
// localstore. Older messages are paged in on scroll-up via // the request cache. Older messages are paged in on scroll-up via
// GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize. // GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize.
limit: 50, limit: 50,
), ),
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart'; import '../../../errors/server_exception.dart';
@@ -45,8 +46,10 @@ class GetChatHistory {
final status = response.statusCode; final status = response.statusCode;
if (status == 304) return null; if (status == 304) return null;
if (status >= 200 && status < 300) { if (status >= 200 && status < 300) {
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body)) // A page holds up to 200 messages and lands while the user is scrolling;
..headers = response.headers; // decoding it on the UI isolate stalls the fling.
final parsed = await compute(_parseChatResponse, response.body);
return parsed..headers = response.headers;
} }
throw ServerException( throw ServerException(
statusCode: status, statusCode: status,
@@ -54,3 +57,6 @@ class GetChatHistory {
); );
} }
} }
GetChatResponse _parseChatResponse(String body) =>
GetChatResponse.fromJson(NextcloudOcs.decode(body));
+10 -2
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
@@ -10,8 +11,12 @@ class GetRoom extends TalkApi<GetRoomResponse> {
GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson()); GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson());
@override @override
GetRoomResponse assemble(String raw) => GetRoomResponse assemble(String raw) => _parseRooms(raw);
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
// The room list with status is 100–250 KB and polled every 15–60 s.
@override
Future<GetRoomResponse> assembleAsync(String raw) =>
compute(_parseRooms, raw);
@override @override
Future<http.Response> request( Future<http.Response> request(
@@ -20,3 +25,6 @@ class GetRoom extends TalkApi<GetRoomResponse> {
Map<String, String>? headers, Map<String, String>? headers,
) => http.get(uri, headers: headers); ) => http.get(uri, headers: headers);
} }
GetRoomResponse _parseRooms(String raw) =>
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
+5 -1
View File
@@ -30,6 +30,10 @@ abstract class TalkApi<T extends ApiResponse?> {
); );
T assemble(String raw); T assemble(String raw);
/// Override to parse large payloads off the UI isolate (e.g. via `compute`
/// with a top-level parser); defaults to the synchronous [assemble].
Future<T> assembleAsync(String raw) async => assemble(raw);
Future<T> run() async { Future<T> run() async {
final endpoint = NextcloudOcs.uri( final endpoint = NextcloudOcs.uri(
'apps/spreed/api/$path', 'apps/spreed/api/$path',
@@ -69,7 +73,7 @@ abstract class TalkApi<T extends ApiResponse?> {
} }
try { try {
final assembled = assemble(data.body); final assembled = await assembleAsync(data.body);
assembled?.headers = data.headers; assembled?.headers = data.headers;
return assembled; return assembled;
} catch (e) { } catch (e) {
@@ -29,6 +29,19 @@ class CacheableFile {
/// file/folder in the list. Nullable so older cached entries decode fine. /// file/folder in the list. Nullable so older cached entries decode fine.
bool? isSharedWithMe; bool? isSharedWithMe;
/// Lower-cased [name] for sorting/filtering, cached because comparators
/// call it for every comparison (name is mutable, hence the identity check).
String get lowerName {
if (!identical(_lowerNameFor, name)) {
_lowerNameFor = name;
_lowerName = name.toLowerCase();
}
return _lowerName;
}
String? _lowerNameFor;
String _lowerName = '';
CacheableFile({ CacheableFile({
required this.path, required this.path,
required this.isDirectory, required this.isDirectory,
@@ -1,9 +1,9 @@
import 'dart:convert'; import 'dart:convert';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:localstore/localstore.dart';
import '../../../../../utils/cache_invalidation_bus.dart'; import '../../../../../utils/cache_invalidation_bus.dart';
import '../../../../cache_store.dart';
import '../../../../request_cache.dart'; import '../../../../request_cache.dart';
import 'list_files.dart'; import 'list_files.dart';
import 'list_files_params.dart'; import 'list_files_params.dart';
@@ -43,14 +43,11 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
/// (slow) root listing is already populated by the time the user /// (slow) root listing is already populated by the time the user
/// navigates to the Files module. /// navigates to the Files module.
/// ///
/// No-ops when a cached root payload is already present in localstore — /// No-ops when a cached root payload is already present in the cache —
/// the regular TTL handling in [RequestCache] takes over from there. /// the regular TTL handling in [RequestCache] takes over from there.
static Future<void> prefetchRootListing() async { static Future<void> prefetchRootListing() async {
const rootPath = ''; const rootPath = '';
final cached = await Localstore.instance final cached = await CacheStore.instance.read(_documentId(rootPath));
.collection(RequestCache.collection)
.doc(_documentId(rootPath))
.get();
if (cached != null) return; if (cached != null) return;
// Drive the same code path as a regular fetch so the result lands in // Drive the same code path as a regular fetch so the result lands in
// the cache; we don't care about the in-memory callback here. // the cache; we don't care about the in-memory callback here.
@@ -69,10 +66,7 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
/// `_FilesView` for that path via [CacheInvalidationBus] so it refetches /// `_FilesView` for that path via [CacheInvalidationBus] so it refetches
/// even while it is sitting in the background of the navigation stack. /// even while it is sitting in the background of the navigation stack.
static Future<void> invalidate(String path) async { static Future<void> invalidate(String path) async {
await Localstore.instance await CacheStore.instance.delete(_documentId(path));
.collection(RequestCache.collection)
.doc(_documentId(path))
.delete();
CacheInvalidationBus.notifyListFiles(path); CacheInvalidationBus.notifyListFiles(path);
} }
} }
@@ -1,6 +1,6 @@
import 'dart:developer'; import 'dart:developer';
import 'package:localstore/localstore.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../../../model/account_data.dart'; import '../../../../model/account_data.dart';
import '../../../demo/demo_mode.dart'; import '../../../demo/demo_mode.dart';
@@ -21,9 +21,7 @@ import 'timetable_custom_events_add.dart';
/// failure only sees the events that were not yet moved, so nothing is /// failure only sees the events that were not yet moved, so nothing is
/// duplicated. The flag is set only once MHSL reports no remaining events. /// duplicated. The flag is set only once MHSL reports no remaining events.
class CustomEventsMigration { class CustomEventsMigration {
static const String _collection = 'MarianumMobile'; static const String _doneKey = 'customEventsMigratedToMc';
static const String _document = 'customEventsMigration';
static const String _doneKey = 'migratedToMc';
const CustomEventsMigration._(); const CustomEventsMigration._();
@@ -57,17 +55,11 @@ class CustomEventsMigration {
} }
} }
static Future<bool> _isDone() async { // SharedPreferences, like the old cache document, is cleared on sign-out,
final data = await Localstore.instance // so the next account runs its own migration.
.collection(_collection) static Future<bool> _isDone() async =>
.doc(_document) (await SharedPreferences.getInstance()).getBool(_doneKey) ?? false;
.get();
return data != null && data[_doneKey] == true;
}
static Future<void> _markDone() async { static Future<void> _markDone() async =>
await Localstore.instance.collection(_collection).doc(_document).set({ (await SharedPreferences.getInstance()).setBool(_doneKey, true);
_doneKey: true,
});
}
} }
@@ -76,10 +76,17 @@ class McTimetableEntry {
/// Combines the calendar date with the hour/minute portion of [startTime] /// Combines the calendar date with the hour/minute portion of [startTime]
/// (which carries a 1970 placeholder date) into a real DateTime. /// (which carries a 1970 placeholder date) into a real DateTime.
DateTime get startDateTime => DateTime get startDateTime => _startDateTime;
DateTime get endDateTime => _endDateTime;
// Cached (private, so json_serializable ignores them): sorting and lesson
// merging read these per comparison, and every local DateTime construction
// does a timezone lookup.
late final DateTime _startDateTime =
DateTime(date.year, date.month, date.day, startTime.hour, startTime.minute); DateTime(date.year, date.month, date.day, startTime.hour, startTime.minute);
DateTime get endDateTime => late final DateTime _endDateTime =
DateTime(date.year, date.month, date.day, endTime.hour, endTime.minute); DateTime(date.year, date.month, date.day, endTime.hour, endTime.minute);
static DateTime _dateFromJson(String raw) => DateTime.parse(raw); static DateTime _dateFromJson(String raw) => DateTime.parse(raw);
+44 -40
View File
@@ -1,10 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:localstore/localstore.dart';
import '../model/account_data.dart'; import '../model/account_data.dart';
import 'api_response.dart'; import 'api_response.dart';
import 'cache_store.dart';
import 'errors/parse_exception.dart'; import 'errors/parse_exception.dart';
import 'errors/stale_session_exception.dart'; import 'errors/stale_session_exception.dart';
@@ -14,12 +13,10 @@ abstract class RequestCache<T extends ApiResponse?> {
static const int cacheHour = 60 * 60; static const int cacheHour = 60 * 60;
static const int cacheDay = 60 * 60 * 24; static const int cacheDay = 60 * 60 * 24;
static String collection = 'MarianumMobile';
int maxCacheTime; int maxCacheTime;
void Function(T)? onUpdate; void Function(T)? onUpdate;
/// Called only when [start] finds a cached payload in localstore. Use this /// Called only when [start] finds a cached payload in the [CacheStore]. Use this
/// (instead of [onUpdate]) when callers need to distinguish stale-but-fast /// (instead of [onUpdate]) when callers need to distinguish stale-but-fast
/// cache hits from authoritative network responses. /// cache hits from authoritative network responses.
void Function(T)? onCacheData; void Function(T)? onCacheData;
@@ -52,47 +49,55 @@ abstract class RequestCache<T extends ApiResponse?> {
Future<void> start(String document) async { Future<void> start(String document) async {
final epoch = AccountData().sessionEpoch; final epoch = AccountData().sessionEpoch;
try { try {
final tableData = await Localstore.instance final entry = await CacheStore.instance.read(document);
.collection(collection) var lastUpdate = entry?.lastUpdate ?? 0;
.doc(document) T? cached;
.get(); if (entry != null) {
if (tableData != null) { try {
final cached = onLocalData(tableData['json'] as String); cached = fromCacheJson(await CacheStore.decode(entry.json));
} on Object {
// A payload from an older model version: treat it as a miss.
await CacheStore.instance.delete(document);
lastUpdate = 0;
}
}
if (cached != null) {
onUpdate?.call(cached); onUpdate?.call(cached);
onCacheData?.call(cached); onCacheData?.call(cached);
} }
await _load(document, epoch, lastUpdate: lastUpdate);
final lastUpdate = (tableData?['lastupdate'] as num?) ?? 0;
if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) <
lastUpdate) {
if (renew == null || !renew!) return;
}
try {
final newValue = await onLoad();
// The collection is shared, so a late response of a signed-out
// account would otherwise be cached for the next one.
if (!AccountData().isCurrentSession(epoch)) {
onError(const StaleSessionException());
return;
}
onUpdate?.call(newValue);
onNetworkData?.call(newValue);
unawaited(
Localstore.instance.collection(collection).doc(document).set({
'json': jsonEncode(newValue),
'lastupdate': DateTime.now().millisecondsSinceEpoch,
}),
);
} on Exception catch (e) {
onError(e);
}
} finally { } finally {
if (!_ready.isCompleted) _ready.complete(); if (!_ready.isCompleted) _ready.complete();
} }
} }
T onLocalData(String json); Future<void> _load(
String document,
int epoch, {
required int lastUpdate,
}) async {
if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) <
lastUpdate) {
if (renew == null || !renew!) return;
}
try {
final newValue = await onLoad();
// The cache is shared, so a late response of a signed-out
// account would otherwise be cached for the next one.
if (!AccountData().isCurrentSession(epoch)) {
onError(const StaleSessionException());
return;
}
onUpdate?.call(newValue);
onNetworkData?.call(newValue);
unawaited(CacheStore.instance.write(document, jsonEncode(newValue)));
} on Exception catch (e) {
onError(e);
}
}
T fromCacheJson(Map<String, dynamic> json);
Future<T> onLoad(); Future<T> onLoad();
} }
@@ -126,8 +131,7 @@ class SimpleCache<T extends ApiResponse?> extends RequestCache<T> {
Future<T> onLoad() => _loader(); Future<T> onLoad() => _loader();
@override @override
T onLocalData(String json) => T fromCacheJson(Map<String, dynamic> json) => _fromJson(json);
_fromJson(jsonDecode(json) as Map<String, dynamic>);
} }
/// Captures the latest cache payload (cached or network) and rethrows the /// Captures the latest cache payload (cached or network) and rethrows the
+51 -20
View File
@@ -10,6 +10,7 @@ import 'api/marianumconnect/marianumconnect_api.dart';
import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
import 'main.dart'; import 'main.dart';
import 'model/account_data.dart';
import 'model/data_cleaner.dart'; import 'model/data_cleaner.dart';
import 'notification/notification_controller.dart'; import 'notification/notification_controller.dart';
import 'notification/notification_tasks.dart'; import 'notification/notification_tasks.dart';
@@ -40,7 +41,6 @@ class App extends StatefulWidget {
} }
class _AppState extends State<App> with WidgetsBindingObserver { class _AppState extends State<App> with WidgetsBindingObserver {
late Timer _updateTimings;
StreamSubscription<dynamic>? _timetableWidgetSync; StreamSubscription<dynamic>? _timetableWidgetSync;
StreamSubscription<RemoteMessage>? _onMessageSub; StreamSubscription<RemoteMessage>? _onMessageSub;
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub; StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
@@ -74,7 +74,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
bloc.setAutoRefreshInterval( bloc.setAutoRefreshInterval(
talkIsActive ? _chatListActiveInterval : _chatListIdleInterval, talkIsActive ? _chatListActiveInterval : _chatListIdleInterval,
); );
if (talkIsActive && refresh) bloc.refresh(); // The poll keeps the list current; switching back to the tab shortly
// after a fetch doesn't need another one mid tab transition.
if (talkIsActive && refresh) {
bloc.refreshIfOlderThan(_chatListActiveInterval);
}
} }
// Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick // Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick
@@ -110,6 +114,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
_syncChatListPolling(refresh: false); _syncChatListPolling(refresh: false);
_handlePendingWidgetNavigation(); _handlePendingWidgetNavigation();
} else if (mounted) { } else if (mounted) {
context.read<SettingsCubit>().flushSilentSave();
// Stop polling while backgrounded: a silent refresh failing in the // Stop polling while backgrounded: a silent refresh failing in the
// background would otherwise leave an error that flashes on the next // background would otherwise leave an error that flashes on the next
// resume before the foreground refetch replaces it. // resume before the foreground refetch replaces it.
@@ -179,26 +184,42 @@ class _AppState extends State<App> with WidgetsBindingObserver {
final settingsCubit = context.read<SettingsCubit>(); final settingsCubit = context.read<SettingsCubit>();
final capabilitiesCubit = context.read<CapabilitiesCubit>(); final capabilitiesCubit = context.read<CapabilitiesCubit>();
_timetableWidgetSync?.cancel(); _timetableWidgetSync?.cancel();
// Debounced: a refresh or week swipe emits several times in a row, and
// only the settled state is worth mirroring.
_timetableWidgetSync = timetable.stream.listen((state) { _timetableWidgetSync = timetable.stream.listen((state) {
final data = state.data; final data = state.data;
if (data is TimetableState && !state.isLoading) { if (data is TimetableState && !state.isLoading) {
unawaited( final epoch = AccountData().sessionEpoch;
WidgetPublisher.publishFromBlocState( Debouncer.debounce(
data, 'widgetPublish',
settings: settingsCubit.val(), const Duration(seconds: 1),
isTeacher: capabilitiesCubit.isTeacher, () => unawaited(
WidgetPublisher.publishFromBlocState(
data,
settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
epoch: epoch,
),
), ),
); );
} }
}); });
// Initial publish in case hydrated storage already has data. // Initial publish in case hydrated storage already has data. The widget
// still shows its last snapshot, so this waits until the cold-start
// frames are done (a fresh bloc emit in the meantime supersedes it).
final initialData = timetable.state.data; final initialData = timetable.state.data;
if (initialData is TimetableState) { if (initialData is TimetableState) {
unawaited( final epoch = AccountData().sessionEpoch;
WidgetPublisher.publishFromBlocState( Debouncer.debounce(
initialData, 'widgetPublish',
settings: settingsCubit.val(), const Duration(seconds: 3),
isTeacher: capabilitiesCubit.isTeacher, () => unawaited(
WidgetPublisher.publishFromBlocState(
initialData,
settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
epoch: epoch,
),
), ),
); );
} }
@@ -208,10 +229,6 @@ class _AppState extends State<App> with WidgetsBindingObserver {
_syncChatListPolling(); _syncChatListPolling();
}); });
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
if (mounted) setState(() {});
});
_reportTelemetry(); _reportTelemetry();
// A refreshed FCM token invalidates the existing push subscription — the // A refreshed FCM token invalidates the existing push subscription — the
@@ -250,12 +267,15 @@ class _AppState extends State<App> with WidgetsBindingObserver {
NotificationController.onAppOpenedByNotification(message, context); NotificationController.onAppOpenedByNotification(message, context);
}); });
DataCleaner.cleanOldCache(); // Housekeeping only; kept out of the cold-start window.
Future<void>.delayed(
const Duration(seconds: 20),
DataCleaner.cleanOldCache,
);
} }
@override @override
void dispose() { void dispose() {
_updateTimings.cancel();
_timetableWidgetSync?.cancel(); _timetableWidgetSync?.cancel();
_onMessageSub?.cancel(); _onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel(); _onMessageOpenedAppSub?.cancel();
@@ -271,7 +291,18 @@ class _AppState extends State<App> with WidgetsBindingObserver {
@override @override
Widget build( Widget build(
BuildContext context, BuildContext context,
) => BlocBuilder<SettingsCubit, model.Settings>( ) => BlocSelector<SettingsCubit, model.Settings, Object>(
// Only the module layout shapes the shell; copied values because the
// settings object is mutated in place.
selector: (settings) {
final m = settings.modulesSettings;
return (
m.moduleOrder.join(','),
m.hiddenModules.join(','),
m.autoFillBottomBar,
m.fixedBottomBarSlots,
);
},
builder: (context, _) { builder: (context, _) {
final bottomBarModules = AppModule.getBottomBarModules(context); final bottomBarModules = AppModule.getBottomBarModules(context);
final totalTabs = bottomBarModules.length + 1; final totalTabs = bottomBarModules.length + 1;
+17 -4
View File
@@ -1,16 +1,29 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
extension TextExt on Text { extension TextExt on Text {
Size get size { /// Single-line width as rendered in [context] (honours the text scaler).
final textPainter = TextPainter( /// Memoised: chat bubbles measure the same few names and times over and
/// over, and every measurement is a full paragraph layout.
double measuredWidth(BuildContext context) {
final scaler = MediaQuery.textScalerOf(context);
final key = (data, style, scaler);
final cached = _widthCache[key];
if (cached != null) return cached;
final painter = TextPainter(
text: TextSpan(text: data, style: style), text: TextSpan(text: data, style: style),
maxLines: 1, maxLines: 1,
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
)..layout(minWidth: 0, maxWidth: double.infinity); textScaler: scaler,
return textPainter.size; )..layout();
final width = painter.width;
painter.dispose();
if (_widthCache.length >= 512) _widthCache.clear();
return _widthCache[key] = width;
} }
} }
final Map<(String?, TextStyle?, TextScaler), double> _widthCache = {};
/// Returns the first non-empty (after trim) entry, or '' if none match. /// Returns the first non-empty (after trim) entry, or '' if none match.
String firstNonEmpty(List<String?> values) { String firstNonEmpty(List<String?> values) {
for (final v in values) { for (final v in values) {
+48 -23
View File
@@ -167,6 +167,12 @@ Future<void> main() async {
await Future.wait(initialisationTasks); await Future.wait(initialisationTasks);
log('app initialisation done!'); log('app initialisation done!');
// Independent of the notification setup below, so it runs alongside it.
final widgetSyncInit = _startupStep(
'widget sync',
WidgetSync.ensureInitialized,
);
// Local notifications: init the plugin (with tap/action callbacks) and the // Local notifications: init the plugin (with tap/action callbacks) and the
// Android channels, then register the FCM background isolate handler that // Android channels, then register the FCM background isolate handler that
// decrypts and renders Nextcloud pushes while the app is not in foreground. // decrypts and renders Nextcloud pushes while the app is not in foreground.
@@ -186,9 +192,9 @@ Future<void> main() async {
), ),
); );
// Wire up the home-screen widget bridge before runApp so any widget render // The home-screen widget bridge must be ready before runApp so any widget
// triggered during startup hits initialised native storage. // render triggered during startup hits initialised native storage.
await _startupStep('widget sync', WidgetSync.ensureInitialized); await widgetSyncInit;
unawaited( unawaited(
WidgetBackgroundTask.initialize().onError( WidgetBackgroundTask.initialize().onError(
(e, _) => log('Workmanager init failed: $e'), (e, _) => log('Workmanager init failed: $e'),
@@ -271,6 +277,10 @@ class Main extends StatefulWidget {
} }
class _MainState extends State<Main> { class _MainState extends State<Main> {
final List<NavigatorObserver> _navigatorObservers = [
AppRoutes.chatRouteObserver,
DownloadRouteObserver(),
];
bool _showPostLoginSplash = false; bool _showPostLoginSplash = false;
bool _appMounted = true; bool _appMounted = true;
late AccountStatus _lastStatus; late AccountStatus _lastStatus;
@@ -364,39 +374,45 @@ class _MainState extends State<Main> {
@override @override
Widget build(BuildContext context) => Directionality( Widget build(BuildContext context) => Directionality(
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
child: BlocBuilder<SettingsCubit, Settings>( // Selects only what the root uses: Settings is mutated in place and
builder: (context, settings) { // re-emitted as a fresh instance on every write, so a plain BlocBuilder
final devToolsSettings = settings.devToolsSettings; // would rebuild MaterialApp (and every route) for unrelated settings.
child: BlocSelector<SettingsCubit, Settings, _RootSettings>(
selector: (settings) => (
appTheme: settings.appTheme,
mcBaseUrl: settings.devToolsSettings.resolveMarianumConnectBaseUrl(),
notificationsEnabled: settings.notificationSettings.enabled,
showPerformanceOverlay:
settings.devToolsSettings.showPerformanceOverlay,
checkerboardOffscreenLayers:
settings.devToolsSettings.checkerboardOffscreenLayers,
checkerboardRasterCacheImages:
settings.devToolsSettings.checkerboardRasterCacheImages,
),
builder: (context, root) {
// Keep the MC dio singleton aligned with the currently selected // Keep the MC dio singleton aligned with the currently selected
// endpoint (live / beta / custom). Idempotent when the URL is // endpoint (live / beta / custom). Idempotent when the URL is
// unchanged so it's safe to call on every rebuild. Mirrored into // unchanged. Mirrored into WidgetSync so the background isolate
// WidgetSync so the background isolate refreshes against the same // refreshes against the same endpoint.
// endpoint. MarianumConnectEndpoint.update(root.mcBaseUrl);
final mcBaseUrl = devToolsSettings.resolveMarianumConnectBaseUrl(); unawaited(WidgetSync.setMarianumConnectBaseUrl(root.mcBaseUrl));
MarianumConnectEndpoint.update(mcBaseUrl);
unawaited(WidgetSync.setMarianumConnectBaseUrl(mcBaseUrl));
// Mirror the notification toggle into group-scoped storage so the FCM // Mirror the notification toggle into group-scoped storage so the FCM
// background isolate and the iOS NSE can suppress rendering when off. // background isolate and the iOS NSE can suppress rendering when off.
unawaited( unawaited(
const PushRegistrationStore().setNotificationsEnabled( const PushRegistrationStore().setNotificationsEnabled(
settings.notificationSettings.enabled, root.notificationsEnabled,
), ),
); );
return MaterialApp( return MaterialApp(
showPerformanceOverlay: devToolsSettings.showPerformanceOverlay, showPerformanceOverlay: root.showPerformanceOverlay,
checkerboardOffscreenLayers: checkerboardOffscreenLayers: root.checkerboardOffscreenLayers,
devToolsSettings.checkerboardOffscreenLayers, checkerboardRasterCacheImages: root.checkerboardRasterCacheImages,
checkerboardRasterCacheImages:
devToolsSettings.checkerboardRasterCacheImages,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
navigatorKey: AppRoutes.rootNavigatorKey, navigatorKey: AppRoutes.rootNavigatorKey,
// Used by ChatView.didPopNext to reclaim the global ChatBloc. // Used by ChatView.didPopNext to reclaim the global ChatBloc.
// DownloadRouteObserver tracks full-page navigations so the downloads // DownloadRouteObserver tracks full-page navigations so the downloads
// chip only surfaces once the user leaves the screen they started on. // chip only surfaces once the user leaves the screen they started on.
navigatorObservers: [ navigatorObservers: _navigatorObservers,
AppRoutes.chatRouteObserver,
DownloadRouteObserver(),
],
localizationsDelegates: const [ localizationsDelegates: const [
...GlobalMaterialLocalizations.delegates, ...GlobalMaterialLocalizations.delegates,
GlobalWidgetsLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
@@ -404,7 +420,7 @@ class _MainState extends State<Main> {
supportedLocales: const [Locale('de'), Locale('en')], supportedLocales: const [Locale('de'), Locale('en')],
locale: const Locale('de'), locale: const Locale('de'),
title: 'Marianum Fulda', title: 'Marianum Fulda',
themeMode: settings.appTheme, themeMode: root.appTheme,
theme: LightAppTheme.theme, theme: LightAppTheme.theme,
darkTheme: DarkAppTheme.theme, darkTheme: DarkAppTheme.theme,
// Brand-colored backdrop behind every route. During the logout // Brand-colored backdrop behind every route. During the logout
@@ -527,3 +543,12 @@ class _MainState extends State<Main> {
), ),
); );
} }
typedef _RootSettings = ({
ThemeMode appTheme,
String mcBaseUrl,
bool notificationsEnabled,
bool showPerformanceOverlay,
bool checkerboardOffscreenLayers,
bool checkerboardRasterCacheImages,
});
+18 -9
View File
@@ -228,16 +228,25 @@ class AccountData {
Future<void> _migrateAndLoad() async { Future<void> _migrateAndLoad() async {
await _migrateFromLegacyStorage(); await _migrateFromLegacyStorage();
await _migrateKeychainAccessibility(); await _migrateKeychainAccessibility();
_username = await _secureStorage.read(key: _usernameField); // Independent keystore reads, each a platform-channel round trip with
_password = await _secureStorage.read(key: _passwordField); // decryption: issued together since this gates the first frame.
_isDemo = (await _secureStorage.read(key: _demoField)) == 'true'; final (username, password, demo, loginFlow) = await (
_usesLoginFlow = _secureStorage.read(key: _usernameField),
(await _secureStorage.read(key: _loginFlowField)) == 'true'; _secureStorage.read(key: _passwordField),
_secureStorage.read(key: _demoField),
_secureStorage.read(key: _loginFlowField),
).wait;
_username = username;
_password = password;
_isDemo = demo == 'true';
_usesLoginFlow = loginFlow == 'true';
try { try {
_appPassword = await pushSecureStorage.read(key: _appPasswordField); final (appPassword, appPasswordTalk) = await (
_appPasswordTalk = await pushSecureStorage.read( pushSecureStorage.read(key: _appPasswordField),
key: _appPasswordTalkField, pushSecureStorage.read(key: _appPasswordTalkField),
); ).wait;
_appPassword = appPassword;
_appPasswordTalk = appPasswordTalk;
} on Object { } on Object {
_appPassword = null; _appPassword = null;
_appPasswordTalk = null; _appPasswordTalk = null;
+3 -19
View File
@@ -1,24 +1,8 @@
import 'package:localstore/localstore.dart'; import '../api/cache_store.dart';
import '../api/request_cache.dart';
class DataCleaner { class DataCleaner {
static Future<void> cleanOldCache() async { static Future<void> cleanOldCache() async {
final cacheData = await Localstore.instance await CacheStore.deleteLegacyLocalstoreCache();
.collection(RequestCache.collection) await CacheStore.instance.deleteOlderThan(const Duration(days: 200));
.get();
cacheData?.forEach((key, value) async {
final lastUpdate = DateTime.fromMillisecondsSinceEpoch(
((value['lastupdate'] as num?) ?? 0).toInt(),
);
if (DateTime.now()
.subtract(const Duration(days: 200))
.isAfter(lastUpdate)) {
await Localstore.instance
.collection(RequestCache.collection)
.doc(key.split('/').last)
.delete();
}
});
} }
} }
+2 -2
View File
@@ -8,6 +8,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../api/cache_store.dart';
import '../api/marianumcloud/talk/share_files_to_chat.dart'; import '../api/marianumcloud/talk/share_files_to_chat.dart';
import '../background/widget_background_task.dart'; import '../background/widget_background_task.dart';
import '../notification/notification_service.dart'; import '../notification/notification_service.dart';
@@ -26,7 +27,6 @@ import '../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../utils/app_paths.dart'; import '../utils/app_paths.dart';
import '../utils/downloads/download_manager.dart'; import '../utils/downloads/download_manager.dart';
import '../utils/file_clipboard.dart'; import '../utils/file_clipboard.dart';
import '../widget/debug/cache_view.dart';
import '../widget_data/widget_sync.dart'; import '../widget_data/widget_sync.dart';
/// Removes everything the signed-out account left on the device. Every step /// Removes everything the signed-out account left on the device. Every step
@@ -98,7 +98,7 @@ abstract final class SessionWipe {
await (await SharedPreferences.getInstance()).clear(); await (await SharedPreferences.getInstance()).clear();
}); });
await _stepAsync('hydrated storage', HydratedBloc.storage.clear); await _stepAsync('hydrated storage', HydratedBloc.storage.clear);
await _stepAsync('request cache', const CacheView().clear); await _stepAsync('request cache', CacheStore.instance.clear);
await _stepAsync('chat background', () async { await _stepAsync('chat background', () async {
final image = File(AppPaths.chatBackgroundImage); final image = File(AppPaths.chatBackgroundImage);
if (image.existsSync()) await image.delete(); if (image.existsSync()) await image.delete();
+10 -1
View File
@@ -7,6 +7,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../push/push_message_handler.dart'; import '../push/push_message_handler.dart';
import '../routing/app_routes.dart'; import '../routing/app_routes.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../utils/debouncer.dart';
import '../widget/debug/debug_tile.dart'; import '../widget/debug/debug_tile.dart';
import '../widget/debug/json_viewer.dart'; import '../widget/debug/json_viewer.dart';
import '../widget/info_dialog.dart'; import '../widget/info_dialog.dart';
@@ -35,7 +37,14 @@ class NotificationController {
); );
await NotificationTasks.refreshBadge(); await NotificationTasks.refreshBadge();
if (!context.mounted) return; if (!context.mounted) return;
NotificationTasks.updateProviders(context); // A busy group chat delivers pushes in bursts; one silent refresh after
// the burst replaces a full room-list fetch per message.
final chatList = context.read<ChatListBloc>();
Debouncer.debounce(
'pushChatListRefresh',
const Duration(seconds: 1),
() => chatList.refresh(silent: true),
);
} }
static Future<void> onAppOpenedByNotification( static Future<void> onAppOpenedByNotification(
+19 -1
View File
@@ -1,7 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import 'package:crypton/crypton.dart'; import 'package:crypton/crypton.dart';
import 'package:flutter/foundation.dart';
import 'package:pointycastle/export.dart' as pc; import 'package:pointycastle/export.dart' as pc;
import 'push_subject.dart'; import 'push_subject.dart';
@@ -25,6 +25,14 @@ class PushDecryptor {
const PushDecryptor({required this.devicePrivateKey, this.serverPublicKey}); const PushDecryptor({required this.devicePrivateKey, this.serverPublicKey});
/// [verify] + [decrypt] on a background isolate: pure-Dart RSA-2048 (with
/// the OAEP → PKCS#1 fallback) takes tens of ms up to >100 ms on older
/// phones, which would otherwise land on the UI thread for every push.
Future<({bool verified, PushSubject? subject})> verifyAndDecryptInBackground(
String subjectBase64,
String signatureBase64,
) => compute(_verifyAndDecrypt, (this, subjectBase64, signatureBase64));
/// Returns true when [signatureBase64] is a valid server signature over the /// Returns true when [signatureBase64] is a valid server signature over the
/// encrypted subject. Returns true when no server key is configured (the /// encrypted subject. Returns true when no server key is configured (the
/// proxy already verified the signature before forwarding). /// proxy already verified the signature before forwarding).
@@ -75,3 +83,13 @@ class PushDecryptor {
} }
} }
} }
({bool verified, PushSubject? subject}) _verifyAndDecrypt(
(PushDecryptor, String, String) args,
) {
final (decryptor, subject, signature) = args;
if (!decryptor.verify(subject, signature)) {
return (verified: false, subject: null);
}
return (verified: true, subject: decryptor.decrypt(subject));
}
+6 -2
View File
@@ -178,11 +178,15 @@ class PushMessageHandler {
devicePrivateKey: privateKey, devicePrivateKey: privateKey,
serverPublicKey: serverPublicKey, serverPublicKey: serverPublicKey,
); );
if (!decryptor.verify(subjectBase64, signatureBase64)) { final result = await decryptor.verifyAndDecryptInBackground(
subjectBase64,
signatureBase64,
);
if (!result.verified) {
log('Push: signature verification failed'); log('Push: signature verification failed');
return; return;
} }
final subject = decryptor.decrypt(subjectBase64); final subject = result.subject;
if (subject == null) { if (subject == null) {
log('Push: could not decrypt subject'); log('Push: could not decrypt subject');
return; return;
@@ -20,10 +20,15 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState>
LoadableStateBloc() : super(const LoadableStateState(connections: null)) { LoadableStateBloc() : super(const LoadableStateState(connections: null)) {
on<ConnectivityChanged>((event, emit) { on<ConnectivityChanged>((event, emit) {
// Only a real reconnect (or a resume) warrants a refetch: the initial
// status after mount and Wi-Fi ↔ mobile handovers would otherwise
// reload an already loaded page for nothing.
final wasOffline = connectivityStatusKnown() && !isConnected();
emit(event.state); emit(event.state);
if (connectivityStatusKnown() && isConnected()) { if ((wasOffline || event.fromResume) &&
if (reFetch == null) return; connectivityStatusKnown() &&
reFetch!(); isConnected()) {
reFetch?.call();
} }
}); });
@@ -54,7 +59,12 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState>
unawaited( unawaited(
Connectivity().checkConnectivity().then((result) { Connectivity().checkConnectivity().then((result) {
if (isClosed) return; if (isClosed) return;
add(ConnectivityChanged(LoadableStateState(connections: result))); add(
ConnectivityChanged(
LoadableStateState(connections: result),
fromResume: true,
),
);
}), }),
); );
} }
@@ -4,5 +4,10 @@ sealed class LoadableStateEvent {}
final class ConnectivityChanged extends LoadableStateEvent { final class ConnectivityChanged extends LoadableStateEvent {
final LoadableStateState state; final LoadableStateState state;
ConnectivityChanged(this.state);
/// Re-check after the app came back to the foreground: refetches whenever
/// online, not only on an offline → online transition.
final bool fromResume;
ConnectivityChanged(this.state, {this.fromResume = false});
} }
@@ -69,7 +69,7 @@ class LoadableStateConsumer<
// null mid-refetch, and toggling the RefreshIndicator on that signal would // null mid-refetch, and toggling the RefreshIndicator on that signal would
// rebuild the tree under the ListView and reset its scroll position. // rebuild the tree under the ListView and reset its scroll position.
final content = SizedBox( final content = SizedBox(
height: MediaQuery.of(context).size.height, height: MediaQuery.sizeOf(context).height,
child: hasContent child: hasContent
? child(typedData as TState, isLoading) ? child(typedData as TState, isLoading)
: const SizedBox.shrink(), : const SizedBox.shrink(),
@@ -95,10 +95,12 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
late Timer _rebuildTimer; late Timer _rebuildTimer;
@override @override
void initState() { void initState() {
_rebuildTimer = Timer.periodic( // Only refresh the relative "last updated" text while this page is the
const Duration(seconds: 10), // visible one; offstage tabs and covered routes have tickers disabled.
(timer) => setState(() {}), _rebuildTimer = Timer.periodic(const Duration(seconds: 10), (timer) {
); if (!mounted) return;
if (TickerMode.getValuesNotifier(context).value.enabled) setState(() {});
});
super.initState(); super.initState();
} }
@@ -33,6 +33,9 @@ class _LoadableStatePrimaryLoadingState
extends State<LoadableStatePrimaryLoading> { extends State<LoadableStatePrimaryLoading> {
Timer? _slowHintTimer; Timer? _slowHintTimer;
bool _showSlowHint = false; bool _showSlowHint = false;
// An indeterminate spinner ticks every frame even at opacity 0, so it is
// unmounted once the fade-out finished instead of just being hidden.
late bool _spinnerMounted = widget.visible;
@override @override
void initState() { void initState() {
@@ -44,6 +47,7 @@ class _LoadableStatePrimaryLoadingState
void didUpdateWidget(covariant LoadableStatePrimaryLoading oldWidget) { void didUpdateWidget(covariant LoadableStatePrimaryLoading oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (widget.visible != oldWidget.visible) _restartSlowHintTimer(); if (widget.visible != oldWidget.visible) _restartSlowHintTimer();
if (widget.visible) _spinnerMounted = true;
} }
void _restartSlowHintTimer() { void _restartSlowHintTimer() {
@@ -62,43 +66,49 @@ class _LoadableStatePrimaryLoadingState
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => AnimatedOpacity(
opacity: widget.visible ? 1.0 : 0.0,
duration: LoadableStateConsumer.animationDuration,
curve: Curves.easeInOut,
onEnd: () {
if (!widget.visible && _spinnerMounted) {
setState(() => _spinnerMounted = false);
}
},
child: _spinnerMounted ? _spinner(context) : const SizedBox.shrink(),
);
Widget _spinner(BuildContext context) {
final status = final status =
widget.statusText ?? widget.statusText ??
(_showSlowHint ? LoadableStatePrimaryLoading.slowHintText : null); (_showSlowHint ? LoadableStatePrimaryLoading.slowHintText : null);
return Center(
return AnimatedOpacity( child: Column(
opacity: widget.visible ? 1.0 : 0.0, mainAxisSize: MainAxisSize.min,
duration: LoadableStateConsumer.animationDuration, children: [
curve: Curves.easeInOut, const AppProgressIndicator.large(),
child: Center( AnimatedSwitcher(
child: Column( duration: LoadableStateConsumer.animationDuration,
mainAxisSize: MainAxisSize.min, child: status == null
children: [ ? const SizedBox.shrink()
const AppProgressIndicator.large(), : Padding(
AnimatedSwitcher( key: ValueKey(status),
duration: LoadableStateConsumer.animationDuration, padding: const EdgeInsets.only(
child: status == null top: 16,
? const SizedBox.shrink() left: 24,
: Padding( right: 24,
key: ValueKey(status), ),
padding: const EdgeInsets.only( child: Text(
top: 16, status,
left: 24, textAlign: TextAlign.center,
right: 24, style: TextStyle(
), fontSize: 13,
child: Text( color: Theme.of(context).hintColor,
status,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).hintColor,
),
), ),
), ),
), ),
], ),
), ],
), ),
); );
} }
@@ -6,6 +6,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/errors/stale_session_exception.dart'; import '../../../../../api/errors/stale_session_exception.dart';
import '../../../../../model/account_data.dart'; import '../../../../../model/account_data.dart';
import '../../../../../utils/session_single_flight.dart';
import '../../loadable_state/loadable_state.dart'; import '../../loadable_state/loadable_state.dart';
import '../../loadable_state/loading_error.dart'; import '../../loadable_state/loading_error.dart';
import '../../repository/repository.dart'; import '../../repository/repository.dart';
@@ -20,6 +21,12 @@ abstract class LoadableHydratedBloc<
extends extends
HydratedBloc<LoadableHydratedBlocEvent<TState>, LoadableState<TState>> { HydratedBloc<LoadableHydratedBlocEvent<TState>, LoadableState<TState>> {
late TRepository _repository; late TRepository _repository;
// HydratedBloc serialises and writes the full state on every emit; loading
// flags, status texts and errors re-emit the very same data, so those
// writes are skipped (see [persistenceKey]).
Object? _lastPersistedKey;
int? _lastPersistedFetch;
LoadableHydratedBloc() LoadableHydratedBloc()
: super( : super(
const LoadableState( const LoadableState(
@@ -113,6 +120,8 @@ abstract class LoadableHydratedBloc<
/// fresh [fetch] (e.g. via [retry] or page-specific refresh) once the user /// fresh [fetch] (e.g. via [retry] or page-specific refresh) once the user
/// is authenticated again, otherwise the UI would stay blank. /// is authenticated again, otherwise the UI would stay blank.
Future<void> reset() async { Future<void> reset() async {
_lastPersistedKey = null;
_lastPersistedFetch = null;
await clear(); await clear();
add(Reset<TState>()); add(Reset<TState>());
} }
@@ -122,10 +131,8 @@ abstract class LoadableHydratedBloc<
/// Runs [body] tagged with the current session: events it adds, also from /// Runs [body] tagged with the current session: events it adds, also from
/// its async continuations, are dropped once the account signed out, so a /// its async continuations, are dropped once the account signed out, so a
/// late response of the previous account cannot refill the reset bloc. /// late response of the previous account cannot refill the reset bloc.
R runInSession<R>(R Function() body) => runZoned( R runInSession<R>(R Function() body) =>
body, runZoned(body, zoneValues: {_sessionKey: AccountData().sessionEpoch});
zoneValues: {_sessionKey: AccountData().sessionEpoch},
);
@override @override
void add(LoadableHydratedBlocEvent<TState> event) { void add(LoadableHydratedBlocEvent<TState> event) {
@@ -160,20 +167,31 @@ abstract class LoadableHydratedBloc<
); );
} }
// The constructor, the app shell and resume/reconnect handlers can all ask
// at once; each parallel gather would re-parse, re-emit and re-persist the
// same data.
final SessionSingleFlight _fetchFlight = SessionSingleFlight();
void fetch() { void fetch() {
log('Fetching data for ${TState.toString()}'); unawaited(
runInSession( _fetchFlight.run(() {
() => gatherData() log('Fetching data for ${TState.toString()}');
.catchError((Object e) { return runInSession(
log('Error while fetching ${TState.toString()}: ${e.toString()}'); () => gatherData()
// The bloc may have been closed before this async error landed; .catchError((Object e) {
// adding to a closed bloc throws, so swallow that case. log(
if (isClosed) return; 'Error while fetching ${TState.toString()}: ${e.toString()}',
addLoadingError(e); );
}) // The bloc may have been closed before this async error
.then((value) { // landed; adding to a closed bloc throws, so swallow that case.
log('Fetch for ${TState.toString()} completed!'); if (isClosed) return;
}), addLoadingError(e);
})
.then((value) {
log('Fetch for ${TState.toString()} completed!');
}),
);
}),
); );
} }
@@ -191,13 +209,27 @@ abstract class LoadableHydratedBloc<
@override @override
Map<String, dynamic>? toJson(LoadableState<TState> state) { Map<String, dynamic>? toJson(LoadableState<TState> state) {
final stateData = state.data;
final key = stateData is TState ? persistenceKey(stateData) : null;
if (key != null &&
// Identity for plain data: a freezed `==` would deep-compare it.
(identical(key, _lastPersistedKey) ||
(key is Record && key == _lastPersistedKey)) &&
state.lastFetch == _lastPersistedFetch) {
return null;
}
_lastPersistedKey = key;
_lastPersistedFetch = state.lastFetch;
Map<String, dynamic>? data; Map<String, dynamic>? data;
try { try {
final stateData = state.data;
data = stateData is TState ? toStorage(stateData) : null; data = stateData is TState ? toStorage(stateData) : null;
} catch (e) { } catch (e) {
log('Failed to save state ${TState.toString()}: ${e.toString()}'); log('Failed to save state ${TState.toString()}: ${e.toString()}');
} }
// Blocs that keep nothing on disk return null from toStorage; writing an
// empty wrapper per emit would be pure overhead.
if (stateData != null && data == null) return null;
return LoadableSaveContext.wrap( return LoadableSaveContext.wrap(
data, data,
@@ -205,6 +237,13 @@ abstract class LoadableHydratedBloc<
); );
} }
/// What has to change for the state to be written to disk again. Defaults
/// to the data instance; blocs whose state also carries transient UI flags
/// can return a record of just the persisted parts (records compare with
/// `==`, so use fields that compare cheaply, e.g. by identity).
Object? persistenceKey(TState data) => data;
Future<void> gatherData(); Future<void> gatherData();
TRepository repository(); TRepository repository();
@@ -59,6 +59,12 @@ class ChatBloc
@override @override
ChatState fromStorage(Map<String, dynamic> json) => ChatState.fromJson(json); ChatState fromStorage(Map<String, dynamic> json) => ChatState.fromJson(json);
// Loading-older and reply-reference flips must not re-persist up to 500
// messages; GetChatResponse has no `==`, so it compares by identity here.
@override
Object? persistenceKey(ChatState data) =>
(data.currentToken, data.chatResponse, data.hasMoreOld);
@override @override
Map<String, dynamic>? toStorage(ChatState state) { Map<String, dynamic>? toStorage(ChatState state) {
final response = state.chatResponse; final response = state.chatResponse;
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'package:flutter_app_badge/flutter_app_badge.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart';
@@ -6,6 +7,7 @@ import 'package:flutter_app_badge/flutter_app_badge.dart';
import '../../../../../api/marianumcloud/talk/actions/talk_actions.dart'; import '../../../../../api/marianumcloud/talk/actions/talk_actions.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../../utils/session_single_flight.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../repository/chat_list_repository.dart'; import '../repository/chat_list_repository.dart';
@@ -74,13 +76,32 @@ class ChatListBloc
renew: renew, renew: renew,
onError: (e) => capturedError = e, onError: (e) => capturedError = e,
); );
_lastRoomsJson = null;
add(DataGathered((s) => s.copyWith(rooms: rooms))); add(DataGathered((s) => s.copyWith(rooms: rooms)));
_updateAppBadge(rooms); _updateAppBadge(rooms);
if (capturedError != null) throw capturedError!; if (capturedError != null) throw capturedError!;
} }
Future<void> refresh({bool renew = true, bool silent = false}) async { final SessionSingleFlight _refreshFlight = SessionSingleFlight();
/// Concurrent callers (tab switch, poll, push, resume) join the running
/// refresh instead of each fetching and re-emitting the full room list.
Future<void> refresh({bool renew = true, bool silent = false}) =>
_refreshFlight.run(() => _refresh(renew: renew, silent: silent));
/// Skips the refresh when the list was fetched within [maxAge].
Future<void> refreshIfOlderThan(Duration maxAge) {
final lastFetch = state.lastFetch;
if (lastFetch != null &&
DateTime.now().millisecondsSinceEpoch - lastFetch <
maxAge.inMilliseconds) {
return Future.value();
}
return refresh();
}
Future<void> _refresh({required bool renew, required bool silent}) async {
if (!silent) add(RefetchStarted<ChatListState>()); if (!silent) add(RefetchStarted<ChatListState>());
Object? capturedError; Object? capturedError;
try { try {
@@ -88,6 +109,11 @@ class ChatListBloc
renew: renew, renew: renew,
onError: (e) => capturedError = e, onError: (e) => capturedError = e,
); );
if (silent) {
if (_isUnchanged(rooms)) return;
} else {
_lastRoomsJson = null;
}
add(DataGathered((s) => s.copyWith(rooms: rooms))); add(DataGathered((s) => s.copyWith(rooms: rooms)));
_updateAppBadge(rooms); _updateAppBadge(rooms);
} catch (e) { } catch (e) {
@@ -96,6 +122,23 @@ class ChatListBloc
if (capturedError != null) addLoadingError(capturedError!); if (capturedError != null) addLoadingError(capturedError!);
} }
// Encoded room data of the last applied poll result; response headers are
// left out because they differ on every request.
String? _lastRoomsJson;
/// A background poll that returns the same rooms would otherwise rebuild the
/// list and re-persist the whole state every 15 s.
bool _isUnchanged(GetRoomResponse rooms) {
final encoded = jsonEncode(rooms.data);
final unchanged =
encoded == _lastRoomsJson &&
innerState?.rooms != null &&
state.error == null &&
!state.isLoading;
_lastRoomsJson = encoded;
return unchanged;
}
/// Creates (or resolves) a 1:1 chat and returns its room token, or null in /// Creates (or resolves) a 1:1 chat and returns its room token, or null in
/// demo mode. Refreshes the list so the room shows up. /// demo mode. Refreshes the list so the room shows up.
Future<String?> createDirectChat(String invite) async { Future<String?> createDirectChat(String invite) async {
@@ -171,6 +214,7 @@ class ChatListBloc
}).toSet(); }).toSet();
if (!changed) return; if (!changed) return;
final newRooms = GetRoomResponse(updated)..headers = rooms.headers; final newRooms = GetRoomResponse(updated)..headers = rooms.headers;
_lastRoomsJson = null;
add(Emit((s) => s.copyWith(rooms: newRooms))); add(Emit((s) => s.copyWith(rooms: newRooms)));
_updateAppBadge(newRooms); _updateAppBadge(newRooms);
} }
@@ -142,12 +142,11 @@ class ForeignTimetableBloc
add( add(
Emit( Emit(
(s) => s.copyWith( (s) => s.withReferenceData(
rooms: rooms, rooms: rooms,
subjects: subjects, subjects: subjects,
schoolHolidays: schoolHolidays, schoolHolidays: schoolHolidays,
schoolyear: schoolyear, schoolyear: schoolyear,
dataVersion: s.dataVersion + 1,
), ),
), ),
); );
@@ -157,11 +156,7 @@ class ForeignTimetableBloc
try { try {
final timegrid = await repo.data.getTimegrid(); final timegrid = await repo.data.getTimegrid();
add( add(Emit((s) => s.withReferenceData(timegrid: timegrid)));
Emit(
(s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1),
),
);
} catch (_) { } catch (_) {
// Timegrid load failure falls back to a hardcoded schedule in the UI. // Timegrid load failure falls back to a hardcoded schedule in the UI.
} }
@@ -1,5 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:enough_icalendar/enough_icalendar.dart'; import 'package:enough_icalendar/enough_icalendar.dart';
import 'package:flutter/foundation.dart';
import '../bloc/marianum_dates_state.dart'; import '../bloc/marianum_dates_state.dart';
@@ -19,6 +20,12 @@ class MarianumDatesGetEvents {
final body = response.data; final body = response.data;
if (body == null || body.isEmpty) return []; if (body == null || body.isEmpty) return [];
// The public feed holds hundreds of events back to 1981; parsing it on
// the UI isolate froze the page for a noticeable moment on every open.
return compute(parseEvents, body);
}
static List<MarianumDate> parseEvents(String body) {
final root = VComponent.parse(body); final root = VComponent.parse(body);
final calendar = root is VCalendar ? root : null; final calendar = root is VCalendar ? root : null;
final source = calendar?.children ?? root.children; final source = calendar?.children ?? root.children;
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../storage/settings.dart'; import '../../../../../storage/settings.dart';
@@ -10,6 +11,8 @@ import '../../../../../view/pages/settings/data/default_settings.dart';
class SettingsCubit extends HydratedCubit<Settings> { class SettingsCubit extends HydratedCubit<Settings> {
static const _debounceTag = 'settings_persist'; static const _debounceTag = 'settings_persist';
bool _emitScheduled = false; bool _emitScheduled = false;
Map<String, dynamic>? _lastEmittedJson;
Timer? _silentSave;
SettingsCubit() : super(DefaultSettings.get()); SettingsCubit() : super(DefaultSettings.get());
@@ -34,21 +37,53 @@ class SettingsCubit extends HydratedCubit<Settings> {
return state; return state;
} }
/// Persists in-place mutations without notifying listeners. For high-frequency
/// writes nobody renders live (chat drafts per keystroke): a regular write
/// would rebuild the app root and every settings watcher on each change.
void saveSilently() {
_silentSave?.cancel();
_silentSave = Timer(const Duration(milliseconds: 800), flushSilentSave);
}
/// Writes a pending [saveSilently] right away (e.g. when the app pauses).
void flushSilentSave() {
if (_silentSave == null) return;
_silentSave!.cancel();
_silentSave = null;
HydratedBloc.storage.write(storageToken, state.toJson());
}
void _emitFreshInstance() { void _emitFreshInstance() {
try { try {
emit(Settings.fromJson(state.toJson())); final json = state.toJson();
// The debounced emit usually follows the microtask emit with identical
// content; skip it instead of rebuilding every listener a second time.
if (const DeepCollectionEquality().equals(json, _lastEmittedJson)) {
return;
}
_lastEmittedJson = json;
emit(Settings.fromJson(json));
} catch (e) { } catch (e) {
log('Failed to refresh settings state: $e'); log('Failed to refresh settings state: $e');
} }
} }
Future<void> reset() async { Future<void> reset() async {
_silentSave?.cancel();
_silentSave = null;
_lastEmittedJson = null;
emit(DefaultSettings.get()); emit(DefaultSettings.get());
} }
// Modules missing from a stale persisted moduleOrder are handled at read // Modules missing from a stale persisted moduleOrder are handled at read
// time by AppModule.effectiveModuleOrder (inserted at their default // time by AppModule.effectiveModuleOrder (inserted at their default
// position) — no healing on hydration needed. // position) — no healing on hydration needed.
@override
Future<void> close() {
flushSilentSave();
return super.close();
}
@override @override
Settings fromJson(Map<String, dynamic> json) { Settings fromJson(Map<String, dynamic> json) {
try { try {
@@ -1,3 +1,8 @@
import 'dart:async';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../../../api/errors/ticker_content_unavailable_exception.dart'; import '../../../../../api/errors/ticker_content_unavailable_exception.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart'; import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
@@ -17,7 +22,35 @@ class TickerPageBloc
> { > {
final String slug; final String slug;
TickerPageBloc(this.slug); TickerPageBloc(this.slug) {
unawaited(_rememberSlug());
}
static const _recentSlugsKey = 'tickerPageRecentSlugs';
static const _keepPages = 50;
/// Every page ever opened used to stay in the hydrated box forever — and
/// the whole box is decoded before the first frame on each cold start.
/// The most recently opened pages (far more than a normal reader revisits)
/// keep their offline copy; only long-forgotten ones are dropped.
Future<void> _rememberSlug() async {
try {
final prefs = await SharedPreferences.getInstance();
final recent = prefs.getStringList(_recentSlugsKey) ?? <String>[];
recent
..remove(slug)
..insert(0, slug);
for (final evicted in recent.skip(_keepPages)) {
await HydratedBloc.storage.delete('$storagePrefix$evicted');
}
await prefs.setStringList(
_recentSlugsKey,
recent.take(_keepPages).toList(),
);
} on Object {
// Best effort: a failed cleanup only leaves an extra offline copy.
}
}
@override @override
String get id => slug; String get id => slug;
@@ -133,11 +133,7 @@ class TimetableBloc
Future<void> _refreshSubjects() async { Future<void> _refreshSubjects() async {
final subjects = await repo.data.getSubjects(renew: true); final subjects = await repo.data.getSubjects(renew: true);
add( add(DataGathered((s) => s.withReferenceData(subjects: subjects)));
DataGathered(
(s) => s.copyWith(subjects: subjects, dataVersion: s.dataVersion + 1),
),
);
} }
Future<void> _loadCurrentWeek( Future<void> _loadCurrentWeek(
@@ -177,12 +173,11 @@ class TimetableBloc
add( add(
Emit( Emit(
(s) => s.copyWith( (s) => s.withReferenceData(
rooms: rooms, rooms: rooms,
subjects: subjects, subjects: subjects,
schoolHolidays: schoolHolidays, schoolHolidays: schoolHolidays,
schoolyear: schoolyear, schoolyear: schoolyear,
dataVersion: s.dataVersion + 1,
), ),
), ),
); );
@@ -192,11 +187,7 @@ class TimetableBloc
try { try {
final timegrid = await repo.data.getTimegrid(renew: renew); final timegrid = await repo.data.getTimegrid(renew: renew);
add( add(Emit((s) => s.withReferenceData(timegrid: timegrid)));
Emit(
(s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1),
),
);
} catch (_) { } catch (_) {
// Timegrid load failure falls back to a hardcoded schedule in the UI layer. // Timegrid load failure falls back to a hardcoded schedule in the UI layer.
} }
@@ -212,12 +203,7 @@ class TimetableBloc
renew: renew, renew: renew,
onError: onError, onError: onError,
); );
add( add(Emit((s) => s.withReferenceData(customEvents: events)));
Emit(
(s) =>
s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1),
),
);
} catch (e) { } catch (e) {
onError?.call(e); onError?.call(e);
} }
@@ -225,11 +211,7 @@ class TimetableBloc
Future<void> _refreshCustomEvents() async { Future<void> _refreshCustomEvents() async {
final events = await repo.data.getCustomEvents(renew: true); final events = await repo.data.getCustomEvents(renew: true);
add( add(DataGathered((s) => s.withReferenceData(customEvents: events)));
DataGathered(
(s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1),
),
);
} }
void _prefetchAdjacentWeeks(DateTime start, DateTime end) { void _prefetchAdjacentWeeks(DateTime start, DateTime end) {
@@ -7,6 +7,7 @@ import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timeta
import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../../../../../utils/json_equality.dart';
import 'week_cache.dart'; import 'week_cache.dart';
part 'timetable_state.freezed.dart'; part 'timetable_state.freezed.dart';
@@ -58,6 +59,45 @@ abstract class TimetableState with _$TimetableState {
return copyWith(weekCache: updated, dataVersion: dataVersion + 1); return copyWith(weekCache: updated, dataVersion: dataVersion + 1);
} }
/// Applies freshly loaded reference data. Values whose content equals the
/// current one keep the old instance, and when nothing changed at all this
/// returns `this` — every `dataVersion` bump and new identity makes the
/// calendar rebuild all appointments and break regions.
TimetableState withReferenceData({
TimetableGetRoomsResponse? rooms,
TimetableGetSubjectsResponse? subjects,
TimetableGetHolidaysResponse? schoolHolidays,
TimetableGetSchoolyearResponse? schoolyear,
TimetableGetTimegridResponse? timegrid,
GetCustomTimetableEventResponse? customEvents,
}) {
T? changed<T>(T? next, T? current) =>
next == null || sameJson(next, current) ? null : next;
final newRooms = changed(rooms, this.rooms);
final newSubjects = changed(subjects, this.subjects);
final newHolidays = changed(schoolHolidays, this.schoolHolidays);
final newSchoolyear = changed(schoolyear, this.schoolyear);
final newTimegrid = changed(timegrid, this.timegrid);
final newCustomEvents = changed(customEvents, this.customEvents);
if (newRooms == null &&
newSubjects == null &&
newHolidays == null &&
newSchoolyear == null &&
newTimegrid == null &&
newCustomEvents == null) {
return this;
}
return copyWith(
rooms: newRooms ?? this.rooms,
subjects: newSubjects ?? this.subjects,
schoolHolidays: newHolidays ?? this.schoolHolidays,
schoolyear: newSchoolyear ?? this.schoolyear,
timegrid: newTimegrid ?? this.timegrid,
customEvents: newCustomEvents ?? this.customEvents,
dataVersion: dataVersion + 1,
);
}
bool get hasReferenceData => bool get hasReferenceData =>
rooms != null && rooms != null &&
subjects != null && subjects != null &&
@@ -1,13 +1,12 @@
import 'package:collection/collection.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../../extensions/date_time.dart'; import '../../../../../extensions/date_time.dart';
import '../../../../../utils/json_equality.dart';
/// Weeks kept around the viewed week and around today's week. Everything /// Weeks kept around the viewed week and around today's week. Everything
/// further away is dropped: every state emit re-serializes the whole cache for /// further away is dropped: every state emit re-serializes the whole cache for
/// HydratedBloc and the calendar rebuilds its appointments from all of it, so /// HydratedBloc and the calendar rebuilds its appointments from all of it, so
/// an unbounded cache makes week swipes slower the longer the app is used. /// an unbounded cache makes week swipes slower the longer the app is used.
const int kWeekCacheRadius = 4; const int kWeekCacheRadius = 8;
/// Returns the cache with [week] stored under [weekStart], pruned to the /// Returns the cache with [week] stored under [weekStart], pruned to the
/// weeks near [viewedWeekStart] or [now]. Returns null when the stored week /// weeks near [viewedWeekStart] or [now]. Returns null when the stored week
@@ -21,10 +20,7 @@ Map<String, TimetableGetWeekResponse>? mergeWeekIntoCache(
}) { }) {
final key = weekStart.weekKey(); final key = weekStart.weekKey();
final existing = cache[key]; final existing = cache[key];
if (existing != null && if (sameJson(existing, week)) return null;
const DeepCollectionEquality().equals(existing.toJson(), week.toJson())) {
return null;
}
final viewedMonday = viewedWeekStart.mondayOfWeek; final viewedMonday = viewedWeekStart.mondayOfWeek;
final todayMonday = now.mondayOfWeek; final todayMonday = now.mondayOfWeek;
+7
View File
@@ -0,0 +1,7 @@
import 'dart:convert';
/// Content equality for API models that define no `==` but serialise to JSON.
/// Comparing the encoded strings is cheaper than a deep map walk and treats
/// both sides identically (same `toJson`, same field order).
bool sameJson(Object? a, Object? b) =>
a != null && b != null && jsonEncode(a) == jsonEncode(b);
+75
View File
@@ -0,0 +1,75 @@
import 'package:rrule/rrule.dart';
/// Memoised RRULE expansion.
///
/// `RecurrenceRule.getInstances` always starts at the series anchor, so asking
/// for one week of a daily event created two years ago walks ~750 occurrences
/// (tens of ms each time on older phones) — and the calendar asks once per
/// week and event, the home widget twice per publish. Here each series keeps
/// its parsed rule, the occurrences found so far and the live iterator, so
/// later queries only extend the expansion instead of restarting it.
class RecurrenceOccurrences {
RecurrenceOccurrences._();
static const int _maxSeries = 64;
static final Map<(String, DateTime), _Series> _cache = {};
/// UTC occurrences of [rule] anchored at [anchorUtc] in
/// `[fromUtc, toUtc)`, in ascending order. Throws like
/// [RecurrenceRule.fromString] for an invalid rule.
static List<DateTime> between(
String rule,
DateTime anchorUtc,
DateTime fromUtc,
DateTime toUtc,
) {
final key = (rule, anchorUtc);
var series = _cache.remove(key);
series ??= _Series(RecurrenceRule.fromString(rule), anchorUtc);
// Re-insert to keep the map in least-recently-used order.
_cache[key] = series;
if (_cache.length > _maxSeries) _cache.remove(_cache.keys.first);
return series.between(fromUtc, toUtc);
}
/// Drops all memoised series (tests).
static void clear() => _cache.clear();
}
class _Series {
final Iterator<DateTime> _iterator;
final List<DateTime> _found = [];
bool _exhausted = false;
_Series(RecurrenceRule rule, DateTime anchorUtc)
: _iterator = rule.getInstances(start: anchorUtc).iterator;
List<DateTime> between(DateTime fromUtc, DateTime toUtc) {
while (!_exhausted && (_found.isEmpty || _found.last.isBefore(toUtc))) {
if (_iterator.moveNext()) {
_found.add(_iterator.current);
} else {
_exhausted = true;
}
}
final start = _lowerBound(fromUtc);
return [
for (var i = start; i < _found.length && _found[i].isBefore(toUtc); i++)
_found[i],
];
}
int _lowerBound(DateTime value) {
var low = 0;
var high = _found.length;
while (low < high) {
final mid = (low + high) >> 1;
if (_found[mid].isBefore(value)) {
low = mid + 1;
} else {
high = mid;
}
}
return low;
}
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/widgets.dart';
/// Wraps [provider] so it decodes at most [scale] × the screen's longest
/// physical side (capped at [maxPx]). Camera-sized photos otherwise decode at
/// full resolution — 50–200 MB each — which gets the app killed on
/// low-memory devices.
ImageProvider screenBoundImage(
BuildContext context,
ImageProvider provider, {
double scale = 1,
int maxPx = 4096,
}) {
final bound =
(MediaQuery.sizeOf(context).longestSide *
MediaQuery.devicePixelRatioOf(context) *
scale)
.round()
.clamp(1, maxPx);
return ResizeImage(
provider,
width: bound,
height: bound,
policy: ResizeImagePolicy.fit,
);
}
+21
View File
@@ -0,0 +1,21 @@
import '../model/account_data.dart';
/// Joins concurrent calls into the one already running for the same account
/// session. A run left over from a signed-out session never blocks the next
/// account's first call.
class SessionSingleFlight {
Future<void>? _running;
int? _epoch;
Future<void> run(Future<void> Function() action) {
final epoch = AccountData().sessionEpoch;
final running = _running;
if (running != null && _epoch == epoch) return running;
_epoch = epoch;
late final Future<void> current;
current = action().whenComplete(() {
if (identical(_running, current)) _running = null;
});
return _running = current;
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ class SortOptions {
SortOption.name: BetterSortOption( SortOption.name: BetterSortOption(
displayName: 'Name', displayName: 'Name',
icon: Icons.sort_by_alpha_outlined, 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( SortOption.date: BetterSortOption(
displayName: 'Datum', displayName: 'Datum',
+24 -9
View File
@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.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/loadable_state.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
@@ -47,6 +49,22 @@ class _FilesViewState extends State<_FilesView> {
late bool currentSortDirection; late bool currentSortDirection;
late final StreamSubscription<String> _invalidationSub; 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 // Cache key in FilesBloc's pathString format: '/' for root, otherwise
// segments joined without leading/trailing slash. // segments joined without leading/trailing slash.
String get _myPathString => widget.path.isEmpty ? '/' : widget.path.join('/'); String get _myPathString => widget.path.isEmpty ? '/' : widget.path.join('/');
@@ -98,6 +116,11 @@ class _FilesViewState extends State<_FilesView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bloc = context.read<FilesBloc>(); 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( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'), title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'),
@@ -153,15 +176,7 @@ class _FilesViewState extends State<_FilesView> {
text: 'Der Ordner ist leer', text: 'Der Ordner ist leer',
); );
} }
final files = listing.sortBy( final files = _sorted(listing, foldersToTop);
sortOption: currentSort,
foldersToTop: context
.watch<SettingsCubit>()
.val()
.fileSettings
.sortFoldersToTop,
reversed: currentSortDirection,
);
return ListView.builder( return ListView.builder(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
itemCount: files.length, itemCount: files.length,
+14 -4
View File
@@ -191,18 +191,23 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
} }
final HttpClientResponse uploadTask; final HttpClientResponse uploadTask;
final fileIndex = _uploadableFiles.indexOf(file);
var lastPercent = -1;
try { try {
uploadTask = await webdavClient.putFile( uploadTask = await webdavClient.putFile(
File(filePath), File(filePath),
fileStat, fileStat,
PathUri.parse(fullRemotePath), PathUri.parse(fullRemotePath),
onProgress: (progress) { 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(() { setState(() {
file._uploadProgress = progress; file._uploadProgress = progress;
_overallProgressValue = _overallProgressValue =
((progress + _uploadableFiles.indexOf(file)) / ((progress + fileIndex) / _uploadableFiles.length).toDouble();
_uploadableFiles.length)
.toDouble();
}); });
}, },
); );
@@ -246,7 +251,12 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
itemCount: _uploadableFiles.length, itemCount: _uploadableFiles.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final currentFile = _uploadableFiles[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( return ListTile(
title: TextField( title: TextField(
readOnly: _isUploading, readOnly: _isUploading,
@@ -26,6 +26,14 @@ class FilesSearchController extends ChangeNotifier {
Object? _serverError; Object? _serverError;
int _serverEpoch = 0; int _serverEpoch = 0;
bool _disposed = false; 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 /// Guards against the race where the search delegate is closed (and the
/// controller disposed) while a debounced cache scan or server call is /// controller disposed) while a debounced cache scan or server call is
@@ -80,7 +88,7 @@ class FilesSearchController extends ChangeNotifier {
_serverError = null; _serverError = null;
_safeNotify(); _safeNotify();
final cacheHits = await searchLocalCaches(_query, pathScope: _pathScope); final cacheHits = await _searchLocal(_pathScope);
if (epoch != _serverEpoch) return; if (epoch != _serverEpoch) return;
_cacheResults = cacheHits; _cacheResults = cacheHits;
_safeNotify(); _safeNotify();
@@ -101,7 +109,7 @@ class FilesSearchController extends ChangeNotifier {
_serverError = null; _serverError = null;
_safeNotify(); _safeNotify();
final cacheHits = await searchLocalCaches(_query); final cacheHits = await _searchLocal(const []);
if (epoch != _serverEpoch) return; if (epoch != _serverEpoch) return;
_cacheResults = cacheHits; _cacheResults = cacheHits;
_safeNotify(); _safeNotify();
@@ -104,32 +104,43 @@ class FilesSearchResults extends StatelessWidget {
Widget _resultList(BuildContext context, List<CacheableFile> combined) { Widget _resultList(BuildContext context, List<CacheableFile> combined) {
final groups = _groupByParent(combined); final groups = _groupByParent(combined);
final orderedKeys = groups.keys.toList()..sort(); final orderedKeys = groups.keys.toList()..sort();
final items = <Widget>[]; // Flat (folder header | file) rows built lazily: results can run into
for (final folder in orderedKeys) { // the hundreds and arrive in several batches per query.
final segments = _segmentsOf(folder); final rows = <(String, CacheableFile?)>[
items.add( for (final folder in orderedKeys) ...[
_FolderHeader( (folder, null),
folder: folder, for (final file in groups[folder]!) (folder, file),
onOpen: () { ],
onResultTap?.call(); ];
AppRoutes.openFolder(context, segments); return ListView.builder(
}, padding: EdgeInsets.zero,
), itemCount: rows.length,
); itemBuilder: (context, index) {
for (final file in groups[folder]!) { final (folder, file) = rows[index];
items.add( final segments = _segmentsOf(folder);
FileElement( if (file == null) {
file, return _FolderHeader(
segments, key: ValueKey('folder:$folder'),
controller.retry, folder: folder,
highlight: controller.query, 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) { Map<String, List<CacheableFile>> _groupByParent(List<CacheableFile> files) {
final map = <String, List<CacheableFile>>{}; final map = <String, List<CacheableFile>>{};
for (final file in files) { for (final file in files) {
@@ -139,7 +150,7 @@ class FilesSearchResults extends StatelessWidget {
} }
String _parentOf(CacheableFile file) { String _parentOf(CacheableFile file) {
final stripped = file.path.replaceAll(RegExp(r'^/+|/+$'), ''); final stripped = file.path.replaceAll(_edgeSlashes, '');
final segments = stripped.split('/'); final segments = stripped.split('/');
if (segments.length <= 1) return '/'; if (segments.length <= 1) return '/';
segments.removeLast(); segments.removeLast();
@@ -147,7 +158,7 @@ class FilesSearchResults extends StatelessWidget {
} }
List<String> _segmentsOf(String folder) { List<String> _segmentsOf(String folder) {
final stripped = folder.replaceAll(RegExp(r'^/+|/+$'), ''); final stripped = folder.replaceAll(_edgeSlashes, '');
if (stripped.isEmpty) return const []; if (stripped.isEmpty) return const [];
return stripped.split('/'); return stripped.split('/');
} }
@@ -156,7 +167,11 @@ class FilesSearchResults extends StatelessWidget {
class _FolderHeader extends StatelessWidget { class _FolderHeader extends StatelessWidget {
final String folder; final String folder;
final VoidCallback onOpen; final VoidCallback onOpen;
const _FolderHeader({required this.folder, required this.onOpen}); const _FolderHeader({
required this.folder,
required this.onOpen,
super.key,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -1,49 +1,29 @@
import 'dart:convert'; 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/cacheable_file.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
import '../../../../api/request_cache.dart';
/// Document key prefix used by `ListFilesCache._documentId`. /// Document key prefix used by `ListFilesCache._documentId`.
const String _folderCachePrefix = 'wd-folder-'; const String _folderCachePrefix = 'wd-folder-';
/// Scans every cached folder listing in Localstore and returns files/folders /// Every file and folder from the cached folder listings, deduplicated by
/// whose name contains [query] (case-insensitive). /// path. Built once per search session: reading and parsing all listings on
/// /// each keystroke used to stall typing.
/// [pathScope] restricts results to entries whose WebDAV path starts with Future<List<CacheableFile>> loadLocalCacheIndex() async {
/// the given folder. Pass an empty list (or null) to search globally. final entries = await CacheStore.instance.readAll(prefix: _folderCachePrefix);
/// if (entries.isEmpty) return const [];
/// [docs] is an injection seam for tests — production callers leave it null final payloads = [for (final entry in entries.values) entry.json];
/// so the helper reads from the real Localstore. return compute(buildLocalCacheIndex, payloads);
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;
/// 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; final ListFilesResponse listing;
try { try {
listing = ListFilesResponse.fromJson( listing = ListFilesResponse.fromJson(
@@ -52,14 +32,32 @@ Future<List<CacheableFile>> searchLocalCaches(
} on Object { } on Object {
continue; continue;
} }
for (final file in listing.files) { for (final file in listing.files) {
if (!file.name.toLowerCase().contains(needle)) continue; byPath[file.path] ??= file;
if (scopePrefix.isNotEmpty && !file.path.startsWith(scopePrefix)) {
continue;
}
results[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_rooms/timetable_get_rooms.dart';
import '../../../api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart'; import '../../../api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart';
import '../../../api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart'; import '../../../api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart';
import '../../../model/account_data.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../storage/timetable_favorites_settings.dart'; import '../../../storage/timetable_favorites_settings.dart';
import '../../../utils/haptics.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) // One in-flight/resolved future per type so switching tabs (or rebuilds)
// never re-fetches a list that's already loaded. // 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. // Memoised combined future for the "Alle" tab; rebuilt on retry.
Future<List<_PickerItem>>? _allFuture; Future<List<_PickerItem>>? _allFuture;
@@ -47,8 +55,25 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
return _allFuture ??= _loadAll(); return _allFuture ??= _loadAll();
} }
Future<List<_PickerItem>> _loadFor(TimetableElementType type) => Future<List<_PickerItem>> _loadFor(TimetableElementType type) {
_futures.putIfAbsent(type, () => _fetch(type)); final epoch = AccountData().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 { Future<List<_PickerItem>> _loadAll() async {
final lists = await Future.wait(TimetableElementType.values.map(_loadFor)); final lists = await Future.wait(TimetableElementType.values.map(_loadFor));
@@ -117,20 +142,16 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
Haptics.selection(); Haptics.selection();
// Hand the selection back to the timetable view, which renders the foreign // Hand the selection back to the timetable view, which renders the foreign
// plan inline. We do not navigate to a new page. // plan inline. We do not navigate to a new page.
Navigator.of(context).pop(( Navigator.of(
type: item.type, context,
id: item.id, ).pop((type: item.type, id: item.id, label: item.primary));
label: item.primary,
));
} }
void _openFavorite(FavoriteTimetableElement favorite) { void _openFavorite(FavoriteTimetableElement favorite) {
Haptics.selection(); Haptics.selection();
Navigator.of(context).pop(( Navigator.of(
type: favorite.type, context,
id: favorite.id, ).pop((type: favorite.type, id: favorite.id, label: favorite.label));
label: favorite.label,
));
} }
void _toggleFavorite(TimetableElementType type, int id, String label) { void _toggleFavorite(TimetableElementType type, int id, String label) {
@@ -325,9 +346,7 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
return ListTile( return ListTile(
leading: Icon(_iconFor(item.type)), leading: Icon(_iconFor(item.type)),
title: Text(item.primary), title: Text(item.primary),
subtitle: subtitleParts.isEmpty subtitle: subtitleParts.isEmpty ? null : Text(subtitleParts.join(' · ')),
? null
: Text(subtitleParts.join(' · ')),
trailing: IconButton( trailing: IconButton(
icon: Icon(isFavorite ? Icons.star : Icons.star_border), icon: Icon(isFavorite ? Icons.star : Icons.star_border),
tooltip: isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren', tooltip: isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren',
@@ -214,8 +214,11 @@ class ChatBackgroundSettingsPage extends StatelessWidget {
final file = File(AppPaths.chatBackgroundImage); final file = File(AppPaths.chatBackgroundImage);
await file.writeAsBytes(bytes); await file.writeAsBytes(bytes);
// Same filename across replacements → the decoded image is cached under // Same filename across replacements → the decoded image is cached under
// an identical key. Evict so the new bytes actually show. // an identical key (for "cover" also wrapped in a viewport-sized
await FileImage(file).evict(); // ResizeImage). Clearing the cache is fine for this rare action.
PaintingBinding.instance.imageCache
..clear()
..clearLiveImages();
final cs = settings.val(write: true).chatBackgroundSettings; final cs = settings.val(write: true).chatBackgroundSettings;
cs.imageVersion++; cs.imageVersion++;
cs.type = ChatBackgroundType.image; cs.type = ChatBackgroundType.image;
@@ -3,13 +3,13 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../api/cache_store.dart';
import '../../../../routing/app_routes.dart'; import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../storage/dev_tools_settings.dart'; import '../../../../storage/dev_tools_settings.dart';
import '../../../../storage/settings.dart' as model; import '../../../../storage/settings.dart' as model;
import '../../../../widget/centered_leading.dart'; import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/debug/cache_view.dart';
import '../../../../widget/debug/json_viewer.dart'; import '../../../../widget/debug/json_viewer.dart';
import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/details_bottom_sheet.dart';
import '../widgets/endpoint_picker.dart'; import '../widgets/endpoint_picker.dart';
@@ -24,6 +24,9 @@ class DevToolsSection extends StatefulWidget {
} }
class _DevToolsSectionState extends State<DevToolsSection> { class _DevToolsSectionState extends State<DevToolsSection> {
// Kept across rebuilds: the size walk lists the whole cache directory.
Future<int>? _cacheSize;
@override @override
Widget build(BuildContext context) => Column( Widget build(BuildContext context) => Column(
children: [ children: [
@@ -153,7 +156,7 @@ class _DevToolsSectionState extends State<DevToolsSection> {
leading: const CenteredLeading(Icon(Icons.data_object)), leading: const CenteredLeading(Icon(Icons.data_object)),
title: const Text('Cache-storage JSON dump'), title: const Text('Cache-storage JSON dump'),
subtitle: FutureBuilder( subtitle: FutureBuilder(
future: const CacheView().totalSize(), future: _cacheSize ??= CacheStore.instance.totalSize(),
builder: (context, snapshot) => Text( builder: (context, snapshot) => Text(
"etwa ${snapshot.hasError "etwa ${snapshot.hasError
? "?" ? "?"
@@ -169,8 +172,9 @@ class _DevToolsSectionState extends State<DevToolsSection> {
content: content:
'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut', 'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut',
confirmButton: 'Unwiederruflich löschen', confirmButton: 'Unwiederruflich löschen',
onConfirm: () => onConfirm: () => CacheStore.instance.clear().then(
const CacheView().clear().then((value) => setState(() {})), (value) => setState(() => _cacheSize = null),
),
).asDialog(context); ).asDialog(context);
}, },
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
+27 -18
View File
@@ -75,6 +75,17 @@ class _ChatListViewState extends State<_ChatListView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bloc = context.read<ChatListBloc>(); 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( return SplitView.material(
placeholder: const SplitViewPlaceholder(), placeholder: const SplitViewPlaceholder(),
breakpoint: 1000, breakpoint: 1000,
@@ -129,13 +140,9 @@ class _ChatListViewState extends State<_ChatListView> {
final rooms = state.rooms; final rooms = state.rooms;
if (rooms == null) return const SizedBox.shrink(); if (rooms == null) return const SizedBox.shrink();
final talkSettings = context
.watch<SettingsCubit>()
.val()
.talkSettings;
final sorted = rooms.sortBy( final sorted = rooms.sortBy(
favoritesToTop: talkSettings.sortFavoritesToTop, favoritesToTop: favoritesToTop,
unreadToTop: talkSettings.sortUnreadToTop, unreadToTop: unreadToTop,
); );
if (sorted.isEmpty) { 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, padding: EdgeInsets.zero,
children: sorted.map((room) { itemCount: sorted.length,
final hasDraft = _settings // Keeps each tile's state (cached avatar bytes) with its room
.val() // when a re-sort moves it to another index.
.talkSettings findChildIndexCallback: (key) =>
.drafts indexByToken[(key as ValueKey<String>).value],
.containsKey(room.token); itemBuilder: (context, index) {
// Stable key keeps element identity across re-sorts so the final room = sorted[index];
// inner UserAvatar reuses its cached bytes instead of
// flashing on every list update.
return ChatTile( return ChatTile(
key: ValueKey(room.token), key: ValueKey(room.token),
data: room, 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_bloc.dart';
import '../../../state/app/modules/chat/bloc/chat_state.dart'; import '../../../state/app/modules/chat/bloc/chat_state.dart';
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../utils/debouncer.dart';
import '../../../widget/chat_background.dart'; import '../../../widget/chat_background.dart';
import '../../../widget/clickable_app_bar.dart'; import '../../../widget/clickable_app_bar.dart';
import '../../../widget/user_avatar.dart'; import '../../../widget/user_avatar.dart';
@@ -118,6 +119,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
_markAsReadFinal(); _markAsReadFinal();
_chatBlocRef?.leaveChat(widget.room.token); _chatBlocRef?.leaveChat(widget.room.token);
_searchTextController.dispose(); _searchTextController.dispose();
Debouncer.cancel(_searchDebounceTag);
super.dispose(); super.dispose();
} }
@@ -150,6 +152,45 @@ class _ChatViewState extends State<ChatView> with RouteAware {
context.read<ChatBloc>().setToken(widget.room.token); 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() { void _enterSearchMode() {
setState(() { setState(() {
_searchActive = true; _searchActive = true;
@@ -163,6 +204,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
} }
void _exitSearchMode() { void _exitSearchMode() {
Debouncer.cancel(_searchDebounceTag);
setState(() { setState(() {
_searchActive = false; _searchActive = false;
_searchQuery = ''; _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) { 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; final chatResponse = context.read<ChatBloc>().state.data?.chatResponse;
setState(() { setState(() {
_searchQuery = q; _searchQuery = q;
@@ -268,9 +325,13 @@ class _ChatViewState extends State<ChatView> with RouteAware {
messages.add( messages.add(
ChatBubble( ChatBubble(
isSender: false, isSender: false,
key: ValueKey(
'day-${elementDate.year}-${elementDate.month}-'
'${elementDate.day}',
),
bubbleData: GetChatResponseObject.getDateDummy(element.timestamp), bubbleData: GetChatResponseObject.getDateDummy(element.timestamp),
chatData: widget.room, chatData: widget.room,
refetch: ({bool renew = false}) => _refresh(), refetch: _refetch,
), ),
); );
} }
@@ -286,6 +347,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
messages.add( messages.add(
ChatBubble( ChatBubble(
key: ValueKey(element.id),
isSender: isSender:
element.actorId == widget.selfId && element.actorId == widget.selfId &&
(element.messageType == (element.messageType ==
@@ -294,7 +356,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
GetRoomResponseObjectMessageType.deletedComment), GetRoomResponseObjectMessageType.deletedComment),
bubbleData: element, bubbleData: element,
chatData: widget.room, chatData: widget.room,
refetch: ({bool renew = false}) => _refresh(), refetch: _refetch,
isRead: element.id <= commonRead, isRead: element.id <= commonRead,
selfId: widget.selfId, selfId: widget.selfId,
highlightQuery: highlightQuery, highlightQuery: highlightQuery,
@@ -317,17 +379,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Swallow the first back gesture while the keyboard is visible so it return _KeyboardDismissPopScope(
// 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();
},
child: Scaffold( child: Scaffold(
backgroundColor: const Color(0xffefeae2), backgroundColor: const Color(0xffefeae2),
appBar: _searchActive appBar: _searchActive
@@ -376,25 +428,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
state.currentToken == widget.room.token, state.currentToken == widget.room.token,
enablePullToRefresh: false, enablePullToRefresh: false,
child: (state, _) { child: (state, _) {
final items = _buildMessages( final items = _itemsFor(state);
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(),
),
);
}
_itemCount = items.length; _itemCount = items.length;
return ScrollablePositionedList.builder( return ScrollablePositionedList.builder(
reverse: true, 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,
);
}
}
+37 -13
View File
@@ -59,6 +59,13 @@ class _ChatBubbleState extends State<ChatBubble>
with SingleTickerProviderStateMixin, DownloadTrigger<ChatBubble> { with SingleTickerProviderStateMixin, DownloadTrigger<ChatBubble> {
late ChatMessage message; 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 _position = Offset.zero;
Offset _dragStartPosition = Offset.zero; Offset _dragStartPosition = Offset.zero;
bool _swipeActionArmed = false; bool _swipeActionArmed = false;
@@ -185,12 +192,34 @@ class _ChatBubbleState extends State<ChatBubble>
} }
} }
@override void _updateMessage(BuildContext context) {
Widget build(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( message = ChatMessage(
originalMessage: widget.bubbleData.message, originalMessage: widget.bubbleData.message,
originalData: widget.bubbleData.messageParameters, originalData: widget.bubbleData.messageParameters,
); );
_messageWidget = message.getWidget(
highlightQuery: widget.highlightQuery,
style: style,
renderMarkdown: renderMarkdown,
);
}
@override
Widget build(BuildContext context) {
_updateMessage(context);
final showActorDisplayName = final showActorDisplayName =
_rendersAsCommentBubble && _rendersAsCommentBubble &&
widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne; widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
@@ -277,14 +306,7 @@ class _ChatBubbleState extends State<ChatBubble>
actorText: actorText, actorText: actorText,
actorWidget: actorWidget, actorWidget: actorWidget,
timeText: timeText, timeText: timeText,
messageWidget: message.getWidget( messageWidget: _messageWidget,
highlightQuery: widget.highlightQuery,
style: _messageTextStyle(context),
renderMarkdown:
widget.bubbleData.markdown &&
widget.bubbleData.messageType ==
GetRoomResponseObjectMessageType.comment,
),
parent: parent, parent: parent,
bubbleData: widget.bubbleData, bubbleData: widget.bubbleData,
isSender: widget.isSender, isSender: widget.isSender,
@@ -350,10 +372,12 @@ class _BubbleContent extends StatelessWidget {
Widget build(BuildContext context) => MergeSemantics( Widget build(BuildContext context) => MergeSemantics(
child: Container( child: Container(
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.9, maxWidth: MediaQuery.sizeOf(context).width * 0.9,
minWidth: showActorDisplayName minWidth: showActorDisplayName
? actorText.size.width ? actorText.measuredWidth(context)
: timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3, : timeText.measuredWidth(context) +
(isSender ? spacing + timeIconSize : 0) +
3,
), ),
child: Stack( child: Stack(
children: [ children: [
@@ -34,7 +34,7 @@ class ChatBubbleReactions extends StatelessWidget {
return Transform.translate( return Transform.translate(
offset: const Offset(0, -10), offset: const Offset(0, -10),
child: Container( child: Container(
width: MediaQuery.of(context).size.width, width: MediaQuery.sizeOf(context).width,
margin: const EdgeInsets.only(left: 15, right: 15), margin: const EdgeInsets.only(left: 15, right: 15),
child: Wrap( child: Wrap(
alignment: isSender ? WrapAlignment.end : WrapAlignment.start, 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) { 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) { if (text.isNotEmpty) {
talkSettings.drafts[widget.sendToToken] = text; drafts[widget.sendToToken] = text;
} else { } 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final chatBloc = context.watch<ChatBloc>(); final chatBloc = context.read<ChatBloc>();
final chatState = chatBloc.state.data; // 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(); Widget replyBanner = const SizedBox.shrink();
if (chatState != null && if (referenceMessageId != null && chatResponse != null) {
chatState.referenceMessageId != null &&
chatState.chatResponse != null) {
try { try {
final referenceMessage = chatState.chatResponse! final referenceMessage = chatResponse.data.firstWhere(
.sortByTimestamp() (e) => e.id == referenceMessageId,
.firstWhere((e) => e.id == chatState.referenceMessageId); );
replyBanner = Row( replyBanner = Row(
children: [ children: [
Expanded( Expanded(
+17 -3
View File
@@ -68,15 +68,25 @@ class _ChatTileState extends State<ChatTile> {
/// One-line preview of the last message: rich-object placeholders resolved, /// One-line preview of the last message: rich-object placeholders resolved,
/// newlines flattened and — for Markdown messages — formatting stripped so /// newlines flattened and — for Markdown messages — formatting stripped so
/// the list shows readable text rather than raw markers. /// 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() { String _lastMessagePreview() {
final last = widget.data.lastMessage; final last = widget.data.lastMessage;
final key = (last.id, last.message, last.markdown);
if (key == _previewFor) return _preview;
final text = RichObjectStringProcessor.parseToString( final text = RichObjectStringProcessor.parseToString(
last.message.replaceAll('\n', ' '), last.message.replaceAll('\n', ' '),
last.messageParameters, 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 { Future<void> _setCurrentAsRead() async {
final token = widget.data.token; final token = widget.data.token;
final lastId = widget.data.lastMessage.id; final lastId = widget.data.lastMessage.id;
@@ -89,7 +99,11 @@ class _ChatTileState extends State<ChatTile> {
@override @override
Widget build(BuildContext context) { 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 = final isGroup =
widget.data.type != GetRoomResponseObjectConversationType.oneToOne; widget.data.type != GetRoomResponseObjectConversationType.oneToOne;
final circleAvatar = UserAvatar( final circleAvatar = UserAvatar(
@@ -100,7 +114,7 @@ class _ChatTileState extends State<ChatTile> {
return ListTile( return ListTile(
style: ListTileStyle.list, style: ListTileStyle.list,
tileColor: tileColor:
chatBloc.state.data?.currentToken == widget.data.token && currentToken == widget.data.token &&
TalkNavigator.isSecondaryVisible(context) TalkNavigator.isSecondaryVisible(context)
? Theme.of(context).primaryColor.withAlpha(100) ? Theme.of(context).primaryColor.withAlpha(100)
: null, : null,
@@ -1,7 +1,7 @@
import 'package:rrule/rrule.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../extensions/date_time.dart'; import '../../../../extensions/date_time.dart';
import '../../../../utils/recurrence_occurrences.dart';
import 'arbitrary_appointment.dart'; import 'arbitrary_appointment.dart';
import 'calendar_layout.dart'; import 'calendar_layout.dart';
import 'lesson_period_schedule.dart'; import 'lesson_period_schedule.dart';
@@ -103,7 +103,6 @@ partitionAppointmentsForWeek(
continue; continue;
} }
try { try {
final parsed = RecurrenceRule.fromString(rule);
final anchorUtc = a.startTime.toUtc(); final anchorUtc = a.startTime.toUtc();
final duration = a.endTime.difference(a.startTime); final duration = a.endTime.difference(a.startTime);
// Day-keyed set of exception dates so occurrences scheduled for one // Day-keyed set of exception dates so occurrences scheduled for one
@@ -112,9 +111,12 @@ partitionAppointmentsForWeek(
final exceptionDayKeys = (a.recurrenceExceptionDates ?? const <DateTime>[]) final exceptionDayKeys = (a.recurrenceExceptionDates ?? const <DateTime>[])
.map((d) => '${d.year}-${d.month}-${d.day}') .map((d) => '${d.year}-${d.month}-${d.day}')
.toSet(); .toSet();
for (final occUtc in parsed.getInstances(start: anchorUtc)) { for (final occUtc in RecurrenceOccurrences.between(
if (!occUtc.isBefore(weekEndUtc)) break; rule,
if (occUtc.isBefore(weekStartUtc)) continue; anchorUtc,
weekStartUtc,
weekEndUtc,
)) {
final occLocal = occUtc.toLocal(); final occLocal = occUtc.toLocal();
if (exceptionDayKeys.contains( if (exceptionDayKeys.contains(
'${occLocal.year}-${occLocal.month}-${occLocal.day}', '${occLocal.year}-${occLocal.month}-${occLocal.day}',
+6 -5
View File
@@ -266,11 +266,12 @@ class _ViewingBanner extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final isFavorite = context final isFavorite = context.select(
.watch<SettingsCubit>() (SettingsCubit c) => c.state.timetableFavoritesSettings.isFavorite(
.val() element.type,
.timetableFavoritesSettings element.id,
.isFavorite(element.type, element.id); ),
);
final onColor = theme.colorScheme.onSecondaryContainer; final onColor = theme.colorScheme.onSecondaryContainer;
// Compact icon button: ~32px square, no extra padding, so the banner stays // Compact icon button: ~32px square, no extra padding, so the banner stays
@@ -7,10 +7,10 @@ import '../../../../extensions/date_time.dart';
import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../../../../storage/timetable_settings.dart';
import '../data/arbitrary_appointment.dart'; import '../data/arbitrary_appointment.dart';
import '../data/lesson_period_schedule.dart'; import '../data/lesson_period_schedule.dart';
import '../data/timetable_appointment_factory.dart'; import '../data/timetable_appointment_factory.dart';
import '../data/timetable_name_mode.dart';
import 'custom_workweek_calendar.dart'; import 'custom_workweek_calendar.dart';
import 'special_regions_builder.dart'; import 'special_regions_builder.dart';
@@ -51,9 +51,10 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
GlobalKey<CustomWorkWeekCalendarState>(); GlobalKey<CustomWorkWeekCalendarState>();
List<Appointment>? _cachedAppointments; List<Appointment>? _cachedAppointments;
// TimetableSettings and List define no `==`, so record equality degrades to // Settings are keyed by the values the factory reads: the settings object is
// the same identity checks the cache always used. // re-created on every settings write, so its identity would miss the cache
(int, TimetableSettings, List<CustomTimetableEvent>, bool)? _cacheKey; // 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 // Stable identities let the calendar reuse its per-week pages across
// rebuilds; rebuilding these every frame would invalidate that cache. // rebuilds; rebuilding these every frame would invalidate that cache.
@@ -71,13 +72,16 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
} }
List<Appointment> _appointments(TimetableState state) { List<Appointment> _appointments(TimetableState state) {
final timetableSettings = context final (connectDoubleLessons, nameMode) = context.select(
.watch<SettingsCubit>() (SettingsCubit c) => (
.val() c.state.timetableSettings.connectDoubleLessons,
.timetableSettings; c.state.timetableSettings.timetableNameMode,
),
);
final key = ( final key = (
state.dataVersion, state.dataVersion,
timetableSettings, connectDoubleLessons,
nameMode,
widget.customEvents, widget.customEvents,
widget.showClassInsteadOfTeacher, widget.showClassInsteadOfTeacher,
); );
@@ -91,7 +95,7 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
customEvents: widget.customEvents, customEvents: widget.customEvents,
subjects: state.subjects?.result ?? const [], subjects: state.subjects?.result ?? const [],
holidays: state.schoolHolidays?.result ?? const [], holidays: state.schoolHolidays?.result ?? const [],
settings: timetableSettings, settings: context.read<SettingsCubit>().val().timetableSettings,
now: DateTime.now(), now: DateTime.now(),
showClassInsteadOfTeacher: widget.showClassInsteadOfTeacher, showClassInsteadOfTeacher: widget.showClassInsteadOfTeacher,
).build(); ).build();
+20 -4
View File
@@ -8,6 +8,7 @@ import '../state/app/modules/settings/bloc/settings_cubit.dart';
import '../storage/chat_background_settings.dart'; import '../storage/chat_background_settings.dart';
import '../theming/app_theme.dart'; import '../theming/app_theme.dart';
import '../utils/app_paths.dart'; import '../utils/app_paths.dart';
import '../utils/screen_bound_image.dart';
/// Renders the configurable chat background behind [child]. /// Renders the configurable chat background behind [child].
/// ///
@@ -24,7 +25,13 @@ class ChatBackground extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = context.watch<SettingsCubit>().val().chatBackgroundSettings; // Value snapshot: the settings object is mutated in place, so only copied
// values can tell whether the background actually changed.
context.select((SettingsCubit c) {
final s = c.state.chatBackgroundSettings;
return (s.type, s.fit, s.colorValue, s.imageVersion, s.dim, s.blur);
});
final s = context.read<SettingsCubit>().val().chatBackgroundSettings;
final dark = AppTheme.isDarkMode(context); final dark = AppTheme.isDarkMode(context);
final Widget background; final Widget background;
@@ -32,7 +39,9 @@ class ChatBackground extends StatelessWidget {
case ChatBackgroundType.none: case ChatBackgroundType.none:
background = ColoredBox(color: Theme.of(context).colorScheme.surface); background = ColoredBox(color: Theme.of(context).colorScheme.surface);
case ChatBackgroundType.color: case ChatBackgroundType.color:
background = ColoredBox(color: Color(s.colorValue ?? _fallbackColor.toARGB32())); background = ColoredBox(
color: Color(s.colorValue ?? _fallbackColor.toARGB32()),
);
case ChatBackgroundType.pattern: case ChatBackgroundType.pattern:
background = _imageLayer( background = _imageLayer(
const AssetImage('assets/background/chat.png'), const AssetImage('assets/background/chat.png'),
@@ -41,12 +50,17 @@ class ChatBackground extends StatelessWidget {
isPattern: true, isPattern: true,
); );
case ChatBackgroundType.image: case ChatBackgroundType.image:
final image = FileImage(File(AppPaths.chatBackgroundImage));
background = KeyedSubtree( background = KeyedSubtree(
// imageVersion changes on every replacement, forcing a fresh subtree // imageVersion changes on every replacement, forcing a fresh subtree
// alongside the explicit ImageCache evict in the settings handler. // alongside the explicit ImageCache evict in the settings handler.
key: ValueKey(s.imageVersion), key: ValueKey(s.imageVersion),
// Only "cover" scales the photo to the screen; tile/center render
// it unscaled, so those keep the natural size.
child: _imageLayer( child: _imageLayer(
FileImage(File(AppPaths.chatBackgroundImage)), s.fit == ChatBackgroundFit.cover
? screenBoundImage(context, image)
: image,
s, s,
dark, dark,
isPattern: false, isPattern: false,
@@ -57,7 +71,9 @@ class ChatBackground extends StatelessWidget {
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
Positioned.fill(child: background), // Own layer: without it every scroll frame of the message list above
// repaints the (possibly tiled or blurred) background as well.
Positioned.fill(child: RepaintBoundary(child: background)),
if (s.dim > 0) if (s.dim > 0)
Positioned.fill( Positioned.fill(
child: ColoredBox(color: Colors.black.withValues(alpha: s.dim)), child: ColoredBox(color: Colors.black.withValues(alpha: s.dim)),
+8 -30
View File
@@ -2,10 +2,9 @@ import 'dart:convert';
import 'package:filesize/filesize.dart'; import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart'; import 'package:jiffy/jiffy.dart';
import 'package:localstore/localstore.dart';
import '../../../widget/placeholder_view.dart'; import '../../../widget/placeholder_view.dart';
import '../../api/request_cache.dart'; import '../../api/cache_store.dart';
import '../app_progress_indicator.dart'; import '../app_progress_indicator.dart';
import 'json_viewer.dart'; import 'json_viewer.dart';
@@ -15,31 +14,11 @@ class CacheView extends StatefulWidget {
@override @override
State<CacheView> createState() => _CacheViewState(); State<CacheView> createState() => _CacheViewState();
Future<void> clear() async {
await Localstore.instance.collection(RequestCache.collection).delete();
}
Future<int> totalSize() async {
final data = await Localstore.instance
.collection(RequestCache.collection)
.get();
if (data == null || data.isEmpty) return 0;
return data.values.fold<int>(
0,
(sum, value) => sum + jsonEncode(value).length,
) *
8;
}
} }
class _CacheViewState extends State<CacheView> { class _CacheViewState extends State<CacheView> {
late Future<Map<String, dynamic>?> files; late final Future<Map<String, CacheEntry>> files = CacheStore.instance
.readAll();
@override
void initState() {
files = Localstore.instance.collection(RequestCache.collection).get();
super.initState();
}
@override @override
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) => Scaffold(
@@ -47,24 +26,23 @@ class _CacheViewState extends State<CacheView> {
body: FutureBuilder( body: FutureBuilder(
future: files, future: files,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData && snapshot.data!.isNotEmpty) {
return ListView.builder( return ListView.builder(
itemCount: snapshot.data!.length, itemCount: snapshot.data!.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final key = snapshot.data!.keys.elementAt(index); final key = snapshot.data!.keys.elementAt(index);
final element = snapshot.data![key] as Map<String, dynamic>; final element = snapshot.data![key]!;
final filename = key.split('/').last;
return ListTile( return ListTile(
leading: const Icon(Icons.text_snippet_outlined), leading: const Icon(Icons.text_snippet_outlined),
title: Text(filename), title: Text(key),
subtitle: Text( subtitle: Text(
'${filesize(jsonEncode(element).length * 8)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element['lastupdate'] as int).fromNow()}', '${filesize(utf8.encode(element.json).length)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element.lastUpdate).fromNow()}',
), ),
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () => JsonViewer.asDialog( onTap: () => JsonViewer.asDialog(
context, context,
jsonDecode(element['json'] as String) as Map<String, dynamic>, jsonDecode(element.json) as Map<String, dynamic>,
), ),
); );
}, },
+58 -37
View File
@@ -4,6 +4,7 @@ import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
@@ -15,6 +16,7 @@ import '../routing/app_routes.dart';
import '../share_intent/remote_file_ref.dart'; import '../share_intent/remote_file_ref.dart';
import '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart';
import '../utils/downloads/download_manager.dart'; import '../utils/downloads/download_manager.dart';
import '../utils/screen_bound_image.dart';
import 'app_progress_indicator.dart'; import 'app_progress_indicator.dart';
import 'async_action_button.dart'; import 'async_action_button.dart';
import 'centered_leading.dart'; import 'centered_leading.dart';
@@ -49,6 +51,7 @@ class FileViewer extends StatefulWidget {
enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud } enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud }
class _FileViewerState extends State<FileViewer> { class _FileViewerState extends State<FileViewer> {
Future<_TextPayload>? _textPayload;
final PhotoViewController photoViewController = PhotoViewController(); final PhotoViewController photoViewController = PhotoViewController();
late SettingsCubit settings = context.read<SettingsCubit>(); late SettingsCubit settings = context.read<SettingsCubit>();
@@ -302,7 +305,13 @@ class _FileViewerState extends State<FileViewer> {
controller: photoViewController, controller: photoViewController,
maxScale: 3.0, maxScale: 3.0,
minScale: 0.1, minScale: 0.1,
imageProvider: Image.file(File(widget.path)).image, // 2× the screen stays sharp while zooming in; the 4096 px cap only
// bites on camera-sized photos.
imageProvider: screenBoundImage(
context,
FileImage(File(widget.path)),
scale: 2,
),
backgroundDecoration: BoxDecoration( backgroundDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
), ),
@@ -348,13 +357,17 @@ class _FileViewerState extends State<FileViewer> {
Widget _buildTextView() => Scaffold( Widget _buildTextView() => Scaffold(
appBar: _appbar(), appBar: _appbar(),
body: FutureBuilder<_TextPayload>( body: FutureBuilder<_TextPayload>(
future: _readTextPayload(), // Cached: a future created in build re-read the file on every rebuild.
future: _textPayload ??= compute(
_loadTextPayload,
(widget.path, _textViewMaxBytes),
),
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const Center(child: AppProgressIndicator.large()); return const Center(child: AppProgressIndicator.large());
} }
final payload = snapshot.data!; final payload = snapshot.data!;
final lines = const LineSplitter().convert(payload.content); final lines = payload.lines;
// Stable gutter width — sized by the highest line number's digit count. // Stable gutter width — sized by the highest line number's digit count.
final gutterWidth = (lines.length.toString().length * 9.0) + 16; final gutterWidth = (lines.length.toString().length * 9.0) + 16;
return SelectionArea( return SelectionArea(
@@ -443,38 +456,6 @@ class _FileViewerState extends State<FileViewer> {
} }
static const int _textViewMaxBytes = 5 * 1024 * 1024; static const int _textViewMaxBytes = 5 * 1024 * 1024;
Future<_TextPayload> _readTextPayload() async {
final file = File(widget.path);
final size = await file.length();
final ext = widget.path.split('.').last.toLowerCase();
if (size <= _textViewMaxBytes) {
final raw = await file.readAsString();
return _TextPayload(content: _maybePrettify(raw, ext), truncated: false);
}
final raf = await file.open();
try {
final bytes = await raf.read(_textViewMaxBytes);
// Truncated payloads stay raw — a parser would choke on the dangling tail.
return _TextPayload(
content: utf8.decode(bytes, allowMalformed: true),
truncated: true,
);
} finally {
await raf.close();
}
}
/// Falls through to the original text on parse errors.
String _maybePrettify(String content, String ext) {
if (ext != 'json') return content;
try {
final parsed = jsonDecode(content);
return const JsonEncoder.withIndent(' ').convert(parsed);
} on Object {
return content;
}
}
} }
class _ActionDescriptor { class _ActionDescriptor {
@@ -489,7 +470,47 @@ class _ActionDescriptor {
} }
class _TextPayload { class _TextPayload {
final String content; final List<String> lines;
final bool truncated; final bool truncated;
const _TextPayload({required this.content, required this.truncated}); const _TextPayload({required this.lines, required this.truncated});
}
/// Reads, prettifies (JSON) and splits a text file on a background isolate:
/// up to 5 MB of decoding and line splitting would otherwise freeze the UI.
Future<_TextPayload> _loadTextPayload((String, int) args) async {
final (path, maxBytes) = args;
final file = File(path);
final size = await file.length();
final ext = path.split('.').last.toLowerCase();
if (size <= maxBytes) {
final raw = await file.readAsString();
return _TextPayload(
lines: const LineSplitter().convert(_maybePrettify(raw, ext)),
truncated: false,
);
}
final raf = await file.open();
try {
final bytes = await raf.read(maxBytes);
// Truncated payloads stay raw — a parser would choke on the dangling tail.
return _TextPayload(
lines: const LineSplitter().convert(
utf8.decode(bytes, allowMalformed: true),
),
truncated: true,
);
} finally {
await raf.close();
}
}
/// Falls through to the original text on parse errors.
String _maybePrettify(String content, String ext) {
if (ext != 'json') return content;
try {
final parsed = jsonDecode(content);
return const JsonEncoder.withIndent(' ').convert(parsed);
} on Object {
return content;
}
} }
+32 -26
View File
@@ -10,9 +10,33 @@ class PmImageView extends StatelessWidget {
const PmImageView({required this.node, super.key}); const PmImageView({required this.node, super.key});
/// Full-resolution source, used for the zoomable fullscreen view.
static ImageProvider? sourceProvider(PmImage node) {
final bytes = node.bytes;
if (bytes != null) return MemoryImage(bytes);
if (node.src.startsWith('http')) {
return CachedNetworkImageProvider(node.src);
}
return null;
}
/// Inline images never render wider than the screen, so they are decoded at
/// most at its physical width instead of the (often camera-sized) original.
/// [PmJsonView] precaches through this too, so both hit the same cache key.
static ImageProvider? inlineProvider(BuildContext context, PmImage node) {
final source = sourceProvider(node);
if (source == null) return null;
final width =
(MediaQuery.sizeOf(context).width *
MediaQuery.devicePixelRatioOf(context))
.round();
return ResizeImage(source, width: width);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = _imageProvider(); final provider = sourceProvider(node);
final inline = inlineProvider(context, node);
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final available = constraints.maxWidth.isFinite final available = constraints.maxWidth.isFinite
@@ -22,7 +46,13 @@ class PmImageView extends StatelessWidget {
Widget image = ConstrainedBox( Widget image = ConstrainedBox(
constraints: BoxConstraints(maxWidth: targetWidth ?? available), constraints: BoxConstraints(maxWidth: targetWidth ?? available),
child: _imageWidget(context, provider), child: inline == null
? _brokenImage(context)
: Image(
image: inline,
errorBuilder: (context, error, stack) =>
_brokenImage(context),
),
); );
final href = node.href; final href = node.href;
@@ -43,30 +73,6 @@ class PmImageView extends StatelessWidget {
); );
} }
ImageProvider? _imageProvider() {
if (node.bytes != null) return MemoryImage(node.bytes!);
if (node.src.startsWith('http')) {
return CachedNetworkImageProvider(node.src);
}
return null;
}
Widget _imageWidget(BuildContext context, ImageProvider? provider) {
if (node.bytes != null) {
return Image.memory(
node.bytes!,
errorBuilder: (context, error, stack) => _brokenImage(context),
);
}
if (node.src.startsWith('http')) {
return CachedNetworkImage(
imageUrl: node.src,
errorWidget: (context, url, error) => _brokenImage(context),
);
}
return _brokenImage(context);
}
Widget _brokenImage(BuildContext context) { Widget _brokenImage(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return DecoratedBox( return DecoratedBox(
+14 -20
View File
@@ -1,9 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../app_progress_indicator.dart'; import '../app_progress_indicator.dart';
import 'pm_document_view.dart'; import 'pm_document_view.dart';
import 'pm_image_view.dart';
import 'pm_node.dart'; import 'pm_node.dart';
/// Renders raw ProseMirror JSON and memoises the parsed tree across rebuilds. /// Renders raw ProseMirror JSON and memoises the parsed tree across rebuilds.
@@ -31,7 +31,7 @@ class PmJsonView extends StatefulWidget {
class _PmJsonViewState extends State<PmJsonView> { class _PmJsonViewState extends State<PmJsonView> {
PmNode? _shown; PmNode? _shown;
PmNode? _pending; PmNode? _pending;
List<ImageProvider> _pendingProviders = const []; List<PmImage> _pendingImages = const [];
bool _precacheStarted = false; bool _precacheStarted = false;
int _generation = 0; int _generation = 0;
@@ -62,17 +62,17 @@ class _PmJsonViewState extends State<PmJsonView> {
void _parse() { void _parse() {
final doc = PmNode.fromJson(widget.json); final doc = PmNode.fromJson(widget.json);
final providers = <ImageProvider>[]; final images = <PmImage>[];
_collectProviders(doc, providers); _collectImages(doc, images);
_generation++; _generation++;
_precacheStarted = false; _precacheStarted = false;
if (providers.isEmpty) { if (images.isEmpty) {
_shown = doc; _shown = doc;
_pending = null; _pending = null;
_pendingProviders = const []; _pendingImages = const [];
} else { } else {
_pending = doc; _pending = doc;
_pendingProviders = providers; _pendingImages = images;
} }
} }
@@ -82,8 +82,9 @@ class _PmJsonViewState extends State<PmJsonView> {
_precacheStarted = true; _precacheStarted = true;
final generation = _generation; final generation = _generation;
Future.wait([ Future.wait([
for (final provider in _pendingProviders) for (final image in _pendingImages)
precacheImage(provider, context, onError: (_, _) {}), if (PmImageView.inlineProvider(context, image) case final provider?)
precacheImage(provider, context, onError: (_, _) {}),
]) ])
.timeout(PmJsonView.precacheTimeout, onTimeout: () => const []) .timeout(PmJsonView.precacheTimeout, onTimeout: () => const [])
.whenComplete(() { .whenComplete(() {
@@ -91,23 +92,16 @@ class _PmJsonViewState extends State<PmJsonView> {
setState(() { setState(() {
_shown = pending; _shown = pending;
_pending = null; _pending = null;
_pendingProviders = const []; _pendingImages = const [];
}); });
} }
}); });
} }
void _collectProviders(PmNode node, List<ImageProvider> out) { void _collectImages(PmNode node, List<PmImage> out) {
if (node is PmImage) { if (node is PmImage) out.add(node);
final bytes = node.bytes;
if (bytes != null) {
out.add(MemoryImage(bytes));
} else if (node.src.startsWith('http')) {
out.add(CachedNetworkImageProvider(node.src));
}
}
for (final child in node.children) { for (final child in node.children) {
_collectProviders(child, out); _collectImages(child, out);
} }
} }
+2 -2
View File
@@ -4,7 +4,7 @@ class SharePositionOrigin {
static Rect get(BuildContext context) => Rect.fromLTWH( static Rect get(BuildContext context) => Rect.fromLTWH(
0, 0,
0, 0,
MediaQuery.of(context).size.width, MediaQuery.sizeOf(context).width,
MediaQuery.of(context).size.height / 2, MediaQuery.sizeOf(context).height / 2,
); );
} }
+4
View File
@@ -389,6 +389,10 @@ class _UserAvatarState extends State<UserAvatar> {
payload.bytes, payload.bytes,
width: radius * 2, width: radius * 2,
height: radius * 2, height: radius * 2,
// Group avatars arrive at a fixed large server size; decoding them
// at display size keeps a chat list from filling the image cache.
cacheWidth: (radius * 2 * MediaQuery.devicePixelRatioOf(context))
.round(),
fit: BoxFit.cover, fit: BoxFit.cover,
gaplessPlayback: true, gaplessPlayback: true,
); );
+7 -6
View File
@@ -1,7 +1,5 @@
import 'dart:developer'; import 'dart:developer';
import 'package:rrule/rrule.dart';
import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart'; import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
@@ -10,6 +8,7 @@ import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week_res
import '../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../extensions/date_time.dart'; import '../extensions/date_time.dart';
import '../utils/recurrence_occurrences.dart';
import '../view/pages/timetable/data/lesson_labels.dart'; import '../view/pages/timetable/data/lesson_labels.dart';
import '../view/pages/timetable/data/lesson_merger.dart'; import '../view/pages/timetable/data/lesson_merger.dart';
import '../view/pages/timetable/data/lesson_period_schedule.dart'; import '../view/pages/timetable/data/lesson_period_schedule.dart';
@@ -443,11 +442,13 @@ class WidgetDataMapper {
} }
try { try {
final parsed = RecurrenceRule.fromString(rule);
final anchorUtc = event.startDate.toUtc(); final anchorUtc = event.startDate.toUtc();
for (final occUtc in parsed.getInstances(start: anchorUtc)) { for (final occUtc in RecurrenceOccurrences.between(
if (!occUtc.isBefore(rangeEndUtc)) break; rule,
if (occUtc.isBefore(rangeStartUtc)) continue; anchorUtc,
rangeStartUtc,
rangeEndUtc,
)) {
final occLocal = occUtc.toLocal(); final occLocal = occUtc.toLocal();
final occStart = DateTime( final occStart = DateTime(
occLocal.year, occLocal.year,
+67 -6
View File
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -20,22 +21,65 @@ class WidgetPublisher {
static DateTime widgetNow() => static DateTime widgetNow() =>
kDebugMode ? DateTime.now().add(debugTimeShift) : DateTime.now(); kDebugMode ? DateTime.now().add(debugTimeShift) : DateTime.now();
/// Identical snapshots are still re-written after this long, so the stored
/// `fetchedAt` (freshness for the background refresh) doesn't go stale.
static const Duration _republishAfter = Duration(minutes: 10);
static Future<void> _queue = Future.value();
static (bool, String, bool)? _lastFlags;
static String? _lastSignature;
static DateTime? _lastPublishedAt;
/// Forgets what was written last, so the next publish writes again. Called
/// whenever the stored widget data is cleared (sign-out).
static void resetDedupe() {
_lastSignature = null;
_lastPublishedAt = null;
}
/// Publishes run one at a time: they are fire-and-forget from the bloc
/// stream, and interleaved writes could leave day and week data from
/// different states behind.
/// [epoch] is the session the [state] belongs to; defaults to the current
/// one. Callers that delay the publish must capture it up front.
static Future<void> publishFromBlocState( static Future<void> publishFromBlocState(
TimetableState state, { TimetableState state, {
Settings? settings, Settings? settings,
bool isTeacher = false, bool isTeacher = false,
int? epoch,
}) {
final sessionEpoch = epoch ?? AccountData().sessionEpoch;
return _queue = _queue.then(
(_) => _publish(
state,
settings: settings,
isTeacher: isTeacher,
epoch: sessionEpoch,
),
);
}
static Future<void> _publish(
TimetableState state, {
required Settings? settings,
required bool isTeacher,
required int epoch,
}) async { }) async {
final epoch = AccountData().sessionEpoch; if (!AccountData().isCurrentSession(epoch)) return;
try { try {
final connectDouble = final connectDouble =
settings?.timetableSettings.connectDoubleLessons ?? true; settings?.timetableSettings.connectDoubleLessons ?? true;
// Mirror into widget storage so the background isolate sees the same // Mirror into widget storage so the background isolate sees the same
// values the user just toggled — concurrently, they are independent. // values the user just toggled — concurrently, they are independent.
await Future.wait([ final flags = (connectDouble, _themeName(settings?.appTheme), isTeacher);
WidgetSync.setConnectDoubleLessons(connectDouble), if (flags != _lastFlags) {
WidgetSync.setThemeMode(_themeName(settings?.appTheme)), await Future.wait([
WidgetSync.setIsTeacher(isTeacher), WidgetSync.setConnectDoubleLessons(flags.$1),
]); WidgetSync.setThemeMode(flags.$2),
WidgetSync.setIsTeacher(flags.$3),
]);
_lastFlags = flags;
}
final lessons = state.getAllKnownLessons(); final lessons = state.getAllKnownLessons();
final now = widgetNow(); final now = widgetNow();
final dayData = WidgetDataMapper.buildDayData( final dayData = WidgetDataMapper.buildDayData(
@@ -63,6 +107,20 @@ class WidgetPublisher {
// A publish still running at sign-out would put the previous account's // A publish still running at sign-out would put the previous account's
// plan back onto the just cleared widget. // plan back onto the just cleared widget.
if (!AccountData().isCurrentSession(epoch)) return; if (!AccountData().isCurrentSession(epoch)) return;
// Most bloc emits (week swipes, prefetches) don't touch the widget's
// window; skip the SharedPreferences commits and widget re-render then.
final signature = jsonEncode([
_withoutFetchedAt(dayData.toJson()),
_withoutFetchedAt(weekData.toJson()),
]);
final lastAt = _lastPublishedAt;
if (signature == _lastSignature &&
lastAt != null &&
now.difference(lastAt) < _republishAfter) {
return;
}
_lastSignature = signature;
_lastPublishedAt = now;
await WidgetSync.writeDayData(dayData); await WidgetSync.writeDayData(dayData);
await WidgetSync.writeWeekData(weekData); await WidgetSync.writeWeekData(weekData);
await WidgetSync.setLoggedIn(true); await WidgetSync.setLoggedIn(true);
@@ -74,6 +132,9 @@ class WidgetPublisher {
} }
} }
static Map<String, Object?> _withoutFetchedAt(Map<String, Object?> json) =>
Map.of(json)..remove('fetchedAt');
static String _themeName(ThemeMode? mode) { static String _themeName(ThemeMode? mode) {
switch (mode) { switch (mode) {
case ThemeMode.light: case ThemeMode.light:
+2
View File
@@ -5,6 +5,7 @@ import 'dart:developer';
import 'package:home_widget/home_widget.dart'; import 'package:home_widget/home_widget.dart';
import 'widget_data.dart'; import 'widget_data.dart';
import 'widget_publisher.dart';
/// Bridge to the native widget host. All keys/names live here so the Kotlin /// Bridge to the native widget host. All keys/names live here so the Kotlin
/// and Swift sides stay in sync. /// and Swift sides stay in sync.
@@ -109,6 +110,7 @@ class WidgetSync {
} }
static Future<void> clear() async { static Future<void> clear() async {
WidgetPublisher.resetDedupe();
await ensureInitialized(); await ensureInitialized();
await HomeWidget.saveWidgetData<String>(dayDataKey, null); await HomeWidget.saveWidgetData<String>(dayDataKey, null);
await HomeWidget.saveWidgetData<String>(weekDataKey, null); await HomeWidget.saveWidgetData<String>(weekDataKey, null);
+21
View File
@@ -0,0 +1,21 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/cache_store.dart';
void main() {
group('CacheStore.parse', () {
test('splits the timestamp header from the payload', () {
final entry = CacheStore.parse('1700000000000\n{"a":"b\\nc"}');
expect(entry!.lastUpdate, 1700000000000);
expect(entry.json, '{"a":"b\\nc"}');
});
test('keeps newlines inside the payload', () {
expect(CacheStore.parse('1\nline1\nline2')!.json, 'line1\nline2');
});
test('rejects files without a valid header', () {
expect(CacheStore.parse('{"a":1}'), isNull);
expect(CacheStore.parse('abc\n{}'), isNull);
});
});
}
@@ -0,0 +1,32 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
import 'package:marianum_mobile/state/app/modules/timetable/bloc/timetable_state.dart';
TimetableGetRoomsResponse _rooms(String name) => TimetableGetRoomsResponse(
result: [McRoom(id: 1, shortName: name, longName: name)],
);
void main() {
final base = TimetableState(
startDate: DateTime(2026, 9, 21),
endDate: DateTime(2026, 9, 25),
rooms: _rooms('A101'),
dataVersion: 3,
);
test('returns the same state when the content is unchanged', () {
expect(base.withReferenceData(rooms: _rooms('A101')), same(base));
});
test('bumps dataVersion and takes the new value when content changed', () {
final next = _rooms('B202');
final result = base.withReferenceData(rooms: next);
expect(result.rooms, same(next));
expect(result.dataVersion, 4);
});
test('keeps unchanged instances when only another field changed', () {
final result = base.withReferenceData(rooms: _rooms('A101'));
expect(result.rooms, same(base.rooms));
});
}
@@ -0,0 +1,48 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/utils/recurrence_occurrences.dart';
void main() {
setUp(RecurrenceOccurrences.clear);
final anchor = DateTime.utc(2024, 9, 2, 8);
test('returns only occurrences inside the half-open range', () {
final hits = RecurrenceOccurrences.between(
'RRULE:FREQ=DAILY',
anchor,
DateTime.utc(2026, 9, 21),
DateTime.utc(2026, 9, 24),
);
expect(hits, [
DateTime.utc(2026, 9, 21, 8),
DateTime.utc(2026, 9, 22, 8),
DateTime.utc(2026, 9, 23, 8),
]);
});
test('answers earlier ranges after a later one was expanded', () {
RecurrenceOccurrences.between(
'RRULE:FREQ=WEEKLY',
anchor,
DateTime.utc(2026, 1, 1),
DateTime.utc(2026, 1, 31),
);
final hits = RecurrenceOccurrences.between(
'RRULE:FREQ=WEEKLY',
anchor,
DateTime.utc(2024, 9, 1),
DateTime.utc(2024, 9, 10),
);
expect(hits, [DateTime.utc(2024, 9, 2, 8), DateTime.utc(2024, 9, 9, 8)]);
});
test('stops at the end of a finite series', () {
final hits = RecurrenceOccurrences.between(
'RRULE:FREQ=DAILY;COUNT=2',
anchor,
DateTime.utc(2024, 1, 1),
DateTime.utc(2030, 1, 1),
);
expect(hits, hasLength(2));
});
}
+21 -30
View File
@@ -11,13 +11,10 @@ CacheableFile _file({
bool isDirectory = false, bool isDirectory = false,
}) => CacheableFile(path: path, isDirectory: isDirectory, name: name); }) => CacheableFile(path: path, isDirectory: isDirectory, name: name);
Map<String, dynamic> _doc(ListFilesResponse listing) => { String _payload(ListFilesResponse listing) => jsonEncode(listing.toJson());
'json': jsonEncode(listing.toJson()),
'lastupdate': 0,
};
void main() { void main() {
group('searchLocalCaches', () { group('local cache index', () {
final root = ListFilesResponse({ final root = ListFilesResponse({
_file(path: 'Documents/', name: 'Documents', isDirectory: true), _file(path: 'Documents/', name: 'Documents', isDirectory: true),
_file(path: 'Photos/', name: 'Photos', isDirectory: true), _file(path: 'Photos/', name: 'Photos', isDirectory: true),
@@ -27,51 +24,45 @@ void main() {
_file(path: 'Documents/Tax-Report.pdf', name: 'Tax-Report.pdf'), _file(path: 'Documents/Tax-Report.pdf', name: 'Tax-Report.pdf'),
_file(path: 'Documents/Notes.txt', name: 'Notes.txt'), _file(path: 'Documents/Notes.txt', name: 'Notes.txt'),
}); });
final docs = { final index = buildLocalCacheIndex([
'/MarianumMobile/wd-folder-aaa': _doc(root), _payload(root),
'/MarianumMobile/wd-folder-bbb': _doc(documents), _payload(documents),
'/MarianumMobile/get-room-ccc': {'json': '{}', 'lastupdate': 0}, ]);
};
test('matches by name case-insensitively across all caches', () async { test('matches by name case-insensitively across all caches', () {
final hits = await searchLocalCaches('report', docs: docs); final hits = searchLocalCacheIndex(index, 'report');
final paths = hits.map((f) => f.path).toSet(); final paths = hits.map((f) => f.path).toSet();
expect(paths, {'Reports.pdf', 'Documents/Tax-Report.pdf'}); expect(paths, {'Reports.pdf', 'Documents/Tax-Report.pdf'});
}); });
test('returns empty list for empty query', () async { test('returns empty list for empty query', () {
expect(await searchLocalCaches(' ', docs: docs), isEmpty); expect(searchLocalCacheIndex(index, ' '), isEmpty);
}); });
test('respects pathScope prefix', () async { test('respects pathScope prefix', () {
final hits = await searchLocalCaches( final hits = searchLocalCacheIndex(
index,
'report', 'report',
pathScope: ['Documents'], pathScope: ['Documents'],
docs: docs,
); );
expect(hits.map((f) => f.path), ['Documents/Tax-Report.pdf']); expect(hits.map((f) => f.path), ['Documents/Tax-Report.pdf']);
}); });
test('ignores non-folder cache documents', () async { test('skips payloads that are not folder listings', () {
final hits = await searchLocalCaches('anything', docs: docs); expect(buildLocalCacheIndex(['not json', '{"foo": 1}']), isEmpty);
// Only documents starting with `wd-folder-` are scanned. The unrelated
// `get-room-ccc` doc must not crash the helper.
expect(hits, isEmpty);
}); });
test('deduplicates entries that appear in multiple cached folders', test('deduplicates entries that appear in multiple cached folders', () {
() async {
final shared = _file( final shared = _file(
path: 'Documents/Tax-Report.pdf', path: 'Documents/Tax-Report.pdf',
name: 'Tax-Report.pdf', name: 'Tax-Report.pdf',
); );
final dedupRoot = ListFilesResponse({shared}); final dedupRoot = ListFilesResponse({shared});
final dedupDocs = { final dedupIndex = buildLocalCacheIndex([
'/MarianumMobile/wd-folder-aaa': _doc(dedupRoot), _payload(dedupRoot),
'/MarianumMobile/wd-folder-bbb': _doc(dedupRoot), _payload(dedupRoot),
}; ]);
final hits = await searchLocalCaches('tax', docs: dedupDocs); expect(searchLocalCacheIndex(dedupIndex, 'tax'), hasLength(1));
expect(hits, hasLength(1));
}); });
}); });
} }