From 56a497985b5fe6d9d13175b2561e40ce3ecef581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Fri, 25 Sep 2026 23:58:08 +0200 Subject: [PATCH] improved performance and battery usage on older devices --- lib/api/cache_store.dart | 185 ++++++++++++++++++ .../talk/chat/get_chat_cache.dart | 2 +- .../talk/chat/get_chat_history.dart | 10 +- lib/api/marianumcloud/talk/room/get_room.dart | 12 +- lib/api/marianumcloud/talk/talk_api.dart | 6 +- .../queries/list_files/cacheable_file.dart | 13 ++ .../queries/list_files/list_files_cache.dart | 14 +- .../custom_events_migration.dart | 24 +-- .../timetable_get_week_response.dart | 11 +- lib/api/request_cache.dart | 84 ++++---- lib/app.dart | 71 +++++-- lib/extensions/text.dart | 21 +- lib/main.dart | 71 ++++--- lib/model/account_data.dart | 27 ++- lib/model/data_cleaner.dart | 22 +-- lib/model/session_wipe.dart | 4 +- lib/notification/notification_controller.dart | 11 +- lib/push/push_decryptor.dart | 20 +- lib/push/push_message_handler.dart | 8 +- .../bloc/loadable_state_bloc.dart | 18 +- .../bloc/loadable_state_event.dart | 7 +- .../view/loadable_state_consumer.dart | 2 +- .../view/loadable_state_error_bar.dart | 10 +- .../view/loadable_state_primary_loading.dart | 74 ++++--- .../loadable_hydrated_bloc.dart | 75 +++++-- .../app/modules/chat/bloc/chat_bloc.dart | 6 + .../chat_list/bloc/chat_list_bloc.dart | 46 ++++- .../bloc/foreign_timetable_bloc.dart | 9 +- .../marianum_dates_get_events.dart | 7 + .../modules/settings/bloc/settings_cubit.dart | 37 +++- .../modules/ticker/bloc/ticker_page_bloc.dart | 35 +++- .../timetable/bloc/timetable_bloc.dart | 28 +-- .../timetable/bloc/timetable_state.dart | 40 ++++ .../modules/timetable/bloc/week_cache.dart | 10 +- lib/utils/json_equality.dart | 7 + lib/utils/recurrence_occurrences.dart | 75 +++++++ lib/utils/screen_bound_image.dart | 25 +++ lib/utils/session_single_flight.dart | 21 ++ lib/view/pages/files/data/sort_options.dart | 2 +- lib/view/pages/files/files.dart | 33 +++- lib/view/pages/files/files_upload_dialog.dart | 18 +- .../files/search/files_search_controller.dart | 12 +- .../files/search/files_search_results.dart | 67 ++++--- .../files/search/local_cache_search.dart | 84 ++++---- .../element_picker_page.dart | 51 +++-- .../chat_background_settings_page.dart | 7 +- .../settings/sections/dev_tools_section.dart | 12 +- lib/view/pages/talk/chat_list.dart | 45 +++-- lib/view/pages/talk/chat_view.dart | 122 +++++++++--- lib/view/pages/talk/widgets/chat_bubble.dart | 50 +++-- .../talk/widgets/chat_bubble_reactions.dart | 2 +- .../pages/talk/widgets/chat_textfield.dart | 35 ++-- lib/view/pages/talk/widgets/chat_tile.dart | 20 +- .../pages/timetable/data/calendar_logic.dart | 12 +- lib/view/pages/timetable/timetable.dart | 11 +- .../widgets/timetable_calendar_view.dart | 24 ++- lib/widget/chat_background.dart | 24 ++- lib/widget/debug/cache_view.dart | 38 +--- lib/widget/file_viewer.dart | 95 +++++---- lib/widget/prosemirror/pm_image_view.dart | 58 +++--- lib/widget/prosemirror/pm_json_view.dart | 34 ++-- lib/widget/share_position_origin.dart | 4 +- lib/widget/user_avatar.dart | 4 + lib/widget_data/widget_data_mapper.dart | 13 +- lib/widget_data/widget_publisher.dart | 73 ++++++- lib/widget_data/widget_sync.dart | 2 + test/api/cache_store_test.dart | 21 ++ test/state/timetable_reference_data_test.dart | 32 +++ test/utils/recurrence_occurrences_test.dart | 48 +++++ test/view/files/local_cache_search_test.dart | 51 ++--- 70 files changed, 1631 insertions(+), 621 deletions(-) create mode 100644 lib/api/cache_store.dart create mode 100644 lib/utils/json_equality.dart create mode 100644 lib/utils/recurrence_occurrences.dart create mode 100644 lib/utils/screen_bound_image.dart create mode 100644 lib/utils/session_single_flight.dart create mode 100644 test/api/cache_store_test.dart create mode 100644 test/state/timetable_reference_data_test.dart create mode 100644 test/utils/recurrence_occurrences_test.dart diff --git a/lib/api/cache_store.dart b/lib/api/cache_store.dart new file mode 100644 index 0000000..bc97c69 --- /dev/null +++ b/lib/api/cache_store.dart @@ -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 (`\n`). +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? _dir; + final Map> _writes = {}; + + Future _directory() => _dir ??= () async { + final base = await getApplicationCacheDirectory(); + final dir = Directory('${base.path}/request_cache'); + await dir.create(recursive: true); + return dir; + }(); + + Future _file(String key) async => + File('${(await _directory()).path}/${_safeName(key)}'); + + /// Keys are internal ids (`nc-chat-`, `wd-folder-`); only path + /// separators would be a problem in a file name. + static String _safeName(String key) => key.replaceAll(RegExp(r'[/\\]'), '_'); + + Future 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> 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 write(String key, String json) { + final previous = _writes[key] ?? Future.value(); + late final Future current; + current = previous.then((_) => _write(key, json)).whenComplete(() { + if (identical(_writes[key], current)) _writes.remove(key); + }); + return _writes[key] = current; + } + + Future _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 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 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> 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 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 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> decode(String json) => + json.length > isolateDecodeThreshold + ? compute(_decodeMap, json) + : Future.value(_decodeMap(json)); + + static Map _decodeMap(String json) => + jsonDecode(json) as Map; + + /// 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 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'); + } + } +} diff --git a/lib/api/marianumcloud/talk/chat/get_chat_cache.dart b/lib/api/marianumcloud/talk/chat/get_chat_cache.dart index 1a99d58..2f224cf 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_cache.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_cache.dart @@ -17,7 +17,7 @@ class GetChatCache extends SimpleCache { lookIntoFuture: GetChatParamsSwitch.off, setReadMarker: GetChatParamsSwitch.on, // 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. limit: 50, ), diff --git a/lib/api/marianumcloud/talk/chat/get_chat_history.dart b/lib/api/marianumcloud/talk/chat/get_chat_history.dart index ab038dd..dad1d84 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_history.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_history.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../../../errors/server_exception.dart'; @@ -45,8 +46,10 @@ class GetChatHistory { final status = response.statusCode; if (status == 304) return null; if (status >= 200 && status < 300) { - return GetChatResponse.fromJson(NextcloudOcs.decode(response.body)) - ..headers = response.headers; + // A page holds up to 200 messages and lands while the user is scrolling; + // decoding it on the UI isolate stalls the fling. + final parsed = await compute(_parseChatResponse, response.body); + return parsed..headers = response.headers; } throw ServerException( statusCode: status, @@ -54,3 +57,6 @@ class GetChatHistory { ); } } + +GetChatResponse _parseChatResponse(String body) => + GetChatResponse.fromJson(NextcloudOcs.decode(body)); diff --git a/lib/api/marianumcloud/talk/room/get_room.dart b/lib/api/marianumcloud/talk/room/get_room.dart index 14df26a..8c9d01f 100644 --- a/lib/api/marianumcloud/talk/room/get_room.dart +++ b/lib/api/marianumcloud/talk/room/get_room.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../../nextcloud_ocs.dart'; @@ -10,8 +11,12 @@ class GetRoom extends TalkApi { GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson()); @override - GetRoomResponse assemble(String raw) => - GetRoomResponse.fromJson(NextcloudOcs.decode(raw)); + GetRoomResponse assemble(String raw) => _parseRooms(raw); + + // The room list with status is 100–250 KB and polled every 15–60 s. + @override + Future assembleAsync(String raw) => + compute(_parseRooms, raw); @override Future request( @@ -20,3 +25,6 @@ class GetRoom extends TalkApi { Map? headers, ) => http.get(uri, headers: headers); } + +GetRoomResponse _parseRooms(String raw) => + GetRoomResponse.fromJson(NextcloudOcs.decode(raw)); diff --git a/lib/api/marianumcloud/talk/talk_api.dart b/lib/api/marianumcloud/talk/talk_api.dart index 78282bd..64e7e29 100644 --- a/lib/api/marianumcloud/talk/talk_api.dart +++ b/lib/api/marianumcloud/talk/talk_api.dart @@ -30,6 +30,10 @@ abstract class TalkApi { ); 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 assembleAsync(String raw) async => assemble(raw); + Future run() async { final endpoint = NextcloudOcs.uri( 'apps/spreed/api/$path', @@ -69,7 +73,7 @@ abstract class TalkApi { } try { - final assembled = assemble(data.body); + final assembled = await assembleAsync(data.body); assembled?.headers = data.headers; return assembled; } catch (e) { diff --git a/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart index b674b10..dcee7b9 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart @@ -29,6 +29,19 @@ class CacheableFile { /// file/folder in the list. Nullable so older cached entries decode fine. 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({ required this.path, required this.isDirectory, diff --git a/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart index a6e8178..feba2a7 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart @@ -1,9 +1,9 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; -import 'package:localstore/localstore.dart'; import '../../../../../utils/cache_invalidation_bus.dart'; +import '../../../../cache_store.dart'; import '../../../../request_cache.dart'; import 'list_files.dart'; import 'list_files_params.dart'; @@ -43,14 +43,11 @@ class ListFilesCache extends SimpleCache { /// (slow) root listing is already populated by the time the user /// 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. static Future prefetchRootListing() async { const rootPath = ''; - final cached = await Localstore.instance - .collection(RequestCache.collection) - .doc(_documentId(rootPath)) - .get(); + final cached = await CacheStore.instance.read(_documentId(rootPath)); if (cached != null) return; // 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. @@ -69,10 +66,7 @@ class ListFilesCache extends SimpleCache { /// `_FilesView` for that path via [CacheInvalidationBus] so it refetches /// even while it is sitting in the background of the navigation stack. static Future invalidate(String path) async { - await Localstore.instance - .collection(RequestCache.collection) - .doc(_documentId(path)) - .delete(); + await CacheStore.instance.delete(_documentId(path)); CacheInvalidationBus.notifyListFiles(path); } } diff --git a/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart b/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart index 613e598..d26e008 100644 --- a/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart +++ b/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart @@ -1,6 +1,6 @@ import 'dart:developer'; -import 'package:localstore/localstore.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../../../model/account_data.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 /// duplicated. The flag is set only once MHSL reports no remaining events. class CustomEventsMigration { - static const String _collection = 'MarianumMobile'; - static const String _document = 'customEventsMigration'; - static const String _doneKey = 'migratedToMc'; + static const String _doneKey = 'customEventsMigratedToMc'; const CustomEventsMigration._(); @@ -57,17 +55,11 @@ class CustomEventsMigration { } } - static Future _isDone() async { - final data = await Localstore.instance - .collection(_collection) - .doc(_document) - .get(); - return data != null && data[_doneKey] == true; - } + // SharedPreferences, like the old cache document, is cleared on sign-out, + // so the next account runs its own migration. + static Future _isDone() async => + (await SharedPreferences.getInstance()).getBool(_doneKey) ?? false; - static Future _markDone() async { - await Localstore.instance.collection(_collection).doc(_document).set({ - _doneKey: true, - }); - } + static Future _markDone() async => + (await SharedPreferences.getInstance()).setBool(_doneKey, true); } diff --git a/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart index e5b5011..50de381 100644 --- a/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart +++ b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart @@ -76,10 +76,17 @@ class McTimetableEntry { /// Combines the calendar date with the hour/minute portion of [startTime] /// (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 get endDateTime => + late final DateTime _endDateTime = DateTime(date.year, date.month, date.day, endTime.hour, endTime.minute); static DateTime _dateFromJson(String raw) => DateTime.parse(raw); diff --git a/lib/api/request_cache.dart b/lib/api/request_cache.dart index bdf86ea..e6c2b03 100644 --- a/lib/api/request_cache.dart +++ b/lib/api/request_cache.dart @@ -1,10 +1,9 @@ import 'dart:async'; import 'dart:convert'; -import 'package:localstore/localstore.dart'; - import '../model/account_data.dart'; import 'api_response.dart'; +import 'cache_store.dart'; import 'errors/parse_exception.dart'; import 'errors/stale_session_exception.dart'; @@ -14,12 +13,10 @@ abstract class RequestCache { static const int cacheHour = 60 * 60; static const int cacheDay = 60 * 60 * 24; - static String collection = 'MarianumMobile'; - int maxCacheTime; 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 /// cache hits from authoritative network responses. void Function(T)? onCacheData; @@ -52,47 +49,55 @@ abstract class RequestCache { Future start(String document) async { final epoch = AccountData().sessionEpoch; try { - final tableData = await Localstore.instance - .collection(collection) - .doc(document) - .get(); - if (tableData != null) { - final cached = onLocalData(tableData['json'] as String); + final entry = await CacheStore.instance.read(document); + var lastUpdate = entry?.lastUpdate ?? 0; + T? cached; + if (entry != null) { + try { + 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); onCacheData?.call(cached); } - - 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); - } + await _load(document, epoch, lastUpdate: lastUpdate); } finally { if (!_ready.isCompleted) _ready.complete(); } } - T onLocalData(String json); + Future _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 json); Future onLoad(); } @@ -126,8 +131,7 @@ class SimpleCache extends RequestCache { Future onLoad() => _loader(); @override - T onLocalData(String json) => - _fromJson(jsonDecode(json) as Map); + T fromCacheJson(Map json) => _fromJson(json); } /// Captures the latest cache payload (cached or network) and rethrows the diff --git a/lib/app.dart b/lib/app.dart index c8ae5d1..e6bb8a4 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -10,6 +10,7 @@ import 'api/marianumconnect/marianumconnect_api.dart'; import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'main.dart'; +import 'model/account_data.dart'; import 'model/data_cleaner.dart'; import 'notification/notification_controller.dart'; import 'notification/notification_tasks.dart'; @@ -40,7 +41,6 @@ class App extends StatefulWidget { } class _AppState extends State with WidgetsBindingObserver { - late Timer _updateTimings; StreamSubscription? _timetableWidgetSync; StreamSubscription? _onMessageSub; StreamSubscription? _onMessageOpenedAppSub; @@ -74,7 +74,11 @@ class _AppState extends State with WidgetsBindingObserver { bloc.setAutoRefreshInterval( 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 @@ -110,6 +114,7 @@ class _AppState extends State with WidgetsBindingObserver { _syncChatListPolling(refresh: false); _handlePendingWidgetNavigation(); } else if (mounted) { + context.read().flushSilentSave(); // Stop polling while backgrounded: a silent refresh failing in the // background would otherwise leave an error that flashes on the next // resume before the foreground refetch replaces it. @@ -179,26 +184,42 @@ class _AppState extends State with WidgetsBindingObserver { final settingsCubit = context.read(); final capabilitiesCubit = context.read(); _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) { final data = state.data; if (data is TimetableState && !state.isLoading) { - unawaited( - WidgetPublisher.publishFromBlocState( - data, - settings: settingsCubit.val(), - isTeacher: capabilitiesCubit.isTeacher, + final epoch = AccountData().sessionEpoch; + Debouncer.debounce( + 'widgetPublish', + const Duration(seconds: 1), + () => 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; if (initialData is TimetableState) { - unawaited( - WidgetPublisher.publishFromBlocState( - initialData, - settings: settingsCubit.val(), - isTeacher: capabilitiesCubit.isTeacher, + final epoch = AccountData().sessionEpoch; + Debouncer.debounce( + 'widgetPublish', + const Duration(seconds: 3), + () => unawaited( + WidgetPublisher.publishFromBlocState( + initialData, + settings: settingsCubit.val(), + isTeacher: capabilitiesCubit.isTeacher, + epoch: epoch, + ), ), ); } @@ -208,10 +229,6 @@ class _AppState extends State with WidgetsBindingObserver { _syncChatListPolling(); }); - _updateTimings = Timer.periodic(const Duration(seconds: 30), (_) { - if (mounted) setState(() {}); - }); - _reportTelemetry(); // A refreshed FCM token invalidates the existing push subscription — the @@ -250,12 +267,15 @@ class _AppState extends State with WidgetsBindingObserver { NotificationController.onAppOpenedByNotification(message, context); }); - DataCleaner.cleanOldCache(); + // Housekeeping only; kept out of the cold-start window. + Future.delayed( + const Duration(seconds: 20), + DataCleaner.cleanOldCache, + ); } @override void dispose() { - _updateTimings.cancel(); _timetableWidgetSync?.cancel(); _onMessageSub?.cancel(); _onMessageOpenedAppSub?.cancel(); @@ -271,7 +291,18 @@ class _AppState extends State with WidgetsBindingObserver { @override Widget build( BuildContext context, - ) => BlocBuilder( + ) => BlocSelector( + // 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, _) { final bottomBarModules = AppModule.getBottomBarModules(context); final totalTabs = bottomBarModules.length + 1; diff --git a/lib/extensions/text.dart b/lib/extensions/text.dart index 879e7ec..4459d82 100644 --- a/lib/extensions/text.dart +++ b/lib/extensions/text.dart @@ -1,16 +1,29 @@ import 'package:flutter/material.dart'; extension TextExt on Text { - Size get size { - final textPainter = TextPainter( + /// Single-line width as rendered in [context] (honours the text scaler). + /// 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), maxLines: 1, textDirection: TextDirection.ltr, - )..layout(minWidth: 0, maxWidth: double.infinity); - return textPainter.size; + textScaler: scaler, + )..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. String firstNonEmpty(List values) { for (final v in values) { diff --git a/lib/main.dart b/lib/main.dart index 235cb10..2aee13f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -167,6 +167,12 @@ Future main() async { await Future.wait(initialisationTasks); 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 // Android channels, then register the FCM background isolate handler that // decrypts and renders Nextcloud pushes while the app is not in foreground. @@ -186,9 +192,9 @@ Future main() async { ), ); - // Wire up the home-screen widget bridge before runApp so any widget render - // triggered during startup hits initialised native storage. - await _startupStep('widget sync', WidgetSync.ensureInitialized); + // The home-screen widget bridge must be ready before runApp so any widget + // render triggered during startup hits initialised native storage. + await widgetSyncInit; unawaited( WidgetBackgroundTask.initialize().onError( (e, _) => log('Workmanager init failed: $e'), @@ -271,6 +277,10 @@ class Main extends StatefulWidget { } class _MainState extends State
{ + final List _navigatorObservers = [ + AppRoutes.chatRouteObserver, + DownloadRouteObserver(), + ]; bool _showPostLoginSplash = false; bool _appMounted = true; late AccountStatus _lastStatus; @@ -364,39 +374,45 @@ class _MainState extends State
{ @override Widget build(BuildContext context) => Directionality( textDirection: TextDirection.ltr, - child: BlocBuilder( - builder: (context, settings) { - final devToolsSettings = settings.devToolsSettings; + // Selects only what the root uses: Settings is mutated in place and + // re-emitted as a fresh instance on every write, so a plain BlocBuilder + // would rebuild MaterialApp (and every route) for unrelated settings. + child: BlocSelector( + 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 // endpoint (live / beta / custom). Idempotent when the URL is - // unchanged so it's safe to call on every rebuild. Mirrored into - // WidgetSync so the background isolate refreshes against the same - // endpoint. - final mcBaseUrl = devToolsSettings.resolveMarianumConnectBaseUrl(); - MarianumConnectEndpoint.update(mcBaseUrl); - unawaited(WidgetSync.setMarianumConnectBaseUrl(mcBaseUrl)); + // unchanged. Mirrored into WidgetSync so the background isolate + // refreshes against the same endpoint. + MarianumConnectEndpoint.update(root.mcBaseUrl); + unawaited(WidgetSync.setMarianumConnectBaseUrl(root.mcBaseUrl)); // Mirror the notification toggle into group-scoped storage so the FCM // background isolate and the iOS NSE can suppress rendering when off. unawaited( const PushRegistrationStore().setNotificationsEnabled( - settings.notificationSettings.enabled, + root.notificationsEnabled, ), ); return MaterialApp( - showPerformanceOverlay: devToolsSettings.showPerformanceOverlay, - checkerboardOffscreenLayers: - devToolsSettings.checkerboardOffscreenLayers, - checkerboardRasterCacheImages: - devToolsSettings.checkerboardRasterCacheImages, + showPerformanceOverlay: root.showPerformanceOverlay, + checkerboardOffscreenLayers: root.checkerboardOffscreenLayers, + checkerboardRasterCacheImages: root.checkerboardRasterCacheImages, debugShowCheckedModeBanner: false, navigatorKey: AppRoutes.rootNavigatorKey, // Used by ChatView.didPopNext to reclaim the global ChatBloc. // DownloadRouteObserver tracks full-page navigations so the downloads // chip only surfaces once the user leaves the screen they started on. - navigatorObservers: [ - AppRoutes.chatRouteObserver, - DownloadRouteObserver(), - ], + navigatorObservers: _navigatorObservers, localizationsDelegates: const [ ...GlobalMaterialLocalizations.delegates, GlobalWidgetsLocalizations.delegate, @@ -404,7 +420,7 @@ class _MainState extends State
{ supportedLocales: const [Locale('de'), Locale('en')], locale: const Locale('de'), title: 'Marianum Fulda', - themeMode: settings.appTheme, + themeMode: root.appTheme, theme: LightAppTheme.theme, darkTheme: DarkAppTheme.theme, // Brand-colored backdrop behind every route. During the logout @@ -527,3 +543,12 @@ class _MainState extends State
{ ), ); } + +typedef _RootSettings = ({ + ThemeMode appTheme, + String mcBaseUrl, + bool notificationsEnabled, + bool showPerformanceOverlay, + bool checkerboardOffscreenLayers, + bool checkerboardRasterCacheImages, +}); diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart index 518d5fb..567b01e 100644 --- a/lib/model/account_data.dart +++ b/lib/model/account_data.dart @@ -228,16 +228,25 @@ class AccountData { Future _migrateAndLoad() async { await _migrateFromLegacyStorage(); await _migrateKeychainAccessibility(); - _username = await _secureStorage.read(key: _usernameField); - _password = await _secureStorage.read(key: _passwordField); - _isDemo = (await _secureStorage.read(key: _demoField)) == 'true'; - _usesLoginFlow = - (await _secureStorage.read(key: _loginFlowField)) == 'true'; + // Independent keystore reads, each a platform-channel round trip with + // decryption: issued together since this gates the first frame. + final (username, password, demo, loginFlow) = await ( + _secureStorage.read(key: _usernameField), + _secureStorage.read(key: _passwordField), + _secureStorage.read(key: _demoField), + _secureStorage.read(key: _loginFlowField), + ).wait; + _username = username; + _password = password; + _isDemo = demo == 'true'; + _usesLoginFlow = loginFlow == 'true'; try { - _appPassword = await pushSecureStorage.read(key: _appPasswordField); - _appPasswordTalk = await pushSecureStorage.read( - key: _appPasswordTalkField, - ); + final (appPassword, appPasswordTalk) = await ( + pushSecureStorage.read(key: _appPasswordField), + pushSecureStorage.read(key: _appPasswordTalkField), + ).wait; + _appPassword = appPassword; + _appPasswordTalk = appPasswordTalk; } on Object { _appPassword = null; _appPasswordTalk = null; diff --git a/lib/model/data_cleaner.dart b/lib/model/data_cleaner.dart index 925980c..d24c197 100644 --- a/lib/model/data_cleaner.dart +++ b/lib/model/data_cleaner.dart @@ -1,24 +1,8 @@ -import 'package:localstore/localstore.dart'; - -import '../api/request_cache.dart'; +import '../api/cache_store.dart'; class DataCleaner { static Future cleanOldCache() async { - final cacheData = await Localstore.instance - .collection(RequestCache.collection) - .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(); - } - }); + await CacheStore.deleteLegacyLocalstoreCache(); + await CacheStore.instance.deleteOlderThan(const Duration(days: 200)); } } diff --git a/lib/model/session_wipe.dart b/lib/model/session_wipe.dart index b54eff8..644f97a 100644 --- a/lib/model/session_wipe.dart +++ b/lib/model/session_wipe.dart @@ -8,6 +8,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../api/cache_store.dart'; import '../api/marianumcloud/talk/share_files_to_chat.dart'; import '../background/widget_background_task.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/downloads/download_manager.dart'; import '../utils/file_clipboard.dart'; -import '../widget/debug/cache_view.dart'; import '../widget_data/widget_sync.dart'; /// 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 _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 { final image = File(AppPaths.chatBackgroundImage); if (image.existsSync()) await image.delete(); diff --git a/lib/notification/notification_controller.dart b/lib/notification/notification_controller.dart index 57f4d8a..817ce2f 100644 --- a/lib/notification/notification_controller.dart +++ b/lib/notification/notification_controller.dart @@ -7,6 +7,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../push/push_message_handler.dart'; import '../routing/app_routes.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/json_viewer.dart'; import '../widget/info_dialog.dart'; @@ -35,7 +37,14 @@ class NotificationController { ); await NotificationTasks.refreshBadge(); 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(); + Debouncer.debounce( + 'pushChatListRefresh', + const Duration(seconds: 1), + () => chatList.refresh(silent: true), + ); } static Future onAppOpenedByNotification( diff --git a/lib/push/push_decryptor.dart b/lib/push/push_decryptor.dart index 7f7785c..a2a49a6 100644 --- a/lib/push/push_decryptor.dart +++ b/lib/push/push_decryptor.dart @@ -1,7 +1,7 @@ import 'dart:convert'; -import 'dart:typed_data'; import 'package:crypton/crypton.dart'; +import 'package:flutter/foundation.dart'; import 'package:pointycastle/export.dart' as pc; import 'push_subject.dart'; @@ -25,6 +25,14 @@ class PushDecryptor { 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 /// encrypted subject. Returns true when no server key is configured (the /// 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)); +} diff --git a/lib/push/push_message_handler.dart b/lib/push/push_message_handler.dart index 277c2c0..f53c11b 100644 --- a/lib/push/push_message_handler.dart +++ b/lib/push/push_message_handler.dart @@ -178,11 +178,15 @@ class PushMessageHandler { devicePrivateKey: privateKey, serverPublicKey: serverPublicKey, ); - if (!decryptor.verify(subjectBase64, signatureBase64)) { + final result = await decryptor.verifyAndDecryptInBackground( + subjectBase64, + signatureBase64, + ); + if (!result.verified) { log('Push: signature verification failed'); return; } - final subject = decryptor.decrypt(subjectBase64); + final subject = result.subject; if (subject == null) { log('Push: could not decrypt subject'); return; diff --git a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart index 9c07d3b..570c155 100644 --- a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart +++ b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart @@ -20,10 +20,15 @@ class LoadableStateBloc extends Bloc LoadableStateBloc() : super(const LoadableStateState(connections: null)) { on((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); - if (connectivityStatusKnown() && isConnected()) { - if (reFetch == null) return; - reFetch!(); + if ((wasOffline || event.fromResume) && + connectivityStatusKnown() && + isConnected()) { + reFetch?.call(); } }); @@ -54,7 +59,12 @@ class LoadableStateBloc extends Bloc unawaited( Connectivity().checkConnectivity().then((result) { if (isClosed) return; - add(ConnectivityChanged(LoadableStateState(connections: result))); + add( + ConnectivityChanged( + LoadableStateState(connections: result), + fromResume: true, + ), + ); }), ); } diff --git a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart index 40955b6..8924fb0 100644 --- a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart +++ b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart @@ -4,5 +4,10 @@ sealed class LoadableStateEvent {} final class ConnectivityChanged extends LoadableStateEvent { 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}); } diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart index 7ddfa98..d18a7ae 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart @@ -69,7 +69,7 @@ class LoadableStateConsumer< // null mid-refetch, and toggling the RefreshIndicator on that signal would // rebuild the tree under the ListView and reset its scroll position. final content = SizedBox( - height: MediaQuery.of(context).size.height, + height: MediaQuery.sizeOf(context).height, child: hasContent ? child(typedData as TState, isLoading) : const SizedBox.shrink(), diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart index 417af24..df6eaef 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart @@ -95,10 +95,12 @@ class _LoadableStateErrorBarTextState extends State { late Timer _rebuildTimer; @override void initState() { - _rebuildTimer = Timer.periodic( - const Duration(seconds: 10), - (timer) => setState(() {}), - ); + // Only refresh the relative "last updated" text while this page is the + // visible one; offstage tabs and covered routes have tickers disabled. + _rebuildTimer = Timer.periodic(const Duration(seconds: 10), (timer) { + if (!mounted) return; + if (TickerMode.getValuesNotifier(context).value.enabled) setState(() {}); + }); super.initState(); } diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart index 2b70ead..23b14b2 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart @@ -33,6 +33,9 @@ class _LoadableStatePrimaryLoadingState extends State { Timer? _slowHintTimer; 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 void initState() { @@ -44,6 +47,7 @@ class _LoadableStatePrimaryLoadingState void didUpdateWidget(covariant LoadableStatePrimaryLoading oldWidget) { super.didUpdateWidget(oldWidget); if (widget.visible != oldWidget.visible) _restartSlowHintTimer(); + if (widget.visible) _spinnerMounted = true; } void _restartSlowHintTimer() { @@ -62,43 +66,49 @@ class _LoadableStatePrimaryLoadingState } @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 = widget.statusText ?? (_showSlowHint ? LoadableStatePrimaryLoading.slowHintText : null); - - return AnimatedOpacity( - opacity: widget.visible ? 1.0 : 0.0, - duration: LoadableStateConsumer.animationDuration, - curve: Curves.easeInOut, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const AppProgressIndicator.large(), - AnimatedSwitcher( - duration: LoadableStateConsumer.animationDuration, - child: status == null - ? const SizedBox.shrink() - : Padding( - key: ValueKey(status), - padding: const EdgeInsets.only( - top: 16, - left: 24, - right: 24, - ), - child: Text( - status, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - color: Theme.of(context).hintColor, - ), + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppProgressIndicator.large(), + AnimatedSwitcher( + duration: LoadableStateConsumer.animationDuration, + child: status == null + ? const SizedBox.shrink() + : Padding( + key: ValueKey(status), + padding: const EdgeInsets.only( + top: 16, + left: 24, + right: 24, + ), + child: Text( + status, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).hintColor, ), ), - ), - ], - ), + ), + ), + ], ), ); } diff --git a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart index 0234055..78df01d 100644 --- a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart @@ -6,6 +6,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/errors/stale_session_exception.dart'; import '../../../../../model/account_data.dart'; +import '../../../../../utils/session_single_flight.dart'; import '../../loadable_state/loadable_state.dart'; import '../../loadable_state/loading_error.dart'; import '../../repository/repository.dart'; @@ -20,6 +21,12 @@ abstract class LoadableHydratedBloc< extends HydratedBloc, LoadableState> { 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() : super( const LoadableState( @@ -113,6 +120,8 @@ abstract class LoadableHydratedBloc< /// fresh [fetch] (e.g. via [retry] or page-specific refresh) once the user /// is authenticated again, otherwise the UI would stay blank. Future reset() async { + _lastPersistedKey = null; + _lastPersistedFetch = null; await clear(); add(Reset()); } @@ -122,10 +131,8 @@ abstract class LoadableHydratedBloc< /// Runs [body] tagged with the current session: events it adds, also from /// its async continuations, are dropped once the account signed out, so a /// late response of the previous account cannot refill the reset bloc. - R runInSession(R Function() body) => runZoned( - body, - zoneValues: {_sessionKey: AccountData().sessionEpoch}, - ); + R runInSession(R Function() body) => + runZoned(body, zoneValues: {_sessionKey: AccountData().sessionEpoch}); @override void add(LoadableHydratedBlocEvent 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() { - log('Fetching data for ${TState.toString()}'); - runInSession( - () => gatherData() - .catchError((Object e) { - log('Error while fetching ${TState.toString()}: ${e.toString()}'); - // The bloc may have been closed before this async error landed; - // adding to a closed bloc throws, so swallow that case. - if (isClosed) return; - addLoadingError(e); - }) - .then((value) { - log('Fetch for ${TState.toString()} completed!'); - }), + unawaited( + _fetchFlight.run(() { + log('Fetching data for ${TState.toString()}'); + return runInSession( + () => gatherData() + .catchError((Object e) { + log( + 'Error while fetching ${TState.toString()}: ${e.toString()}', + ); + // The bloc may have been closed before this async error + // landed; adding to a closed bloc throws, so swallow that case. + if (isClosed) return; + addLoadingError(e); + }) + .then((value) { + log('Fetch for ${TState.toString()} completed!'); + }), + ); + }), ); } @@ -191,13 +209,27 @@ abstract class LoadableHydratedBloc< @override Map? toJson(LoadableState 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? data; try { - final stateData = state.data; data = stateData is TState ? toStorage(stateData) : null; } catch (e) { 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( 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 gatherData(); TRepository repository(); diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index a36acfc..ebba68a 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -59,6 +59,12 @@ class ChatBloc @override ChatState fromStorage(Map 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 Map? toStorage(ChatState state) { final response = state.chatResponse; diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart index d9aec94..c1cd1f5 100644 --- a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:developer'; 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/chat/get_chat_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_event.dart'; import '../repository/chat_list_repository.dart'; @@ -74,13 +76,32 @@ class ChatListBloc renew: renew, onError: (e) => capturedError = e, ); + _lastRoomsJson = null; add(DataGathered((s) => s.copyWith(rooms: rooms))); _updateAppBadge(rooms); if (capturedError != null) throw capturedError!; } - Future 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 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 refreshIfOlderThan(Duration maxAge) { + final lastFetch = state.lastFetch; + if (lastFetch != null && + DateTime.now().millisecondsSinceEpoch - lastFetch < + maxAge.inMilliseconds) { + return Future.value(); + } + return refresh(); + } + + Future _refresh({required bool renew, required bool silent}) async { if (!silent) add(RefetchStarted()); Object? capturedError; try { @@ -88,6 +109,11 @@ class ChatListBloc renew: renew, onError: (e) => capturedError = e, ); + if (silent) { + if (_isUnchanged(rooms)) return; + } else { + _lastRoomsJson = null; + } add(DataGathered((s) => s.copyWith(rooms: rooms))); _updateAppBadge(rooms); } catch (e) { @@ -96,6 +122,23 @@ class ChatListBloc 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 /// demo mode. Refreshes the list so the room shows up. Future createDirectChat(String invite) async { @@ -171,6 +214,7 @@ class ChatListBloc }).toSet(); if (!changed) return; final newRooms = GetRoomResponse(updated)..headers = rooms.headers; + _lastRoomsJson = null; add(Emit((s) => s.copyWith(rooms: newRooms))); _updateAppBadge(newRooms); } diff --git a/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart b/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart index 4a58546..98f3bd3 100644 --- a/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart +++ b/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart @@ -142,12 +142,11 @@ class ForeignTimetableBloc add( Emit( - (s) => s.copyWith( + (s) => s.withReferenceData( rooms: rooms, subjects: subjects, schoolHolidays: schoolHolidays, schoolyear: schoolyear, - dataVersion: s.dataVersion + 1, ), ), ); @@ -157,11 +156,7 @@ class ForeignTimetableBloc try { final timegrid = await repo.data.getTimegrid(); - add( - Emit( - (s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1), - ), - ); + add(Emit((s) => s.withReferenceData(timegrid: timegrid))); } catch (_) { // Timegrid load failure falls back to a hardcoded schedule in the UI. } diff --git a/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart b/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart index 63bcbbb..34793a6 100644 --- a/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart +++ b/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:enough_icalendar/enough_icalendar.dart'; +import 'package:flutter/foundation.dart'; import '../bloc/marianum_dates_state.dart'; @@ -19,6 +20,12 @@ class MarianumDatesGetEvents { final body = response.data; 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 parseEvents(String body) { final root = VComponent.parse(body); final calendar = root is VCalendar ? root : null; final source = calendar?.children ?? root.children; diff --git a/lib/state/app/modules/settings/bloc/settings_cubit.dart b/lib/state/app/modules/settings/bloc/settings_cubit.dart index 7ba12ed..4b420a4 100644 --- a/lib/state/app/modules/settings/bloc/settings_cubit.dart +++ b/lib/state/app/modules/settings/bloc/settings_cubit.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:developer'; +import 'package:collection/collection.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../storage/settings.dart'; @@ -10,6 +11,8 @@ import '../../../../../view/pages/settings/data/default_settings.dart'; class SettingsCubit extends HydratedCubit { static const _debounceTag = 'settings_persist'; bool _emitScheduled = false; + Map? _lastEmittedJson; + Timer? _silentSave; SettingsCubit() : super(DefaultSettings.get()); @@ -34,21 +37,53 @@ class SettingsCubit extends HydratedCubit { 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() { 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) { log('Failed to refresh settings state: $e'); } } Future reset() async { + _silentSave?.cancel(); + _silentSave = null; + _lastEmittedJson = null; emit(DefaultSettings.get()); } // Modules missing from a stale persisted moduleOrder are handled at read // time by AppModule.effectiveModuleOrder (inserted at their default // position) — no healing on hydration needed. + @override + Future close() { + flushSilentSave(); + return super.close(); + } + @override Settings fromJson(Map json) { try { diff --git a/lib/state/app/modules/ticker/bloc/ticker_page_bloc.dart b/lib/state/app/modules/ticker/bloc/ticker_page_bloc.dart index 48ae302..7c9d7a0 100644 --- a/lib/state/app/modules/ticker/bloc/ticker_page_bloc.dart +++ b/lib/state/app/modules/ticker/bloc/ticker_page_bloc.dart @@ -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/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; @@ -17,7 +22,35 @@ class TickerPageBloc > { 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 _rememberSlug() async { + try { + final prefs = await SharedPreferences.getInstance(); + final recent = prefs.getStringList(_recentSlugsKey) ?? []; + 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 String get id => slug; diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index 60380a2..5f081d5 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -133,11 +133,7 @@ class TimetableBloc Future _refreshSubjects() async { final subjects = await repo.data.getSubjects(renew: true); - add( - DataGathered( - (s) => s.copyWith(subjects: subjects, dataVersion: s.dataVersion + 1), - ), - ); + add(DataGathered((s) => s.withReferenceData(subjects: subjects))); } Future _loadCurrentWeek( @@ -177,12 +173,11 @@ class TimetableBloc add( Emit( - (s) => s.copyWith( + (s) => s.withReferenceData( rooms: rooms, subjects: subjects, schoolHolidays: schoolHolidays, schoolyear: schoolyear, - dataVersion: s.dataVersion + 1, ), ), ); @@ -192,11 +187,7 @@ class TimetableBloc try { final timegrid = await repo.data.getTimegrid(renew: renew); - add( - Emit( - (s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1), - ), - ); + add(Emit((s) => s.withReferenceData(timegrid: timegrid))); } catch (_) { // Timegrid load failure falls back to a hardcoded schedule in the UI layer. } @@ -212,12 +203,7 @@ class TimetableBloc renew: renew, onError: onError, ); - add( - Emit( - (s) => - s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1), - ), - ); + add(Emit((s) => s.withReferenceData(customEvents: events))); } catch (e) { onError?.call(e); } @@ -225,11 +211,7 @@ class TimetableBloc Future _refreshCustomEvents() async { final events = await repo.data.getCustomEvents(renew: true); - add( - DataGathered( - (s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1), - ), - ); + add(DataGathered((s) => s.withReferenceData(customEvents: events))); } void _prefetchAdjacentWeeks(DateTime start, DateTime end) { diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.dart b/lib/state/app/modules/timetable/bloc/timetable_state.dart index 12117a5..a6f6641 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.dart @@ -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_week/timetable_get_week_response.dart'; import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; +import '../../../../../utils/json_equality.dart'; import 'week_cache.dart'; part 'timetable_state.freezed.dart'; @@ -58,6 +59,45 @@ abstract class TimetableState with _$TimetableState { 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? 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 => rooms != null && subjects != null && diff --git a/lib/state/app/modules/timetable/bloc/week_cache.dart b/lib/state/app/modules/timetable/bloc/week_cache.dart index 5281912..c1d3c9e 100644 --- a/lib/state/app/modules/timetable/bloc/week_cache.dart +++ b/lib/state/app/modules/timetable/bloc/week_cache.dart @@ -1,13 +1,12 @@ -import 'package:collection/collection.dart'; - import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../../extensions/date_time.dart'; +import '../../../../../utils/json_equality.dart'; /// 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 /// 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. -const int kWeekCacheRadius = 4; +const int kWeekCacheRadius = 8; /// Returns the cache with [week] stored under [weekStart], pruned to the /// weeks near [viewedWeekStart] or [now]. Returns null when the stored week @@ -21,10 +20,7 @@ Map? mergeWeekIntoCache( }) { final key = weekStart.weekKey(); final existing = cache[key]; - if (existing != null && - const DeepCollectionEquality().equals(existing.toJson(), week.toJson())) { - return null; - } + if (sameJson(existing, week)) return null; final viewedMonday = viewedWeekStart.mondayOfWeek; final todayMonday = now.mondayOfWeek; diff --git a/lib/utils/json_equality.dart b/lib/utils/json_equality.dart new file mode 100644 index 0000000..295d7e6 --- /dev/null +++ b/lib/utils/json_equality.dart @@ -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); diff --git a/lib/utils/recurrence_occurrences.dart b/lib/utils/recurrence_occurrences.dart new file mode 100644 index 0000000..376424e --- /dev/null +++ b/lib/utils/recurrence_occurrences.dart @@ -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 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 _iterator; + final List _found = []; + bool _exhausted = false; + + _Series(RecurrenceRule rule, DateTime anchorUtc) + : _iterator = rule.getInstances(start: anchorUtc).iterator; + + List 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; + } +} diff --git a/lib/utils/screen_bound_image.dart b/lib/utils/screen_bound_image.dart new file mode 100644 index 0000000..dd97b36 --- /dev/null +++ b/lib/utils/screen_bound_image.dart @@ -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, + ); +} diff --git a/lib/utils/session_single_flight.dart b/lib/utils/session_single_flight.dart new file mode 100644 index 0000000..5ee8d2e --- /dev/null +++ b/lib/utils/session_single_flight.dart @@ -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? _running; + int? _epoch; + + Future run(Future Function() action) { + final epoch = AccountData().sessionEpoch; + final running = _running; + if (running != null && _epoch == epoch) return running; + _epoch = epoch; + late final Future current; + current = action().whenComplete(() { + if (identical(_running, current)) _running = null; + }); + return _running = current; + } +} diff --git a/lib/view/pages/files/data/sort_options.dart b/lib/view/pages/files/data/sort_options.dart index 3532560..e9d263d 100644 --- a/lib/view/pages/files/data/sort_options.dart +++ b/lib/view/pages/files/data/sort_options.dart @@ -21,7 +21,7 @@ class SortOptions { SortOption.name: BetterSortOption( displayName: 'Name', icon: Icons.sort_by_alpha_outlined, - compare: (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + compare: (a, b) => a.lowerName.compareTo(b.lowerName), ), SortOption.date: BetterSortOption( displayName: 'Datum', diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index 649427b..ffcacf4 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; +import '../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import '../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; @@ -47,6 +49,22 @@ class _FilesViewState extends State<_FilesView> { late bool currentSortDirection; late final StreamSubscription _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 _sortedFiles = const []; + + List _sorted(ListFilesResponse listing, bool foldersToTop) { + final key = (listing, currentSort, currentSortDirection, foldersToTop); + if (key == _sortedKey) return _sortedFiles; + _sortedKey = key; + return _sortedFiles = listing.sortBy( + sortOption: currentSort, + foldersToTop: foldersToTop, + reversed: currentSortDirection, + ); + } + // Cache key in FilesBloc's pathString format: '/' for root, otherwise // segments joined without leading/trailing slash. String get _myPathString => widget.path.isEmpty ? '/' : widget.path.join('/'); @@ -98,6 +116,11 @@ class _FilesViewState extends State<_FilesView> { @override Widget build(BuildContext context) { final bloc = context.read(); + // Selected here, not in the LoadableStateConsumer child: that closure runs + // during the consumer's build, where this context may not subscribe. + final foldersToTop = context.select( + (SettingsCubit c) => c.state.fileSettings.sortFoldersToTop, + ); return Scaffold( appBar: AppBar( title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'), @@ -153,15 +176,7 @@ class _FilesViewState extends State<_FilesView> { text: 'Der Ordner ist leer', ); } - final files = listing.sortBy( - sortOption: currentSort, - foldersToTop: context - .watch() - .val() - .fileSettings - .sortFoldersToTop, - reversed: currentSortDirection, - ); + final files = _sorted(listing, foldersToTop); return ListView.builder( padding: EdgeInsets.zero, itemCount: files.length, diff --git a/lib/view/pages/files/files_upload_dialog.dart b/lib/view/pages/files/files_upload_dialog.dart index bec671f..0288c78 100644 --- a/lib/view/pages/files/files_upload_dialog.dart +++ b/lib/view/pages/files/files_upload_dialog.dart @@ -191,18 +191,23 @@ class _FilesUploadDialogState extends State { } final HttpClientResponse uploadTask; + final fileIndex = _uploadableFiles.indexOf(file); + var lastPercent = -1; try { uploadTask = await webdavClient.putFile( File(filePath), fileStat, PathUri.parse(fullRemotePath), onProgress: (progress) { + // Called per 64 KB chunk — thousands of times for a video. Only + // rebuild when the visible percentage actually moves. + final percent = (progress * 100).floor(); + if (!mounted || percent == lastPercent) return; + lastPercent = percent; setState(() { file._uploadProgress = progress; _overallProgressValue = - ((progress + _uploadableFiles.indexOf(file)) / - _uploadableFiles.length) - .toDouble(); + ((progress + fileIndex) / _uploadableFiles.length).toDouble(); }); }, ); @@ -246,7 +251,12 @@ class _FilesUploadDialogState extends State { itemCount: _uploadableFiles.length, itemBuilder: (context, index) { final currentFile = _uploadableFiles[index]; - currentFile.fileNameController.text = currentFile.fileName; + // Only sync when it differs: assigning text resets selection + // and notifies the field on every (progress) rebuild. + if (currentFile.fileNameController.text != + currentFile.fileName) { + currentFile.fileNameController.text = currentFile.fileName; + } return ListTile( title: TextField( readOnly: _isUploading, diff --git a/lib/view/pages/files/search/files_search_controller.dart b/lib/view/pages/files/search/files_search_controller.dart index 21e0399..1086a20 100644 --- a/lib/view/pages/files/search/files_search_controller.dart +++ b/lib/view/pages/files/search/files_search_controller.dart @@ -26,6 +26,14 @@ class FilesSearchController extends ChangeNotifier { Object? _serverError; int _serverEpoch = 0; bool _disposed = false; + Future>? _localIndex; + + Future> _searchLocal(List pathScope) async => + searchLocalCacheIndex( + await (_localIndex ??= loadLocalCacheIndex()), + _query, + pathScope: pathScope, + ); /// Guards against the race where the search delegate is closed (and the /// controller disposed) while a debounced cache scan or server call is @@ -80,7 +88,7 @@ class FilesSearchController extends ChangeNotifier { _serverError = null; _safeNotify(); - final cacheHits = await searchLocalCaches(_query, pathScope: _pathScope); + final cacheHits = await _searchLocal(_pathScope); if (epoch != _serverEpoch) return; _cacheResults = cacheHits; _safeNotify(); @@ -101,7 +109,7 @@ class FilesSearchController extends ChangeNotifier { _serverError = null; _safeNotify(); - final cacheHits = await searchLocalCaches(_query); + final cacheHits = await _searchLocal(const []); if (epoch != _serverEpoch) return; _cacheResults = cacheHits; _safeNotify(); diff --git a/lib/view/pages/files/search/files_search_results.dart b/lib/view/pages/files/search/files_search_results.dart index ff9b611..69db680 100644 --- a/lib/view/pages/files/search/files_search_results.dart +++ b/lib/view/pages/files/search/files_search_results.dart @@ -104,32 +104,43 @@ class FilesSearchResults extends StatelessWidget { Widget _resultList(BuildContext context, List combined) { final groups = _groupByParent(combined); final orderedKeys = groups.keys.toList()..sort(); - final items = []; - for (final folder in orderedKeys) { - final segments = _segmentsOf(folder); - items.add( - _FolderHeader( - folder: folder, - onOpen: () { - onResultTap?.call(); - AppRoutes.openFolder(context, segments); - }, - ), - ); - for (final file in groups[folder]!) { - items.add( - FileElement( - file, - segments, - controller.retry, - highlight: controller.query, - ), + // Flat (folder header | file) rows built lazily: results can run into + // the hundreds and arrive in several batches per query. + final rows = <(String, CacheableFile?)>[ + for (final folder in orderedKeys) ...[ + (folder, null), + for (final file in groups[folder]!) (folder, file), + ], + ]; + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: rows.length, + itemBuilder: (context, index) { + final (folder, file) = rows[index]; + final segments = _segmentsOf(folder); + if (file == null) { + return _FolderHeader( + key: ValueKey('folder:$folder'), + folder: folder, + onOpen: () { + onResultTap?.call(); + AppRoutes.openFolder(context, segments); + }, + ); + } + return FileElement( + file, + segments, + controller.retry, + key: ValueKey('file:${file.path}'), + highlight: controller.query, ); - } - } - return ListView(padding: EdgeInsets.zero, children: items); + }, + ); } + static final RegExp _edgeSlashes = RegExp(r'^/+|/+$'); + Map> _groupByParent(List files) { final map = >{}; for (final file in files) { @@ -139,7 +150,7 @@ class FilesSearchResults extends StatelessWidget { } String _parentOf(CacheableFile file) { - final stripped = file.path.replaceAll(RegExp(r'^/+|/+$'), ''); + final stripped = file.path.replaceAll(_edgeSlashes, ''); final segments = stripped.split('/'); if (segments.length <= 1) return '/'; segments.removeLast(); @@ -147,7 +158,7 @@ class FilesSearchResults extends StatelessWidget { } List _segmentsOf(String folder) { - final stripped = folder.replaceAll(RegExp(r'^/+|/+$'), ''); + final stripped = folder.replaceAll(_edgeSlashes, ''); if (stripped.isEmpty) return const []; return stripped.split('/'); } @@ -156,7 +167,11 @@ class FilesSearchResults extends StatelessWidget { class _FolderHeader extends StatelessWidget { final String folder; final VoidCallback onOpen; - const _FolderHeader({required this.folder, required this.onOpen}); + const _FolderHeader({ + required this.folder, + required this.onOpen, + super.key, + }); @override Widget build(BuildContext context) { diff --git a/lib/view/pages/files/search/local_cache_search.dart b/lib/view/pages/files/search/local_cache_search.dart index 45a14e4..ea090da 100644 --- a/lib/view/pages/files/search/local_cache_search.dart +++ b/lib/view/pages/files/search/local_cache_search.dart @@ -1,49 +1,29 @@ import 'dart:convert'; -import 'package:localstore/localstore.dart'; +import 'package:flutter/foundation.dart'; +import '../../../../api/cache_store.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; -import '../../../../api/request_cache.dart'; /// Document key prefix used by `ListFilesCache._documentId`. const String _folderCachePrefix = 'wd-folder-'; -/// Scans every cached folder listing in Localstore and returns files/folders -/// whose name contains [query] (case-insensitive). -/// -/// [pathScope] restricts results to entries whose WebDAV path starts with -/// the given folder. Pass an empty list (or null) to search globally. -/// -/// [docs] is an injection seam for tests — production callers leave it null -/// so the helper reads from the real Localstore. -Future> searchLocalCaches( - String query, { - List? pathScope, - Map? 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 = {}; - for (final entry in raw.entries) { - final docKey = entry.key.split('/').last; - if (!docKey.startsWith(_folderCachePrefix)) continue; - - final value = entry.value; - if (value is! Map) continue; - final json = value['json']; - if (json is! String) continue; +/// Every file and folder from the cached folder listings, deduplicated by +/// path. Built once per search session: reading and parsing all listings on +/// each keystroke used to stall typing. +Future> loadLocalCacheIndex() async { + final entries = await CacheStore.instance.readAll(prefix: _folderCachePrefix); + if (entries.isEmpty) return const []; + final payloads = [for (final entry in entries.values) entry.json]; + return compute(buildLocalCacheIndex, payloads); +} +/// Parses cached `ListFilesResponse` payloads into a deduplicated file list. +/// Unparsable payloads are skipped. +List buildLocalCacheIndex(List payloads) { + final byPath = {}; + for (final json in payloads) { final ListFilesResponse listing; try { listing = ListFilesResponse.fromJson( @@ -52,14 +32,32 @@ Future> searchLocalCaches( } on Object { continue; } - for (final file in listing.files) { - if (!file.name.toLowerCase().contains(needle)) continue; - if (scopePrefix.isNotEmpty && !file.path.startsWith(scopePrefix)) { - continue; - } - results[file.path] ??= file; + byPath[file.path] ??= file; } } - return results.values.toList(); + return byPath.values.toList(); +} + +/// Files in [index] whose name contains [query] (case-insensitive). +/// +/// [pathScope] restricts results to entries whose WebDAV path starts with +/// the given folder. Pass an empty list (or null) to search globally. +List searchLocalCacheIndex( + List index, + String query, { + List? 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, + ]; } diff --git a/lib/view/pages/foreign_timetable/element_picker_page.dart b/lib/view/pages/foreign_timetable/element_picker_page.dart index 61db705..1d226de 100644 --- a/lib/view/pages/foreign_timetable/element_picker_page.dart +++ b/lib/view/pages/foreign_timetable/element_picker_page.dart @@ -7,6 +7,7 @@ import '../../../api/marianumconnect/queries/timetable_get_element_week/timetabl import '../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart'; import '../../../api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart'; import '../../../api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart'; +import '../../../model/account_data.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../storage/timetable_favorites_settings.dart'; import '../../../utils/haptics.dart'; @@ -31,7 +32,14 @@ class _ElementPickerPageState extends State { // One in-flight/resolved future per type so switching tabs (or rebuilds) // never re-fetches a list that's already loaded. - final Map>> _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> items}) + > + _futures = {}; + static const Duration _listMaxAge = Duration(minutes: 15); // Memoised combined future for the "Alle" tab; rebuilt on retry. Future>? _allFuture; @@ -47,8 +55,25 @@ class _ElementPickerPageState extends State { return _allFuture ??= _loadAll(); } - Future> _loadFor(TimetableElementType type) => - _futures.putIfAbsent(type, () => _fetch(type)); + Future> _loadFor(TimetableElementType 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> _loadAll() async { final lists = await Future.wait(TimetableElementType.values.map(_loadFor)); @@ -117,20 +142,16 @@ class _ElementPickerPageState extends State { Haptics.selection(); // Hand the selection back to the timetable view, which renders the foreign // plan inline. We do not navigate to a new page. - Navigator.of(context).pop(( - type: item.type, - id: item.id, - label: item.primary, - )); + Navigator.of( + context, + ).pop((type: item.type, id: item.id, label: item.primary)); } void _openFavorite(FavoriteTimetableElement favorite) { Haptics.selection(); - Navigator.of(context).pop(( - type: favorite.type, - id: favorite.id, - label: favorite.label, - )); + Navigator.of( + context, + ).pop((type: favorite.type, id: favorite.id, label: favorite.label)); } void _toggleFavorite(TimetableElementType type, int id, String label) { @@ -325,9 +346,7 @@ class _ElementPickerPageState extends State { return ListTile( leading: Icon(_iconFor(item.type)), title: Text(item.primary), - subtitle: subtitleParts.isEmpty - ? null - : Text(subtitleParts.join(' · ')), + subtitle: subtitleParts.isEmpty ? null : Text(subtitleParts.join(' · ')), trailing: IconButton( icon: Icon(isFavorite ? Icons.star : Icons.star_border), tooltip: isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren', diff --git a/lib/view/pages/settings/chat_background_settings_page.dart b/lib/view/pages/settings/chat_background_settings_page.dart index 0012913..9bc47b0 100644 --- a/lib/view/pages/settings/chat_background_settings_page.dart +++ b/lib/view/pages/settings/chat_background_settings_page.dart @@ -214,8 +214,11 @@ class ChatBackgroundSettingsPage extends StatelessWidget { final file = File(AppPaths.chatBackgroundImage); await file.writeAsBytes(bytes); // Same filename across replacements → the decoded image is cached under - // an identical key. Evict so the new bytes actually show. - await FileImage(file).evict(); + // an identical key (for "cover" also wrapped in a viewport-sized + // ResizeImage). Clearing the cache is fine for this rare action. + PaintingBinding.instance.imageCache + ..clear() + ..clearLiveImages(); final cs = settings.val(write: true).chatBackgroundSettings; cs.imageVersion++; cs.type = ChatBackgroundType.image; diff --git a/lib/view/pages/settings/sections/dev_tools_section.dart b/lib/view/pages/settings/sections/dev_tools_section.dart index 2d70847..ac8963d 100644 --- a/lib/view/pages/settings/sections/dev_tools_section.dart +++ b/lib/view/pages/settings/sections/dev_tools_section.dart @@ -3,13 +3,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; +import '../../../../api/cache_store.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../storage/dev_tools_settings.dart'; import '../../../../storage/settings.dart' as model; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; -import '../../../../widget/debug/cache_view.dart'; import '../../../../widget/debug/json_viewer.dart'; import '../../../../widget/details_bottom_sheet.dart'; import '../widgets/endpoint_picker.dart'; @@ -24,6 +24,9 @@ class DevToolsSection extends StatefulWidget { } class _DevToolsSectionState extends State { + // Kept across rebuilds: the size walk lists the whole cache directory. + Future? _cacheSize; + @override Widget build(BuildContext context) => Column( children: [ @@ -153,7 +156,7 @@ class _DevToolsSectionState extends State { leading: const CenteredLeading(Icon(Icons.data_object)), title: const Text('Cache-storage JSON dump'), subtitle: FutureBuilder( - future: const CacheView().totalSize(), + future: _cacheSize ??= CacheStore.instance.totalSize(), builder: (context, snapshot) => Text( "etwa ${snapshot.hasError ? "?" @@ -169,8 +172,9 @@ class _DevToolsSectionState extends State { content: 'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut', confirmButton: 'Unwiederruflich löschen', - onConfirm: () => - const CacheView().clear().then((value) => setState(() {})), + onConfirm: () => CacheStore.instance.clear().then( + (value) => setState(() => _cacheSize = null), + ), ).asDialog(context); }, trailing: const Icon(Icons.arrow_right), diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index 18051e4..d0923e2 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -75,6 +75,17 @@ class _ChatListViewState extends State<_ChatListView> { @override Widget build(BuildContext context) { final bloc = context.read(); + // Selected here, not in the LoadableStateConsumer child: that closure runs + // during the consumer's build, where this context may not subscribe. + // Draft keys are part of the selection so the draft marker follows; the + // draft text itself is saved without notifying. + final (favoritesToTop, unreadToTop, _) = context.select( + (SettingsCubit c) => ( + c.state.talkSettings.sortFavoritesToTop, + c.state.talkSettings.sortUnreadToTop, + c.state.talkSettings.drafts.keys.join('|'), + ), + ); return SplitView.material( placeholder: const SplitViewPlaceholder(), breakpoint: 1000, @@ -129,13 +140,9 @@ class _ChatListViewState extends State<_ChatListView> { final rooms = state.rooms; if (rooms == null) return const SizedBox.shrink(); - final talkSettings = context - .watch() - .val() - .talkSettings; final sorted = rooms.sortBy( - favoritesToTop: talkSettings.sortFavoritesToTop, - unreadToTop: talkSettings.sortUnreadToTop, + favoritesToTop: favoritesToTop, + unreadToTop: unreadToTop, ); if (sorted.isEmpty) { @@ -145,23 +152,25 @@ class _ChatListViewState extends State<_ChatListView> { ); } - return ListView( + final drafts = _settings.val().talkSettings.drafts; + final indexByToken = { + for (var i = 0; i < sorted.length; i++) sorted[i].token: i, + }; + return ListView.builder( padding: EdgeInsets.zero, - children: sorted.map((room) { - final hasDraft = _settings - .val() - .talkSettings - .drafts - .containsKey(room.token); - // Stable key keeps element identity across re-sorts so the - // inner UserAvatar reuses its cached bytes instead of - // flashing on every list update. + itemCount: sorted.length, + // Keeps each tile's state (cached avatar bytes) with its room + // when a re-sort moves it to another index. + findChildIndexCallback: (key) => + indexByToken[(key as ValueKey).value], + itemBuilder: (context, index) { + final room = sorted[index]; return ChatTile( key: ValueKey(room.token), data: room, - hasDraft: hasDraft, + hasDraft: drafts.containsKey(room.token), ); - }).toList(), + }, ); }, ), diff --git a/lib/view/pages/talk/chat_view.dart b/lib/view/pages/talk/chat_view.dart index f3a1ba9..6005875 100644 --- a/lib/view/pages/talk/chat_view.dart +++ b/lib/view/pages/talk/chat_view.dart @@ -14,6 +14,7 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con import '../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../state/app/modules/chat/bloc/chat_state.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import '../../../utils/debouncer.dart'; import '../../../widget/chat_background.dart'; import '../../../widget/clickable_app_bar.dart'; import '../../../widget/user_avatar.dart'; @@ -118,6 +119,7 @@ class _ChatViewState extends State with RouteAware { _markAsReadFinal(); _chatBlocRef?.leaveChat(widget.room.token); _searchTextController.dispose(); + Debouncer.cancel(_searchDebounceTag); super.dispose(); } @@ -150,6 +152,45 @@ class _ChatViewState extends State with RouteAware { context.read().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 _items = const []; + + List _itemsFor(ChatState state) { + final key = ( + state.chatResponse, + state.isLoadingOlder, + state.hasMoreOld, + _searchActive, + _searchQuery, + _activeMatchIndex, + widget.room, + ); + if (key == _itemsKey) return _items; + _itemsKey = key; + final items = _buildMessages(state.chatResponse!).reversed.toList(); + // reverse:true renders index 0 at the bottom, so the top marker + // (spinner / start-of-chat) goes at the end. + if (state.isLoadingOlder) { + items.add(const _LoadingOlderIndicator()); + } else if (!state.hasMoreOld) { + items.add( + ChatBubble( + key: const ValueKey('chat-start'), + isSender: false, + bubbleData: GetChatResponseObject.getTextDummy('Anfang des Chats'), + chatData: widget.room, + refetch: _refetch, + ), + ); + } + return _items = items; + } + void _enterSearchMode() { setState(() { _searchActive = true; @@ -163,6 +204,7 @@ class _ChatViewState extends State with RouteAware { } void _exitSearchMode() { + Debouncer.cancel(_searchDebounceTag); setState(() { _searchActive = false; _searchQuery = ''; @@ -175,7 +217,22 @@ class _ChatViewState extends State with RouteAware { }); } + late final String _searchDebounceTag = + 'chat-search-${identityHashCode(this)}'; + + /// Matching re-scans the whole history and rebuilds every row, so it runs + /// once typing pauses instead of per keystroke. void _onSearchChanged(String q) { + Debouncer.debounce( + _searchDebounceTag, + const Duration(milliseconds: 200), + () { + if (mounted) _applySearch(q); + }, + ); + } + + void _applySearch(String q) { final chatResponse = context.read().state.data?.chatResponse; setState(() { _searchQuery = q; @@ -268,9 +325,13 @@ class _ChatViewState extends State with RouteAware { messages.add( ChatBubble( isSender: false, + key: ValueKey( + 'day-${elementDate.year}-${elementDate.month}-' + '${elementDate.day}', + ), bubbleData: GetChatResponseObject.getDateDummy(element.timestamp), chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), + refetch: _refetch, ), ); } @@ -286,6 +347,7 @@ class _ChatViewState extends State with RouteAware { messages.add( ChatBubble( + key: ValueKey(element.id), isSender: element.actorId == widget.selfId && (element.messageType == @@ -294,7 +356,7 @@ class _ChatViewState extends State with RouteAware { GetRoomResponseObjectMessageType.deletedComment), bubbleData: element, chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), + refetch: _refetch, isRead: element.id <= commonRead, selfId: widget.selfId, highlightQuery: highlightQuery, @@ -317,17 +379,7 @@ class _ChatViewState extends State with RouteAware { @override Widget build(BuildContext context) { - // Swallow the first back gesture while the keyboard is visible so it - // dismisses the IME instead of popping the chat — matches platform UX - // expectations (e.g. WhatsApp/Telegram) and prevents accidental exits - // mid-typing. - final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0; - return PopScope( - canPop: !keyboardOpen, - onPopInvokedWithResult: (didPop, _) { - if (didPop) return; - FocusManager.instance.primaryFocus?.unfocus(); - }, + return _KeyboardDismissPopScope( child: Scaffold( backgroundColor: const Color(0xffefeae2), appBar: _searchActive @@ -376,25 +428,7 @@ class _ChatViewState extends State with RouteAware { state.currentToken == widget.room.token, enablePullToRefresh: false, child: (state, _) { - final items = _buildMessages( - state.chatResponse!, - ).reversed.toList(); - // reverse:true renders index 0 at the bottom, so the top - // marker (spinner / start-of-chat) goes at the end. - if (state.isLoadingOlder) { - items.add(const _LoadingOlderIndicator()); - } else if (!state.hasMoreOld) { - items.add( - ChatBubble( - isSender: false, - bubbleData: GetChatResponseObject.getTextDummy( - 'Anfang des Chats', - ), - chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), - ), - ); - } + final items = _itemsFor(state); _itemCount = items.length; return ScrollablePositionedList.builder( reverse: true, @@ -448,3 +482,27 @@ class _LoadingOlderIndicator extends StatelessWidget { ), ); } + +/// Swallows the first back gesture while the keyboard is visible so it +/// dismisses the IME instead of popping the chat — matches platform UX +/// expectations (e.g. WhatsApp/Telegram) and prevents accidental exits +/// mid-typing. A separate widget so the per-frame inset changes during the +/// keyboard animation only rebuild this scope, not the whole chat. +class _KeyboardDismissPopScope extends StatelessWidget { + final Widget child; + + const _KeyboardDismissPopScope({required this.child}); + + @override + Widget build(BuildContext context) { + final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0; + return PopScope( + canPop: !keyboardOpen, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + FocusManager.instance.primaryFocus?.unfocus(); + }, + child: child, + ); + } +} diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index 936e800..cb58ecf 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -59,6 +59,13 @@ class _ChatBubbleState extends State with SingleTickerProviderStateMixin, DownloadTrigger { late ChatMessage message; + // The parsed message and its widget are only rebuilt when their inputs + // change. The bubble itself rebuilds on every chat emit, swipe frame and + // keyboard frame; re-running rich-object parsing, linkify/Markdown and emoji + // detection each time is what makes long chats stutter. + Object? _messageKey; + late Widget _messageWidget; + Offset _position = Offset.zero; Offset _dragStartPosition = Offset.zero; bool _swipeActionArmed = false; @@ -185,12 +192,34 @@ class _ChatBubbleState extends State } } - @override - Widget build(BuildContext context) { + void _updateMessage(BuildContext context) { + final style = _messageTextStyle(context); + final renderMarkdown = + widget.bubbleData.markdown && + widget.bubbleData.messageType == + GetRoomResponseObjectMessageType.comment; + final key = ( + widget.bubbleData, + widget.highlightQuery, + style, + renderMarkdown, + ); + if (key == _messageKey) return; + _messageKey = key; message = ChatMessage( originalMessage: widget.bubbleData.message, originalData: widget.bubbleData.messageParameters, ); + _messageWidget = message.getWidget( + highlightQuery: widget.highlightQuery, + style: style, + renderMarkdown: renderMarkdown, + ); + } + + @override + Widget build(BuildContext context) { + _updateMessage(context); final showActorDisplayName = _rendersAsCommentBubble && widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne; @@ -277,14 +306,7 @@ class _ChatBubbleState extends State actorText: actorText, actorWidget: actorWidget, timeText: timeText, - messageWidget: message.getWidget( - highlightQuery: widget.highlightQuery, - style: _messageTextStyle(context), - renderMarkdown: - widget.bubbleData.markdown && - widget.bubbleData.messageType == - GetRoomResponseObjectMessageType.comment, - ), + messageWidget: _messageWidget, parent: parent, bubbleData: widget.bubbleData, isSender: widget.isSender, @@ -350,10 +372,12 @@ class _BubbleContent extends StatelessWidget { Widget build(BuildContext context) => MergeSemantics( child: Container( constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.9, + maxWidth: MediaQuery.sizeOf(context).width * 0.9, minWidth: showActorDisplayName - ? actorText.size.width - : timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3, + ? actorText.measuredWidth(context) + : timeText.measuredWidth(context) + + (isSender ? spacing + timeIconSize : 0) + + 3, ), child: Stack( children: [ diff --git a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart index e7bd55c..9583670 100644 --- a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart +++ b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart @@ -34,7 +34,7 @@ class ChatBubbleReactions extends StatelessWidget { return Transform.translate( offset: const Offset(0, -10), child: Container( - width: MediaQuery.of(context).size.width, + width: MediaQuery.sizeOf(context).width, margin: const EdgeInsets.only(left: 15, right: 15), child: Wrap( alignment: isSender ? WrapAlignment.end : WrapAlignment.start, diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index 7769b5a..2ef9438 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -83,12 +83,20 @@ class _ChatTextfieldState extends State { ); } + /// Called per keystroke, so the text itself is saved silently; only a draft + /// appearing or disappearing notifies listeners (the chat list's marker). void _setDraft(String text) { - final talkSettings = settings.val(write: true).talkSettings; + final drafts = settings.val().talkSettings.drafts; + final hadDraft = drafts.containsKey(widget.sendToToken); if (text.isNotEmpty) { - talkSettings.drafts[widget.sendToToken] = text; + drafts[widget.sendToToken] = text; } else { - talkSettings.drafts.removeWhere((key, _) => key == widget.sendToToken); + drafts.remove(widget.sendToToken); + } + if (hadDraft != text.isNotEmpty) { + settings.val(write: true); + } else { + settings.saveSilently(); } } @@ -276,17 +284,22 @@ class _ChatTextfieldState extends State { @override Widget build(BuildContext context) { - final chatBloc = context.watch(); - final chatState = chatBloc.state.data; + final chatBloc = context.read(); + // Only the reply reference is rendered from the chat state; loading flags + // and paging emits don't need to rebuild the input. + final (referenceMessageId, chatResponse) = context.select( + (ChatBloc b) => ( + b.state.data?.referenceMessageId, + b.state.data?.chatResponse, + ), + ); Widget replyBanner = const SizedBox.shrink(); - if (chatState != null && - chatState.referenceMessageId != null && - chatState.chatResponse != null) { + if (referenceMessageId != null && chatResponse != null) { try { - final referenceMessage = chatState.chatResponse! - .sortByTimestamp() - .firstWhere((e) => e.id == chatState.referenceMessageId); + final referenceMessage = chatResponse.data.firstWhere( + (e) => e.id == referenceMessageId, + ); replyBanner = Row( children: [ Expanded( diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index a90b129..69cf23e 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -68,15 +68,25 @@ class _ChatTileState extends State { /// One-line preview of the last message: rich-object placeholders resolved, /// newlines flattened and — for Markdown messages — formatting stripped so /// the list shows readable text rather than raw markers. + /// + /// Memoised per last message: the tile rebuilds on every chat-list emit and + /// the Markdown strip is a full parse. Keyed by content, since every refresh + /// delivers new message objects. String _lastMessagePreview() { final last = widget.data.lastMessage; + final key = (last.id, last.message, last.markdown); + if (key == _previewFor) return _preview; final text = RichObjectStringProcessor.parseToString( last.message.replaceAll('\n', ' '), last.messageParameters, ); - return last.markdown ? markdownToPlainText(text) : text; + _previewFor = key; + return _preview = last.markdown ? markdownToPlainText(text) : text; } + Object? _previewFor; + String _preview = ''; + Future _setCurrentAsRead() async { final token = widget.data.token; final lastId = widget.data.lastMessage.id; @@ -89,7 +99,11 @@ class _ChatTileState extends State { @override Widget build(BuildContext context) { - final chatBloc = context.watch(); + // Only the open token matters here (split-view highlight); watching the + // whole bloc rebuilt every tile on each message of the open chat. + final currentToken = context.select( + (ChatBloc b) => b.state.data?.currentToken, + ); final isGroup = widget.data.type != GetRoomResponseObjectConversationType.oneToOne; final circleAvatar = UserAvatar( @@ -100,7 +114,7 @@ class _ChatTileState extends State { return ListTile( style: ListTileStyle.list, tileColor: - chatBloc.state.data?.currentToken == widget.data.token && + currentToken == widget.data.token && TalkNavigator.isSecondaryVisible(context) ? Theme.of(context).primaryColor.withAlpha(100) : null, diff --git a/lib/view/pages/timetable/data/calendar_logic.dart b/lib/view/pages/timetable/data/calendar_logic.dart index 56def74..54b9dd7 100644 --- a/lib/view/pages/timetable/data/calendar_logic.dart +++ b/lib/view/pages/timetable/data/calendar_logic.dart @@ -1,7 +1,7 @@ -import 'package:rrule/rrule.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../../extensions/date_time.dart'; +import '../../../../utils/recurrence_occurrences.dart'; import 'arbitrary_appointment.dart'; import 'calendar_layout.dart'; import 'lesson_period_schedule.dart'; @@ -103,7 +103,6 @@ partitionAppointmentsForWeek( continue; } try { - final parsed = RecurrenceRule.fromString(rule); final anchorUtc = a.startTime.toUtc(); final duration = a.endTime.difference(a.startTime); // Day-keyed set of exception dates so occurrences scheduled for one @@ -112,9 +111,12 @@ partitionAppointmentsForWeek( final exceptionDayKeys = (a.recurrenceExceptionDates ?? const []) .map((d) => '${d.year}-${d.month}-${d.day}') .toSet(); - for (final occUtc in parsed.getInstances(start: anchorUtc)) { - if (!occUtc.isBefore(weekEndUtc)) break; - if (occUtc.isBefore(weekStartUtc)) continue; + for (final occUtc in RecurrenceOccurrences.between( + rule, + anchorUtc, + weekStartUtc, + weekEndUtc, + )) { final occLocal = occUtc.toLocal(); if (exceptionDayKeys.contains( '${occLocal.year}-${occLocal.month}-${occLocal.day}', diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 366942a..23c178e 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -266,11 +266,12 @@ class _ViewingBanner extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final isFavorite = context - .watch() - .val() - .timetableFavoritesSettings - .isFavorite(element.type, element.id); + final isFavorite = context.select( + (SettingsCubit c) => c.state.timetableFavoritesSettings.isFavorite( + element.type, + element.id, + ), + ); final onColor = theme.colorScheme.onSecondaryContainer; // Compact icon button: ~32px square, no extra padding, so the banner stays diff --git a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart index 5224f1f..c48f7fa 100644 --- a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart +++ b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart @@ -7,10 +7,10 @@ import '../../../../extensions/date_time.dart'; import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; -import '../../../../storage/timetable_settings.dart'; import '../data/arbitrary_appointment.dart'; import '../data/lesson_period_schedule.dart'; import '../data/timetable_appointment_factory.dart'; +import '../data/timetable_name_mode.dart'; import 'custom_workweek_calendar.dart'; import 'special_regions_builder.dart'; @@ -51,9 +51,10 @@ class TimetableCalendarViewState extends State { GlobalKey(); List? _cachedAppointments; - // TimetableSettings and List define no `==`, so record equality degrades to - // the same identity checks the cache always used. - (int, TimetableSettings, List, bool)? _cacheKey; + // Settings are keyed by the values the factory reads: the settings object is + // re-created on every settings write, so its identity would miss the cache + // for unrelated changes. The event list has no `==` and compares by identity. + (int, bool, TimetableNameMode, List, bool)? _cacheKey; // Stable identities let the calendar reuse its per-week pages across // rebuilds; rebuilding these every frame would invalidate that cache. @@ -71,13 +72,16 @@ class TimetableCalendarViewState extends State { } List _appointments(TimetableState state) { - final timetableSettings = context - .watch() - .val() - .timetableSettings; + final (connectDoubleLessons, nameMode) = context.select( + (SettingsCubit c) => ( + c.state.timetableSettings.connectDoubleLessons, + c.state.timetableSettings.timetableNameMode, + ), + ); final key = ( state.dataVersion, - timetableSettings, + connectDoubleLessons, + nameMode, widget.customEvents, widget.showClassInsteadOfTeacher, ); @@ -91,7 +95,7 @@ class TimetableCalendarViewState extends State { customEvents: widget.customEvents, subjects: state.subjects?.result ?? const [], holidays: state.schoolHolidays?.result ?? const [], - settings: timetableSettings, + settings: context.read().val().timetableSettings, now: DateTime.now(), showClassInsteadOfTeacher: widget.showClassInsteadOfTeacher, ).build(); diff --git a/lib/widget/chat_background.dart b/lib/widget/chat_background.dart index 2505413..8118fb6 100644 --- a/lib/widget/chat_background.dart +++ b/lib/widget/chat_background.dart @@ -8,6 +8,7 @@ import '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../storage/chat_background_settings.dart'; import '../theming/app_theme.dart'; import '../utils/app_paths.dart'; +import '../utils/screen_bound_image.dart'; /// Renders the configurable chat background behind [child]. /// @@ -24,7 +25,13 @@ class ChatBackground extends StatelessWidget { @override Widget build(BuildContext context) { - final s = context.watch().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().val().chatBackgroundSettings; final dark = AppTheme.isDarkMode(context); final Widget background; @@ -32,7 +39,9 @@ class ChatBackground extends StatelessWidget { case ChatBackgroundType.none: background = ColoredBox(color: Theme.of(context).colorScheme.surface); case ChatBackgroundType.color: - background = ColoredBox(color: Color(s.colorValue ?? _fallbackColor.toARGB32())); + background = ColoredBox( + color: Color(s.colorValue ?? _fallbackColor.toARGB32()), + ); case ChatBackgroundType.pattern: background = _imageLayer( const AssetImage('assets/background/chat.png'), @@ -41,12 +50,17 @@ class ChatBackground extends StatelessWidget { isPattern: true, ); case ChatBackgroundType.image: + final image = FileImage(File(AppPaths.chatBackgroundImage)); background = KeyedSubtree( // imageVersion changes on every replacement, forcing a fresh subtree // alongside the explicit ImageCache evict in the settings handler. 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( - FileImage(File(AppPaths.chatBackgroundImage)), + s.fit == ChatBackgroundFit.cover + ? screenBoundImage(context, image) + : image, s, dark, isPattern: false, @@ -57,7 +71,9 @@ class ChatBackground extends StatelessWidget { return Stack( fit: StackFit.expand, 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) Positioned.fill( child: ColoredBox(color: Colors.black.withValues(alpha: s.dim)), diff --git a/lib/widget/debug/cache_view.dart b/lib/widget/debug/cache_view.dart index ff78296..75cd750 100644 --- a/lib/widget/debug/cache_view.dart +++ b/lib/widget/debug/cache_view.dart @@ -2,10 +2,9 @@ import 'dart:convert'; import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:localstore/localstore.dart'; import '../../../widget/placeholder_view.dart'; -import '../../api/request_cache.dart'; +import '../../api/cache_store.dart'; import '../app_progress_indicator.dart'; import 'json_viewer.dart'; @@ -15,31 +14,11 @@ class CacheView extends StatefulWidget { @override State createState() => _CacheViewState(); - Future clear() async { - await Localstore.instance.collection(RequestCache.collection).delete(); - } - - Future totalSize() async { - final data = await Localstore.instance - .collection(RequestCache.collection) - .get(); - if (data == null || data.isEmpty) return 0; - return data.values.fold( - 0, - (sum, value) => sum + jsonEncode(value).length, - ) * - 8; - } } class _CacheViewState extends State { - late Future?> files; - - @override - void initState() { - files = Localstore.instance.collection(RequestCache.collection).get(); - super.initState(); - } + late final Future> files = CacheStore.instance + .readAll(); @override Widget build(BuildContext context) => Scaffold( @@ -47,24 +26,23 @@ class _CacheViewState extends State { body: FutureBuilder( future: files, builder: (context, snapshot) { - if (snapshot.hasData) { + if (snapshot.hasData && snapshot.data!.isNotEmpty) { return ListView.builder( itemCount: snapshot.data!.length, itemBuilder: (context, index) { final key = snapshot.data!.keys.elementAt(index); - final element = snapshot.data![key] as Map; - final filename = key.split('/').last; + final element = snapshot.data![key]!; return ListTile( leading: const Icon(Icons.text_snippet_outlined), - title: Text(filename), + title: Text(key), 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), onTap: () => JsonViewer.asDialog( context, - jsonDecode(element['json'] as String) as Map, + jsonDecode(element.json) as Map, ), ); }, diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index e659ead..17a0b2d 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'dart:math'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.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 '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../utils/downloads/download_manager.dart'; +import '../utils/screen_bound_image.dart'; import 'app_progress_indicator.dart'; import 'async_action_button.dart'; import 'centered_leading.dart'; @@ -49,6 +51,7 @@ class FileViewer extends StatefulWidget { enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud } class _FileViewerState extends State { + Future<_TextPayload>? _textPayload; final PhotoViewController photoViewController = PhotoViewController(); late SettingsCubit settings = context.read(); @@ -302,7 +305,13 @@ class _FileViewerState extends State { controller: photoViewController, maxScale: 3.0, 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( color: Theme.of(context).colorScheme.surface, ), @@ -348,13 +357,17 @@ class _FileViewerState extends State { Widget _buildTextView() => Scaffold( appBar: _appbar(), 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) { if (!snapshot.hasData) { return const Center(child: AppProgressIndicator.large()); } 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. final gutterWidth = (lines.length.toString().length * 9.0) + 16; return SelectionArea( @@ -443,38 +456,6 @@ class _FileViewerState extends State { } 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 { @@ -489,7 +470,47 @@ class _ActionDescriptor { } class _TextPayload { - final String content; + final List lines; 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; + } } diff --git a/lib/widget/prosemirror/pm_image_view.dart b/lib/widget/prosemirror/pm_image_view.dart index 950d54d..1a347f4 100644 --- a/lib/widget/prosemirror/pm_image_view.dart +++ b/lib/widget/prosemirror/pm_image_view.dart @@ -10,9 +10,33 @@ class PmImageView extends StatelessWidget { 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 Widget build(BuildContext context) { - final provider = _imageProvider(); + final provider = sourceProvider(node); + final inline = inlineProvider(context, node); return LayoutBuilder( builder: (context, constraints) { final available = constraints.maxWidth.isFinite @@ -22,7 +46,13 @@ class PmImageView extends StatelessWidget { Widget image = ConstrainedBox( 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; @@ -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) { final theme = Theme.of(context); return DecoratedBox( diff --git a/lib/widget/prosemirror/pm_json_view.dart b/lib/widget/prosemirror/pm_json_view.dart index d17bfeb..077171c 100644 --- a/lib/widget/prosemirror/pm_json_view.dart +++ b/lib/widget/prosemirror/pm_json_view.dart @@ -1,9 +1,9 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import '../app_progress_indicator.dart'; import 'pm_document_view.dart'; +import 'pm_image_view.dart'; import 'pm_node.dart'; /// Renders raw ProseMirror JSON and memoises the parsed tree across rebuilds. @@ -31,7 +31,7 @@ class PmJsonView extends StatefulWidget { class _PmJsonViewState extends State { PmNode? _shown; PmNode? _pending; - List _pendingProviders = const []; + List _pendingImages = const []; bool _precacheStarted = false; int _generation = 0; @@ -62,17 +62,17 @@ class _PmJsonViewState extends State { void _parse() { final doc = PmNode.fromJson(widget.json); - final providers = []; - _collectProviders(doc, providers); + final images = []; + _collectImages(doc, images); _generation++; _precacheStarted = false; - if (providers.isEmpty) { + if (images.isEmpty) { _shown = doc; _pending = null; - _pendingProviders = const []; + _pendingImages = const []; } else { _pending = doc; - _pendingProviders = providers; + _pendingImages = images; } } @@ -82,8 +82,9 @@ class _PmJsonViewState extends State { _precacheStarted = true; final generation = _generation; Future.wait([ - for (final provider in _pendingProviders) - precacheImage(provider, context, onError: (_, _) {}), + for (final image in _pendingImages) + if (PmImageView.inlineProvider(context, image) case final provider?) + precacheImage(provider, context, onError: (_, _) {}), ]) .timeout(PmJsonView.precacheTimeout, onTimeout: () => const []) .whenComplete(() { @@ -91,23 +92,16 @@ class _PmJsonViewState extends State { setState(() { _shown = pending; _pending = null; - _pendingProviders = const []; + _pendingImages = const []; }); } }); } - void _collectProviders(PmNode node, List out) { - if (node is PmImage) { - final bytes = node.bytes; - if (bytes != null) { - out.add(MemoryImage(bytes)); - } else if (node.src.startsWith('http')) { - out.add(CachedNetworkImageProvider(node.src)); - } - } + void _collectImages(PmNode node, List out) { + if (node is PmImage) out.add(node); for (final child in node.children) { - _collectProviders(child, out); + _collectImages(child, out); } } diff --git a/lib/widget/share_position_origin.dart b/lib/widget/share_position_origin.dart index 2086f09..9a27a61 100644 --- a/lib/widget/share_position_origin.dart +++ b/lib/widget/share_position_origin.dart @@ -4,7 +4,7 @@ class SharePositionOrigin { static Rect get(BuildContext context) => Rect.fromLTWH( 0, 0, - MediaQuery.of(context).size.width, - MediaQuery.of(context).size.height / 2, + MediaQuery.sizeOf(context).width, + MediaQuery.sizeOf(context).height / 2, ); } diff --git a/lib/widget/user_avatar.dart b/lib/widget/user_avatar.dart index 3db7fb8..5556d6c 100644 --- a/lib/widget/user_avatar.dart +++ b/lib/widget/user_avatar.dart @@ -389,6 +389,10 @@ class _UserAvatarState extends State { payload.bytes, width: 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, gaplessPlayback: true, ); diff --git a/lib/widget_data/widget_data_mapper.dart b/lib/widget_data/widget_data_mapper.dart index eb30bda..1e2bd1a 100644 --- a/lib/widget_data/widget_data_mapper.dart +++ b/lib/widget_data/widget_data_mapper.dart @@ -1,7 +1,5 @@ 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_rooms/timetable_get_rooms_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/get/get_custom_timetable_event_response.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_merger.dart'; import '../view/pages/timetable/data/lesson_period_schedule.dart'; @@ -443,11 +442,13 @@ class WidgetDataMapper { } try { - final parsed = RecurrenceRule.fromString(rule); final anchorUtc = event.startDate.toUtc(); - for (final occUtc in parsed.getInstances(start: anchorUtc)) { - if (!occUtc.isBefore(rangeEndUtc)) break; - if (occUtc.isBefore(rangeStartUtc)) continue; + for (final occUtc in RecurrenceOccurrences.between( + rule, + anchorUtc, + rangeStartUtc, + rangeEndUtc, + )) { final occLocal = occUtc.toLocal(); final occStart = DateTime( occLocal.year, diff --git a/lib/widget_data/widget_publisher.dart b/lib/widget_data/widget_publisher.dart index e5233f6..d087234 100644 --- a/lib/widget_data/widget_publisher.dart +++ b/lib/widget_data/widget_publisher.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:developer'; import 'package:flutter/foundation.dart'; @@ -20,22 +21,65 @@ class WidgetPublisher { static DateTime widgetNow() => 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 _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 publishFromBlocState( TimetableState state, { Settings? settings, bool isTeacher = false, + int? epoch, + }) { + final sessionEpoch = epoch ?? AccountData().sessionEpoch; + return _queue = _queue.then( + (_) => _publish( + state, + settings: settings, + isTeacher: isTeacher, + epoch: sessionEpoch, + ), + ); + } + + static Future _publish( + TimetableState state, { + required Settings? settings, + required bool isTeacher, + required int epoch, }) async { - final epoch = AccountData().sessionEpoch; + if (!AccountData().isCurrentSession(epoch)) return; try { final connectDouble = settings?.timetableSettings.connectDoubleLessons ?? true; // Mirror into widget storage so the background isolate sees the same // values the user just toggled — concurrently, they are independent. - await Future.wait([ - WidgetSync.setConnectDoubleLessons(connectDouble), - WidgetSync.setThemeMode(_themeName(settings?.appTheme)), - WidgetSync.setIsTeacher(isTeacher), - ]); + final flags = (connectDouble, _themeName(settings?.appTheme), isTeacher); + if (flags != _lastFlags) { + await Future.wait([ + WidgetSync.setConnectDoubleLessons(flags.$1), + WidgetSync.setThemeMode(flags.$2), + WidgetSync.setIsTeacher(flags.$3), + ]); + _lastFlags = flags; + } final lessons = state.getAllKnownLessons(); final now = widgetNow(); final dayData = WidgetDataMapper.buildDayData( @@ -63,6 +107,20 @@ class WidgetPublisher { // A publish still running at sign-out would put the previous account's // plan back onto the just cleared widget. 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.writeWeekData(weekData); await WidgetSync.setLoggedIn(true); @@ -74,6 +132,9 @@ class WidgetPublisher { } } + static Map _withoutFetchedAt(Map json) => + Map.of(json)..remove('fetchedAt'); + static String _themeName(ThemeMode? mode) { switch (mode) { case ThemeMode.light: diff --git a/lib/widget_data/widget_sync.dart b/lib/widget_data/widget_sync.dart index 500fc36..e5de012 100644 --- a/lib/widget_data/widget_sync.dart +++ b/lib/widget_data/widget_sync.dart @@ -5,6 +5,7 @@ import 'dart:developer'; import 'package:home_widget/home_widget.dart'; import 'widget_data.dart'; +import 'widget_publisher.dart'; /// Bridge to the native widget host. All keys/names live here so the Kotlin /// and Swift sides stay in sync. @@ -109,6 +110,7 @@ class WidgetSync { } static Future clear() async { + WidgetPublisher.resetDedupe(); await ensureInitialized(); await HomeWidget.saveWidgetData(dayDataKey, null); await HomeWidget.saveWidgetData(weekDataKey, null); diff --git a/test/api/cache_store_test.dart b/test/api/cache_store_test.dart new file mode 100644 index 0000000..ae1d676 --- /dev/null +++ b/test/api/cache_store_test.dart @@ -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); + }); + }); +} diff --git a/test/state/timetable_reference_data_test.dart b/test/state/timetable_reference_data_test.dart new file mode 100644 index 0000000..4f78de7 --- /dev/null +++ b/test/state/timetable_reference_data_test.dart @@ -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)); + }); +} diff --git a/test/utils/recurrence_occurrences_test.dart b/test/utils/recurrence_occurrences_test.dart new file mode 100644 index 0000000..0b4f7fb --- /dev/null +++ b/test/utils/recurrence_occurrences_test.dart @@ -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)); + }); +} diff --git a/test/view/files/local_cache_search_test.dart b/test/view/files/local_cache_search_test.dart index c1a0868..ebb487d 100644 --- a/test/view/files/local_cache_search_test.dart +++ b/test/view/files/local_cache_search_test.dart @@ -11,13 +11,10 @@ CacheableFile _file({ bool isDirectory = false, }) => CacheableFile(path: path, isDirectory: isDirectory, name: name); -Map _doc(ListFilesResponse listing) => { - 'json': jsonEncode(listing.toJson()), - 'lastupdate': 0, -}; +String _payload(ListFilesResponse listing) => jsonEncode(listing.toJson()); void main() { - group('searchLocalCaches', () { + group('local cache index', () { final root = ListFilesResponse({ _file(path: 'Documents/', name: 'Documents', 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/Notes.txt', name: 'Notes.txt'), }); - final docs = { - '/MarianumMobile/wd-folder-aaa': _doc(root), - '/MarianumMobile/wd-folder-bbb': _doc(documents), - '/MarianumMobile/get-room-ccc': {'json': '{}', 'lastupdate': 0}, - }; + final index = buildLocalCacheIndex([ + _payload(root), + _payload(documents), + ]); - test('matches by name case-insensitively across all caches', () async { - final hits = await searchLocalCaches('report', docs: docs); + test('matches by name case-insensitively across all caches', () { + final hits = searchLocalCacheIndex(index, 'report'); final paths = hits.map((f) => f.path).toSet(); expect(paths, {'Reports.pdf', 'Documents/Tax-Report.pdf'}); }); - test('returns empty list for empty query', () async { - expect(await searchLocalCaches(' ', docs: docs), isEmpty); + test('returns empty list for empty query', () { + expect(searchLocalCacheIndex(index, ' '), isEmpty); }); - test('respects pathScope prefix', () async { - final hits = await searchLocalCaches( + test('respects pathScope prefix', () { + final hits = searchLocalCacheIndex( + index, 'report', pathScope: ['Documents'], - docs: docs, ); expect(hits.map((f) => f.path), ['Documents/Tax-Report.pdf']); }); - test('ignores non-folder cache documents', () async { - final hits = await searchLocalCaches('anything', docs: docs); - // Only documents starting with `wd-folder-` are scanned. The unrelated - // `get-room-ccc` doc must not crash the helper. - expect(hits, isEmpty); + test('skips payloads that are not folder listings', () { + expect(buildLocalCacheIndex(['not json', '{"foo": 1}']), isEmpty); }); - test('deduplicates entries that appear in multiple cached folders', - () async { + test('deduplicates entries that appear in multiple cached folders', () { final shared = _file( path: 'Documents/Tax-Report.pdf', name: 'Tax-Report.pdf', ); final dedupRoot = ListFilesResponse({shared}); - final dedupDocs = { - '/MarianumMobile/wd-folder-aaa': _doc(dedupRoot), - '/MarianumMobile/wd-folder-bbb': _doc(dedupRoot), - }; - final hits = await searchLocalCaches('tax', docs: dedupDocs); - expect(hits, hasLength(1)); + final dedupIndex = buildLocalCacheIndex([ + _payload(dedupRoot), + _payload(dedupRoot), + ]); + expect(searchLocalCacheIndex(dedupIndex, 'tax'), hasLength(1)); }); }); }