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