merged develop

This commit is contained in:
2026-09-27 14:58:07 +02:00
188 changed files with 7474 additions and 2558 deletions
+219
View File
@@ -0,0 +1,219 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import '../session/session_manager.dart';
import '../utils/directory_size.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>`).
///
/// Every account has its own directory; all access goes to the one of the
/// active account, in every isolate.
class CacheStore {
CacheStore._();
static final CacheStore instance = CacheStore._();
/// Payloads above this size are decoded on a background isolate.
static const int isolateDecodeThreshold = 64 * 1024;
static const _directoryPrefix = 'request_cache';
final Map<String, Future<Directory>> _dirs = {};
final Map<String, Future<void>> _writes = {};
/// Directory name for an account's storage [namespace]; null while no
/// account is active. The account from before multi-account support
/// (namespace '') keeps the original directory.
@visibleForTesting
static String directoryName(String? namespace) => switch (namespace) {
null => '$_directoryPrefix-signed-out',
'' => _directoryPrefix,
_ => '$_directoryPrefix-$namespace',
};
Future<Directory> _directory() {
final name = directoryName(SessionManager().activeAccount?.namespace);
return _dirs[name] ??= () async {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/$name');
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 {
final file = await _file(key);
await _writes[file.path];
return parse(await file.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) async {
// Resolved up front: a queued write must not follow an account switch
// into the next account's directory.
final file = await _file(key);
final path = file.path;
final previous = _writes[path] ?? Future<void>.value();
late final Future<void> current;
current = previous.then((_) => _write(file, json)).whenComplete(() {
if (identical(_writes[path], current)) _writes.remove(path);
});
return _writes[path] = current;
}
Future<void> _write(File file, String json) async {
try {
// 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(${file.path}) failed: $e');
}
}
Future<void> delete(String key) async {
try {
final file = await _file(key);
await _writes[file.path];
await file.delete();
} on Object {
// A missing or locked file is as good as deleted for a cache.
}
}
/// Empties the active account's 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');
}
_dirs.clear();
}
/// Removes the cache of the account with [namespace], active or not.
Future<void> deleteNamespace(String namespace) async {
final name = directoryName(namespace);
try {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/$name');
if (dir.existsSync()) await dir.delete(recursive: true);
} on Object catch (e) {
debugPrint('CacheStore.deleteNamespace failed: $e');
}
_dirs.removeWhere((key, _) => key == name);
}
/// 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] from every account's cache,
/// judged by file mtime so nothing has to be read or decoded.
Future<void> deleteOlderThan(Duration maxAge) async {
try {
final base = await getApplicationCacheDirectory();
final cutoff = DateTime.now().subtract(maxAge);
await for (final dir in base.list()) {
if (dir is! Directory) continue;
final name = dir.uri.pathSegments.lastWhere((s) => s.isNotEmpty);
if (!name.startsWith(_directoryPrefix)) continue;
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 => directorySize(await _directory());
/// 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');
}
}
}
@@ -0,0 +1,11 @@
import 'app_exception.dart';
/// A request finished after the account it was started for signed out. Its
/// result must not reach the state or cache of whoever is signed in now.
class StaleSessionException extends AppException {
const StaleSessionException()
: super(
userMessage: 'Die Anmeldung hat sich geändert. Bitte lade neu.',
technicalDetails: 'result of a previous session discarded',
);
}
@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
import '../../../model/endpoint_data.dart';
import '../../errors/server_exception.dart';
import '../../http_errors.dart';
import '../nextcloud_ocs.dart';
import 'autocomplete_response.dart';
@@ -30,7 +31,12 @@ class AutocompleteApi {
'limit': '10',
},
);
final response = await http.get(uri, headers: NextcloudOcs.headers());
final response = (await sendGuarded(
'Autocomplete $uri',
() => http
.get(uri, headers: NextcloudOcs.headers())
.timeout(const Duration(seconds: 20)),
))!;
if (response.statusCode != HttpStatus.ok) {
throw ServerException(
statusCode: response.statusCode,
@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
import '../../../api_params.dart';
import '../../../api_response.dart';
import '../room/get_room_response.dart';
import '../talk_api.dart';
/// Small POST/DELETE-only Talk endpoints that have no response payload.
@@ -105,3 +106,21 @@ class DeleteRoomAvatar extends TalkApi {
Map<String, String>? headers,
) => http.delete(uri, headers: headers);
}
class SetNotificationLevel extends TalkApi {
final String chatToken;
final GetRoomResponseObjectParticipantNotificationLevel level;
SetNotificationLevel(this.chatToken, this.level)
: super('v4/room/$chatToken/notify', null);
@override
ApiResponse? assemble(String raw) => null;
@override
Future<http.Response> request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) => http.post(uri, headers: headers, body: {'level': '${level.apiValue}'});
}
@@ -17,7 +17,7 @@ class GetChatCache extends SimpleCache<GetChatResponse> {
lookIntoFuture: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.on,
// Small initial page; also the per-chat offline snapshot written to
// localstore. Older messages are paged in on scroll-up via
// the request cache. Older messages are paged in on scroll-up via
// GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize.
limit: 50,
),
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart';
@@ -45,8 +46,10 @@ class GetChatHistory {
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
..headers = response.headers;
// A page holds up to 200 messages and lands while the user is scrolling;
// decoding it on the UI isolate stalls the fling.
final parsed = await compute(_parseChatResponse, response.body);
return parsed..headers = response.headers;
}
throw ServerException(
statusCode: status,
@@ -54,3 +57,6 @@ class GetChatHistory {
);
}
}
GetChatResponse _parseChatResponse(String body) =>
GetChatResponse.fromJson(NextcloudOcs.decode(body));
+10 -2
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
@@ -10,8 +11,12 @@ class GetRoom extends TalkApi<GetRoomResponse> {
GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson());
@override
GetRoomResponse assemble(String raw) =>
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
GetRoomResponse assemble(String raw) => _parseRooms(raw);
// The room list with status is 100–250 KB and polled every 15–60 s.
@override
Future<GetRoomResponse> assembleAsync(String raw) =>
compute(_parseRooms, raw);
@override
Future<http.Response> request(
@@ -20,3 +25,6 @@ class GetRoom extends TalkApi<GetRoomResponse> {
Map<String, String>? headers,
) => http.get(uri, headers: headers);
}
GetRoomResponse _parseRooms(String raw) =>
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
@@ -130,6 +130,12 @@ enum GetRoomResponseObjectParticipantNotificationLevel {
neverNotify,
}
extension NotificationLevelApiValue
on GetRoomResponseObjectParticipantNotificationLevel {
int get apiValue =>
_$GetRoomResponseObjectParticipantNotificationLevelEnumMap[this]!;
}
enum GetRoomResponseObjectMessageActorType {
@JsonValue('deleted_users')
deletedUsers,
@@ -0,0 +1,27 @@
import 'get_room_response.dart';
typedef NotificationLevel = GetRoomResponseObjectParticipantNotificationLevel;
/// The levels a user can pick in the UI; [NotificationLevel.defaultLevel]
/// is only ever reported by the server, never chosen.
const selectableNotificationLevels = [
NotificationLevel.alwaysNotify,
NotificationLevel.notifyOnMention,
NotificationLevel.neverNotify,
];
extension RoomNotificationLevel on GetRoomResponseObject {
/// Resolves "default" the way Spreed does: 1:1 chats notify on every
/// message, group chats only on mentions (`default_group_notification`).
NotificationLevel get effectiveNotificationLevel {
if (notificationLevel != NotificationLevel.defaultLevel) {
return notificationLevel;
}
return type == GetRoomResponseObjectConversationType.oneToOne
? NotificationLevel.alwaysNotify
: NotificationLevel.notifyOnMention;
}
bool get isMuted =>
effectiveNotificationLevel == NotificationLevel.neverNotify;
}
@@ -17,6 +17,10 @@ Future<void>? _shareFolderReady;
Future<void> ensureTalkShareFolder() =>
_shareFolderReady ??= _createTalkShareFolder();
/// The folder is per user; after a sign-out the next account has to check
/// again.
void resetTalkShareFolderCache() => _shareFolderReady = null;
Future<void> _createTalkShareFolder() async {
try {
final webdav = await WebdavApi.webdav;
+10 -2
View File
@@ -12,6 +12,10 @@ import '../nextcloud_ocs.dart';
import 'talk_error.dart';
abstract class TalkApi<T extends ApiResponse?> {
// package:http has no timeout of its own; a request stuck on a dead
// connection (e.g. one started right before a suspend) would spin forever.
static const Duration _timeout = Duration(seconds: 30);
String path;
ApiParams? body;
Map<String, String>? headers;
@@ -26,6 +30,10 @@ abstract class TalkApi<T extends ApiResponse?> {
);
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 {
final endpoint = NextcloudOcs.uri(
'apps/spreed/api/$path',
@@ -35,7 +43,7 @@ abstract class TalkApi<T extends ApiResponse?> {
final data = await sendGuarded(
'Talk $endpoint',
() => request(endpoint, body, mergedHeaders),
() => request(endpoint, body, mergedHeaders)?.timeout(_timeout),
);
if (data == null) {
throw const NetworkException(
@@ -65,7 +73,7 @@ abstract class TalkApi<T extends ApiResponse?> {
}
try {
final assembled = assemble(data.body);
final assembled = await assembleAsync(data.body);
assembled?.headers = data.headers;
return assembled;
} catch (e) {
@@ -29,6 +29,19 @@ class CacheableFile {
/// file/folder in the list. Nullable so older cached entries decode fine.
bool? isSharedWithMe;
/// Lower-cased [name] for sorting/filtering, cached because comparators
/// call it for every comparison (name is mutable, hence the identity check).
String get lowerName {
if (!identical(_lowerNameFor, name)) {
_lowerNameFor = name;
_lowerName = name.toLowerCase();
}
return _lowerName;
}
String? _lowerNameFor;
String _lowerName = '';
CacheableFile({
required this.path,
required this.isDirectory,
@@ -1,9 +1,9 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:localstore/localstore.dart';
import '../../../../../utils/cache_invalidation_bus.dart';
import '../../../../cache_store.dart';
import '../../../../request_cache.dart';
import 'list_files.dart';
import 'list_files_params.dart';
@@ -43,14 +43,11 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
/// (slow) root listing is already populated by the time the user
/// navigates to the Files module.
///
/// No-ops when a cached root payload is already present in localstore —
/// No-ops when a cached root payload is already present in the cache —
/// the regular TTL handling in [RequestCache] takes over from there.
static Future<void> prefetchRootListing() async {
const rootPath = '';
final cached = await Localstore.instance
.collection(RequestCache.collection)
.doc(_documentId(rootPath))
.get();
final cached = await CacheStore.instance.read(_documentId(rootPath));
if (cached != null) return;
// Drive the same code path as a regular fetch so the result lands in
// the cache; we don't care about the in-memory callback here.
@@ -69,10 +66,7 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
/// `_FilesView` for that path via [CacheInvalidationBus] so it refetches
/// even while it is sitting in the background of the navigation stack.
static Future<void> invalidate(String path) async {
await Localstore.instance
.collection(RequestCache.collection)
.doc(_documentId(path))
.delete();
await CacheStore.instance.delete(_documentId(path));
CacheInvalidationBus.notifyListFiles(path);
}
}
@@ -18,6 +18,8 @@ abstract class WebdavApi<T> {
/// changes (app password minted/renewed, account switch) so it never keeps
/// authenticating with stale credentials.
static Future<WebDavClient> get webdav {
// Keyed by user too: two accounts may share a password (no app password
// minted), and the client would keep the previous login name.
final nextcloud = SessionManager().requireNextcloud();
final secret = '${nextcloud.username}:${nextcloud.secret}';
if (_webdav == null || _webdavSecret != secret) {
@@ -105,17 +105,29 @@ class MarianumConnectAuthInterceptor extends Interceptor {
Future<bool> _performReLogin() async {
final session = SessionManager().current;
if (session is! CredentialSession) return false;
final username = session.username;
// A background engine (widget task) keeps the account it loaded. When
// the app signed that account out, its revoked token answers 401 — a
// re-login would mint a fresh token for it into the shared keystore.
if (await SessionManager().readStoredUsername() != username) return false;
try {
await _loginClient.run(
username: session.username,
username: username,
password: session.password,
tokenName: await DeviceTokenName.resolve(),
);
return true;
} catch (_) {
await _tokenStorage.clear();
if (await SessionManager().readStoredUsername() == username) {
await _tokenStorage.clear();
}
return false;
}
final stored = await SessionManager().readStoredUsername();
if (stored == username) return true;
// Signed out during the login: drop the orphaned token, unless another
// account already stored its own.
if (stored == null) await _tokenStorage.clear();
return false;
}
Future<Response<dynamic>> _retryWithFreshToken(
@@ -19,6 +19,7 @@ class SessionValidator {
// The probes use their own dio (bypassing the demo interceptor), so a demo
// session must be skipped or its missing token would 401 into a logout.
if (session == null || session.isDemo) return;
final epoch = SessionManager().sessionEpoch;
try {
switch (session) {
case CredentialSession(:final username, :final password):
@@ -28,6 +29,9 @@ class SessionValidator {
}
} on AuthException catch (e) {
if (e.statusCode != 401) return;
// The probed account is no longer the active one; the 401 must not
// sign out whoever took over meanwhile.
if (!SessionManager().isCurrentSession(epoch)) return;
log('MC: stored session rejected — forcing re-login');
final next = await SessionLifecycle.signOut(
notice: switch (session) {
@@ -16,6 +16,16 @@ class MarianumConnectApi {
static Dio dio() => _instance;
/// Drops the pooled keep-alive sockets. After a suspend the OS or a NAT
/// has usually killed them already, but dart:io only notices once a request
/// is written into one — which then hangs until [_receiveTimeout].
/// In-flight requests finish on the old adapter.
static void resetConnections() {
final stale = _instance.httpClientAdapter;
_instance.httpClientAdapter = HttpClientAdapter();
stale.close();
}
/// A fresh dio with the standard JSON options but no interceptors — used by
/// the auth queries (login/verify) that must bypass the bearer/demo
/// interceptors to avoid a re-auth loop.
@@ -3,41 +3,71 @@ import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../../push/push_registration.dart';
import '../../../../push/push_registration_store.dart';
import '../../../../push/push_registration_type.dart';
import '../../marianumconnect_query.dart';
import 'telemetry_device_id.dart';
typedef _PushState = ({
bool enabled,
AuthorizationStatus? permission,
String? pushDeviceIdentifier,
});
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
/// upserts the stable install id, platform, app version and device info. Sent
/// on app start, again once push registration completes that session (so a
/// fresh registration isn't under-reported until the next launch), and on
/// resume after the app spent more than 15 minutes in the background.
/// on app start, on resume and once push registration completes, throttled to
/// one per 15 minutes unless the push state changed in between.
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
/// mhsl.eu `server/userIndex/update` call.
class TelemetryHeartbeat extends MarianumConnectQuery {
TelemetryHeartbeat({super.dio});
static const Duration _interval = Duration(minutes: 15);
// Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick
// reliably while the app is suspended, so the window would still be open on
// the resume it is supposed to let through.
static DateTime? _lastSentAt;
static _PushState? _lastPushState;
/// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed
/// send never disrupts app start. Used from the app shell's initState and
/// lifecycle handler, and re-emitted once push registration completes (see
/// `_MainState._syncPush`).
/// send never disrupts app start. A changed push state (toggle, OS
/// permission — e.g. back from the system settings — or a fresh
/// registration) bypasses the throttle: the server picks visible vs. silent
/// delivery from it, so it must not wait for the next window.
static void report({required bool notificationsEnabled}) {
unawaited(
TelemetryHeartbeat()
.send(notificationsEnabled: notificationsEnabled)
.catchError((Object _) {}),
);
unawaited(_report(notificationsEnabled).catchError((Object _) {}));
}
Future<void> send({required bool notificationsEnabled}) => guard(() async {
static Future<void> _report(bool enabled) async {
final pushState = (
enabled: enabled,
permission: await PushRegistration.osPermissionStatus(),
pushDeviceIdentifier: await const PushRegistrationStore()
.deviceIdentifier(PushRegistrationType.general),
);
final now = DateTime.now();
final last = _lastSentAt;
if (last != null &&
now.difference(last) < _interval &&
pushState == _lastPushState) {
return;
}
// Claimed before sending so overlapping triggers don't both go out.
_lastSentAt = now;
_lastPushState = pushState;
await TelemetryHeartbeat()._send(pushState);
}
Future<void> _send(_PushState push) => guard(() async {
final info = DeviceInfoPlugin();
final package = await PackageInfo.fromPlatform();
final deviceIdentifier = await TelemetryDeviceId.resolve();
final pushDeviceIdentifier = await const PushRegistrationStore()
.deviceIdentifier(PushRegistrationType.general);
var platform = 'unknown';
String? deviceModel;
@@ -62,11 +92,12 @@ class TelemetryHeartbeat extends MarianumConnectQuery {
data: {
'deviceIdentifier': deviceIdentifier,
// `pushDeviceIdentifier` reflects a *completed* registration and is
// absent until it lands; `pushEnabled` carries the user's intent
// (the notification toggle) so the backend can tell "user wants push"
// apart from "registration not finished yet".
'pushDeviceIdentifier': ?pushDeviceIdentifier,
'pushEnabled': notificationsEnabled,
// absent until it lands; `pushEnabled` carries the user's intent (the
// toggle). The server combines it with `osPermission` into the
// visible-vs-silent delivery decision.
'pushDeviceIdentifier': ?push.pushDeviceIdentifier,
'pushEnabled': push.enabled,
'osPermission': ?osPermissionWireValue(push.permission),
'platform': platform,
'appVersion': package.version,
'appBuild': int.tryParse(package.buildNumber),
@@ -77,3 +108,13 @@ class TelemetryHeartbeat extends MarianumConnectQuery {
);
});
}
/// Wire value of the OS notification permission for the heartbeat.
String? osPermissionWireValue(AuthorizationStatus? status) => switch (status) {
AuthorizationStatus.authorized => 'granted',
AuthorizationStatus.provisional => 'provisional',
AuthorizationStatus.denied ||
AuthorizationStatus.deniedPermanently => 'denied',
AuthorizationStatus.notDetermined => 'notDetermined',
null => null,
};
@@ -1,6 +1,6 @@
import 'dart:developer';
import 'package:localstore/localstore.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../../session/session.dart';
import '../../../../session/session_manager.dart';
@@ -21,9 +21,7 @@ import 'timetable_custom_events_add.dart';
/// failure only sees the events that were not yet moved, so nothing is
/// duplicated. The flag is set only once MHSL reports no remaining events.
class CustomEventsMigration {
static const String _collection = 'MarianumMobile';
static const String _document = 'customEventsMigration';
static const String _doneKey = 'migratedToMc';
static const String _doneKey = 'customEventsMigratedToMc';
const CustomEventsMigration._();
@@ -34,18 +32,23 @@ class CustomEventsMigration {
if (session is! CredentialSession || session.isDemo) return;
if (await _isDone()) return;
final epoch = SessionManager().sessionEpoch;
try {
final response = await GetCustomTimetableEvent(
GetCustomTimetableEventParams(session.legacyUserSecret),
).run();
for (final event in response.events) {
// The POST authenticates with whoever is signed in now; after a
// sign-out the previous account's events would land in the next one.
if (!SessionManager().isCurrentSession(epoch)) return;
await TimetableCustomEventsAdd().run(event);
await RemoveCustomTimetableEvent(
RemoveCustomTimetableEventParams(event.id),
).run();
}
if (!SessionManager().isCurrentSession(epoch)) return;
await _markDone();
log(
'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.',
@@ -57,17 +60,11 @@ class CustomEventsMigration {
}
}
static Future<bool> _isDone() async {
final data = await Localstore.instance
.collection(_collection)
.doc(_document)
.get();
return data != null && data[_doneKey] == true;
}
// SharedPreferences, like the old cache document, is cleared on sign-out,
// so the next account runs its own migration.
static Future<bool> _isDone() async =>
(await SharedPreferences.getInstance()).getBool(_doneKey) ?? false;
static Future<void> _markDone() async {
await Localstore.instance.collection(_collection).doc(_document).set({
_doneKey: true,
});
}
static Future<void> _markDone() async =>
(await SharedPreferences.getInstance()).setBool(_doneKey, true);
}
@@ -10,12 +10,17 @@ class McTimetableTeacher {
final String displayName;
final String? originalShortName;
final String? originalDisplayName;
// Webuntis element ids; null from servers that predate them.
final int? id;
final int? originalId;
McTimetableTeacher({
required this.shortName,
required this.displayName,
this.originalShortName,
this.originalDisplayName,
this.id,
this.originalId,
});
factory McTimetableTeacher.fromJson(Map<String, dynamic> json) =>
@@ -45,6 +50,8 @@ class McTimetableEntry {
final String? substitutionText;
final String? lessonText;
final String? infoText;
// Index-aligned with [classNames]; null from servers that predate it.
final List<int>? classIds;
McTimetableEntry({
required this.id,
@@ -60,6 +67,7 @@ class McTimetableEntry {
required this.substitutionText,
required this.lessonText,
required this.infoText,
this.classIds,
});
factory McTimetableEntry.fromJson(Map<String, dynamic> json) =>
@@ -68,10 +76,17 @@ class McTimetableEntry {
/// Combines the calendar date with the hour/minute portion of [startTime]
/// (which carries a 1970 placeholder date) into a real DateTime.
DateTime get startDateTime =>
DateTime get startDateTime => _startDateTime;
DateTime get endDateTime => _endDateTime;
// Cached (private, so json_serializable ignores them): sorting and lesson
// merging read these per comparison, and every local DateTime construction
// does a timezone lookup.
late final DateTime _startDateTime =
DateTime(date.year, date.month, date.day, startTime.hour, startTime.minute);
DateTime get endDateTime =>
late final DateTime _endDateTime =
DateTime(date.year, date.month, date.day, endTime.hour, endTime.minute);
static DateTime _dateFromJson(String raw) => DateTime.parse(raw);
@@ -12,6 +12,8 @@ McTimetableTeacher _$McTimetableTeacherFromJson(Map<String, dynamic> json) =>
displayName: json['displayName'] as String,
originalShortName: json['originalShortName'] as String?,
originalDisplayName: json['originalDisplayName'] as String?,
id: (json['id'] as num?)?.toInt(),
originalId: (json['originalId'] as num?)?.toInt(),
);
Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
@@ -20,6 +22,8 @@ Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
'displayName': instance.displayName,
'originalShortName': instance.originalShortName,
'originalDisplayName': instance.originalDisplayName,
'id': instance.id,
'originalId': instance.originalId,
};
McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) =>
@@ -43,6 +47,9 @@ McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) =>
substitutionText: json['substitutionText'] as String?,
lessonText: json['lessonText'] as String?,
infoText: json['infoText'] as String?,
classIds: (json['classIds'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList(),
);
Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
@@ -60,6 +67,7 @@ Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
'substitutionText': instance.substitutionText,
'lessonText': instance.lessonText,
'infoText': instance.infoText,
'classIds': instance.classIds,
};
TimetableGetWeekResponse _$TimetableGetWeekResponseFromJson(
+50 -35
View File
@@ -1,10 +1,11 @@
import 'dart:async';
import 'dart:convert';
import 'package:localstore/localstore.dart';
import '../session/session_manager.dart';
import 'api_response.dart';
import 'cache_store.dart';
import 'errors/parse_exception.dart';
import 'errors/stale_session_exception.dart';
abstract class RequestCache<T extends ApiResponse?> {
static const int cacheNothing = 0;
@@ -12,12 +13,10 @@ abstract class RequestCache<T extends ApiResponse?> {
static const int cacheHour = 60 * 60;
static const int cacheDay = 60 * 60 * 24;
static String collection = 'MarianumMobile';
int maxCacheTime;
void Function(T)? onUpdate;
/// Called only when [start] finds a cached payload in localstore. Use this
/// Called only when [start] finds a cached payload in the [CacheStore]. Use this
/// (instead of [onUpdate]) when callers need to distinguish stale-but-fast
/// cache hits from authoritative network responses.
void Function(T)? onCacheData;
@@ -48,42 +47,57 @@ abstract class RequestCache<T extends ApiResponse?> {
static void ignore(Exception e) {}
Future<void> start(String document) async {
final epoch = SessionManager().sessionEpoch;
try {
final tableData = await Localstore.instance
.collection(collection)
.doc(document)
.get();
if (tableData != null) {
final cached = onLocalData(tableData['json'] as String);
final entry = await CacheStore.instance.read(document);
var lastUpdate = entry?.lastUpdate ?? 0;
T? cached;
if (entry != null) {
try {
cached = fromCacheJson(await CacheStore.decode(entry.json));
} on Object {
// A payload from an older model version: treat it as a miss.
await CacheStore.instance.delete(document);
lastUpdate = 0;
}
}
if (cached != null) {
onUpdate?.call(cached);
onCacheData?.call(cached);
}
final lastUpdate = (tableData?['lastupdate'] as num?) ?? 0;
if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) <
lastUpdate) {
if (renew == null || !renew!) return;
}
try {
final newValue = await onLoad();
onUpdate?.call(newValue);
onNetworkData?.call(newValue);
unawaited(
Localstore.instance.collection(collection).doc(document).set({
'json': jsonEncode(newValue),
'lastupdate': DateTime.now().millisecondsSinceEpoch,
}),
);
} on Exception catch (e) {
onError(e);
}
await _load(document, epoch, lastUpdate: lastUpdate);
} finally {
if (!_ready.isCompleted) _ready.complete();
}
}
T onLocalData(String json);
Future<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 (!SessionManager().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();
}
@@ -117,8 +131,7 @@ class SimpleCache<T extends ApiResponse?> extends RequestCache<T> {
Future<T> onLoad() => _loader();
@override
T onLocalData(String json) =>
_fromJson(jsonDecode(json) as Map<String, dynamic>);
T fromCacheJson(Map<String, dynamic> json) => _fromJson(json);
}
/// Captures the latest cache payload (cached or network) and rethrows the
@@ -141,8 +154,10 @@ Future<T> resolveFromCache<T extends ApiResponse?>(
onError?.call(e);
});
await cache.ready;
if (latest != null) return latest as T;
final err = capturedError;
// `latest` may still hold the cache hit read before the sign-out.
if (err is StaleSessionException) throw err;
if (latest != null) return latest as T;
if (err != null) throw err;
throw ParseException(
technicalDetails: operationName != null
+34 -24
View File
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import 'api/marianumconnect/marianumconnect_api.dart';
import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
import 'main.dart';
@@ -45,18 +46,15 @@ class App extends StatefulWidget {
}
class _AppState extends State<App> with WidgetsBindingObserver {
late Timer _updateTimings;
StreamSubscription<RemoteMessage>? _onMessageSub;
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
StreamSubscription<String>? _fcmTokenRefreshSub;
int _knownTotalTabs = 1;
int _lastTabIndex = 0;
bool _userOnLastTab = false;
DateTime? _lastTelemetryAt;
static const Duration _chatListActiveInterval = Duration(seconds: 15);
static const Duration _chatListIdleInterval = Duration(seconds: 60);
static const Duration _telemetryInterval = Duration(minutes: 15);
void _onTabControllerChanged() {
final newIndex = Main.bottomNavigator.index;
@@ -78,18 +76,15 @@ class _AppState extends State<App> with WidgetsBindingObserver {
bloc.setAutoRefreshInterval(
talkIsActive ? _chatListActiveInterval : _chatListIdleInterval,
);
if (talkIsActive && refresh) bloc.refresh();
// The poll keeps the list current; switching back to the tab shortly
// after a fetch doesn't need another one mid tab transition.
if (talkIsActive && refresh) {
bloc.refreshIfOlderThan(_chatListActiveInterval);
}
}
// Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick
// reliably while the app is suspended, so the window would still be open on
// the resume it is supposed to let through.
void _reportTelemetry() {
if (!mounted) return;
final now = DateTime.now();
final last = _lastTelemetryAt;
if (last != null && now.difference(last) < _telemetryInterval) return;
_lastTelemetryAt = now;
TelemetryHeartbeat.report(
notificationsEnabled: context
.read<SettingsCubit>()
@@ -103,6 +98,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
void didChangeAppLifecycleState(AppLifecycleState state) {
log('AppLifecycle: $state');
if (state == AppLifecycleState.resumed) {
MarianumConnectApi.resetConnections();
_reportTelemetry();
Debouncer.throttle('appLifecycleState', const Duration(seconds: 10), () {
if (!mounted) return;
@@ -113,6 +109,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
_syncChatListPolling(refresh: false);
_handlePendingWidgetNavigation();
} else if (mounted) {
context.read<SettingsCubit>().flushSilentSave();
// Stop polling while backgrounded: a silent refresh failing in the
// background would otherwise leave an error that flashes on the next
// resume before the foreground refetch replaces it.
@@ -162,7 +159,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
void _handlePendingShare() {
if (!mounted) return;
final share = ShareIntentListener.pending.value;
if (share == null) return;
if (share == null || ShareIntentListener.instance.isShown(share)) return;
// A second share would otherwise leave the previous share-flow page
// on top with stale (already-cleared) file paths.
// Sharing targets Talk chats and Files folders only.
@@ -181,6 +178,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst || route is PopupRoute);
}
ShareIntentListener.instance.markShown(share);
AppRoutes.openShareTarget(context, share);
}
@@ -198,19 +196,18 @@ class _AppState extends State<App> with WidgetsBindingObserver {
context.read<BreakerBloc>().refresh();
context.read<ChatListBloc>().refresh();
// Initial publish in case hydrated storage already has data. No refresh
// needed: PrimaryTimetableScope hands out a freshly loading bloc.
_publishWidget(context.read<TimetableBloc>());
// needed: PrimaryTimetableScope hands out a freshly loading bloc. The
// widget still shows its last snapshot, so this waits until the
// cold-start frames are done (a bloc emit in the meantime supersedes it).
Debouncer.debounce('widgetPublish', const Duration(seconds: 3), () {
if (mounted) _publishWidget(context.read<TimetableBloc>());
});
unawaited(_handlePendingWidgetNavigation());
ShareIntentListener.instance.attach();
ShareIntentListener.pending.addListener(_handlePendingShare);
_handlePendingShare();
_syncChatListPolling();
});
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
if (mounted) setState(() {});
});
_reportTelemetry();
// A refreshed FCM token invalidates the existing push subscription — the
@@ -253,18 +250,20 @@ class _AppState extends State<App> with WidgetsBindingObserver {
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
void dispose() {
_updateTimings.cancel();
_onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel();
PushTapRouter.pendingTarget.removeListener(_onPushTargetPending);
ShareIntentListener.pending.removeListener(_handlePendingShare);
ShareIntentListener.instance.detach();
Main.bottomNavigator.removeListener(_onTabControllerChanged);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
@@ -280,7 +279,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
// one widget reload for the burst is enough.
listener: (_, _) => Debouncer.debounce(
'widgetPublish',
const Duration(milliseconds: 500),
const Duration(seconds: 1),
() {
if (mounted) _publishWidget(context.read<TimetableBloc>());
},
@@ -290,7 +289,18 @@ class _AppState extends State<App> with WidgetsBindingObserver {
Widget _buildShell(
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, _) {
final bottomBarModules = AppModule.getBottomBarModules(context);
final totalTabs = bottomBarModules.length + 1;
+5
View File
@@ -36,6 +36,11 @@ extension CalendarDayArithmetic on DateTime {
);
DateTime subtractDays(int days) => addDays(-days);
DateTime get dateOnly => DateTime(year, month, day);
/// Monday 00:00 of this date's week.
DateTime get mondayOfWeek => subtractDays(weekday - 1).dateOnly;
}
/// Formatting helpers backed by Jiffy. Centralises the patterns that previously
+17 -4
View File
@@ -1,16 +1,29 @@
import 'package:flutter/material.dart';
extension TextExt on Text {
Size get size {
final textPainter = TextPainter(
/// Single-line width as rendered in [context] (honours the text scaler).
/// Memoised: chat bubbles measure the same few names and times over and
/// over, and every measurement is a full paragraph layout.
double measuredWidth(BuildContext context) {
final scaler = MediaQuery.textScalerOf(context);
final key = (data, style, scaler);
final cached = _widthCache[key];
if (cached != null) return cached;
final painter = TextPainter(
text: TextSpan(text: data, style: style),
maxLines: 1,
textDirection: TextDirection.ltr,
)..layout(minWidth: 0, maxWidth: double.infinity);
return textPainter.size;
textScaler: scaler,
)..layout();
final width = painter.width;
painter.dispose();
if (_widthCache.length >= 512) _widthCache.clear();
return _widthCache[key] = width;
}
}
final Map<(String?, TextStyle?, TextScaler), double> _widthCache = {};
/// Returns the first non-empty (after trim) entry, or '' if none match.
String firstNonEmpty(List<String?> values) {
for (final v in values) {
+162 -117
View File
@@ -7,6 +7,7 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show timeDilation;
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
@@ -14,8 +15,8 @@ import 'package:jiffy/jiffy.dart';
import 'package:loader_overlay/loader_overlay.dart';
import 'package:path_provider/path_provider.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'access/user_role.dart';
import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
import 'api/marianumconnect/auth/session_validator.dart';
import 'api/marianumconnect/marianumconnect_endpoint.dart';
@@ -26,6 +27,7 @@ import 'app.dart';
import 'auth_link/guardian_link_listener.dart';
import 'background/widget_background_task.dart';
import 'firebase_options.dart';
import 'model/session_wipe.dart';
import 'notification/notification_service.dart';
import 'push/notification_permission_prompt.dart';
import 'push/push_message_handler.dart';
@@ -40,6 +42,7 @@ import 'share_intent/share_intent_listener.dart';
import 'state/app/modules/account/account_scope.dart';
import 'state/app/modules/account/bloc/account_bloc.dart';
import 'state/app/modules/account/bloc/account_state.dart';
import 'state/app/modules/app_modules.dart';
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
@@ -57,6 +60,7 @@ import 'view/login/login.dart';
import 'view/login/post_login_splash.dart';
import 'widget/avatar_disk_cache.dart';
import 'widget/breaker/breaker.dart';
import 'widget/debug/debug_stats_overlay.dart';
import 'widget/downloads/download_tray.dart';
import 'widget/emergency/emergency_notice_gate.dart';
import 'widget/info_dialog.dart';
@@ -120,6 +124,7 @@ void _installErrorHandlers() {
Future<void> main() async {
log('MarianumMobile started');
WidgetsFlutterBinding.ensureInitialized();
SessionManager().markUiEngine();
// Before any initialisation so startup failures reach the backend too.
_installErrorHandlers();
@@ -179,6 +184,12 @@ Future<void> main() async {
);
log('app initialisation done!');
// Independent of the notification setup below, so it runs alongside it.
final widgetSyncInit = _startupStep(
'widget sync',
WidgetSync.ensureInitialized,
);
// Local notifications: init the plugin (with tap/action callbacks) and the
// Android channels, then register the FCM background isolate handler that
// decrypts and renders Nextcloud pushes while the app is not in foreground.
@@ -198,9 +209,9 @@ Future<void> main() async {
),
);
// Wire up the home-screen widget bridge before runApp so any widget render
// triggered during startup hits initialised native storage.
await _startupStep('widget sync', WidgetSync.ensureInitialized);
// The home-screen widget bridge must be ready before runApp so any widget
// render triggered during startup hits initialised native storage.
await widgetSyncInit;
unawaited(
WidgetBackgroundTask.initialize().onError(
(e, _) => log('Workmanager init failed: $e'),
@@ -297,13 +308,19 @@ class Main extends StatefulWidget {
}
class _MainState extends State<Main> {
final List<NavigatorObserver> _navigatorObservers = [
AppRoutes.chatRouteObserver,
DownloadRouteObserver(),
];
bool _showPostLoginSplash = false;
bool _appMounted = true;
late AccountStatus _lastStatus;
@override
void initState() {
super.initState();
Jiffy.setLocale('de');
_lastStatus = context.read<AccountBloc>().state.status;
SessionManager().unauthorizedSignal.addListener(_onUnauthorized);
GuardianLinkListener.pending.addListener(_onGuardianLink);
@@ -332,8 +349,10 @@ class _MainState extends State<Main> {
final capabilitiesCubit = scopeContext.read<CapabilitiesCubit>();
unawaited(
capabilitiesCubit.load().then((_) {
if (!mounted) return;
// Closed: the account was switched away from while loading.
if (!mounted || capabilitiesCubit.isClosed) return;
_syncPush(settingsCubit, capabilitiesCubit);
_applyRoleDefaults(settingsCubit, capabilitiesCubit);
_promptGuardianNotifications();
}),
);
@@ -393,6 +412,19 @@ class _MainState extends State<Main> {
);
}
/// userType is only known once capabilities are loaded.
void _applyRoleDefaults(
SettingsCubit settings,
CapabilitiesCubit capabilities,
) {
if (AppModule.applyTeacherDefaults(
settings.val().modulesSettings,
isTeacher: capabilities.state.role == UserRole.teacher,
)) {
settings.val(write: true);
}
}
@override
void dispose() {
SessionManager().unauthorizedSignal.removeListener(_onUnauthorized);
@@ -444,55 +476,72 @@ class _MainState extends State<Main> {
if (mounted) setState(() => _appMounted = true);
});
}
if (accountState.status == AccountStatus.addingAccount) {
// Routes pushed on the root navigator (Settings) would otherwise keep
// covering the login screen that replaces the home route.
AppRoutes.rootNavigatorKey.currentState?.popUntil(
(route) => route.isFirst,
);
}
if (accountState.status != AccountStatus.loggedOut) return;
// A pending share would otherwise survive logout and be re-applied after
// re-login with file paths the OS may already have evicted from the cache.
ShareIntentListener.instance.clear();
final wasLoggedIn = _lastStatus == AccountStatus.loggedIn;
_lastStatus = accountState.status;
// A cold start that merely resolves as signed out has nothing to wipe,
// and a share waiting for the login must survive it.
if (accountState.status != AccountStatus.loggedOut || !wasLoggedIn) return;
// Deferred until the account-scoped tree is torn down.
WidgetsBinding.instance.addPostFrameCallback(
(_) => unawaited(_wipeDeviceState()),
(_) => unawaited(SessionWipe.deviceLeft()),
);
}
String? _scopeAccountId;
/// The account-scoped tree only lives while its account is the one in use:
/// adding another account parks it, so its blocs go as well.
static Object _scopeOf(AccountState state) => (
state.accountId,
state.status == AccountStatus.addingAccount,
);
Object? _scope;
@override
Widget build(BuildContext context) => Directionality(
textDirection: TextDirection.ltr,
child: BlocBuilder<SettingsCubit, Settings>(
builder: (context, settings) {
// Selects only what the root uses: Settings is mutated in place and
// re-emitted as a fresh instance on every write, so a plain BlocBuilder
// would rebuild MaterialApp (and every route) for unrelated settings.
child: BlocSelector<SettingsCubit, Settings, _RootSettings>(
selector: (settings) => (
appTheme: settings.appTheme,
notificationsEnabled: settings.notificationSettings.enabled,
showPerformanceOverlay:
settings.devToolsSettings.showPerformanceOverlay,
showSemanticsDebugger: settings.devToolsSettings.showSemanticsDebugger,
showFrameStats: settings.devToolsSettings.showFrameStats,
showMemoryStats: settings.devToolsSettings.showMemoryStats,
slowAnimations: settings.devToolsSettings.slowAnimations,
textScaleOverride: settings.devToolsSettings.textScaleOverride,
),
builder: (context, root) {
// Mirror the notification toggle into group-scoped storage so the FCM
// background isolate and the iOS NSE can suppress rendering when off.
unawaited(
const PushRegistrationStore().setNotificationsEnabled(
settings.notificationSettings.enabled,
root.notificationsEnabled,
),
);
timeDilation = root.slowAnimations ? 5.0 : 1.0;
return BlocConsumer<AccountBloc, AccountState>(
listenWhen: (previous, current) =>
previous.status != current.status ||
previous.accountId != current.accountId,
listener: _onAccountChanged,
buildWhen: (previous, current) =>
previous.accountId != current.accountId,
_scopeOf(previous) != _scopeOf(current),
builder: (context, account) {
if (account.accountId != _scopeAccountId) {
_scopeAccountId = account.accountId;
final scope = _scopeOf(account);
if (scope != _scope) {
_scope = scope;
// The old navigator (and every route on it) goes with the old
// account; a shared GlobalKey would carry it over instead.
AppRoutes.rootNavigatorKey = GlobalKey<NavigatorState>();
}
return AccountScope(
key: ValueKey(account.accountId),
key: ValueKey(scope),
onActive: _onSessionActive,
child: _buildApp(settings),
child: _buildApp(root),
);
},
);
@@ -500,101 +549,97 @@ class _MainState extends State<Main> {
),
);
Widget _buildApp(Settings settings) {
final devToolsSettings = settings.devToolsSettings;
return MaterialApp(
showPerformanceOverlay: devToolsSettings.showPerformanceOverlay,
checkerboardOffscreenLayers: devToolsSettings.checkerboardOffscreenLayers,
checkerboardRasterCacheImages:
devToolsSettings.checkerboardRasterCacheImages,
debugShowCheckedModeBanner: false,
navigatorKey: AppRoutes.rootNavigatorKey,
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
// DownloadRouteObserver tracks full-page navigations so the downloads
// chip only surfaces once the user leaves the screen they started on.
navigatorObservers: [
AppRoutes.chatRouteObserver,
DownloadRouteObserver(),
],
localizationsDelegates: const [
...GlobalMaterialLocalizations.delegates,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: const [Locale('de'), Locale('en')],
locale: const Locale('de'),
title: 'Marianum Fulda',
themeMode: settings.appTheme,
theme: LightAppTheme.theme,
darkTheme: DarkAppTheme.theme,
// Brand-colored backdrop behind every route. During the logout
// home-swap and route pop animations the framework can briefly
// expose the layer below the topmost Scaffold; without this
// the dark Material default shows through and the user sees a
// black flash.
builder: (context, child) => ColoredBox(
Widget _buildApp(_RootSettings root) => MaterialApp(
showPerformanceOverlay: root.showPerformanceOverlay,
showSemanticsDebugger: root.showSemanticsDebugger,
debugShowCheckedModeBanner: false,
navigatorKey: AppRoutes.rootNavigatorKey,
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
// DownloadRouteObserver tracks full-page navigations so the downloads
// chip only surfaces once the user leaves the screen they started on.
navigatorObservers: _navigatorObservers,
localizationsDelegates: const [
...GlobalMaterialLocalizations.delegates,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: const [Locale('de'), Locale('en')],
locale: const Locale('de'),
title: 'Marianum Fulda',
themeMode: root.appTheme,
theme: LightAppTheme.theme,
darkTheme: DarkAppTheme.theme,
// Brand-colored backdrop behind every route. During the logout
// home-swap and route pop animations the framework can briefly
// expose the layer below the topmost Scaffold; without this
// the dark Material default shows through and the user sees a
// black flash.
builder: (context, child) {
Widget app = ColoredBox(
color: LightAppTheme.marianumRed,
// Downloads tray mounted ABOVE the navigator so its chip floats over
// every route (folder views, chat, viewer are full-page pushes that
// would otherwise cover it).
// Downloads tray mounted ABOVE the navigator so its chip floats
// over every route (folder views, chat, viewer are full-page
// pushes that would otherwise cover it).
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
),
home: EmergencyNoticeGate(
child: LoaderOverlay(
child: Breaker(
breaker: BreakerArea.global,
child: BlocBuilder<AccountBloc, AccountState>(
buildWhen: (previous, current) =>
previous.status != current.status,
builder: (context, accountState) {
switch (accountState.status) {
case AccountStatus.loggedIn:
return Stack(
fit: StackFit.expand,
children: [
if (_appMounted) const App(key: ValueKey('app-shell')),
if (_showPostLoginSplash)
PostLoginSplash(
key: const ValueKey('post-login-splash'),
onComplete: () {
setState(() => _showPostLoginSplash = false);
_promptGuardianNotifications();
},
),
],
);
case AccountStatus.loggedOut:
return const Login();
case AccountStatus.addingAccount:
return const Login(addingAccount: true);
case AccountStatus.undefined:
return const AccountLoadingScreen();
}
},
),
);
final scale = root.textScaleOverride;
if (scale > 0) {
app = MediaQuery.withClampedTextScaling(
minScaleFactor: scale,
maxScaleFactor: scale,
child: app,
);
}
return DebugStatsOverlay(
showFrameStats: root.showFrameStats,
showMemoryStats: root.showMemoryStats,
child: app,
);
},
home: EmergencyNoticeGate(
child: LoaderOverlay(
child: Breaker(
breaker: BreakerArea.global,
child: BlocBuilder<AccountBloc, AccountState>(
buildWhen: (previous, current) => previous.status != current.status,
builder: (context, accountState) {
switch (accountState.status) {
case AccountStatus.loggedIn:
return Stack(
fit: StackFit.expand,
children: [
if (_appMounted) const App(key: ValueKey('app-shell')),
if (_showPostLoginSplash)
PostLoginSplash(
key: const ValueKey('post-login-splash'),
onComplete: () {
setState(() => _showPostLoginSplash = false);
_promptGuardianNotifications();
},
),
],
);
case AccountStatus.loggedOut:
return const Login();
case AccountStatus.addingAccount:
return const Login(addingAccount: true);
case AccountStatus.undefined:
return const AccountLoadingScreen();
}
},
),
),
),
);
}
),
);
}
/// Wipes what outlives accounts once the last one signed out. Account data
/// itself is removed by [SessionLifecycle.signOut].
Future<void> _wipeDeviceState() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
// The chat background image lives outside HydratedStorage, so clear it too
// (best-effort) to avoid orphaning the previous user's wallpaper.
final backgroundImage = File(AppPaths.chatBackgroundImage);
if (backgroundImage.existsSync()) backgroundImage.deleteSync();
// Stop the periodic widget refresh job so the background isolate doesn't
// wake up every 30 minutes only to write `loggedIn=false`. Re-registers
// on the next successful login.
await WidgetBackgroundTask.cancelAll();
await WidgetSync.clear();
await WidgetSync.triggerUpdate();
} catch (e, s) {
log('Device state wipe failed: $e', stackTrace: s);
}
}
typedef _RootSettings = ({
ThemeMode appTheme,
bool notificationsEnabled,
bool showPerformanceOverlay,
bool showSemanticsDebugger,
bool showFrameStats,
bool showMemoryStats,
bool slowAnimations,
double textScaleOverride,
});
+3 -19
View File
@@ -1,24 +1,8 @@
import 'package:localstore/localstore.dart';
import '../api/request_cache.dart';
import '../api/cache_store.dart';
class DataCleaner {
static Future<void> cleanOldCache() async {
final cacheData = await Localstore.instance
.collection(RequestCache.collection)
.get();
cacheData?.forEach((key, value) async {
final lastUpdate = DateTime.fromMillisecondsSinceEpoch(
((value['lastupdate'] as num?) ?? 0).toInt(),
);
if (DateTime.now()
.subtract(const Duration(days: 200))
.isAfter(lastUpdate)) {
await Localstore.instance
.collection(RequestCache.collection)
.doc(key.split('/').last)
.delete();
}
});
await CacheStore.deleteLegacyLocalstoreCache();
await CacheStore.instance.deleteOlderThan(const Duration(days: 200));
}
}
+122
View File
@@ -0,0 +1,122 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_app_badge/flutter_app_badge.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../api/marianumcloud/talk/share_files_to_chat.dart';
import '../background/widget_background_task.dart';
import '../notification/notification_service.dart';
import '../push/chat_thread_store.dart';
import '../push/nid_store.dart';
import '../push/push_keypair.dart';
import '../push/push_tap_router.dart';
import '../routing/app_routes.dart';
import '../share_intent/share_intent_listener.dart';
import '../utils/app_paths.dart';
import '../utils/downloads/download_manager.dart';
import '../utils/file_clipboard.dart';
import '../widget_data/widget_sync.dart';
/// Removes what an account leaves on the device outside its own storage (see
/// `AccountStorage` for that). Every step is isolated: one failing step used
/// to skip all later ones and leave the previous account's data behind.
abstract final class SessionWipe {
static Future<void>? _running;
/// Completes once a running wipe finished. A login awaits it, otherwise the
/// wipe could clear what the next account just stored (widget job, caches).
static Future<void> get done => _running ?? Future.value();
/// State that must be gone before the next frame (a login screen or another
/// account can be reached right away): pending navigation and in-memory
/// singletons.
static void clearImmediate() {
_step('share intents', ShareIntentListener.instance.clearAll);
_step('share folder cache', resetTalkShareFolderCache);
_step('pending navigation', () {
AppRoutes.pendingChatToken.value = null;
PushTapRouter.pendingTarget.value = null;
});
_step('file clipboard', FileClipboard.instance.clear);
_step('downloads', DownloadManager.instance.clearAll);
}
/// Runs whenever the active account stops being active (sign-out or
/// switch). The tray and its bookkeeping belong to that account — a tap or
/// inline reply there would otherwise act as the next one. A new keypair
/// and FCM token make pushes still addressed to the previous registration
/// undecryptable and undeliverable (an offline sign-out or switch could not
/// unregister it).
static Future<void> accountLeft() async {
await _stepAsync('download files', () async {
final dir = Directory('${(await getTemporaryDirectory()).path}/downloads');
if (dir.existsSync()) await dir.delete(recursive: true);
});
await _stepAsync(
'notification tray',
NotificationService().flutterLocalNotificationsPlugin.cancelAll,
);
await _stepAsync('app badge', () => FlutterAppBadge.count(0));
await _stepAsync('push nid store', NidStore().clear);
await _stepAsync('push thread store', ChatThreadStore().clearAll);
await _stepAsync('push keypair', const PushKeypair().clear);
// Bounded: the next account waits for this, also without a network.
await _stepAsync(
'fcm token',
() => FirebaseMessaging.instance.deleteToken().timeout(
const Duration(seconds: 5),
),
);
}
/// Wipes what outlives accounts, once the last one signed out.
static Future<void> deviceLeft() {
final wipe = _deviceLeft();
_running = wipe;
return wipe.whenComplete(() {
if (identical(_running, wipe)) _running = null;
});
}
static Future<void> _deviceLeft() async {
// SettingsCubit is deliberately left alone: its selector wraps
// MaterialApp, and emitting a fresh state here tore down the freshly
// mounted Login tree (blank screen until the next interaction).
await _stepAsync('shared preferences', () async {
await (await SharedPreferences.getInstance()).clear();
});
await _stepAsync('chat background', () async {
final image = File(AppPaths.chatBackgroundImage);
if (image.existsSync()) await image.delete();
});
// Stop the periodic widget refresh job so the background isolate doesn't
// wake up every 30 minutes only to write `loggedIn=false`. Re-registers
// on the next successful login.
await _stepAsync('widget task', WidgetBackgroundTask.cancelAll);
await _stepAsync('widget data', WidgetSync.clear);
await _stepAsync('widget update', WidgetSync.triggerUpdate);
}
static void _step(String name, void Function() step) {
try {
step();
} catch (e, s) {
log('Session wipe step "$name" failed: $e', stackTrace: s);
}
}
static Future<void> _stepAsync(
String name,
Future<void> Function() step,
) async {
try {
await step();
} catch (e, s) {
log('Session wipe step "$name" failed: $e', stackTrace: s);
}
}
}
+10 -1
View File
@@ -7,6 +7,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../push/push_message_handler.dart';
import '../push/push_target.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../utils/debouncer.dart';
import '../widget/debug/debug_tile.dart';
import '../widget/debug/json_viewer.dart';
import '../widget/info_dialog.dart';
@@ -35,7 +37,14 @@ class NotificationController {
);
await NotificationTasks.refreshBadge();
if (!context.mounted) return;
NotificationTasks.updateProviders(context);
// A busy group chat delivers pushes in bursts; one silent refresh after
// the burst replaces a full room-list fetch per message.
final chatList = context.read<ChatListBloc>();
Debouncer.debounce(
'pushChatListRefresh',
const Duration(seconds: 1),
() => chatList.refresh(silent: true),
);
}
static Future<void> onAppOpenedByNotification(
@@ -25,7 +25,12 @@ class NotificationService {
// iOS Talk category mirrors the Android inline reply + mark-as-read actions
// so both platforms expose the same quick actions. The actual delivery of
// these while the app is terminated is handled by the (Phase 3) NSE.
// The plugin defaults to prompting here, which would block startup on the
// OS dialog; the prompt belongs to maybePromptTalkNotifications instead.
final iosSettings = DarwinInitializationSettings(
requestAlertPermission: false,
requestSoundPermission: false,
requestBadgePermission: false,
notificationCategories: [
DarwinNotificationCategory(
PushRenderer.iosTalkCategory,
+4
View File
@@ -60,6 +60,10 @@ class PushActions {
// auth header, the Talk POST never happens and the RemoteInput spinner
// runs forever.
DartPluginRegistrant.ensureInitialized();
// The action engine lives as long as the process: without a reload a
// reply would be sent with the account that was signed in when the
// engine first started.
await SessionManager().reloadFromStorage();
_plog(
'action=${response.actionId} payload=${response.payload} '
+19 -1
View File
@@ -1,7 +1,7 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypton/crypton.dart';
import 'package:flutter/foundation.dart';
import 'package:pointycastle/export.dart' as pc;
import 'push_subject.dart';
@@ -25,6 +25,14 @@ class PushDecryptor {
const PushDecryptor({required this.devicePrivateKey, this.serverPublicKey});
/// [verify] + [decrypt] on a background isolate: pure-Dart RSA-2048 (with
/// the OAEP → PKCS#1 fallback) takes tens of ms up to >100 ms on older
/// phones, which would otherwise land on the UI thread for every push.
Future<({bool verified, PushSubject? subject})> verifyAndDecryptInBackground(
String subjectBase64,
String signatureBase64,
) => compute(_verifyAndDecrypt, (this, subjectBase64, signatureBase64));
/// Returns true when [signatureBase64] is a valid server signature over the
/// encrypted subject. Returns true when no server key is configured (the
/// proxy already verified the signature before forwarding).
@@ -75,3 +83,13 @@ class PushDecryptor {
}
}
}
({bool verified, PushSubject? subject}) _verifyAndDecrypt(
(PushDecryptor, String, String) args,
) {
final (decryptor, subject, signature) = args;
if (!decryptor.verify(subject, signature)) {
return (verified: false, subject: null);
}
return (verified: true, subject: decryptor.decrypt(subject));
}
+9 -2
View File
@@ -5,6 +5,7 @@ import 'package:firebase_messaging/firebase_messaging.dart';
import '../background/widget_background_task.dart';
import '../notification/notification_service.dart';
import '../session/session_manager.dart';
import 'chat_thread_store.dart';
import 'nid_store.dart';
import 'push_decryptor.dart';
@@ -54,6 +55,8 @@ PushKind classifyPush(Map<String, dynamic> data) {
/// "must be annotated"), so a plain function is the reliable form.
@pragma('vm:entry-point')
Future<void> pushOnBackgroundMessage(RemoteMessage message) async {
// This engine outlives sign-outs and logins in the app.
await SessionManager().reloadFromStorage();
await NotificationService().initializeNotifications();
await PushRenderer.ensureChannels();
await PushMessageHandler().handle(message);
@@ -175,11 +178,15 @@ class PushMessageHandler {
devicePrivateKey: privateKey,
serverPublicKey: serverPublicKey,
);
if (!decryptor.verify(subjectBase64, signatureBase64)) {
final result = await decryptor.verifyAndDecryptInBackground(
subjectBase64,
signatureBase64,
);
if (!result.verified) {
log('Push: signature verification failed');
return;
}
final subject = decryptor.decrypt(subjectBase64);
final subject = result.subject;
if (subject == null) {
log('Push: could not decrypt subject');
return;
+32 -34
View File
@@ -118,6 +118,7 @@ class PushRegistration {
Future<bool> register() async {
if (DemoMode.active) return false;
if (_nextcloudOrNull == null) return _direct.register();
final epoch = SessionManager().sessionEpoch;
final String? fcmToken;
try {
fcmToken = await FirebaseMessaging.instance.getToken();
@@ -167,6 +168,7 @@ class PushRegistration {
fcmToken: fcmToken,
pems: pems,
appVersion: appVersion,
epoch: epoch,
);
allOk = allOk && ok;
}
@@ -178,6 +180,7 @@ class PushRegistration {
required String fcmToken,
required PushKeypairPems pems,
required String? appVersion,
required int epoch,
}) async {
try {
final proxyServer = currentProxyServer;
@@ -193,6 +196,11 @@ class PushRegistration {
userAgent: isTalk ? _talkUserAgent : null,
);
// Signed out while registering: persisting or announcing this
// registration would keep delivering the previous account's pushes,
// and logoutCleanup already ran so nothing would unregister it.
if (!SessionManager().isCurrentSession(epoch)) return false;
await _store.save(
type: type,
deviceIdentifier: registration.deviceIdentifier,
@@ -355,9 +363,8 @@ class PushRegistration {
status != AuthorizationStatus.deniedPermanently;
/// Requests the OS notification permission (covers iOS + Android 13) and
/// returns whether registration should proceed. Errors from the plugin are
/// treated as usable — better a possibly-idle registration than silently
/// losing push over a transient failure.
/// returns whether it is usable (not explicitly denied). Errors from the
/// plugin are treated as usable so a transient failure never nags the user.
static Future<bool> requestOsPermission() async {
try {
final settings = await FirebaseMessaging.instance.requestPermission();
@@ -368,56 +375,47 @@ class PushRegistration {
}
}
/// True when the user has explicitly denied the OS notification permission.
/// Read-only (no prompt) — used by the settings UI to surface the state.
static Future<bool> isOsPermissionDenied() async {
/// Current OS notification permission, or null when the plugin can't tell.
/// Read-only — never triggers the OS prompt.
static Future<AuthorizationStatus?> osPermissionStatus() async {
try {
final settings = await FirebaseMessaging.instance
.getNotificationSettings();
return !isPermissionUsable(settings.authorizationStatus);
return settings.authorizationStatus;
} on Object {
return false;
return null;
}
}
/// True when the OS notification permission is already granted
/// (`authorized`/`provisional`). Read-only — never triggers the OS prompt.
/// Used by the cold-start/self-heal path so it registers only for devices
/// that already opted in, leaving the actual prompt to the first Talk visit.
static Future<bool> isOsPermissionGranted() async {
try {
final settings = await FirebaseMessaging.instance
.getNotificationSettings();
return settings.authorizationStatus == AuthorizationStatus.authorized ||
settings.authorizationStatus == AuthorizationStatus.provisional;
} on Object {
return false;
}
final status = await osPermissionStatus();
return status != null && canDisplay(status);
}
/// Whether the OS will actually show a visible notification in [status].
/// Stricter than [isPermissionUsable]: `notDetermined` shows nothing yet.
static bool canDisplay(AuthorizationStatus status) =>
status == AuthorizationStatus.authorized ||
status == AuthorizationStatus.provisional;
/// Registers this device whenever the backend advertises the push capability.
/// Deliberately independent of the in-app notification toggle: a user who
/// turned notifications off stays registered so silent sync pushes keep
/// flowing — the display is suppressed downstream via the mirrored flag (see
/// [PushRegistrationStore.notificationsEnabled]). Only registers when the OS
/// notification permission is *already* granted — it never triggers the OS
/// prompt itself. Requesting the permission is the job of the first Talk visit
/// (see `maybePromptTalkNotifications`), which keeps the prompt out of the
/// cold-start path. Safe to call on every start — Nextcloud dedups an
/// unchanged registration — which also self-heals a device whose registration
/// was lost.
/// Deliberately independent of both the in-app notification toggle and the
/// OS notification permission: silent sync pushes (deletes, chat/badge
/// refresh, widget refresh) need no permission — data-only FCM on Android,
/// `content-available` on iOS — so every device stays in sync. Whether a
/// visible notification is wanted is reported separately via the telemetry
/// heartbeat (toggle + OS permission), from which the server picks silent
/// pushes on iOS. Never triggers the OS prompt. Safe to call on every
/// start — Nextcloud dedups an unchanged registration — which also
/// self-heals a device whose registration was lost.
/// Returns whether registration was actually *attempted* (all gates passed).
/// Even a partial success persists the `general` device identifier, so the
/// caller re-emits telemetry on `true` to reflect the fresh registration in
/// the same session instead of lagging until the next launch.
static Future<bool> syncSubscription({required bool capable}) async {
if (!capable) return false;
if (!await isOsPermissionGranted()) {
log(
'Push: OS notification permission not granted, skipping registration',
);
return false;
}
final registration = PushRegistration();
// register() below refreshes an unchanged subscription anyway; the check
// only surfaces the endpoint switch in the log for diagnosability.
+5 -16
View File
@@ -1,4 +1,3 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import '../session/session_manager.dart';
@@ -142,21 +141,11 @@ Future<PushStatusReport> collectPushStatus({
}
Future<PushCheck> _osPermission() async {
try {
final settings = await FirebaseMessaging.instance.getNotificationSettings();
switch (settings.authorizationStatus) {
case AuthorizationStatus.authorized:
case AuthorizationStatus.provisional:
return PushCheck.ok;
case AuthorizationStatus.denied:
case AuthorizationStatus.deniedPermanently:
return PushCheck.fail;
case AuthorizationStatus.notDetermined:
return PushCheck.unknown;
}
} on Object {
return PushCheck.unknown;
}
final status = await PushRegistration.osPermissionStatus();
if (status == null) return PushCheck.unknown;
if (PushRegistration.canDisplay(status)) return PushCheck.ok;
if (!PushRegistration.isPermissionUsable(status)) return PushCheck.fail;
return PushCheck.unknown;
}
/// One line in the status checklist.
+23 -2
View File
@@ -28,6 +28,7 @@ import '../view/pages/more/roomplan/roomplan.dart';
import '../view/pages/more/share/qr_share_view.dart';
import '../view/pages/parent_letters/parent_letter_view.dart';
import '../view/pages/settings/chat_background_settings_page.dart';
import '../view/pages/settings/diagnostics_page.dart';
import '../view/pages/settings/modules_settings_page.dart';
import '../view/pages/settings/settings.dart';
import '../view/pages/share_intent/share_chat_picker.dart';
@@ -40,7 +41,9 @@ import '../view/pages/ticker/ticker_page_view.dart';
import '../view/pages/timetable/custom_events/custom_events_view.dart';
import '../view/pages/timetable/subject_colors/subject_colors_view.dart';
import '../widget/avatar_crop_page.dart';
import '../widget/debug/bloc_storage_view.dart';
import '../widget/debug/cache_view.dart';
import '../widget/debug/json_viewer.dart';
import '../widget/file_viewer.dart';
import '../widget/large_profile_picture_view.dart';
import '../widget/user_avatar.dart';
@@ -294,8 +297,26 @@ class AppRoutes {
pushScreen(context, withNavBar: false, screen: const FeedbackDialog());
}
static void openCacheView(BuildContext context) {
pushScreen(context, withNavBar: false, screen: const CacheView());
static Future<void> openCacheView(BuildContext context) =>
pushScreen(context, withNavBar: false, screen: const CacheView());
static Future<void> openBlocStorageView(BuildContext context) =>
pushScreen(context, withNavBar: false, screen: const BlocStorageView());
static void openDiagnostics(BuildContext context) {
pushScreen(context, withNavBar: false, screen: const DiagnosticsPage());
}
static void openJsonViewer(
BuildContext context,
Object? data, {
String? title,
}) {
pushScreen(
context,
withNavBar: false,
screen: JsonViewer(data: data, title: title ?? 'Rohdaten'),
);
}
static void openRoomplan(BuildContext context) {
+19 -16
View File
@@ -10,8 +10,7 @@ import '../api/marianumconnect/queries/auth_logout/auth_logout.dart';
import '../api/marianumconnect/queries/auth_me/auth_me.dart';
import '../auth_link/guardian_link_listener.dart';
import '../background/widget_background_task.dart';
import '../push/chat_thread_store.dart';
import '../push/nid_store.dart';
import '../model/session_wipe.dart';
import '../push/push_registration.dart';
import '../storage/account_storage.dart';
import '../widget_data/widget_sync.dart';
@@ -41,10 +40,11 @@ abstract final class SessionLifecycle {
}
await AuthLogout().run();
final removed = await SessionManager().signOut();
SessionWipe.clearImmediate();
// A login link that arrived while signed in must not be replayed on the
// login screen that follows.
GuardianLinkListener.clear();
await _clearPushStores();
await SessionWipe.accountLeft();
if (removed != null) await AccountStorage.delete(removed.namespace);
for (
@@ -78,14 +78,22 @@ abstract final class SessionLifecycle {
log('Switch: push deactivation failed: $e');
}
await SessionManager().stashActive();
SessionWipe.clearImmediate();
var restored = false;
try {
await SessionManager().activate(id);
} on Object {
if (previous != null) await SessionManager().activate(previous.id);
if (previous != null) {
await SessionManager().activate(previous.id);
restored = true;
}
rethrow;
} finally {
await _clearPushStores();
await SessionWipe.accountLeft();
await AccountStorage.activate(SessionManager().activeAccount);
// Nothing remounts for the account that stays, so its push would
// remain unregistered until the next start.
if (restored) unawaited(PushRegistration().register());
}
await _resetWidget();
}
@@ -94,6 +102,10 @@ abstract final class SessionLifecycle {
static Future<void> beginAddAccount() async {
await SessionManager().stashActive();
_addReturnId = SessionManager().activeAccount?.id;
SessionWipe.clearImmediate();
// Whatever runs behind the login screen must not write into the parked
// account's data.
await AccountStorage.activate(null);
}
static bool get isAddingAccount => _addReturnId != null;
@@ -109,6 +121,7 @@ abstract final class SessionLifecycle {
await SessionManager().signOut();
}
await SessionManager().activate(returnId);
await AccountStorage.activate(SessionManager().activeAccount);
}
/// Called once a login finished. After "add account" the previous account's
@@ -127,7 +140,7 @@ abstract final class SessionLifecycle {
log('Add account: push deactivation failed: $e');
}
await SessionManager().activate(added.id);
await _clearPushStores();
await SessionWipe.accountLeft();
}
await AccountStorage.activate(SessionManager().activeAccount);
return added.id;
@@ -191,14 +204,4 @@ abstract final class SessionLifecycle {
log('Widget reset failed: $e');
}
}
// Tray bookkeeping of the previous account's pushes.
static Future<void> _clearPushStores() async {
try {
await NidStore().clear();
await ChatThreadStore().clearAll();
} on Object catch (e) {
log('Push store cleanup failed: $e');
}
}
}
+60 -5
View File
@@ -58,6 +58,21 @@ class SessionManager {
Session? get current => _current;
int _sessionEpoch = 0;
/// Bumped whenever the active account changes (sign-out, switch, another
/// account signing in). Async work captures it when it starts and drops its
/// result when it changed meanwhile, so a request of the previous account
/// cannot land in the next account's state or cache.
int get sessionEpoch => _sessionEpoch;
bool isCurrentSession(int epoch) => epoch == _sessionEpoch;
bool _isUiEngine = false;
/// Called from `main()`; background entry points never run it.
void markUiEngine() => _isUiEngine = true;
/// Every signed-in account and which one is active.
final ValueNotifier<AccountIndex> accounts = ValueNotifier(
AccountIndex.empty,
@@ -115,6 +130,7 @@ class SessionManager {
}
Future<void> _writeActive(Session session) async {
if (!_isSameAccount(_current, session)) _sessionEpoch++;
await Future.wait([
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
_writeSecret(key, value),
@@ -134,6 +150,7 @@ class SessionManager {
/// stay in their vaults; see [activate].
Future<AccountEntry?> signOut() async {
final removed = activeAccount;
_sessionEpoch++;
_loaded = Completer();
_current = null;
await Future.wait([
@@ -193,6 +210,11 @@ class SessionManager {
await _saveIndex(accounts.value.remove(id));
}
static bool _isSameAccount(Session? a, Session? b) {
if (a == null || b == null) return a == b;
return identityOf(a) == identityOf(b) && a.isDemo == b.isDemo;
}
Future<void> _saveIndex(AccountIndex index) async {
accounts.value = index;
await _secureStorage.write(key: _indexKey, value: index.encode());
@@ -280,8 +302,15 @@ class SessionManager {
Future<void> _migrateAndLoad() async {
await _migrateFromLegacyStorage();
await _migrateKeychainAccessibility();
// On the startup critical path (and every background wake): read in
// parallel instead of one keychain round-trip after the other.
_current = await _readActive();
await _loadIndex();
if (!_loaded.isCompleted) _loaded.complete();
}
/// The session in the active slots. On the startup critical path (and every
/// background wake): read in parallel instead of one keychain round-trip
/// after the other.
Future<Session?> _readActive() async {
final values = await Future.wait(
_sessionFields.map((field) => _secureStorage.read(key: field)),
);
@@ -296,9 +325,35 @@ class SessionManager {
} on Object {
// Group keystore unavailable: fall back to the real password.
}
_current = decodeSession(raw);
await _loadIndex();
if (!_loaded.isCompleted) _loaded.complete();
return decodeSession(raw);
}
/// Username currently in the active slots. Other engines (widget task, push
/// isolates) sign out, in or switch without this instance noticing.
Future<String?> readStoredUsername() =>
_secureStorage.read(key: SessionKeys.username);
/// Re-reads the session for long-lived background engines: they load once
/// and would otherwise keep acting with an account that signed out or was
/// switched away from in the app meanwhile. Keeps the known state when the
/// keystore is unreadable.
Future<void> reloadFromStorage() async {
// The UI engine performs sign-in, sign-out and switches itself, so its
// state is current; re-reading in between could resurrect the removed
// account.
if (_isUiEngine) return;
try {
final session = await _readActive();
final index = AccountIndex.decode(
await _secureStorage.read(key: _indexKey),
);
if (!_isSameAccount(_current, session)) _sessionEpoch++;
_current = session;
accounts.value = index;
if (!_loaded.isCompleted) _loaded.complete();
} on Object catch (e) {
log('Session reload failed, keeping loaded state: $e');
}
}
Future<void> _loadIndex() async {
+8 -2
View File
@@ -5,11 +5,17 @@ class PendingShare {
final String? text;
final DateTime receivedAt;
/// Paths as delivered by the platform, before the listener moved them into
/// a per-share folder. Duplicate detection compares these, since the
/// relocated [filePaths] differ for every delivery.
final List<String> sourcePaths;
const PendingShare({
required this.filePaths,
required this.text,
required this.receivedAt,
});
List<String>? sourcePaths,
}) : sourcePaths = sourcePaths ?? filePaths;
bool get hasFiles => filePaths.isNotEmpty;
bool get hasText => text != null && text!.isNotEmpty;
@@ -20,5 +26,5 @@ class PendingShare {
/// the same share can arrive twice on the media stream — receivedAt is
/// deliberately ignored here so such duplicates compare equal.
bool contentEquals(PendingShare other) =>
text == other.text && listEquals(filePaths, other.filePaths);
text == other.text && listEquals(sourcePaths, other.sourcePaths);
}
+171 -46
View File
@@ -2,6 +2,8 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import '../auth_link/guardian_login_link.dart';
@@ -15,55 +17,63 @@ class ShareIntentListener {
static final ValueNotifier<PendingShare?> pending = ValueNotifier(null);
StreamSubscription<List<SharedMediaFile>>? _streamSub;
bool _initialized = false;
static const _androidChannel = MethodChannel('eu.mhsl.marianum.share');
/// Reads the cold-start payload exactly once. Call from `main()` before
/// `runApp` so the share is queued before the UI mounts.
/// Per-share folders live below this directory name — on Android in the
/// cache dir (created here), on iOS in the App Group container (created by
/// the Share Extension).
static const _shareDirName = 'share_intent';
bool _initialized = false;
String? _cacheDir;
int _flowDepth = 0;
PendingShare? _queued;
PendingShare? _shown;
/// Subscribes to warm shares and reads the cold-start payload. Call from
/// `main()` before `runApp`. The subscription stays for the process
/// lifetime: the plugin drops shares that arrive while nobody listens
/// (login screen, app remount), and [pending] buffers until the UI is ready.
Future<void> initialize() async {
if (_initialized) return;
_initialized = true;
try {
final initial = await ReceiveSharingIntent.instance.getInitialMedia();
final share = _toPendingShare(initial);
if (share != null) _publish(share);
if (Platform.isAndroid) {
_cacheDir = (await getTemporaryDirectory()).path;
_sweepStaleAndroidShares();
}
ReceiveSharingIntent.instance.getMediaStream().listen(
_onItems,
onError: (Object e) => debugPrint('ShareIntentListener stream: $e'),
);
_onItems(await ReceiveSharingIntent.instance.getInitialMedia());
await ReceiveSharingIntent.instance.reset();
if (Platform.isAndroid) {
await _androidChannel.invokeMethod<void>('listenerReady');
}
} catch (e) {
debugPrint('ShareIntentListener.initialize failed: $e');
}
}
/// Subscribes to warm-share stream events. Safe to call multiple times.
void attach() {
_streamSub ??= ReceiveSharingIntent.instance.getMediaStream().listen(
(items) {
final share = _toPendingShare(items);
if (share != null) _publish(share);
},
onError: (Object e) => debugPrint('ShareIntentListener stream error: $e'),
);
/// Whether the share flow for [share] is already on screen. Re-running the
/// navigation for it would pop its own page and thereby clear it.
bool isShown(PendingShare share) => identical(share, _shown);
void markShown(PendingShare share) => _shown = share;
/// Marks a share flow step that must not be interrupted (upload, share API
/// call). Shares arriving meanwhile are queued instead of replacing the
/// current one, whose files are still in use. Pair with [endFlow].
void beginFlow() => _flowDepth++;
void endFlow() {
if (_flowDepth > 0) _flowDepth--;
if (_flowDepth == 0) _drainQueue();
}
/// The iOS Share Extension fires two `open(url)` requests per share, so the
/// same payload can arrive twice in quick succession. Publishing the
/// duplicate would re-trigger the share-flow navigation, pop the already
/// open ShareTargetPage and thereby delete the temp files of the share that
/// is still in flight — swallow it instead.
void _publish(PendingShare share) {
final current = pending.value;
if (current != null && current.contentEquals(share)) return;
pending.value = share;
}
/// Cancels the warm-share subscription. The singleton survives, so a
/// subsequent [attach] re-subscribes.
void detach() {
_streamSub?.cancel();
_streamSub = null;
}
/// Discards the current share and removes any temp files the plugin copied
/// into the app cache. Idempotent.
/// Discards the current share and deletes its temp files. Idempotent.
///
/// Pass [ifCurrent] from UI that owns a specific share (e.g. the
/// ShareTargetPage pop handler): the call then only acts while that share
@@ -73,18 +83,58 @@ class ShareIntentListener {
void clear({PendingShare? ifCurrent}) {
final current = pending.value;
if (ifCurrent != null && !identical(current, ifCurrent)) return;
_shown = null;
pending.value = null;
if (current != null) {
for (final path in current.filePaths) {
try {
final f = File(path);
if (f.existsSync()) f.deleteSync();
} catch (_) {
// best-effort cleanup; OS will reclaim cache eventually
}
if (current != null) _deleteFiles(current);
unawaited(ReceiveSharingIntent.instance.reset());
if (_flowDepth == 0) _drainQueue();
}
/// Drops every share, including a queued one — for sign-out.
void clearAll() {
final queued = _queued;
_queued = null;
_flowDepth = 0;
if (queued != null) _deleteFiles(queued);
clear();
}
void _onItems(List<SharedMediaFile> items) {
final share = _toPendingShare(items);
if (share == null) return;
// The iOS Share Extension opens the app twice per share, so the same
// payload can arrive again while it is still pending. Publishing it would
// re-run the navigation and pop the share that is in flight.
for (final live in [pending.value, _queued]) {
if (live != null && live.contentEquals(share)) {
_deleteFiles(share, keep: live.filePaths);
return;
}
}
unawaited(ReceiveSharingIntent.instance.reset());
final claimed = _claim(share);
if (_flowDepth > 0) {
final replaced = _queued;
_queued = claimed;
if (replaced != null) _deleteFiles(replaced);
return;
}
_replaceCurrent(claimed);
}
void _drainQueue() {
final queued = _queued;
if (queued == null) return;
_queued = null;
_replaceCurrent(queued);
}
// No flow is running, so nothing uses the previous share's files anymore;
// its pages are popped when the new share is routed.
void _replaceCurrent(PendingShare share) {
final previous = pending.value;
_shown = null;
pending.value = share;
if (previous != null) _deleteFiles(previous);
}
/// Android hands an opened App Link (guardian login mail) to the share
@@ -106,7 +156,9 @@ class ShareIntentListener {
case SharedMediaType.image:
case SharedMediaType.video:
case SharedMediaType.file:
files.add(item.path);
// A replayed share can point at temp copies deleted after the
// original share completed.
if (File(item.path).existsSync()) files.add(item.path);
case SharedMediaType.text:
case SharedMediaType.url:
if (!isAppLink(item.path)) texts.add(item.path);
@@ -119,4 +171,77 @@ class ShareIntentListener {
receivedAt: DateTime.now(),
);
}
/// The Android plugin copies shared content to `cacheDir/<display name>`,
/// so a second share with an equally named file would overwrite the first
/// one's copy. Move owned copies into a folder of their own. iOS shares
/// already arrive in a per-share folder.
PendingShare _claim(PendingShare share) {
final cacheDir = _cacheDir;
if (!Platform.isAndroid || cacheDir == null || !share.hasFiles) {
return share;
}
final dir = Directory(
'$cacheDir/$_shareDirName/${share.receivedAt.microsecondsSinceEpoch}',
);
final claimed = <String>[];
for (final (i, path) in share.filePaths.indexed) {
if (!_isOwned(path)) {
claimed.add(path);
continue;
}
try {
dir.createSync(recursive: true);
final name = path.split(Platform.pathSeparator).last;
var target = '${dir.path}/$name';
if (File(target).existsSync()) target = '${dir.path}/$i-$name';
claimed.add(File(path).renameSync(target).path);
} catch (_) {
claimed.add(path);
}
}
return PendingShare(
filePaths: claimed,
text: share.text,
receivedAt: share.receivedAt,
sourcePaths: share.sourcePaths,
);
}
/// Only temp copies are deleted: on Android the plugin hands out the real
/// path for some providers (e.g. a file in Downloads), which must survive.
bool _isOwned(String path) {
if (Platform.isIOS) return true;
final cacheDir = _cacheDir;
return cacheDir != null && path.startsWith('$cacheDir/');
}
void _deleteFiles(PendingShare share, {List<String> keep = const []}) {
for (final path in share.filePaths) {
if (keep.contains(path) || !_isOwned(path)) continue;
try {
final file = File(path);
if (file.existsSync()) file.deleteSync();
final dir = file.parent;
if (dir.parent.path.endsWith('/$_shareDirName') &&
dir.listSync().isEmpty) {
dir.deleteSync();
}
} catch (_) {
// best-effort cleanup; OS will reclaim cache eventually
}
}
}
// Shares whose flow never finished (process killed) leave their folder
// behind; the pending one of this process is not claimed yet.
void _sweepStaleAndroidShares() {
try {
final root = Directory('$_cacheDir/$_shareDirName');
if (!root.existsSync()) return;
root.deleteSync(recursive: true);
} catch (_) {
// best-effort
}
}
}
@@ -20,10 +20,15 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState>
LoadableStateBloc() : super(const LoadableStateState(connections: null)) {
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);
if (connectivityStatusKnown() && isConnected()) {
if (reFetch == null) return;
reFetch!();
if ((wasOffline || event.fromResume) &&
connectivityStatusKnown() &&
isConnected()) {
reFetch?.call();
}
});
@@ -54,7 +59,12 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState>
unawaited(
Connectivity().checkConnectivity().then((result) {
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 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
// rebuild the tree under the ListView and reset its scroll position.
final content = SizedBox(
height: MediaQuery.of(context).size.height,
height: MediaQuery.sizeOf(context).height,
child: hasContent
? child(typedData as TState, isLoading)
: const SizedBox.shrink(),
@@ -95,10 +95,12 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
late Timer _rebuildTimer;
@override
void initState() {
_rebuildTimer = Timer.periodic(
const Duration(seconds: 10),
(timer) => setState(() {}),
);
// Only refresh the relative "last updated" text while this page is the
// visible one; offstage tabs and covered routes have tickers disabled.
_rebuildTimer = Timer.periodic(const Duration(seconds: 10), (timer) {
if (!mounted) return;
if (TickerMode.getValuesNotifier(context).value.enabled) setState(() {});
});
super.initState();
}
@@ -33,6 +33,9 @@ class _LoadableStatePrimaryLoadingState
extends State<LoadableStatePrimaryLoading> {
Timer? _slowHintTimer;
bool _showSlowHint = false;
// An indeterminate spinner ticks every frame even at opacity 0, so it is
// unmounted once the fade-out finished instead of just being hidden.
late bool _spinnerMounted = widget.visible;
@override
void initState() {
@@ -44,6 +47,7 @@ class _LoadableStatePrimaryLoadingState
void didUpdateWidget(covariant LoadableStatePrimaryLoading oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.visible != oldWidget.visible) _restartSlowHintTimer();
if (widget.visible) _spinnerMounted = true;
}
void _restartSlowHintTimer() {
@@ -62,43 +66,49 @@ class _LoadableStatePrimaryLoadingState
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context) => AnimatedOpacity(
opacity: widget.visible ? 1.0 : 0.0,
duration: LoadableStateConsumer.animationDuration,
curve: Curves.easeInOut,
onEnd: () {
if (!widget.visible && _spinnerMounted) {
setState(() => _spinnerMounted = false);
}
},
child: _spinnerMounted ? _spinner(context) : const SizedBox.shrink(),
);
Widget _spinner(BuildContext context) {
final status =
widget.statusText ??
(_showSlowHint ? LoadableStatePrimaryLoading.slowHintText : null);
return AnimatedOpacity(
opacity: widget.visible ? 1.0 : 0.0,
duration: LoadableStateConsumer.animationDuration,
curve: Curves.easeInOut,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const AppProgressIndicator.large(),
AnimatedSwitcher(
duration: LoadableStateConsumer.animationDuration,
child: status == null
? const SizedBox.shrink()
: Padding(
key: ValueKey(status),
padding: const EdgeInsets.only(
top: 16,
left: 24,
right: 24,
),
child: Text(
status,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).hintColor,
),
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const AppProgressIndicator.large(),
AnimatedSwitcher(
duration: LoadableStateConsumer.animationDuration,
child: status == null
? const SizedBox.shrink()
: Padding(
key: ValueKey(status),
padding: const EdgeInsets.only(
top: 16,
left: 24,
right: 24,
),
child: Text(
status,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).hintColor,
),
),
),
],
),
),
),
],
),
);
}
@@ -1,10 +1,13 @@
import 'dart:async';
import 'dart:developer';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../access/access_requirement.dart';
import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/errors/stale_session_exception.dart';
import '../../../../../session/session_manager.dart';
import '../../../../../utils/session_single_flight.dart';
import '../../loadable_state/loadable_state.dart';
import '../../loadable_state/loading_error.dart';
import '../../repository/repository.dart';
@@ -19,6 +22,12 @@ abstract class LoadableHydratedBloc<
extends
HydratedBloc<LoadableHydratedBlocEvent<TState>, LoadableState<TState>> {
late TRepository _repository;
// HydratedBloc serialises and writes the full state on every emit; loading
// flags, status texts and errors re-emit the very same data, so those
// writes are skipped (see [persistenceKey]).
Object? _lastPersistedKey;
int? _lastPersistedFetch;
LoadableHydratedBloc()
: super(
const LoadableState(
@@ -112,6 +121,8 @@ abstract class LoadableHydratedBloc<
/// fresh [fetch] (e.g. via [retry] or page-specific refresh) once the user
/// is authenticated again, otherwise the UI would stay blank.
Future<void> reset() async {
_lastPersistedKey = null;
_lastPersistedFetch = null;
await clear();
add(Reset<TState>());
}
@@ -123,6 +134,30 @@ abstract class LoadableHydratedBloc<
bool get requirementsMet => requirements.areMetBy(SessionManager().current);
// Blocs are rebuilt per account (see AccountScope). One that briefly
// outlives its account would otherwise load with the next account's
// credentials and persist the result into its own storage.
final int _ownSession = SessionManager().sessionEpoch;
/// Whether the account this bloc was created for is still the active one.
bool get ownsSession => SessionManager().isCurrentSession(_ownSession);
static const _sessionKey = #loadableSessionEpoch;
/// Runs [body] tagged with the current session: events it adds, also from
/// its async continuations, are dropped once the account signed out, so a
/// late response of the previous account cannot refill the reset bloc.
R runInSession<R>(R Function() body) =>
runZoned(body, zoneValues: {_sessionKey: SessionManager().sessionEpoch});
@override
void add(LoadableHydratedBlocEvent<TState> event) {
if (!ownsSession) return;
final epoch = Zone.current[_sessionKey];
if (epoch is int && !SessionManager().isCurrentSession(epoch)) return;
super.add(event);
}
TState? get innerState => state.data;
TRepository get repo => _repository;
@@ -135,30 +170,47 @@ abstract class LoadableHydratedBloc<
/// Maps [e] through the shared error mapper and emits it as an [Error] event.
/// Does not guard [isClosed] — callers decide whether a late error still
/// applies.
void addLoadingError(Object e) => add(
Error(
LoadingError(
message: errorToUserMessage(e),
technicalDetails: errorToTechnicalDetails(e),
allowRetry: errorAllowsRetry(e),
void addLoadingError(Object e) {
// Belongs to a signed-out account; the current one loads on its own.
if (e is StaleSessionException) return;
add(
Error(
LoadingError(
message: errorToUserMessage(e),
technicalDetails: errorToTechnicalDetails(e),
allowRetry: errorAllowsRetry(e),
),
),
),
);
);
}
// 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() {
if (!requirementsMet) return;
log('Fetching data for ${TState.toString()}');
gatherData()
.catchError((Object e) {
log('Error while fetching ${TState.toString()}: ${e.toString()}');
// The bloc may have been closed before this async error landed;
// adding to a closed bloc throws, so swallow that case.
if (isClosed) return;
addLoadingError(e);
})
.then((value) {
log('Fetch for ${TState.toString()} completed!');
});
if (!requirementsMet || !ownsSession) return;
unawaited(
_fetchFlight.run(() {
log('Fetching data for ${TState.toString()}');
return runInSession(
() => gatherData()
.catchError((Object e) {
log(
'Error while fetching ${TState.toString()}: ${e.toString()}',
);
// The bloc may have been closed before this async error
// landed; adding to a closed bloc throws, so swallow that case.
if (isClosed) return;
addLoadingError(e);
})
.then((value) {
log('Fetch for ${TState.toString()} completed!');
}),
);
}),
);
}
@override
@@ -175,13 +227,27 @@ abstract class LoadableHydratedBloc<
@override
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;
try {
final stateData = state.data;
data = stateData is TState ? toStorage(stateData) : null;
} catch (e) {
log('Failed to save state ${TState.toString()}: ${e.toString()}');
}
// Blocs that keep nothing on disk return null from toStorage; writing an
// empty wrapper per emit would be pure overhead.
if (stateData != null && data == null) return null;
return LoadableSaveContext.wrap(
data,
@@ -189,6 +255,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();
TRepository repository();
+21
View File
@@ -199,6 +199,27 @@ class AppModule {
return order;
}
/// Modules that are of no use to teachers and start out hidden for them.
static const Set<Modules> hiddenForTeachersByDefault = {
Modules.absenceReport,
};
/// Hides [hiddenForTeachersByDefault] once for teacher accounts; the teacher
/// can re-enable them in the module settings. Returns whether [settings]
/// changed.
static bool applyTeacherDefaults(
ModulesSettings settings, {
required bool isTeacher,
}) {
if (!isTeacher || settings.teacherDefaultsApplied) return false;
settings.hiddenModules = {
...settings.hiddenModules,
...hiddenForTeachersByDefault,
}.toList();
settings.teacherDefaultsApplied = true;
return true;
}
// The settings list displays a capability-filtered subset, so reorder
// indices refer to that subset; the move is applied there and merged back
// into the full order (non-displayed modules keep their relative slots).
@@ -37,8 +37,12 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
);
return;
}
final epoch = SessionManager().sessionEpoch;
try {
final response = await GetCapabilities().run();
// A slow answer for the previous account must not decide the modules
// of the next one.
if (!SessionManager().isCurrentSession(epoch)) return;
emit(
CapabilitiesState(
viewForeignTimetables: response.viewForeignTimetables,
@@ -56,7 +60,7 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
}
}
void reset() => emit(const CapabilitiesState());
Future<void> reset() async => emit(const CapabilitiesState());
@override
CapabilitiesState fromJson(Map<String, dynamic> json) {
+25 -5
View File
@@ -23,6 +23,9 @@ class ChatBloc
final ChatListBloc? _chatListBloc;
String? _pollingToken;
// A loop parked in its request survives stop+start for the same token
// (pause/resume); the generation retires it so only one loop polls.
int _pollGeneration = 0;
int _backoffMs = 0;
int _lastKnownMessageId = 0;
bool _appResumed = true;
@@ -56,6 +59,12 @@ class ChatBloc
@override
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
Map<String, dynamic>? toStorage(ChatState state) {
final response = state.chatResponse;
@@ -123,6 +132,15 @@ class ChatBloc
/// No-op when the bloc has already moved on to a different token: when
/// popping a stacked chat (B over A), A's didPopNext runs setToken(A)
/// before B's dispose fires.
/// The chat view may still be popping when the sign-out resets this bloc,
/// so leaveChat would find no token and the long-poll would keep running.
@override
Future<void> reset() {
_chatViewActive = false;
_stopLongPoll();
return super.reset();
}
void leaveChat(String fromToken) {
if ((innerState?.currentToken ?? '') != fromToken) return;
_chatViewActive = false;
@@ -256,23 +274,25 @@ class ChatBloc
_pollingToken = token;
_backoffMs = 0;
_lastKnownMessageId = _maxMessageId(innerState?.chatResponse);
unawaited(_pollLoop(token));
unawaited(_pollLoop(token, ++_pollGeneration));
}
void _stopLongPoll() {
_pollingToken = null;
_pollGeneration++;
_backoffMs = 0;
}
Future<void> _pollLoop(String token) async {
while (_pollingToken == token && !isClosed) {
Future<void> _pollLoop(String token, int generation) async {
bool active() => generation == _pollGeneration && !isClosed;
while (active()) {
try {
final response = await LongPollChat(
chatToken: token,
lastKnownMessageId: _lastKnownMessageId,
).run();
if (_pollingToken != token || isClosed) return;
if (!active()) return;
_backoffMs = 0;
if (response == null) continue;
@@ -296,7 +316,7 @@ class ChatBloc
_chatListBloc?.markRoomAsRead(token, _lastKnownMessageId);
}
} on Object catch (e) {
if (_pollingToken != token || isClosed) return;
if (!active()) return;
log('LongPoll error for $token: $e');
_backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000);
await Future.delayed(Duration(milliseconds: _backoffMs));
@@ -1,11 +1,14 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:flutter_app_badge/flutter_app_badge.dart';
import '../../../../../access/access_requirement.dart';
import '../../../../../api/marianumcloud/talk/actions/talk_actions.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../../utils/session_single_flight.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../repository/chat_list_repository.dart';
@@ -31,6 +34,13 @@ class ChatListBloc
return super.close();
}
// The timer outlives the app shell; the next shell re-arms it after login.
@override
Future<void> reset() {
setAutoRefreshInterval(null);
return super.reset();
}
/// Silent refresh — explicit pull-to-refresh and tab-activation are non-silent.
void setAutoRefreshInterval(Duration? interval) {
if (interval == _autoRefreshInterval) return;
@@ -72,14 +82,34 @@ class ChatListBloc
renew: renew,
onError: (e) => capturedError = e,
);
_lastRoomsJson = null;
add(DataGathered((s) => s.copyWith(rooms: rooms)));
_updateAppBadge(rooms);
if (capturedError != null) throw capturedError!;
}
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}) async {
if (!requirementsMet) return;
if (!requirementsMet || !ownsSession) return;
await _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>());
Object? capturedError;
try {
@@ -87,6 +117,11 @@ class ChatListBloc
renew: renew,
onError: (e) => capturedError = e,
);
if (silent) {
if (_isUnchanged(rooms)) return;
} else {
_lastRoomsJson = null;
}
add(DataGathered((s) => s.copyWith(rooms: rooms)));
_updateAppBadge(rooms);
} catch (e) {
@@ -95,6 +130,23 @@ class ChatListBloc
if (capturedError != null) addLoadingError(capturedError!);
}
// Encoded room data of the last applied poll result; response headers are
// left out because they differ on every request.
String? _lastRoomsJson;
/// A background poll that returns the same rooms would otherwise rebuild the
/// list and re-persist the whole state every 15 s.
bool _isUnchanged(GetRoomResponse rooms) {
final encoded = jsonEncode(rooms.data.toList());
final unchanged =
encoded == _lastRoomsJson &&
innerState?.rooms != null &&
state.error == null &&
!state.isLoading;
_lastRoomsJson = encoded;
return unchanged;
}
/// Creates (or resolves) a 1:1 chat and returns its room token, or null in
/// demo mode. Refreshes the list so the room shows up.
Future<String?> createDirectChat(String invite) async {
@@ -126,6 +178,18 @@ class ChatListBloc
});
}
Future<void> setNotificationLevel(
String token,
GetRoomResponseObjectParticipantNotificationLevel level,
) async {
await SetNotificationLevel(token, level).run();
_mutateRoom(token, (r) {
if (r.notificationLevel == level) return false;
r.notificationLevel = level;
return true;
});
}
/// Clears unread too — long-poll only feeds this in for an actively-open chat.
void applyIncomingMessage(String token, GetChatResponseObject message) {
_mutateRoom(token, (r) {
@@ -158,6 +222,7 @@ class ChatListBloc
}).toSet();
if (!changed) return;
final newRooms = GetRoomResponse(updated)..headers = rooms.headers;
_lastRoomsJson = null;
add(Emit((s) => s.copyWith(rooms: newRooms)));
_updateAppBadge(newRooms);
}
@@ -1,5 +1,6 @@
import 'package:dio/dio.dart';
import 'package:enough_icalendar/enough_icalendar.dart';
import 'package:flutter/foundation.dart';
import '../bloc/marianum_dates_state.dart';
@@ -19,6 +20,12 @@ class MarianumDatesGetEvents {
final body = response.data;
if (body == null || body.isEmpty) return [];
// The public feed holds hundreds of events back to 1981; parsing it on
// the UI isolate froze the page for a noticeable moment on every open.
return compute(parseEvents, body);
}
static List<MarianumDate> parseEvents(String body) {
final root = VComponent.parse(body);
final calendar = root is VCalendar ? root : null;
final source = calendar?.children ?? root.children;
@@ -65,8 +65,12 @@ class NextcloudCapabilitiesCubit
emit(DemoNextcloudCapabilities.state());
return;
}
final epoch = SessionManager().sessionEpoch;
try {
final caps = await GetNextcloudCapabilities().run();
// A slow answer for the previous account must not decide the modules
// of the next one.
if (!SessionManager().isCurrentSession(epoch)) return;
emit(
NextcloudCapabilitiesState(
apiEnabled: caps.apiEnabled,
@@ -92,7 +96,7 @@ class NextcloudCapabilitiesCubit
}
}
void reset() => emit(const NextcloudCapabilitiesState());
Future<void> reset() async => emit(const NextcloudCapabilitiesState());
@override
NextcloudCapabilitiesState fromJson(Map<String, dynamic> json) {
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../storage/settings.dart';
@@ -10,8 +11,15 @@ import '../../../../../view/pages/settings/data/default_settings.dart';
class SettingsCubit extends HydratedCubit<Settings> {
static const _debounceTag = 'settings_persist';
bool _emitScheduled = false;
Map<String, dynamic>? _lastEmittedJson;
Timer? _silentSave;
SettingsCubit({super.storage}) : super(DefaultSettings.get());
final Storage? _storage;
// ignore: use_super_parameters
SettingsCubit({Storage? storage})
: _storage = storage,
super(DefaultSettings.get(), storage: storage);
Settings val({bool write = false}) {
if (write) {
@@ -34,21 +42,53 @@ class SettingsCubit extends HydratedCubit<Settings> {
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;
(_storage ?? HydratedBloc.storage).write(storageToken, state.toJson());
}
void _emitFreshInstance() {
try {
emit(Settings.fromJson(state.toJson()));
final json = state.toJson();
// The debounced emit usually follows the microtask emit with identical
// content; skip it instead of rebuilding every listener a second time.
if (const DeepCollectionEquality().equals(json, _lastEmittedJson)) {
return;
}
_lastEmittedJson = json;
emit(Settings.fromJson(json));
} catch (e) {
log('Failed to refresh settings state: $e');
}
}
Future<void> reset() async {
_silentSave?.cancel();
_silentSave = null;
_lastEmittedJson = null;
emit(DefaultSettings.get());
}
// Modules missing from a stale persisted moduleOrder are handled at read
// time by AppModule.effectiveModuleOrder (inserted at their default
// position) — no healing on hydration needed.
@override
Future<void> close() {
flushSilentSave();
return super.close();
}
@override
Settings fromJson(Map<String, dynamic> json) {
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/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
@@ -17,7 +22,35 @@ class TickerPageBloc
> {
final String slug;
TickerPageBloc(this.slug);
TickerPageBloc(this.slug) {
unawaited(_rememberSlug());
}
static const _recentSlugsKey = 'tickerPageRecentSlugs';
static const _keepPages = 50;
/// Every page ever opened used to stay in the hydrated box forever — and
/// the whole box is decoded before the first frame on each cold start.
/// The most recently opened pages (far more than a normal reader revisits)
/// keep their offline copy; only long-forgotten ones are dropped.
Future<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
String get id => slug;
@@ -48,7 +48,7 @@ class TimetableBloc
TimetableState fromNothing() {
final reference = DateTime.now().addDays(2);
return TimetableState(
startDate: _startOfWeek(reference),
startDate: reference.mondayOfWeek,
endDate: _endOfWeek(reference),
);
}
@@ -61,7 +61,7 @@ class TimetableBloc
final stored = TimetableState.fromJson(json);
final reference = DateTime.now().addDays(2);
return stored.copyWith(
startDate: _startOfWeek(reference),
startDate: reference.mondayOfWeek,
endDate: _endOfWeek(reference),
accessibleStartDate: null,
accessibleEndDate: null,
@@ -120,13 +120,15 @@ class TimetableBloc
final current = innerState ?? fromNothing();
if (current.startDate == startDate && current.endDate == endDate) return;
add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate)));
_loadCurrentWeek(startDate, endDate);
_prefetchAdjacentWeeks(startDate, endDate);
runInSession(() {
_loadCurrentWeek(startDate, endDate);
_prefetchAdjacentWeeks(startDate, endDate);
});
}
void resetWeek() {
final reference = DateTime.now().addDays(2);
changeWeek(_startOfWeek(reference), _endOfWeek(reference));
changeWeek(reference.mondayOfWeek, _endOfWeek(reference));
}
void refresh() => fetch();
@@ -171,11 +173,7 @@ class TimetableBloc
Future<void> _refreshSubjects() async {
final subjects = await repo.data.getSubjects(renew: true);
add(
DataGathered(
(s) => s.copyWith(subjects: subjects, dataVersion: s.dataVersion + 1),
),
);
add(DataGathered((s) => s.withReferenceData(subjects: subjects)));
}
Future<void> _loadCurrentWeek(
@@ -214,12 +212,11 @@ class TimetableBloc
add(
Emit(
(s) => s.copyWith(
(s) => s.withReferenceData(
rooms: rooms,
subjects: subjects,
schoolHolidays: schoolHolidays,
schoolyear: schoolyear,
dataVersion: s.dataVersion + 1,
),
),
);
@@ -229,11 +226,7 @@ class TimetableBloc
try {
final timegrid = await repo.data.getTimegrid(renew: renew);
add(
Emit(
(s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1),
),
);
add(Emit((s) => s.withReferenceData(timegrid: timegrid)));
} catch (_) {
// Timegrid load failure falls back to a hardcoded schedule in the UI layer.
}
@@ -249,12 +242,7 @@ class TimetableBloc
renew: renew,
onError: onError,
);
add(
Emit(
(s) =>
s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1),
),
);
add(Emit((s) => s.withReferenceData(customEvents: events)));
} catch (e) {
onError?.call(e);
}
@@ -262,11 +250,7 @@ class TimetableBloc
Future<void> _refreshCustomEvents() async {
final events = await repo.data.getCustomEvents(renew: true);
add(
DataGathered(
(s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1),
),
);
add(DataGathered((s) => s.withReferenceData(customEvents: events)));
}
void _prefetchAdjacentWeeks(DateTime start, DateTime end) {
@@ -282,19 +266,7 @@ class TimetableBloc
}
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
final key = weekStart.weekKey();
add(
Emit((s) {
final updated = Map<String, TimetableGetWeekResponse>.of(s.weekCache);
updated[key] = week;
return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1);
}),
);
}
static DateTime _startOfWeek(DateTime reference) {
final monday = reference.subtractDays(reference.weekday - 1);
return DateTime(monday.year, monday.month, monday.day);
add(Emit((s) => s.withFetchedWeek(weekStart, week)));
}
static DateTime _endOfWeek(DateTime reference) {
@@ -7,6 +7,8 @@ import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timeta
import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../../../../../utils/json_equality.dart';
import 'week_cache.dart';
part 'timetable_state.freezed.dart';
part 'timetable_state.g.dart';
@@ -40,6 +42,62 @@ abstract class TimetableState with _$TimetableState {
Iterable<McTimetableEntry> getAllKnownLessons() =>
weekCache.values.expand((response) => response.entries);
/// This state with [week] cached, or `this` when its content is unchanged
/// (so the emit is dropped as equal).
TimetableState withFetchedWeek(
DateTime weekStart,
TimetableGetWeekResponse week,
) {
final updated = mergeWeekIntoCache(
weekCache,
weekStart,
week,
viewedWeekStart: startDate,
now: DateTime.now(),
);
if (updated == null) return this;
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,
);
}
/// Whether the calendar has everything it needs to render. Custom events
/// only exist for subjects that support them; requiring them elsewhere would
/// keep foreign plans loading forever.
@@ -0,0 +1,41 @@
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../../extensions/date_time.dart';
import '../../../../../utils/json_equality.dart';
/// Weeks kept around the viewed week and around today's week. Everything
/// further away is dropped: every state emit re-serializes the whole cache for
/// HydratedBloc and the calendar rebuilds its appointments from all of it, so
/// an unbounded cache makes week swipes slower the longer the app is used.
const int kWeekCacheRadius = 8;
/// Returns the cache with [week] stored under [weekStart], pruned to the
/// weeks near [viewedWeekStart] or [now]. Returns null when the stored week
/// already has identical content, so callers can skip the emit entirely.
Map<String, TimetableGetWeekResponse>? mergeWeekIntoCache(
Map<String, TimetableGetWeekResponse> cache,
DateTime weekStart,
TimetableGetWeekResponse week, {
required DateTime viewedWeekStart,
required DateTime now,
}) {
final key = weekStart.weekKey();
final existing = cache[key];
if (sameJson(existing, week)) return null;
final viewedMonday = viewedWeekStart.mondayOfWeek;
final todayMonday = now.mondayOfWeek;
bool isNear(DateTime monday, DateTime anchor) =>
monday.difference(anchor).inDays.abs() <= kWeekCacheRadius * 7 + 1;
final updated = <String, TimetableGetWeekResponse>{};
for (final entry in cache.entries) {
final monday = DateTime.tryParse(entry.key);
if (monday == null ||
isNear(monday, viewedMonday) ||
isNear(monday, todayMonday)) {
updated[entry.key] = entry.value;
}
}
updated[key] = week;
return updated;
}
+10 -19
View File
@@ -2,17 +2,15 @@ import 'dart:developer';
import 'dart:io';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:localstore/localstore.dart';
import '../api/request_cache.dart';
import '../api/cache_store.dart';
import '../session/account_codec.dart';
import 'hydrated_storage_bootstrap.dart';
/// Per-account local data: every account gets its own HydratedBloc box and
/// Localstore cache collection, so switching back is instant and offline.
/// Only the app settings live in the shared [global] box.
/// request cache directory (see [CacheStore]), so switching back is instant
/// and offline. Only the app settings live in the shared [global] box.
abstract final class AccountStorage {
static const _legacyCollection = 'MarianumMobile';
static const _settingsKey = 'SettingsCubit';
static late String _baseDir;
@@ -36,17 +34,13 @@ abstract final class AccountStorage {
}
}
/// Points HydratedBloc and the request cache at [account]'s data. Blocs
/// keep the storage they were created with, so the per-account blocs have
/// to be recreated afterwards.
/// Points HydratedBloc at [account]'s data; the request cache follows the
/// active account on its own. Blocs keep the storage they were created
/// with, so the per-account blocs have to be recreated afterwards.
static Future<void> activate(AccountEntry? account) async {
if (account == null) {
HydratedBloc.storage = InMemoryStorage();
RequestCache.collection = _legacyCollection;
return;
}
HydratedBloc.storage = await _storageFor(account.namespace);
RequestCache.collection = cacheCollection(account.namespace);
HydratedBloc.storage = account == null
? InMemoryStorage()
: await _storageFor(account.namespace);
}
/// Removes all local data of the account with [namespace].
@@ -62,15 +56,12 @@ abstract final class AccountStorage {
final dir = Directory(_dirFor(namespace));
if (dir.existsSync()) await dir.delete(recursive: true);
}
await Localstore.instance.collection(cacheCollection(namespace)).delete();
} on Object catch (e, s) {
log('Account storage delete failed: $e', stackTrace: s);
}
await CacheStore.instance.deleteNamespace(namespace);
}
static String cacheCollection(String namespace) =>
namespace.isEmpty ? _legacyCollection : '$_legacyCollection-$namespace';
static Future<Storage> _storageFor(String namespace) async =>
_open[namespace] ??= await buildHydratedStorageWithFallback(
_ensureDir(_dirFor(namespace)),
+23 -5
View File
@@ -8,8 +8,22 @@ enum MarianumConnectEndpoint { live, beta, custom }
@JsonSerializable()
class DevToolsSettings {
bool showPerformanceOverlay;
bool checkerboardOffscreenLayers;
bool checkerboardRasterCacheImages;
@JsonKey(defaultValue: false)
bool showFrameStats;
@JsonKey(defaultValue: false)
bool showMemoryStats;
@JsonKey(defaultValue: false)
bool showSemanticsDebugger;
@JsonKey(defaultValue: false)
bool slowAnimations;
/// Forced text scale factor; `0` follows the system setting.
@JsonKey(defaultValue: 0)
double textScaleOverride;
@JsonKey(defaultValue: MarianumConnectEndpoint.live)
MarianumConnectEndpoint marianumConnectEndpoint;
@@ -24,8 +38,11 @@ class DevToolsSettings {
DevToolsSettings({
required this.showPerformanceOverlay,
required this.checkerboardOffscreenLayers,
required this.checkerboardRasterCacheImages,
this.showFrameStats = false,
this.showMemoryStats = false,
this.showSemanticsDebugger = false,
this.slowAnimations = false,
this.textScaleOverride = 0,
this.marianumConnectEndpoint = MarianumConnectEndpoint.live,
this.marianumConnectCustomUrl = '',
this.emergencyNoticeUrl = '',
@@ -51,7 +68,8 @@ class DevToolsSettings {
/// Compiled-in source for the backend-independent emergency notice. Must live
/// on infrastructure that is reachable even when MarianumConnect is down.
static const String emergencyNoticeDefaultUrl = 'https://www.marianum-fulda.de/~aushang/marMobile.override';
static const String emergencyNoticeDefaultUrl =
'https://www.marianum-fulda.de/~aushang/marMobile.override';
String? resolveEmergencyNoticeUrl() =>
sanitizeCustomUrl(emergencyNoticeUrl) ??
+23 -17
View File
@@ -6,27 +6,33 @@ part of 'dev_tools_settings.dart';
// JsonSerializableGenerator
// **************************************************************************
DevToolsSettings _$DevToolsSettingsFromJson(
Map<String, dynamic> json,
) => DevToolsSettings(
showPerformanceOverlay: json['showPerformanceOverlay'] as bool,
checkerboardOffscreenLayers: json['checkerboardOffscreenLayers'] as bool,
checkerboardRasterCacheImages: json['checkerboardRasterCacheImages'] as bool,
marianumConnectEndpoint:
$enumDecodeNullable(
_$MarianumConnectEndpointEnumMap,
json['marianumConnectEndpoint'],
) ??
MarianumConnectEndpoint.live,
marianumConnectCustomUrl: json['marianumConnectCustomUrl'] as String? ?? '',
emergencyNoticeUrl: json['emergencyNoticeUrl'] as String? ?? '',
);
DevToolsSettings _$DevToolsSettingsFromJson(Map<String, dynamic> json) =>
DevToolsSettings(
showPerformanceOverlay: json['showPerformanceOverlay'] as bool,
showFrameStats: json['showFrameStats'] as bool? ?? false,
showMemoryStats: json['showMemoryStats'] as bool? ?? false,
showSemanticsDebugger: json['showSemanticsDebugger'] as bool? ?? false,
slowAnimations: json['slowAnimations'] as bool? ?? false,
textScaleOverride: (json['textScaleOverride'] as num?)?.toDouble() ?? 0,
marianumConnectEndpoint:
$enumDecodeNullable(
_$MarianumConnectEndpointEnumMap,
json['marianumConnectEndpoint'],
) ??
MarianumConnectEndpoint.live,
marianumConnectCustomUrl:
json['marianumConnectCustomUrl'] as String? ?? '',
emergencyNoticeUrl: json['emergencyNoticeUrl'] as String? ?? '',
);
Map<String, dynamic> _$DevToolsSettingsToJson(DevToolsSettings instance) =>
<String, dynamic>{
'showPerformanceOverlay': instance.showPerformanceOverlay,
'checkerboardOffscreenLayers': instance.checkerboardOffscreenLayers,
'checkerboardRasterCacheImages': instance.checkerboardRasterCacheImages,
'showFrameStats': instance.showFrameStats,
'showMemoryStats': instance.showMemoryStats,
'showSemanticsDebugger': instance.showSemanticsDebugger,
'slowAnimations': instance.slowAnimations,
'textScaleOverride': instance.textScaleOverride,
'marianumConnectEndpoint':
_$MarianumConnectEndpointEnumMap[instance.marianumConnectEndpoint]!,
'marianumConnectCustomUrl': instance.marianumConnectCustomUrl,
+28 -2
View File
@@ -9,19 +9,43 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
Future<Storage> buildHydratedStorageWithFallback(String directoryPath) async {
final directory = HydratedStorageDirectory(directoryPath);
try {
return await HydratedStorage.build(storageDirectory: directory);
return await _build(directory);
} catch (e, s) {
log('HydratedStorage open failed, rebuilding: $e', stackTrace: s);
}
try {
await _deleteHydratedBox(directoryPath);
return await HydratedStorage.build(storageDirectory: directory);
return await _build(directory);
} catch (e, s) {
log('HydratedStorage rebuild failed, using memory: $e', stackTrace: s);
return InMemoryStorage();
}
}
final Expando<List<String> Function()> _keyReaders = Expando();
Future<Storage> _build(HydratedStorageDirectory directory) async {
final storage = await HydratedStorage.build(storageDirectory: directory);
// hydrated_bloc exposes no key listing; the Hive instance is the only way
// in. Every build replaces the static one, so it is captured per storage.
// ignore: invalid_use_of_visible_for_testing_member
final hive = HydratedStorage.hive;
_keyReaders[storage] = () =>
hive.box<dynamic>('hydrated_box').keys.map((k) => '$k').toList();
return storage;
}
/// All keys persisted in [storage], sorted; `null` when they can't be
/// enumerated.
List<String>? hydratedStorageKeys(Storage storage) {
if (storage is InMemoryStorage) return storage.keys.toList()..sort();
try {
return _keyReaders[storage]?.call()?..sort();
} on Object {
return null;
}
}
Future<void> _deleteHydratedBox(String directoryPath) async {
final directory = Directory(directoryPath);
if (!directory.existsSync()) return;
@@ -37,6 +61,8 @@ Future<void> _deleteHydratedBox(String directoryPath) async {
class InMemoryStorage implements Storage {
final Map<String, dynamic> _values = {};
Iterable<String> get keys => _values.keys;
@override
dynamic read(String key) => _values[key];
+4
View File
@@ -10,12 +10,16 @@ class ModulesSettings {
List<Modules> hiddenModules;
bool autoFillBottomBar;
int fixedBottomBarSlots;
// Set once the teacher module defaults were applied, so a module the teacher
// re-enables afterwards stays visible.
bool teacherDefaultsApplied;
ModulesSettings({
required this.moduleOrder,
required this.hiddenModules,
this.autoFillBottomBar = true,
this.fixedBottomBarSlots = 3,
this.teacherDefaultsApplied = false,
});
factory ModulesSettings.fromJson(Map<String, dynamic> json) =>
+2
View File
@@ -16,6 +16,7 @@ ModulesSettings _$ModulesSettingsFromJson(Map<String, dynamic> json) =>
.toList(),
autoFillBottomBar: json['autoFillBottomBar'] as bool? ?? true,
fixedBottomBarSlots: (json['fixedBottomBarSlots'] as num?)?.toInt() ?? 3,
teacherDefaultsApplied: json['teacherDefaultsApplied'] as bool? ?? false,
);
Map<String, dynamic> _$ModulesSettingsToJson(
@@ -27,6 +28,7 @@ Map<String, dynamic> _$ModulesSettingsToJson(
.toList(),
'autoFillBottomBar': instance.autoFillBottomBar,
'fixedBottomBarSlots': instance.fixedBottomBarSlots,
'teacherDefaultsApplied': instance.teacherDefaultsApplied,
};
const _$ModulesEnumMap = {
+16
View File
@@ -0,0 +1,16 @@
import 'dart:io';
/// Total byte size of all files below [dir]; `0` when it doesn't exist or
/// can't be listed.
Future<int> directorySize(Directory dir) async {
var sum = 0;
try {
if (!dir.existsSync()) return 0;
await for (final entity in dir.list(recursive: true, followLinks: false)) {
if (entity is File) sum += await entity.length();
}
} on FileSystemException {
// Files can vanish mid-walk (cache pruning); report what was counted.
}
return sum;
}
+7
View File
@@ -0,0 +1,7 @@
import 'dart:convert';
/// Content equality for API models that define no `==` but serialise to JSON.
/// Comparing the encoded strings is cheaper than a deep map walk and treats
/// both sides identically (same `toJson`, same field order).
bool sameJson(Object? a, Object? b) =>
a != null && b != null && jsonEncode(a) == jsonEncode(b);
+75
View File
@@ -0,0 +1,75 @@
import 'package:rrule/rrule.dart';
/// Memoised RRULE expansion.
///
/// `RecurrenceRule.getInstances` always starts at the series anchor, so asking
/// for one week of a daily event created two years ago walks ~750 occurrences
/// (tens of ms each time on older phones) — and the calendar asks once per
/// week and event, the home widget twice per publish. Here each series keeps
/// its parsed rule, the occurrences found so far and the live iterator, so
/// later queries only extend the expansion instead of restarting it.
class RecurrenceOccurrences {
RecurrenceOccurrences._();
static const int _maxSeries = 64;
static final Map<(String, DateTime), _Series> _cache = {};
/// UTC occurrences of [rule] anchored at [anchorUtc] in
/// `[fromUtc, toUtc)`, in ascending order. Throws like
/// [RecurrenceRule.fromString] for an invalid rule.
static List<DateTime> between(
String rule,
DateTime anchorUtc,
DateTime fromUtc,
DateTime toUtc,
) {
final key = (rule, anchorUtc);
var series = _cache.remove(key);
series ??= _Series(RecurrenceRule.fromString(rule), anchorUtc);
// Re-insert to keep the map in least-recently-used order.
_cache[key] = series;
if (_cache.length > _maxSeries) _cache.remove(_cache.keys.first);
return series.between(fromUtc, toUtc);
}
/// Drops all memoised series (tests).
static void clear() => _cache.clear();
}
class _Series {
final Iterator<DateTime> _iterator;
final List<DateTime> _found = [];
bool _exhausted = false;
_Series(RecurrenceRule rule, DateTime anchorUtc)
: _iterator = rule.getInstances(start: anchorUtc).iterator;
List<DateTime> between(DateTime fromUtc, DateTime toUtc) {
while (!_exhausted && (_found.isEmpty || _found.last.isBefore(toUtc))) {
if (_iterator.moveNext()) {
_found.add(_iterator.current);
} else {
_exhausted = true;
}
}
final start = _lowerBound(fromUtc);
return [
for (var i = start; i < _found.length && _found[i].isBefore(toUtc); i++)
_found[i],
];
}
int _lowerBound(DateTime value) {
var low = 0;
var high = _found.length;
while (low < high) {
final mid = (low + high) >> 1;
if (_found[mid].isBefore(value)) {
low = mid + 1;
} else {
high = mid;
}
}
return low;
}
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/widgets.dart';
/// Wraps [provider] so it decodes at most [scale] × the screen's longest
/// physical side (capped at [maxPx]). Camera-sized photos otherwise decode at
/// full resolution — 50–200 MB each — which gets the app killed on
/// low-memory devices.
ImageProvider screenBoundImage(
BuildContext context,
ImageProvider provider, {
double scale = 1,
int maxPx = 4096,
}) {
final bound =
(MediaQuery.sizeOf(context).longestSide *
MediaQuery.devicePixelRatioOf(context) *
scale)
.round()
.clamp(1, maxPx);
return ResizeImage(
provider,
width: bound,
height: bound,
policy: ResizeImagePolicy.fit,
);
}
+21
View File
@@ -0,0 +1,21 @@
import '../session/session_manager.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 = SessionManager().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;
}
}
@@ -11,6 +11,7 @@ import '../../api/marianumconnect/queries/auth_guardian/guardian_login_exception
import '../../auth_link/device_binding.dart';
import '../../auth_link/guardian_login_link.dart';
import '../../auth_link/pending_guardian_request.dart';
import '../../model/session_wipe.dart';
import '../../session/session.dart';
import '../../session/session_manager.dart';
import '../../widget_data/widget_sync.dart';
@@ -216,6 +217,9 @@ class GuardianLoginController extends ChangeNotifier {
}
static Future<void> _defaultSignIn(Session session) async {
// The previous account's wipe runs deferred; racing it would let it
// clear what this login stores.
await SessionWipe.done;
// Sign in first: the one-time code is already spent at this point, so a
// failing widget reset must not cost the session (the user would have to
// request a fresh mail for a login that actually succeeded).
+5
View File
@@ -10,6 +10,7 @@ import '../../api/marianumconnect/auth/device_token_name.dart';
import '../../api/marianumconnect/auth/token_storage.dart';
import '../../api/marianumconnect/queries/auth_login/auth_login.dart';
import '../../api/marianumconnect/queries/auth_logout/auth_logout.dart';
import '../../model/session_wipe.dart';
import '../../session/session.dart';
import '../../session/session_manager.dart';
import '../../widget_data/widget_sync.dart';
@@ -47,6 +48,10 @@ class LoginController extends ChangeNotifier {
_errorDetails = null;
notifyListeners();
// The previous account's wipe runs deferred; racing it would let it
// clear what this login stores (widget job, caches).
await SessionWipe.done;
final user = username.trim().toLowerCase();
// Demo login: the prefix enters local demo mode, password ignored, no
+2 -2
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
/// Filled, borderless text field look shared by both login cards.
/// Filled, borderless text field look shared by the login cards.
InputDecoration loginInputDecoration(
ThemeData theme,
String label,
@@ -24,7 +24,7 @@ InputDecoration loginInputDecoration(
),
);
/// Card frame with heading and hint line shared by both login cards.
/// Card frame with heading and hint line shared by the login cards.
class LoginCardFrame extends StatelessWidget {
final String title;
final String hint;
+1 -1
View File
@@ -21,7 +21,7 @@ class SortOptions {
SortOption.name: BetterSortOption(
displayName: 'Name',
icon: Icons.sort_by_alpha_outlined,
compare: (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
compare: (a, b) => a.lowerName.compareTo(b.lowerName),
),
SortOption.date: BetterSortOption(
displayName: 'Datum',
+24 -9
View File
@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import '../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
@@ -47,6 +49,22 @@ class _FilesViewState extends State<_FilesView> {
late bool currentSortDirection;
late final StreamSubscription<String> _invalidationSub;
// The list builder also runs for loading flips and parent rebuilds; only a
// new listing or a changed sort needs another sort pass.
Object? _sortedKey;
List<CacheableFile> _sortedFiles = const [];
List<CacheableFile> _sorted(ListFilesResponse listing, bool foldersToTop) {
final key = (listing, currentSort, currentSortDirection, foldersToTop);
if (key == _sortedKey) return _sortedFiles;
_sortedKey = key;
return _sortedFiles = listing.sortBy(
sortOption: currentSort,
foldersToTop: foldersToTop,
reversed: currentSortDirection,
);
}
// Cache key in FilesBloc's pathString format: '/' for root, otherwise
// segments joined without leading/trailing slash.
String get _myPathString => widget.path.isEmpty ? '/' : widget.path.join('/');
@@ -98,6 +116,11 @@ class _FilesViewState extends State<_FilesView> {
@override
Widget build(BuildContext context) {
final bloc = context.read<FilesBloc>();
// Selected here, not in the LoadableStateConsumer child: that closure runs
// during the consumer's build, where this context may not subscribe.
final foldersToTop = context.select(
(SettingsCubit c) => c.state.fileSettings.sortFoldersToTop,
);
return Scaffold(
appBar: AppBar(
title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'),
@@ -153,15 +176,7 @@ class _FilesViewState extends State<_FilesView> {
text: 'Der Ordner ist leer',
);
}
final files = listing.sortBy(
sortOption: currentSort,
foldersToTop: context
.watch<SettingsCubit>()
.val()
.fileSettings
.sortFoldersToTop,
reversed: currentSortDirection,
);
final files = _sorted(listing, foldersToTop);
return ListView.builder(
padding: EdgeInsets.zero,
itemCount: files.length,
+26 -20
View File
@@ -53,15 +53,6 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
}).toList();
}
void showHttpErrorCode(int httpErrorCode) {
InfoDialog.show(
context,
'Error code: $httpErrorCode',
title: 'Ein Fehler ist aufgetreten',
copyable: true,
);
}
void _resetProgress() {
_isUploading = false;
_overallProgressValue = 0.0;
@@ -88,6 +79,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
});
final webdavClient = await WebdavApi.webdav;
if (!mounted) return;
if (!override) {
List<dynamic> result;
@@ -157,6 +149,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
),
);
if (!mounted) return;
if (replaceFiles != true) {
setState(() {
_resetProgress();
@@ -200,18 +193,23 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
}
final HttpClientResponse uploadTask;
final fileIndex = _uploadableFiles.indexOf(file);
var lastPercent = -1;
try {
uploadTask = await webdavClient.putFile(
File(filePath),
fileStat,
PathUri.parse(fullRemotePath),
onProgress: (progress) {
// Called per 64 KB chunk — thousands of times for a video. Only
// rebuild when the visible percentage actually moves.
final percent = (progress * 100).floor();
if (!mounted || percent == lastPercent) return;
lastPercent = percent;
setState(() {
file._uploadProgress = progress;
_overallProgressValue =
((progress + _uploadableFiles.indexOf(file)) /
_uploadableFiles.length)
.toDouble();
((progress + fileIndex) / _uploadableFiles.length).toDouble();
});
},
);
@@ -221,18 +219,21 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
return;
}
if (!mounted) return;
// Stay on the dialog: popping here reported the partial result as a
// success to onUploadFinished and closed the error dialog right away.
if (uploadTask.statusCode < 200 || uploadTask.statusCode > 299) {
setState(_resetProgress);
if (!mounted) return;
Navigator.of(context).pop();
showHttpErrorCode(uploadTask.statusCode);
} else {
uploadetFilePaths.add(fullRemotePath);
_showUploadError(
'Upload fehlgeschlagen für "$fileName" '
'(Fehlercode ${uploadTask.statusCode}).',
);
return;
}
uploadetFilePaths.add(fullRemotePath);
}
setState(_resetProgress);
if (!mounted) return;
setState(_resetProgress);
Navigator.of(context).pop();
widget.onUploadFinished(uploadetFilePaths);
}
@@ -252,7 +253,12 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
itemCount: _uploadableFiles.length,
itemBuilder: (context, index) {
final currentFile = _uploadableFiles[index];
currentFile.fileNameController.text = currentFile.fileName;
// Only sync when it differs: assigning text resets selection
// and notifies the field on every (progress) rebuild.
if (currentFile.fileNameController.text !=
currentFile.fileName) {
currentFile.fileNameController.text = currentFile.fileName;
}
return ListTile(
title: TextField(
readOnly: _isUploading,
@@ -26,6 +26,14 @@ class FilesSearchController extends ChangeNotifier {
Object? _serverError;
int _serverEpoch = 0;
bool _disposed = false;
Future<List<CacheableFile>>? _localIndex;
Future<List<CacheableFile>> _searchLocal(List<String> pathScope) async =>
searchLocalCacheIndex(
await (_localIndex ??= loadLocalCacheIndex()),
_query,
pathScope: pathScope,
);
/// Guards against the race where the search delegate is closed (and the
/// controller disposed) while a debounced cache scan or server call is
@@ -80,7 +88,7 @@ class FilesSearchController extends ChangeNotifier {
_serverError = null;
_safeNotify();
final cacheHits = await searchLocalCaches(_query, pathScope: _pathScope);
final cacheHits = await _searchLocal(_pathScope);
if (epoch != _serverEpoch) return;
_cacheResults = cacheHits;
_safeNotify();
@@ -101,7 +109,7 @@ class FilesSearchController extends ChangeNotifier {
_serverError = null;
_safeNotify();
final cacheHits = await searchLocalCaches(_query);
final cacheHits = await _searchLocal(const []);
if (epoch != _serverEpoch) return;
_cacheResults = cacheHits;
_safeNotify();
@@ -104,32 +104,43 @@ class FilesSearchResults extends StatelessWidget {
Widget _resultList(BuildContext context, List<CacheableFile> combined) {
final groups = _groupByParent(combined);
final orderedKeys = groups.keys.toList()..sort();
final items = <Widget>[];
for (final folder in orderedKeys) {
final segments = _segmentsOf(folder);
items.add(
_FolderHeader(
folder: folder,
onOpen: () {
onResultTap?.call();
AppRoutes.openFolder(context, segments);
},
),
);
for (final file in groups[folder]!) {
items.add(
FileElement(
file,
segments,
controller.retry,
highlight: controller.query,
),
// Flat (folder header | file) rows built lazily: results can run into
// the hundreds and arrive in several batches per query.
final rows = <(String, CacheableFile?)>[
for (final folder in orderedKeys) ...[
(folder, null),
for (final file in groups[folder]!) (folder, file),
],
];
return ListView.builder(
padding: EdgeInsets.zero,
itemCount: rows.length,
itemBuilder: (context, index) {
final (folder, file) = rows[index];
final segments = _segmentsOf(folder);
if (file == null) {
return _FolderHeader(
key: ValueKey('folder:$folder'),
folder: folder,
onOpen: () {
onResultTap?.call();
AppRoutes.openFolder(context, segments);
},
);
}
return FileElement(
file,
segments,
controller.retry,
key: ValueKey('file:${file.path}'),
highlight: controller.query,
);
}
}
return ListView(padding: EdgeInsets.zero, children: items);
},
);
}
static final RegExp _edgeSlashes = RegExp(r'^/+|/+$');
Map<String, List<CacheableFile>> _groupByParent(List<CacheableFile> files) {
final map = <String, List<CacheableFile>>{};
for (final file in files) {
@@ -139,7 +150,7 @@ class FilesSearchResults extends StatelessWidget {
}
String _parentOf(CacheableFile file) {
final stripped = file.path.replaceAll(RegExp(r'^/+|/+$'), '');
final stripped = file.path.replaceAll(_edgeSlashes, '');
final segments = stripped.split('/');
if (segments.length <= 1) return '/';
segments.removeLast();
@@ -147,7 +158,7 @@ class FilesSearchResults extends StatelessWidget {
}
List<String> _segmentsOf(String folder) {
final stripped = folder.replaceAll(RegExp(r'^/+|/+$'), '');
final stripped = folder.replaceAll(_edgeSlashes, '');
if (stripped.isEmpty) return const [];
return stripped.split('/');
}
@@ -156,7 +167,11 @@ class FilesSearchResults extends StatelessWidget {
class _FolderHeader extends StatelessWidget {
final String folder;
final VoidCallback onOpen;
const _FolderHeader({required this.folder, required this.onOpen});
const _FolderHeader({
required this.folder,
required this.onOpen,
super.key,
});
@override
Widget build(BuildContext context) {
@@ -1,49 +1,29 @@
import 'dart:convert';
import 'package:localstore/localstore.dart';
import 'package:flutter/foundation.dart';
import '../../../../api/cache_store.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
import '../../../../api/request_cache.dart';
/// Document key prefix used by `ListFilesCache._documentId`.
const String _folderCachePrefix = 'wd-folder-';
/// Scans every cached folder listing in Localstore and returns files/folders
/// whose name contains [query] (case-insensitive).
///
/// [pathScope] restricts results to entries whose WebDAV path starts with
/// the given folder. Pass an empty list (or null) to search globally.
///
/// [docs] is an injection seam for tests — production callers leave it null
/// so the helper reads from the real Localstore.
Future<List<CacheableFile>> searchLocalCaches(
String query, {
List<String>? pathScope,
Map<String, dynamic>? docs,
}) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
final needle = trimmed.toLowerCase();
final scopePrefix = pathScope == null || pathScope.isEmpty
? ''
: '${pathScope.join('/')}/';
final raw =
docs ??
await Localstore.instance.collection(RequestCache.collection).get();
if (raw == null || raw.isEmpty) return const [];
final results = <String, CacheableFile>{};
for (final entry in raw.entries) {
final docKey = entry.key.split('/').last;
if (!docKey.startsWith(_folderCachePrefix)) continue;
final value = entry.value;
if (value is! Map) continue;
final json = value['json'];
if (json is! String) continue;
/// Every file and folder from the cached folder listings, deduplicated by
/// path. Built once per search session: reading and parsing all listings on
/// each keystroke used to stall typing.
Future<List<CacheableFile>> loadLocalCacheIndex() async {
final entries = await CacheStore.instance.readAll(prefix: _folderCachePrefix);
if (entries.isEmpty) return const [];
final payloads = [for (final entry in entries.values) entry.json];
return compute(buildLocalCacheIndex, payloads);
}
/// Parses cached `ListFilesResponse` payloads into a deduplicated file list.
/// Unparsable payloads are skipped.
List<CacheableFile> buildLocalCacheIndex(List<String> payloads) {
final byPath = <String, CacheableFile>{};
for (final json in payloads) {
final ListFilesResponse listing;
try {
listing = ListFilesResponse.fromJson(
@@ -52,14 +32,32 @@ Future<List<CacheableFile>> searchLocalCaches(
} on Object {
continue;
}
for (final file in listing.files) {
if (!file.name.toLowerCase().contains(needle)) continue;
if (scopePrefix.isNotEmpty && !file.path.startsWith(scopePrefix)) {
continue;
}
results[file.path] ??= file;
byPath[file.path] ??= file;
}
}
return results.values.toList();
return byPath.values.toList();
}
/// Files in [index] whose name contains [query] (case-insensitive).
///
/// [pathScope] restricts results to entries whose WebDAV path starts with
/// the given folder. Pass an empty list (or null) to search globally.
List<CacheableFile> searchLocalCacheIndex(
List<CacheableFile> index,
String query, {
List<String>? pathScope,
}) {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
final needle = trimmed.toLowerCase();
final scopePrefix = pathScope == null || pathScope.isEmpty
? ''
: '${pathScope.join('/')}/';
return [
for (final file in index)
if (file.lowerName.contains(needle) &&
(scopePrefix.isEmpty || file.path.startsWith(scopePrefix)))
file,
];
}
@@ -7,6 +7,7 @@ import '../../../api/marianumconnect/queries/timetable_get_element_week/timetabl
import '../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart';
import '../../../api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart';
import '../../../api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart';
import '../../../session/session_manager.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../storage/timetable_favorites_settings.dart';
import '../../../utils/haptics.dart';
@@ -31,7 +32,14 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
// One in-flight/resolved future per type so switching tabs (or rebuilds)
// never re-fetches a list that's already loaded.
final Map<TimetableElementType, Future<List<_PickerItem>>> _futures = {};
// Session-wide (per account): the student list alone has 1000+ entries and
// used to be downloaded again every time the picker opened.
static final Map<
TimetableElementType,
({int epoch, DateTime at, Future<List<_PickerItem>> items})
>
_futures = {};
static const Duration _listMaxAge = Duration(minutes: 15);
// Memoised combined future for the "Alle" tab; rebuilt on retry.
Future<List<_PickerItem>>? _allFuture;
@@ -47,8 +55,25 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
return _allFuture ??= _loadAll();
}
Future<List<_PickerItem>> _loadFor(TimetableElementType type) =>
_futures.putIfAbsent(type, () => _fetch(type));
Future<List<_PickerItem>> _loadFor(TimetableElementType type) {
final epoch = SessionManager().sessionEpoch;
final cached = _futures[type];
if (cached != null &&
cached.epoch == epoch &&
DateTime.now().difference(cached.at) < _listMaxAge) {
return cached.items;
}
final items = _fetch(type);
_futures[type] = (epoch: epoch, at: DateTime.now(), items: items);
// A failed load must not stick for the rest of the session.
items.then(
(_) {},
onError: (Object _) {
if (identical(_futures[type]?.items, items)) _futures.remove(type);
},
);
return items;
}
Future<List<_PickerItem>> _loadAll() async {
final lists = await Future.wait(TimetableElementType.values.map(_loadFor));
@@ -117,20 +142,16 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
Haptics.selection();
// Hand the selection back to the timetable view, which renders the foreign
// plan inline. We do not navigate to a new page.
Navigator.of(context).pop((
type: item.type,
id: item.id,
label: item.primary,
));
Navigator.of(
context,
).pop((type: item.type, id: item.id, label: item.primary));
}
void _openFavorite(FavoriteTimetableElement favorite) {
Haptics.selection();
Navigator.of(context).pop((
type: favorite.type,
id: favorite.id,
label: favorite.label,
));
Navigator.of(
context,
).pop((type: favorite.type, id: favorite.id, label: favorite.label));
}
void _toggleFavorite(TimetableElementType type, int id, String label) {
@@ -325,9 +346,7 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
return ListTile(
leading: Icon(_iconFor(item.type)),
title: Text(item.primary),
subtitle: subtitleParts.isEmpty
? null
: Text(subtitleParts.join(' · ')),
subtitle: subtitleParts.isEmpty ? null : Text(subtitleParts.join(' · ')),
trailing: IconButton(
icon: Icon(isFavorite ? Icons.star : Icons.star_border),
tooltip: isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren',
@@ -214,8 +214,11 @@ class ChatBackgroundSettingsPage extends StatelessWidget {
final file = File(AppPaths.chatBackgroundImage);
await file.writeAsBytes(bytes);
// Same filename across replacements → the decoded image is cached under
// an identical key. Evict so the new bytes actually show.
await FileImage(file).evict();
// an identical key (for "cover" also wrapped in a viewport-sized
// ResizeImage). Clearing the cache is fine for this rare action.
PaintingBinding.instance.imageCache
..clear()
..clearLiveImages();
final cs = settings.val(write: true).chatBackgroundSettings;
cs.imageVersion++;
cs.type = ChatBackgroundType.image;
@@ -71,8 +71,6 @@ class DefaultSettings {
fileViewSettings: FileViewSettings(alwaysOpenExternally: Platform.isIOS),
notificationSettings: NotificationSettings(enabled: true),
devToolsSettings: DevToolsSettings(
checkerboardOffscreenLayers: false,
checkerboardRasterCacheImages: false,
showPerformanceOverlay: false,
marianumConnectEndpoint: MarianumConnectEndpoint.live,
marianumConnectCustomUrl: '',
@@ -0,0 +1,227 @@
import 'dart:async';
import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../api/marianumconnect/auth/device_token_name.dart';
import '../../../api/marianumconnect/auth/token_storage.dart';
import '../../../api/marianumconnect/marianumconnect_endpoint.dart';
import '../../../api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart';
import '../../../background/widget_background_task.dart';
import '../../../extensions/date_time.dart';
import '../../../model/endpoint_data.dart';
import '../../../push/push_registration_store.dart';
import '../../../push/push_registration_type.dart';
import '../../../session/account_codec.dart';
import '../../../session/session_manager.dart';
import '../../../state/app/modules/account/bloc/account_bloc.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../utils/clipboard_helper.dart';
import '../../../widget/app_progress_indicator.dart';
import '../../../widget/async_action_button.dart';
import '../../../widget/centered_leading.dart';
import '../../../widget_data/widget_sync.dart';
import 'widgets/push_status_sheet.dart';
typedef _Row = ({String label, String value});
typedef _Section = ({String title, List<_Row> rows});
/// Read-only snapshot of build, endpoint, session, push and widget state for
/// bug reports. Secrets (passwords, bearer token) are never shown.
class DiagnosticsPage extends StatefulWidget {
const DiagnosticsPage({super.key});
@override
State<DiagnosticsPage> createState() => _DiagnosticsPageState();
}
class _DiagnosticsPageState extends State<DiagnosticsPage> {
late Future<List<_Section>> _sections = _collect();
Future<List<_Section>> _collect() async {
final settings = context.read<SettingsCubit>().val();
final accountStatus = context.read<AccountBloc>().state.status;
final session = SessionManager().current;
final nextcloud = session?.nextcloud;
const tokens = MarianumConnectTokenStorage();
final push = PushRegistrationStore();
Future<String> safe(Future<Object?> Function() read) async {
try {
final value = await read().timeout(const Duration(seconds: 5));
return switch (value) {
null => '–',
bool() => value ? 'ja' : 'nein',
DateTime() => '${value.formatDateTime()} (${value.formatRelative()})',
_ => '$value',
};
} on Object catch (e) {
return 'Fehler: $e';
}
}
Future<List<_Row>> rows(Map<String, Future<Object?> Function()> reads) =>
Future.wait(
reads.entries.map(
(e) async => (label: e.key, value: await safe(e.value)),
),
);
final info = await PackageInfo.fromPlatform();
return [
(
title: 'App',
rows: await rows({
'Version': () async => '${info.version} (${info.buildNumber})',
'Paket': () async => info.packageName,
'Build-Modus': () async => kReleaseMode
? 'Release'
: kProfileMode
? 'Profile'
: 'Debug',
'Plattform': () async =>
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
'Gerät': DeviceTokenName.resolve,
'Dart': () async => Platform.version.split(' ').first,
}),
),
(
title: 'Server',
rows: await rows({
'Marianum Connect': () async => MarianumConnectEndpoint.current(),
'Nextcloud': () async =>
'https://${EndpointData().nextcloud().full()}',
'Notfall-Nachricht': () async =>
settings.devToolsSettings.resolveEmergencyNoticeUrl(),
}),
),
(
title: 'Sitzung',
rows: await rows({
'Status': () async => accountStatus.name,
'Benutzer': () async =>
session == null ? null : identityOf(session).$2,
'Konten': () async => SessionManager().accounts.value.accounts.length,
'Demo-Modus': () async => session?.isDemo ?? false,
'Login Flow v2': () async => nextcloud?.usesLoginFlow ?? false,
'App-Passwort (Dateien)': () async =>
nextcloud?.hasAppPassword ?? false,
'App-Passwort (Talk)': () async =>
nextcloud?.hasAppPasswordTalk ?? false,
'Session-Epoche': () async => SessionManager().sessionEpoch,
'MC-Token-ID': tokens.readTokenId,
'MC-Token läuft ab': tokens.readExpiresAt,
}),
),
(
title: 'Push',
rows: await rows({
'Benachrichtigungen': () async =>
settings.notificationSettings.enabled,
'FCM-Token': FirebaseMessaging.instance.getToken,
for (final type in PushRegistrationType.values) ...{
'Registriert (${type.name})': () => push.isRegistered(type),
'Letzte Registrierung (${type.name})': () =>
push.lastRegistrationAt(type),
'Letzter Fehler (${type.name})': () =>
push.lastRegistrationError(type),
},
}),
),
(
title: 'Sonstiges',
rows: await rows({
'Telemetrie-Geräte-ID': TelemetryDeviceId.resolve,
'Widget zuletzt abgerufen': WidgetSync.getFetchedAt,
}),
),
];
}
String _asText(List<_Section> sections) => [
for (final s in sections) ...[
'## ${s.title}',
for (final r in s.rows) '${r.label}: ${r.value}',
'',
],
].join('\n');
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Diagnose'),
actions: [
IconButton(
tooltip: 'Neu laden',
icon: const Icon(Icons.refresh),
onPressed: () => setState(() {
_sections = _collect();
}),
),
FutureBuilder(
future: _sections,
builder: (context, snapshot) => IconButton(
tooltip: 'Alles kopieren',
icon: const Icon(Icons.copy_all_outlined),
onPressed: snapshot.hasData
? () => copyToClipboard(context, _asText(snapshot.data!))
: null,
),
),
],
),
body: FutureBuilder(
future: _sections,
builder: (context, snapshot) {
final sections = snapshot.data;
if (sections == null) {
return const Center(child: AppProgressIndicator.large());
}
return ListView(
children: [
for (final section in sections) ...[
ListTile(
dense: true,
title: Text(
section.title,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
for (final row in section.rows)
ListTile(
dense: true,
title: Text(row.label),
subtitle: SelectableText(row.value),
onLongPress: () => copyToClipboard(context, row.value),
),
if (section.title == 'Push')
ListTile(
leading: const CenteredLeading(
Icon(Icons.notifications_active_outlined),
),
title: const Text('Push-Status prüfen'),
trailing: const Icon(Icons.arrow_right),
onTap: () => showPushStatusSheet(context),
),
if (section.title == 'Sonstiges')
AsyncListTile(
leading: const CenteredLeading(Icon(Icons.widgets_outlined)),
title: const Text('Widget jetzt aktualisieren'),
closeOnSuccess: false,
onPressed: WidgetBackgroundTask.requestImmediateRefresh,
),
const Divider(),
],
],
);
},
),
);
}
@@ -212,14 +212,17 @@ class _SchoolAccountState extends State<_SchoolAccount> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
displayName ?? username,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerStart,
child: Text(
displayName ?? username,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
),
maxLines: 1,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (displayName != null) ...[
const SizedBox(height: 2),
@@ -304,11 +307,14 @@ class _AccountName extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerStart,
child: Text(
name,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
maxLines: 1,
),
),
const SizedBox(height: 2),
Text(
@@ -1,19 +1,30 @@
import 'dart:convert';
import 'dart:io';
import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:path_provider/path_provider.dart';
import '../../../../api/cache_store.dart';
import '../../../../push/chat_thread_store.dart';
import '../../../../push/nid_store.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../storage/dev_tools_settings.dart';
import '../../../../storage/settings.dart' as model;
import '../../../../utils/directory_size.dart';
import '../../../../utils/downloads/download_manager.dart';
import '../../../../widget/avatar_disk_cache.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/debug/cache_view.dart';
import '../../../../widget/debug/json_viewer.dart';
import '../../../../widget/debug/bloc_storage_view.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/user_avatar.dart';
import '../widgets/endpoint_picker.dart';
import '../widgets/settings_checkbox_tile.dart';
import '../widgets/settings_dropdown_tile.dart';
class DevToolsSection extends StatefulWidget {
final SettingsCubit settings;
@@ -27,9 +38,16 @@ class _DevToolsSectionState extends State<DevToolsSection> {
@override
Widget build(BuildContext context) => Column(
children: [
ListTile(
leading: const CenteredLeading(Icon(Icons.monitor_heart_outlined)),
title: const Text('Diagnose'),
subtitle: const Text('Version, Server, Sitzung, Push'),
trailing: const Icon(Icons.arrow_right),
onTap: () => AppRoutes.openDiagnostics(context),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.speed_outlined)),
title: const Text('Performance overlays'),
title: const Text('Debug-Overlays'),
trailing: const Icon(Icons.arrow_right),
onTap: () {
showDetailsBottomSheet(
@@ -39,35 +57,57 @@ class _DevToolsSectionState extends State<DevToolsSection> {
bloc: widget.settings,
builder: (_, _) {
final dev = widget.settings.val().devToolsSettings;
DevToolsSettings write() =>
widget.settings.val(write: true).devToolsSettings;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SettingsCheckboxTile(
icon: Icons.auto_graph_outlined,
title: 'Performance graph',
title: 'Performance-Graph',
value: dev.showPerformanceOverlay,
onChanged: (e) => widget.settings
.val(write: true)
.devToolsSettings
.showPerformanceOverlay = e,
onChanged: (e) => write().showPerformanceOverlay = e,
),
SettingsCheckboxTile(
icon: Icons.screen_search_desktop_outlined,
title: 'Indicate offscreen layers',
value: dev.checkerboardOffscreenLayers,
onChanged: (e) => widget.settings
.val(write: true)
.devToolsSettings
.checkerboardOffscreenLayers = e,
icon: Icons.timer_outlined,
title: 'Frame-Statistik',
subtitle: 'Build-/Raster-Zeiten und Jank-Zähler',
value: dev.showFrameStats,
onChanged: (e) => write().showFrameStats = e,
),
SettingsCheckboxTile(
icon: Icons.imagesearch_roller_outlined,
title: 'Indicate raster cache images',
value: dev.checkerboardRasterCacheImages,
onChanged: (e) => widget.settings
.val(write: true)
.devToolsSettings
.checkerboardRasterCacheImages = e,
icon: Icons.memory_outlined,
title: 'Speicher-Statistik',
subtitle: 'Prozess-RAM und Bild-Cache',
value: dev.showMemoryStats,
onChanged: (e) => write().showMemoryStats = e,
),
SettingsCheckboxTile(
icon: Icons.slow_motion_video_outlined,
title: 'Langsame Animationen',
subtitle: '5× verlangsamt',
value: dev.slowAnimations,
onChanged: (e) => write().slowAnimations = e,
),
SettingsCheckboxTile(
icon: Icons.accessibility_new_outlined,
title: 'Semantics-Debugger',
subtitle: 'Zeigt die Screenreader-Sicht',
value: dev.showSemanticsDebugger,
onChanged: (e) => write().showSemanticsDebugger = e,
),
SettingsDropdownTile<double>(
icon: Icons.format_size_outlined,
title: 'Textgröße',
value: dev.textScaleOverride,
options: const [0, 0.85, 1, 1.3, 1.6, 2],
optionIcon: (e) => e == 0
? Icons.phone_android_outlined
: Icons.text_fields_outlined,
optionLabel: (e) => e == 0
? 'System'
: '${e == e.roundToDouble() ? e.toInt() : e}×',
onChanged: (e) => write().textScaleOverride = e,
),
],
);
@@ -99,98 +139,204 @@ class _DevToolsSectionState extends State<DevToolsSection> {
.trim();
return ListTile(
leading: const CenteredLeading(Icon(Icons.emergency_outlined)),
title: const Text('Notfall-Nachricht (Quelle)'),
subtitle: Text(
override.isEmpty
? 'Standardquelle (im Code hinterlegt)'
: override,
),
title: const Text('Notfall-Nachrichtenquelle'),
subtitle: Text(override.isEmpty ? 'Standardquelle' : override),
trailing: const Icon(Icons.arrow_right),
onTap: () => _EmergencyNoticeUrlEditor.show(context, widget.settings),
onTap: () =>
_EmergencyNoticeUrlEditor.show(context, widget.settings),
);
},
),
ListTile(
leading: const CenteredLeading(Icon(Icons.image_outlined)),
title: const Text('Thumb-storage'),
subtitle: Text(
'etwa ${filesize(PaintingBinding.instance.imageCache.currentSizeBytes)}\nLange tippen um zu löschen',
const Divider(),
_StorageTile(
icon: Icons.settings_applications_outlined,
title: 'Einstellungen',
describe: () async => filesize(
utf8.encode(jsonEncode(widget.settings.val().toJson())).length,
),
onLongPress: () {
ConfirmDialog(
title: 'Thumbs cache löschen',
content: 'Alle zwischengespeicherten Bilder werden gelöscht.',
confirmButton: 'Unwiederruflich löschen',
onConfirm: () => PaintingBinding.instance.imageCache.clear(),
).asDialog(context);
},
onOpen: (_) async => AppRoutes.openJsonViewer(
context,
widget.settings.val().toJson(),
title: 'Einstellungen',
),
clearTitle: 'Einstellungen zurücksetzen',
clearContent:
'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten '
'sind nicht betroffen.',
onClear: widget.settings.reset,
),
ListTile(
leading: const CenteredLeading(
Icon(Icons.settings_applications_outlined),
),
title: const Text('Settings-storage JSON dump'),
subtitle: Text(
'etwa ${filesize(widget.settings.val().toJson().toString().length * 8)}\nLange tippen um zu löschen',
),
onTap: () {
JsonViewer.asDialog(context, widget.settings.val().toJson());
_StorageTile(
icon: Icons.account_tree_outlined,
title: 'BLoC-Speicher',
describe: () async {
final keys = BlocStorageView.keys();
return keys == null ? 'nicht auflistbar' : '${keys.length} Einträge';
},
onLongPress: () {
ConfirmDialog(
title: 'Einstellungen löschen',
content:
'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten sind nicht betroffen.',
confirmButton: 'Unwiederruflich Löschen',
onConfirm: () {
context.read<SettingsCubit>().reset();
},
).asDialog(context);
},
trailing: const Icon(Icons.arrow_right),
onOpen: AppRoutes.openBlocStorageView,
),
ListTile(
leading: const CenteredLeading(Icon(Icons.data_object)),
title: const Text('Cache-storage JSON dump'),
subtitle: FutureBuilder(
future: const CacheView().totalSize(),
builder: (context, snapshot) => Text(
"etwa ${snapshot.hasError
? "?"
: snapshot.hasData
? filesize(snapshot.data)
: "..."}\nLange tippen um zu löschen",
_StorageTile(
icon: Icons.data_object,
title: 'Request-Cache',
describe: () async => filesize(await CacheStore.instance.totalSize()),
onOpen: AppRoutes.openCacheView,
),
_StorageTile(
icon: Icons.image_outlined,
title: 'Bild-Speicher (RAM)',
describe: () async {
final cache = PaintingBinding.instance.imageCache;
return '${cache.currentSize} Bilder · '
'${filesize(cache.currentSizeBytes)} von '
'${filesize(cache.maximumSizeBytes)}';
},
clearTitle: 'Bild-Speicher leeren',
clearContent:
'Alle dekodierten Bilder im Arbeitsspeicher werden verworfen.',
onClear: () async => PaintingBinding.instance.imageCache.clear(),
),
_StorageTile(
icon: Icons.photo_library_outlined,
title: 'Netzwerk-Bilder',
describe: () async => filesize(
await directorySize(
Directory(
'${(await getTemporaryDirectory()).path}/${DefaultCacheManager.key}',
),
),
),
onTap: () => AppRoutes.openCacheView(context),
onLongPress: () {
ConfirmDialog(
title: 'App-Cache löschen',
content:
'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut',
confirmButton: 'Unwiederruflich löschen',
onConfirm: () =>
const CacheView().clear().then((value) => setState(() {})),
).asDialog(context);
},
trailing: const Icon(Icons.arrow_right),
clearTitle: 'Netzwerk-Bilder löschen',
clearContent:
'Heruntergeladene Chat- und Vorschaubilder werden gelöscht und bei '
'Bedarf neu geladen.',
onClear: DefaultCacheManager().emptyCache,
),
ListTile(
leading: const CenteredLeading(Icon(Icons.data_object)),
title: const Text('BLOC-storage state cache'),
subtitle: const Text('Lange tippen um zu löschen'),
onLongPress: () {
ConfirmDialog(
title: 'BLOC-Cache löschen',
content:
'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut',
confirmButton: 'Unwiederruflich löschen',
onConfirm: () => HydratedBloc.storage.clear(),
).asDialog(context);
_StorageTile(
icon: Icons.account_circle_outlined,
title: 'Avatar-Cache',
describe: () async =>
filesize(await AvatarDiskCache.instance.totalSize()),
clearTitle: 'Avatar-Cache löschen',
clearContent: 'Profilbilder werden bei Bedarf neu geladen.',
onClear: () async {
invalidateAvatarCache();
await AvatarDiskCache.instance.clear();
},
),
_StorageTile(
icon: Icons.notifications_outlined,
title: 'Push-Zuordnungen',
describe: () async =>
'${(await NidStore().all()).length} Benachrichtigungen zugeordnet',
clearTitle: 'Push-Zuordnungen löschen',
clearContent:
'Die Zuordnung angezeigter Benachrichtigungen zu Chats und der '
'gruppierte Chat-Verlauf gehen verloren. Bereits angezeigte '
'Benachrichtigungen werden danach nicht mehr automatisch entfernt.',
onClear: () async {
await NidStore().clear();
await ChatThreadStore().clearAll();
},
),
_StorageTile(
icon: Icons.download_outlined,
title: 'Downloads',
describe: () async =>
filesize(await directorySize(await _downloadsDir())),
clearTitle: 'Downloads löschen',
clearContent:
'Laufende Downloads werden abgebrochen und heruntergeladene '
'Dateien gelöscht.',
onClear: () async {
DownloadManager.instance.clearAll();
final dir = await _downloadsDir();
if (!dir.existsSync()) return;
// Cancelled tasks can still be flushing into the folder, so deleting
// it as a whole races them; whatever survives shows up in the size.
await for (final entity in dir.list()) {
try {
await entity.delete(recursive: true);
} on FileSystemException {
// Retried on the next clear.
}
}
},
),
],
);
static Future<Directory> _downloadsDir() async =>
Directory('${(await getTemporaryDirectory()).path}/downloads');
}
/// Storage row: tap opens a detail view ([onOpen]), the trailing button clears
/// it after confirmation ([onClear]). The subtitle re-evaluates [describe]
/// after either.
class _StorageTile extends StatefulWidget {
final IconData icon;
final String title;
final Future<String> Function() describe;
final Future<void> Function(BuildContext context)? onOpen;
final String? clearTitle;
final String? clearContent;
final Future<void> Function()? onClear;
const _StorageTile({
required this.icon,
required this.title,
required this.describe,
this.onOpen,
this.clearTitle,
this.clearContent,
this.onClear,
});
@override
State<_StorageTile> createState() => _StorageTileState();
}
class _StorageTileState extends State<_StorageTile> {
late Future<String> _description = widget.describe();
void _refresh() => setState(() {
_description = widget.describe();
});
@override
Widget build(BuildContext context) {
final onOpen = widget.onOpen;
final onClear = widget.onClear;
return ListTile(
leading: CenteredLeading(Icon(widget.icon)),
title: Text(widget.title),
subtitle: FutureBuilder(
future: _description,
builder: (context, snapshot) =>
Text(snapshot.hasError ? '?' : snapshot.data ?? '…'),
),
onTap: onOpen == null
? null
: () async {
await onOpen(context);
if (mounted) _refresh();
},
trailing: onClear == null
? const Icon(Icons.arrow_right)
: IconButton(
tooltip: 'Löschen',
icon: const Icon(Icons.delete_outline),
onPressed: () => ConfirmDialog(
title: widget.clearTitle ?? '${widget.title} löschen',
content: widget.clearContent ?? '',
confirmButton: 'Löschen',
errorBuilder: (e) => '$e',
onConfirmAsync: () async {
await onClear();
if (mounted) _refresh();
},
).asDialog(context),
),
);
}
}
/// Bottom-sheet editor for the backend-independent emergency-notice override
@@ -202,7 +348,7 @@ class _EmergencyNoticeUrlEditor extends StatefulWidget {
static void show(BuildContext context, SettingsCubit settings) {
showDetailsBottomSheet(
context,
header: const ListTile(title: Text('Notfall-Nachricht (Quelle)')),
header: const ListTile(title: Text('Notfall-Nachrichtenquelle')),
children: (sheetCtx) => [_EmergencyNoticeUrlEditor(settings: settings)],
);
}
@@ -233,8 +379,7 @@ class _EmergencyNoticeUrlEditorState extends State<_EmergencyNoticeUrlEditor> {
void _save() {
final raw = _controller.text.trim();
// Empty is valid: it clears the override and falls back to the default.
if (raw.isNotEmpty &&
DevToolsSettings.sanitizeCustomUrl(raw) == null) {
if (raw.isNotEmpty && DevToolsSettings.sanitizeCustomUrl(raw) == null) {
setState(
() => _error = DevToolsSettings.allowsHttpCustomEndpoint
? 'Ungültige URL (http(s)://host[:port]/...)'
@@ -253,19 +398,11 @@ class _EmergencyNoticeUrlEditorState extends State<_EmergencyNoticeUrlEditor> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.only(bottom: 12),
child: Text(
'Überschreibt die im Code hinterlegte Standardquelle. Leer '
'lassen, um die Standardquelle zu verwenden.',
style: TextStyle(fontSize: 12),
),
),
TextField(
controller: _controller,
keyboardType: TextInputType.url,
decoration: InputDecoration(
labelText: 'https://...',
labelText: 'Leer für Standard',
errorText: _error,
),
onChanged: (_) {
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
import '../../../../push/push_registration.dart';
import '../../../../push/push_status.dart';
import '../../../../session/session_manager.dart';
@@ -104,6 +105,8 @@ class _PushSettingsState extends State<_PushSettings>
void _onToggle(bool enabled) {
widget.settings.val(write: true).notificationSettings.enabled = enabled;
// A permission granted below arrives via the resume heartbeat.
TelemetryHeartbeat.report(notificationsEnabled: enabled);
// Turning off does NOT unregister: the device stays subscribed so silent
// sync pushes keep arriving; the message handler and iOS NSE suppress only
// the visible notification (via the mirrored flag). Enabling (re-)registers
@@ -113,11 +116,10 @@ class _PushSettingsState extends State<_PushSettings>
final messenger = ScaffoldMessenger.of(context);
unawaited(() async {
try {
// Only register when the OS permission isn't explicitly denied —
// otherwise NC + proxy would push into the void.
if (await PushRegistration.requestOsPermission()) {
await PushRegistration().register();
} else {
final granted = await PushRegistration.requestOsPermission();
// Registered regardless: silent sync pushes need no permission.
await PushRegistration().register();
if (!granted) {
messenger.showSnackBar(
const SnackBar(
content: Text(
@@ -32,18 +32,21 @@ class SettingsDropdownTile<T> extends StatelessWidget {
trailing: DropdownButton<T>(
value: value,
icon: const Icon(Icons.arrow_drop_down),
alignment: AlignmentDirectional.centerEnd,
selectedItemBuilder: (context) => options
.map(
(e) => Align(
alignment: AlignmentDirectional.centerEnd,
child: _optionRow(e),
),
)
.toList(),
items: options
.map(
(e) => DropdownMenuItem<T>(
value: e,
enabled: e != value,
child: Row(
children: [
Icon(optionIcon(e)),
const SizedBox(width: 10),
Text(optionLabel(e)),
],
),
child: _optionRow(e),
),
)
.toList(),
@@ -53,4 +56,19 @@ class SettingsDropdownTile<T> extends StatelessWidget {
),
);
}
Widget _optionRow(T e) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(optionIcon(e)),
const SizedBox(width: 10),
Flexible(
child: Text(
optionLabel(e),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
@@ -135,19 +135,24 @@ Future<void> _externalShareFlow(
PendingShare share,
) async {
if (share.hasFiles) {
await ensureTalkShareFolder();
if (!context.mounted) return;
await pushScreen(
context,
withNavBar: false,
screen: FilesUploadDialog(
filePaths: share.filePaths,
remotePath: talkShareFolder,
uniqueNames: true,
onUploadFinished: (uploaded) =>
_afterExternalFilesUploaded(context, room, uploaded, share),
),
);
ShareIntentListener.instance.beginFlow();
try {
await ensureTalkShareFolder();
if (!context.mounted) return;
await pushScreen(
context,
withNavBar: false,
screen: FilesUploadDialog(
filePaths: share.filePaths,
remotePath: talkShareFolder,
uniqueNames: true,
onUploadFinished: (uploaded) =>
_afterExternalFilesUploaded(context, room, uploaded, share),
),
);
} finally {
ShareIntentListener.instance.endFlow();
}
return;
}
if (share.hasText) {
@@ -155,17 +160,28 @@ Future<void> _externalShareFlow(
}
}
// Called synchronously when the upload dialog pops, so the flow is entered
// before the upload step above releases it.
Future<void> _afterExternalFilesUploaded(
BuildContext context,
GetRoomResponseObject room,
List<String> uploadedRemotePaths,
PendingShare share,
) => _runShareFlow(
context,
action: () =>
shareFilesToChat(token: room.token, remoteFilePaths: uploadedRemotePaths),
onSuccess: () => _setExternalDraftAndOpenChat(context, room, share),
);
) async {
ShareIntentListener.instance.beginFlow();
try {
await _runShareFlow(
context,
action: () => shareFilesToChat(
token: room.token,
remoteFilePaths: uploadedRemotePaths,
),
onSuccess: () => _setExternalDraftAndOpenChat(context, room, share),
);
} finally {
ShareIntentListener.instance.endFlow();
}
}
/// Shared share-flow scaffolding: shows the blocking spinner, runs [action],
/// maps failures to an error dialog (popping the spinner first), and invokes
@@ -206,16 +206,21 @@ Future<void> _externalUploadFlow(
List<String> targetPath,
PendingShare share,
) async {
await pushScreen(
context,
withNavBar: false,
screen: FilesUploadDialog(
filePaths: share.filePaths,
remotePath: targetPath.join('/'),
onUploadFinished: (_) =>
_afterExternalUploaded(context, targetPath, share),
),
);
ShareIntentListener.instance.beginFlow();
try {
await pushScreen(
context,
withNavBar: false,
screen: FilesUploadDialog(
filePaths: share.filePaths,
remotePath: targetPath.join('/'),
onUploadFinished: (_) =>
_afterExternalUploaded(context, targetPath, share),
),
);
} finally {
ShareIntentListener.instance.endFlow();
}
}
void _afterExternalUploaded(
+27 -18
View File
@@ -75,6 +75,17 @@ class _ChatListViewState extends State<_ChatListView> {
@override
Widget build(BuildContext context) {
final bloc = context.read<ChatListBloc>();
// Selected here, not in the LoadableStateConsumer child: that closure runs
// during the consumer's build, where this context may not subscribe.
// Draft keys are part of the selection so the draft marker follows; the
// draft text itself is saved without notifying.
final (favoritesToTop, unreadToTop, _) = context.select(
(SettingsCubit c) => (
c.state.talkSettings.sortFavoritesToTop,
c.state.talkSettings.sortUnreadToTop,
c.state.talkSettings.drafts.keys.join('|'),
),
);
return SplitView.material(
placeholder: const SplitViewPlaceholder(),
breakpoint: 1000,
@@ -129,13 +140,9 @@ class _ChatListViewState extends State<_ChatListView> {
final rooms = state.rooms;
if (rooms == null) return const SizedBox.shrink();
final talkSettings = context
.watch<SettingsCubit>()
.val()
.talkSettings;
final sorted = rooms.sortBy(
favoritesToTop: talkSettings.sortFavoritesToTop,
unreadToTop: talkSettings.sortUnreadToTop,
favoritesToTop: favoritesToTop,
unreadToTop: unreadToTop,
);
if (sorted.isEmpty) {
@@ -145,23 +152,25 @@ class _ChatListViewState extends State<_ChatListView> {
);
}
return ListView(
final drafts = _settings.val().talkSettings.drafts;
final indexByToken = {
for (var i = 0; i < sorted.length; i++) sorted[i].token: i,
};
return ListView.builder(
padding: EdgeInsets.zero,
children: sorted.map((room) {
final hasDraft = _settings
.val()
.talkSettings
.drafts
.containsKey(room.token);
// Stable key keeps element identity across re-sorts so the
// inner UserAvatar reuses its cached bytes instead of
// flashing on every list update.
itemCount: sorted.length,
// Keeps each tile's state (cached avatar bytes) with its room
// when a re-sort moves it to another index.
findChildIndexCallback: (key) =>
indexByToken[(key as ValueKey<String>).value],
itemBuilder: (context, index) {
final room = sorted[index];
return ChatTile(
key: ValueKey(room.token),
data: room,
hasDraft: hasDraft,
hasDraft: drafts.containsKey(room.token),
);
}).toList(),
},
);
},
),
+90 -32
View File
@@ -14,6 +14,7 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con
import '../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../state/app/modules/chat/bloc/chat_state.dart';
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../utils/debouncer.dart';
import '../../../widget/chat_background.dart';
import '../../../widget/clickable_app_bar.dart';
import '../../../widget/user_avatar.dart';
@@ -118,6 +119,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
_markAsReadFinal();
_chatBlocRef?.leaveChat(widget.room.token);
_searchTextController.dispose();
Debouncer.cancel(_searchDebounceTag);
super.dispose();
}
@@ -150,6 +152,45 @@ class _ChatViewState extends State<ChatView> with RouteAware {
context.read<ChatBloc>().setToken(widget.room.token);
}
void _refetch({bool renew = false}) => _refresh();
// The built rows only depend on the chat data and the search state; the list
// itself rebuilds far more often (loading flips, keyboard, parent rebuilds),
// and re-sorting plus re-creating every bubble each time is wasted work.
Object? _itemsKey;
List<Widget> _items = const [];
List<Widget> _itemsFor(ChatState state) {
final key = (
state.chatResponse,
state.isLoadingOlder,
state.hasMoreOld,
_searchActive,
_searchQuery,
_activeMatchIndex,
widget.room,
);
if (key == _itemsKey) return _items;
_itemsKey = key;
final items = _buildMessages(state.chatResponse!).reversed.toList();
// reverse:true renders index 0 at the bottom, so the top marker
// (spinner / start-of-chat) goes at the end.
if (state.isLoadingOlder) {
items.add(const _LoadingOlderIndicator());
} else if (!state.hasMoreOld) {
items.add(
ChatBubble(
key: const ValueKey('chat-start'),
isSender: false,
bubbleData: GetChatResponseObject.getTextDummy('Anfang des Chats'),
chatData: widget.room,
refetch: _refetch,
),
);
}
return _items = items;
}
void _enterSearchMode() {
setState(() {
_searchActive = true;
@@ -163,6 +204,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
}
void _exitSearchMode() {
Debouncer.cancel(_searchDebounceTag);
setState(() {
_searchActive = false;
_searchQuery = '';
@@ -175,7 +217,22 @@ class _ChatViewState extends State<ChatView> with RouteAware {
});
}
late final String _searchDebounceTag =
'chat-search-${identityHashCode(this)}';
/// Matching re-scans the whole history and rebuilds every row, so it runs
/// once typing pauses instead of per keystroke.
void _onSearchChanged(String q) {
Debouncer.debounce(
_searchDebounceTag,
const Duration(milliseconds: 200),
() {
if (mounted) _applySearch(q);
},
);
}
void _applySearch(String q) {
final chatResponse = context.read<ChatBloc>().state.data?.chatResponse;
setState(() {
_searchQuery = q;
@@ -268,9 +325,13 @@ class _ChatViewState extends State<ChatView> with RouteAware {
messages.add(
ChatBubble(
isSender: false,
key: ValueKey(
'day-${elementDate.year}-${elementDate.month}-'
'${elementDate.day}',
),
bubbleData: GetChatResponseObject.getDateDummy(element.timestamp),
chatData: widget.room,
refetch: ({bool renew = false}) => _refresh(),
refetch: _refetch,
),
);
}
@@ -286,6 +347,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
messages.add(
ChatBubble(
key: ValueKey(element.id),
isSender:
element.actorId == widget.selfId &&
(element.messageType ==
@@ -294,7 +356,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
GetRoomResponseObjectMessageType.deletedComment),
bubbleData: element,
chatData: widget.room,
refetch: ({bool renew = false}) => _refresh(),
refetch: _refetch,
isRead: element.id <= commonRead,
selfId: widget.selfId,
highlightQuery: highlightQuery,
@@ -317,17 +379,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
@override
Widget build(BuildContext context) {
// Swallow the first back gesture while the keyboard is visible so it
// dismisses the IME instead of popping the chat — matches platform UX
// expectations (e.g. WhatsApp/Telegram) and prevents accidental exits
// mid-typing.
final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0;
return PopScope(
canPop: !keyboardOpen,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
FocusManager.instance.primaryFocus?.unfocus();
},
return _KeyboardDismissPopScope(
child: Scaffold(
backgroundColor: const Color(0xffefeae2),
appBar: _searchActive
@@ -376,25 +428,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
state.currentToken == widget.room.token,
enablePullToRefresh: false,
child: (state, _) {
final items = _buildMessages(
state.chatResponse!,
).reversed.toList();
// reverse:true renders index 0 at the bottom, so the top
// marker (spinner / start-of-chat) goes at the end.
if (state.isLoadingOlder) {
items.add(const _LoadingOlderIndicator());
} else if (!state.hasMoreOld) {
items.add(
ChatBubble(
isSender: false,
bubbleData: GetChatResponseObject.getTextDummy(
'Anfang des Chats',
),
chatData: widget.room,
refetch: ({bool renew = false}) => _refresh(),
),
);
}
final items = _itemsFor(state);
_itemCount = items.length;
return ScrollablePositionedList.builder(
reverse: true,
@@ -448,3 +482,27 @@ class _LoadingOlderIndicator extends StatelessWidget {
),
);
}
/// Swallows the first back gesture while the keyboard is visible so it
/// dismisses the IME instead of popping the chat — matches platform UX
/// expectations (e.g. WhatsApp/Telegram) and prevents accidental exits
/// mid-typing. A separate widget so the per-frame inset changes during the
/// keyboard animation only rebuild this scope, not the whole chat.
class _KeyboardDismissPopScope extends StatelessWidget {
final Widget child;
const _KeyboardDismissPopScope({required this.child});
@override
Widget build(BuildContext context) {
final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0;
return PopScope(
canPop: !keyboardOpen,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
FocusManager.instance.primaryFocus?.unfocus();
},
child: child,
);
}
}
@@ -6,6 +6,7 @@ import '../../../../api/marianumcloud/talk/get_participants/get_participants_cac
import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart';
import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_overview_response.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../api/marianumcloud/talk/room/notification_level.dart';
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/async_action_button.dart';
@@ -14,6 +15,7 @@ import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/large_profile_picture_view.dart';
import '../../../../widget/user_avatar.dart';
import '../talk_navigator.dart';
import '../widgets/notification_level_sheet.dart';
import 'participants_list_view.dart';
import 'shared_items_view.dart';
@@ -29,6 +31,7 @@ class _ChatInfoState extends State<ChatInfo> {
GetParticipantsResponse? participants;
GetSharedItemsOverviewResponse? _sharesOverview;
late bool _isFavorite;
late NotificationLevel _notificationLevel;
int _avatarVersion = 0;
bool _avatarBusy = false;
@@ -36,6 +39,7 @@ class _ChatInfoState extends State<ChatInfo> {
void initState() {
super.initState();
_isFavorite = widget.room.isFavorite;
_notificationLevel = widget.room.effectiveNotificationLevel;
GetParticipantsCache(
chatToken: widget.room.token,
onUpdate: (GetParticipantsResponse data) {
@@ -67,6 +71,16 @@ class _ChatInfoState extends State<ChatInfo> {
_refreshList();
}
Future<void> _editNotificationLevel() async {
final level = await showNotificationLevelSheet(
context,
widget.room,
current: _notificationLevel,
);
if (level == null || !mounted) return;
setState(() => _notificationLevel = level);
}
Future<void> _confirmLeave() async {
final closed = await showDialog<bool>(
context: context,
@@ -243,6 +257,11 @@ class _ChatInfoState extends State<ChatInfo> {
title: const Text('Zu Favoriten hinzufügen'),
onPressed: _toggleFavorite,
),
NotificationLevelTile(
level: _notificationLevel,
trailing: const Icon(Icons.arrow_right),
onTap: _editNotificationLevel,
),
const Divider(),
ListTile(
leading: Icon(
+37 -13
View File
@@ -59,6 +59,13 @@ class _ChatBubbleState extends State<ChatBubble>
with SingleTickerProviderStateMixin, DownloadTrigger<ChatBubble> {
late ChatMessage message;
// The parsed message and its widget are only rebuilt when their inputs
// change. The bubble itself rebuilds on every chat emit, swipe frame and
// keyboard frame; re-running rich-object parsing, linkify/Markdown and emoji
// detection each time is what makes long chats stutter.
Object? _messageKey;
late Widget _messageWidget;
Offset _position = Offset.zero;
Offset _dragStartPosition = Offset.zero;
bool _swipeActionArmed = false;
@@ -185,12 +192,34 @@ class _ChatBubbleState extends State<ChatBubble>
}
}
@override
Widget build(BuildContext context) {
void _updateMessage(BuildContext context) {
final style = _messageTextStyle(context);
final renderMarkdown =
widget.bubbleData.markdown &&
widget.bubbleData.messageType ==
GetRoomResponseObjectMessageType.comment;
final key = (
widget.bubbleData,
widget.highlightQuery,
style,
renderMarkdown,
);
if (key == _messageKey) return;
_messageKey = key;
message = ChatMessage(
originalMessage: widget.bubbleData.message,
originalData: widget.bubbleData.messageParameters,
);
_messageWidget = message.getWidget(
highlightQuery: widget.highlightQuery,
style: style,
renderMarkdown: renderMarkdown,
);
}
@override
Widget build(BuildContext context) {
_updateMessage(context);
final showActorDisplayName =
_rendersAsCommentBubble &&
widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
@@ -277,14 +306,7 @@ class _ChatBubbleState extends State<ChatBubble>
actorText: actorText,
actorWidget: actorWidget,
timeText: timeText,
messageWidget: message.getWidget(
highlightQuery: widget.highlightQuery,
style: _messageTextStyle(context),
renderMarkdown:
widget.bubbleData.markdown &&
widget.bubbleData.messageType ==
GetRoomResponseObjectMessageType.comment,
),
messageWidget: _messageWidget,
parent: parent,
bubbleData: widget.bubbleData,
isSender: widget.isSender,
@@ -350,10 +372,12 @@ class _BubbleContent extends StatelessWidget {
Widget build(BuildContext context) => MergeSemantics(
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.9,
maxWidth: MediaQuery.sizeOf(context).width * 0.9,
minWidth: showActorDisplayName
? actorText.size.width
: timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3,
? actorText.measuredWidth(context)
: timeText.measuredWidth(context) +
(isSender ? spacing + timeIconSize : 0) +
3,
),
child: Stack(
children: [
@@ -34,7 +34,7 @@ class ChatBubbleReactions extends StatelessWidget {
return Transform.translate(
offset: const Offset(0, -10),
child: Container(
width: MediaQuery.of(context).size.width,
width: MediaQuery.sizeOf(context).width,
margin: const EdgeInsets.only(left: 15, right: 15),
child: Wrap(
alignment: isSender ? WrapAlignment.end : WrapAlignment.start,
+24 -11
View File
@@ -83,12 +83,20 @@ class _ChatTextfieldState extends State<ChatTextfield> {
);
}
/// Called per keystroke, so the text itself is saved silently; only a draft
/// appearing or disappearing notifies listeners (the chat list's marker).
void _setDraft(String text) {
final talkSettings = settings.val(write: true).talkSettings;
final drafts = settings.val().talkSettings.drafts;
final hadDraft = drafts.containsKey(widget.sendToToken);
if (text.isNotEmpty) {
talkSettings.drafts[widget.sendToToken] = text;
drafts[widget.sendToToken] = text;
} else {
talkSettings.drafts.removeWhere((key, _) => key == widget.sendToToken);
drafts.remove(widget.sendToToken);
}
if (hadDraft != text.isNotEmpty) {
settings.val(write: true);
} else {
settings.saveSilently();
}
}
@@ -276,17 +284,22 @@ class _ChatTextfieldState extends State<ChatTextfield> {
@override
Widget build(BuildContext context) {
final chatBloc = context.watch<ChatBloc>();
final chatState = chatBloc.state.data;
final chatBloc = context.read<ChatBloc>();
// Only the reply reference is rendered from the chat state; loading flags
// and paging emits don't need to rebuild the input.
final (referenceMessageId, chatResponse) = context.select(
(ChatBloc b) => (
b.state.data?.referenceMessageId,
b.state.data?.chatResponse,
),
);
Widget replyBanner = const SizedBox.shrink();
if (chatState != null &&
chatState.referenceMessageId != null &&
chatState.chatResponse != null) {
if (referenceMessageId != null && chatResponse != null) {
try {
final referenceMessage = chatState.chatResponse!
.sortByTimestamp()
.firstWhere((e) => e.id == chatState.referenceMessageId);
final referenceMessage = chatResponse.data.firstWhere(
(e) => e.id == referenceMessageId,
);
replyBanner = Row(
children: [
Expanded(
+39 -3
View File
@@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/marianumcloud/talk/actions/talk_actions.dart';
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../api/marianumcloud/talk/room/notification_level.dart';
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
import '../../../../extensions/date_time.dart';
import '../../../../notification/notification_tasks.dart';
@@ -22,6 +23,7 @@ import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/user_avatar.dart';
import '../data/talk_markdown.dart';
import '../talk_navigator.dart';
import 'notification_level_sheet.dart';
class ChatTile extends StatefulWidget {
final GetRoomResponseObject data;
@@ -62,15 +64,25 @@ class _ChatTileState extends State<ChatTile> {
/// One-line preview of the last message: rich-object placeholders resolved,
/// newlines flattened and — for Markdown messages — formatting stripped so
/// the list shows readable text rather than raw markers.
///
/// Memoised per last message: the tile rebuilds on every chat-list emit and
/// the Markdown strip is a full parse. Keyed by content, since every refresh
/// delivers new message objects.
String _lastMessagePreview() {
final last = widget.data.lastMessage;
final key = (last.id, last.message, last.markdown);
if (key == _previewFor) return _preview;
final text = RichObjectStringProcessor.parseToString(
last.message.replaceAll('\n', ' '),
last.messageParameters,
);
return last.markdown ? markdownToPlainText(text) : text;
_previewFor = key;
return _preview = last.markdown ? markdownToPlainText(text) : text;
}
Object? _previewFor;
String _preview = '';
Future<void> _setCurrentAsRead() async {
final token = widget.data.token;
final lastId = widget.data.lastMessage.id;
@@ -83,7 +95,11 @@ class _ChatTileState extends State<ChatTile> {
@override
Widget build(BuildContext context) {
final chatBloc = context.watch<ChatBloc>();
// Only the open token matters here (split-view highlight); watching the
// whole bloc rebuilt every tile on each message of the open chat.
final currentToken = context.select(
(ChatBloc b) => b.state.data?.currentToken,
);
final isGroup =
widget.data.type != GetRoomResponseObjectConversationType.oneToOne;
final circleAvatar = UserAvatar(
@@ -94,7 +110,7 @@ class _ChatTileState extends State<ChatTile> {
return ListTile(
style: ListTileStyle.list,
tileColor:
chatBloc.state.data?.currentToken == widget.data.token &&
currentToken == widget.data.token &&
TalkNavigator.isSecondaryVisible(context)
? Theme.of(context).primaryColor.withAlpha(100)
: null,
@@ -147,6 +163,15 @@ class _ChatTileState extends State<ChatTile> {
semanticLabel: A11yLabels.draft,
),
],
if (widget.data.isMuted) ...[
const SizedBox(width: 5),
Icon(
NotificationLevel.neverNotify.icon,
size: 15,
color: Theme.of(context).colorScheme.onSurfaceVariant,
semanticLabel: A11yLabels.muted,
),
],
],
),
subtitle: Text(
@@ -234,6 +259,17 @@ class _ChatTileState extends State<ChatTile> {
if (mounted) _refreshList();
},
),
NotificationLevelTile(
level: widget.data.effectiveNotificationLevel,
onTap: () {
Navigator.of(sheetCtx).pop();
showNotificationLevelSheet(
context,
widget.data,
current: widget.data.effectiveNotificationLevel,
);
},
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Talk-Chat verlassen'),
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../api/marianumcloud/talk/room/notification_level.dart';
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/details_bottom_sheet.dart';
extension NotificationLevelPresentation on NotificationLevel {
// defaultLevel never reaches the UI (see effectiveNotificationLevel), but
// the switch has to stay exhaustive.
({String label, String description, IconData icon}) get _presentation =>
switch (this) {
NotificationLevel.alwaysNotify || NotificationLevel.defaultLevel => (
label: 'Alle Nachrichten',
description: 'Bei jeder neuen Nachricht',
icon: Icons.notifications_outlined,
),
NotificationLevel.notifyOnMention => (
label: 'Nur Erwähnungen',
description: 'Nur wenn du erwähnt wirst',
icon: Icons.alternate_email,
),
NotificationLevel.neverNotify => (
label: 'Aus',
description: 'Keine Benachrichtigungen',
icon: Icons.notifications_off_outlined,
),
};
String get label => _presentation.label;
String get description => _presentation.description;
IconData get icon => _presentation.icon;
}
/// Lets the user pick the room's Talk notification level. Nextcloud applies
/// it server-side, so no push for the room is sent at all when muted.
/// Returns the new level, or null when nothing changed.
Future<NotificationLevel?> showNotificationLevelSheet(
BuildContext context,
GetRoomResponseObject room, {
required NotificationLevel current,
}) async {
final bloc = context.read<ChatListBloc>();
NotificationLevel? selected;
await showDetailsBottomSheet(
context,
header: ListTile(
leading: const Icon(Icons.notifications_outlined),
title: const Text('Benachrichtigungen'),
subtitle: Text(room.displayName, overflow: TextOverflow.ellipsis),
),
children: (_) => [
for (final level in selectableNotificationLevels)
AsyncListTile(
leading: Icon(
level == current
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
),
title: Text(level.label),
subtitle: Text(level.description),
onPressed: () async {
if (level == current) return;
await bloc.setNotificationLevel(room.token, level);
selected = level;
},
),
],
);
return selected;
}
class NotificationLevelTile extends StatelessWidget {
final NotificationLevel level;
final VoidCallback onTap;
final Widget? trailing;
const NotificationLevelTile({
required this.level,
required this.onTap,
this.trailing,
super.key,
});
@override
Widget build(BuildContext context) => ListTile(
leading: Icon(level.icon),
title: const Text('Benachrichtigungen'),
subtitle: Text(level.label),
trailing: trailing,
onTap: onTap,
);
}
@@ -1,7 +1,7 @@
import 'package:rrule/rrule.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../extensions/date_time.dart';
import '../../../../utils/recurrence_occurrences.dart';
import 'arbitrary_appointment.dart';
import 'calendar_layout.dart';
import 'lesson_period_schedule.dart';
@@ -103,7 +103,6 @@ partitionAppointmentsForWeek(
continue;
}
try {
final parsed = RecurrenceRule.fromString(rule);
final anchorUtc = a.startTime.toUtc();
final duration = a.endTime.difference(a.startTime);
// Day-keyed set of exception dates so occurrences scheduled for one
@@ -112,9 +111,12 @@ partitionAppointmentsForWeek(
final exceptionDayKeys = (a.recurrenceExceptionDates ?? const <DateTime>[])
.map((d) => '${d.year}-${d.month}-${d.day}')
.toSet();
for (final occUtc in parsed.getInstances(start: anchorUtc)) {
if (!occUtc.isBefore(weekEndUtc)) break;
if (occUtc.isBefore(weekStartUtc)) continue;
for (final occUtc in RecurrenceOccurrences.between(
rule,
anchorUtc,
weekStartUtc,
weekEndUtc,
)) {
final occLocal = occUtc.toLocal();
if (exceptionDayKeys.contains(
'${occLocal.year}-${occLocal.month}-${occLocal.day}',
@@ -73,5 +73,6 @@ class LessonMerger {
substitutionText: source.substitutionText,
lessonText: source.lessonText,
infoText: source.infoText,
classIds: source.classIds,
);
}
@@ -1,4 +1,5 @@
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import 'lesson_labels.dart';
/// Derives a human-readable title from the Marianum-Connect `lessonType` for
/// lessons that have no subject of their own (Aufsicht, Sprechstunde, …).
@@ -10,10 +11,20 @@ class LessonTypeLabel {
static String forEntry(McTimetableEntry lesson) {
final base = baseLabel(lesson.lessonType);
if (base == fallback) {
final text = entryText(lesson);
if (text != null) return text;
}
final room = (lesson.rooms.firstOrNull ?? '').trim();
return room.isEmpty ? base : '$room $base';
}
/// Webuntis-Freitext eines fachlosen Eintrags („Klassenfahrt 8a“), der
/// aussagekräftiger ist als das generische „Event“.
static String? entryText(McTimetableEntry lesson) =>
collapseWhitespace(lesson.substitutionText) ??
collapseWhitespace(lesson.lessonText);
/// Returns just the type wording without the room — useful when the caller
/// renders the room separately (e.g. as a subtitle line).
static String baseLabel(String lessonType) {
@@ -213,10 +213,12 @@ class TimetableAppointmentFactory {
return LessonColor.forStatus(status);
}
McSubject? _findSubject(String? subjectShort) {
if (subjectShort == null) return null;
return subjects.where((s) => s.shortName == subjectShort).firstOrNull;
}
late final Map<String, McSubject> _subjectsByShort = {
for (final s in subjects.reversed) s.shortName: s,
};
McSubject? _findSubject(String? subjectShort) =>
subjectShort == null ? null : _subjectsByShort[subjectShort];
String _subjectName(String? subjectShort, McTimetableEntry lesson) {
if (subjectShort != null) {
@@ -240,12 +242,14 @@ class TimetableAppointmentFactory {
String _locationLabel(McTimetableEntry lesson) {
final roomName =
collapseWhitespace(lesson.rooms.firstOrNull) ?? 'Unbekannt';
// Klassenlose Einträge (Aufsichten etc.) fallen auf den Lehrer zurück.
final secondLine =
(showClassInsteadOfTeacher ? lesson.classLabel : null) ??
_teacherLabel(lesson.teachers.firstOrNull) ??
'Unbekannt';
return '$roomName\n$secondLine';
if (showClassInsteadOfTeacher) {
// Klassenlose Einträge (Aufsichten etc.) zeigen nur den Raum — die
// Lehrkraft wäre hier die Plan-Inhaberin selbst.
final classLabel = lesson.classLabel;
return classLabel == null ? roomName : '$roomName\n$classLabel';
}
final teacher = _teacherLabel(lesson.teachers.firstOrNull) ?? 'Unbekannt';
return '$roomName\n$teacher';
}
/// Backend serves teachers with their full display name ("Stefan Müller"),
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../data/arbitrary_appointment.dart';
import 'custom_event_sheet.dart';
@@ -12,6 +13,8 @@ class AppointmentDetailsDispatcher {
TimetableState? state,
Appointment appointment, {
bool canEditSubjectColor = false,
bool teacherPlan = false,
void Function(TimetableElementRef element)? onOpenElement,
}) {
final id = appointment.id;
if (id is! ArbitraryAppointment) return;
@@ -23,6 +26,8 @@ class AppointmentDetailsDispatcher {
appointment,
entry,
canEditSubjectColor: canEditSubjectColor,
teacherPlan: teacherPlan,
onOpenElement: onOpenElement,
),
custom: (event) => CustomEventSheet.show(context, event),
);

Some files were not shown because too many files have changed in this diff Show More