From e452409b2a08d09505e687497a71b008701ceb30 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:49:44 +0000 Subject: [PATCH 01/10] fix: don't let pre-restore auto-export clobber the WebDAV backup being restored When restoring into the currently open library with auto-export enabled, _restoreWebDavBackupInternal ran _persistOpenDatabaseState (which triggers an auto-export) before downloading the remote backup. That upload overwrote the exact canonical file the restore was about to read, archiving the real newer backup away under a timestamp and leaving the "restore" silently re-import the device's own stale data. Skip the auto-export step when the restore destination is the currently open library. --- lib/core/session/app_session_controller.dart | 23 ++++- test/app_session_controller_test.dart | 99 ++++++++++++++++++++ 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/lib/core/session/app_session_controller.dart b/lib/core/session/app_session_controller.dart index 9b4d5c8..df1547c 100644 --- a/lib/core/session/app_session_controller.dart +++ b/lib/core/session/app_session_controller.dart @@ -3,6 +3,7 @@ import 'dart:developer' as developer; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; import 'package:webdav_client/webdav_client.dart' as webdav; @@ -923,6 +924,7 @@ class AppSessionController extends ChangeNotifier { Future _persistOpenDatabaseState({ required bool markSessionClean, bool closeDatabaseAfterSnapshot = true, + bool runAutoExport = true, }) async { if (_database == null || _currentPassphrase == null) { developer.log( @@ -935,7 +937,9 @@ class AppSessionController extends ChangeNotifier { await _prepareDatabaseSnapshot( closeDatabaseAfterSnapshot: closeDatabaseAfterSnapshot, ); - await _runAutoExportIfConfigured(); + if (runAutoExport) { + await _runAutoExportIfConfigured(); + } await _securityPreferencesService.setSessionDirty(!markSessionClean); await _updatePendingAutoImportAvailability(); } @@ -1172,7 +1176,22 @@ class AppSessionController extends ChangeNotifier { try { await _loadSecurityPreferences(); await _loadBackupPreferences(); - await _persistOpenDatabaseState(markSessionClean: true); + + // If we're restoring into the same library that's currently open, an + // auto-export here would upload the (stale) local state to the exact + // remote path we're about to download from, archiving away the newer + // backup we're trying to restore before we ever read it. Skip the + // auto-export in that case; it's about to be discarded anyway. + final currentDatabasePath = await _databasePathService + .getCurrentDatabasePath(); + final isRestoringCurrentLibrary = p.equals( + p.normalize(currentDatabasePath), + p.normalize(destinationPath), + ); + await _persistOpenDatabaseState( + markSessionClean: true, + runAutoExport: !isRestoringCurrentLibrary, + ); final client = await createWebDavClient(); if (client == null) throw StateError('WebDAV is not configured.'); diff --git a/test/app_session_controller_test.dart b/test/app_session_controller_test.dart index b8b35ee..e3807cf 100644 --- a/test/app_session_controller_test.dart +++ b/test/app_session_controller_test.dart @@ -314,6 +314,77 @@ void main() { }, ); + test( + 'restoring into the currently open library does not let a pre-restore ' + 'auto-export clobber the remote backup being restored', + () async { + final remoteLibraryDirectory = Directory( + '${tempDirectory.path}/remote-source.classi', + ); + await remoteLibraryDirectory.create(recursive: true); + await File( + '${remoteLibraryDirectory.path}/data.db', + ).writeAsString('remote-db'); + await File( + '${remoteLibraryDirectory.path}/data.db.security.json', + ).writeAsString('remote-security'); + await File( + '${remoteLibraryDirectory.path}/data.db.integrity.json', + ).writeAsString('remote-integrity'); + + final archiveBytes = await LibraryBackupService().buildBackupArchive( + remoteLibraryDirectory.path, + ); + final restoreService = _RestoringSelfLibraryBackupService(archiveBytes); + controller.dispose(); + final databasePathService = _TestDatabasePathService( + '${tempDirectory.path}/test.classi', + ); + controller = _RestoringAppSessionController( + keyService: keyService, + databasePathService: databasePathService, + securityPreferencesService: _securityPreferencesServiceFor( + databasePathService, + ), + libraryBackupPreferencesService: _libraryBackupPreferencesServiceFor( + databasePathService, + ), + libraryBackupService: restoreService, + biometricService: BiometricService(), + ); + + await controller.initialize(); + await controller.createDatabase('test'); + controller.clearPendingRecoveryKey(); + await controller.setWebDavUrl('https://example.invalid/remote.php/dav'); + await controller.setWebDavAutoExportEnabled(true); + + final currentPath = await controller.currentDatabasePath(); + final errorCode = await controller.restoreWebDavBackup( + remotePath: '/backups/test.classi-backup', + destinationPath: currentPath, + createNew: false, + ); + + expect(errorCode, isNull); + expect( + restoreService.exportCalled, + isFalse, + reason: + 'auto-export must not run before restoring into the currently ' + 'open library, or it uploads stale local state over the exact ' + 'remote backup being restored', + ); + expect( + await File('$currentPath/data.db').readAsString(), + 'remote-db', + reason: + 'the restored data must be the remote backup, not a ' + 're-uploaded local copy', + ); + }, + ); + test('auto-export is skipped when WebDAV is not configured', () async { await controller.initialize(); await controller.createDatabase('test'); @@ -526,6 +597,34 @@ class _RestoringLibraryBackupService extends LibraryBackupService { } } +class _RestoringSelfLibraryBackupService extends LibraryBackupService { + _RestoringSelfLibraryBackupService(this.archiveBytes); + + final Uint8List archiveBytes; + bool exportCalled = false; + String? lastRemotePath; + + @override + Future exportBackupToWebDav({ + required webdav.Client client, + required String sourceDatabasePath, + required String serverPath, + int maxVersions = 3, + }) async { + exportCalled = true; + return DateTime.now().toUtc(); + } + + @override + Future downloadBackupFromWebDav({ + required webdav.Client client, + required String remotePath, + }) async { + lastRemotePath = remotePath; + return archiveBytes; + } +} + class _RestoringAppSessionController extends AppSessionController { _RestoringAppSessionController({ required super.keyService, From bb0ce2aa010b6243ba0b13843cc50289229dcfe0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 19:45:34 +0000 Subject: [PATCH 02/10] feat: attach device identity to WebDAV backups Add a DeviceIdentityService that generates a stable per-install device id and lets the user set a friendly device name (defaulting to a generic, non-identifying platform label rather than the OS hostname). Every export now embeds deviceId/deviceName in the backup manifest and uploads a small .meta.json sidecar next to the backup (kept in sync through archive/rename/prune), so the device that produced a backup can be shown without downloading the whole archive. Surfaced in the WebDAV restore picker, the settings backup list, and the auto-import prompt ("Backup exported from Kitchen iPad on ..."), plus a new "Device name" field in WebDAV settings. --- assets/translations/de.json | 3 + assets/translations/en.json | 3 + lib/core/providers/app_providers.dart | 6 + lib/core/session/app_session_controller.dart | 44 ++++- lib/core/storage/library_backup_service.dart | 152 ++++++++++++++++-- lib/core/sync/device_identity_service.dart | 77 +++++++++ lib/features/settings/settings_screen.dart | 26 ++- .../setup/auto_import_prompt_card.dart | 38 +++-- lib/features/setup/webdav_restore_flow.dart | 5 + test/app_session_controller_test.dart | 130 +++++++++++++++ test/device_identity_service_test.dart | 53 ++++++ test/library_backup_service_test.dart | 37 +++++ 12 files changed, 549 insertions(+), 25 deletions(-) create mode 100644 lib/core/sync/device_identity_service.dart create mode 100644 test/device_identity_service_test.dart diff --git a/assets/translations/de.json b/assets/translations/de.json index 018da9c..e0d1337 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -98,6 +98,8 @@ "database_overwrite_warning": "Eine Bibliothek mit diesem Namen existiert bereits und wird überschrieben.", "date": "Datum", "delete": "Löschen", + "device_name": "Gerätename", + "device_name_hint": "Wird anderen Geräten angezeigt, wenn ein Backup von hier zur Wiederherstellung verfügbar ist. Standardmäßig eine allgemeine Bezeichnung basierend auf der Plattform dieses Geräts.", "dismiss": "Schließen", "done": "Erledigt", "edit": "Bearbeiten", @@ -223,6 +225,7 @@ "newer_backup_available": "Ein neueres Backup ist verfügbar.", "newer_backup_available_hint": "Stelle es vor dem Entsperren wieder her, um diese lokale Bibliothek durch den synchronisierten Stand zu ersetzen.", "newer_backup_exported_at": "Backup wurde von einem anderen Gerät am {datetime} exportiert.", + "newer_backup_exported_from_at": "Backup wurde von {device} am {datetime} exportiert.", "next": "Weiter", "no_folder_selected": "Kein Ordner ausgewählt", "no_grade": "Keine Note", diff --git a/assets/translations/en.json b/assets/translations/en.json index 6744499..f64f9c8 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -98,6 +98,8 @@ "database_overwrite_warning": "A library with this name already exists and will be overwritten.", "date": "Date", "delete": "Delete", + "device_name": "Device name", + "device_name_hint": "Shown to other devices when a backup from here is available to restore. Defaults to a generic label based on this device's platform.", "dismiss": "Dismiss", "done": "Done", "edit": "Edit", @@ -223,6 +225,7 @@ "newer_backup_available": "A newer backup is available.", "newer_backup_available_hint": "Restore it before unlocking to replace this local library with the synced snapshot.", "newer_backup_exported_at": "Backup exported by another device on {datetime}.", + "newer_backup_exported_from_at": "Backup exported from {device} on {datetime}.", "next": "Next", "no_folder_selected": "No folder chosen", "no_grade": "No grade", diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index 2ae0194..fefb8ab 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -31,6 +31,7 @@ import '../storage/database_path_service.dart'; import '../storage/library_backup_preferences_service.dart'; import '../storage/library_backup_service.dart'; import '../storage/project_settings_store.dart'; +import '../sync/device_identity_service.dart'; import '../update/app_update_controller.dart'; final keyServiceProvider = Provider((ref) => KeyService()); @@ -66,6 +67,10 @@ final biometricServiceProvider = Provider( (ref) => BiometricService(), ); +final deviceIdentityServiceProvider = Provider( + (ref) => DeviceIdentityService(), +); + final appUpdateControllerProvider = ChangeNotifierProvider( (ref) => AppUpdateController(), ); @@ -80,6 +85,7 @@ final appSessionProvider = ChangeNotifierProvider((ref) { ), libraryBackupService: ref.watch(libraryBackupServiceProvider), biometricService: ref.watch(biometricServiceProvider), + deviceIdentityService: ref.watch(deviceIdentityServiceProvider), ); unawaited(controller.initialize()); return controller; diff --git a/lib/core/session/app_session_controller.dart b/lib/core/session/app_session_controller.dart index df1547c..9272d95 100644 --- a/lib/core/session/app_session_controller.dart +++ b/lib/core/session/app_session_controller.dart @@ -14,6 +14,7 @@ import '../security/security_preferences_service.dart'; import '../storage/database_path_service.dart'; import '../storage/library_backup_preferences_service.dart'; import '../storage/library_backup_service.dart'; +import '../sync/device_identity_service.dart'; enum AppSessionStatus { loading, needsSetup, locked, ready, error } @@ -51,12 +52,14 @@ class AppSessionController extends ChangeNotifier { required LibraryBackupPreferencesService libraryBackupPreferencesService, required LibraryBackupService libraryBackupService, required BiometricService biometricService, + DeviceIdentityService? deviceIdentityService, }) : _keyService = keyService, _databasePathService = databasePathService, _securityPreferencesService = securityPreferencesService, _libraryBackupPreferencesService = libraryBackupPreferencesService, _libraryBackupService = libraryBackupService, - _biometricService = biometricService; + _biometricService = biometricService, + _deviceIdentityService = deviceIdentityService ?? DeviceIdentityService(); final KeyService _keyService; final DatabasePathService _databasePathService; @@ -64,6 +67,7 @@ class AppSessionController extends ChangeNotifier { final LibraryBackupPreferencesService _libraryBackupPreferencesService; final LibraryBackupService _libraryBackupService; final BiometricService _biometricService; + final DeviceIdentityService _deviceIdentityService; AppDatabase? _database; String? _databasePath; @@ -87,6 +91,7 @@ class AppSessionController extends ChangeNotifier { int _webDavMaxVersions = LibraryBackupPreferencesService.defaultMaxVersions; bool _pendingWebDavImport = false; DateTime? _pendingImportRemoteModifiedAt; + String? _pendingImportDeviceName; DateTime? _lastExportedAt; DateTime? _lastImportedAt; bool _isExporting = false; @@ -117,6 +122,10 @@ class AppSessionController extends ChangeNotifier { /// prompt, or `null` when no import is pending. DateTime? get pendingImportRemoteModifiedAt => _pendingImportRemoteModifiedAt; + /// The device that uploaded the pending remote backup, or `null` when no + /// import is pending or the uploading device could not be determined. + String? get pendingImportDeviceName => _pendingImportDeviceName; + DateTime? get lastExportedAt => _lastExportedAt; DateTime? get lastImportedAt => _lastImportedAt; bool get isExporting => _isExporting; @@ -632,6 +641,19 @@ class AppSessionController extends ChangeNotifier { notifyListeners(); } + /// The label embedded in backups this device uploads, shown to other + /// devices in the restore picker and the pending-import prompt. Falls back + /// to a generic platform-based label until the user sets one explicitly. + Future deviceName() => _deviceIdentityService.deviceName(); + + /// The user-chosen device label, or `null` if the user hasn't set one (in + /// which case [deviceName] falls back to a generic platform-based label). + Future storedDeviceName() => + _deviceIdentityService.storedDeviceName(); + + Future setDeviceName(String? name) => + _deviceIdentityService.setDeviceName(name); + Future refreshWebDavSyncStatus() async { _webDavSyncStatus = WebDavSyncStatus.checking; notifyListeners(); @@ -1018,6 +1040,8 @@ class AppSessionController extends ChangeNotifier { sourceDatabasePath: currentDatabasePath, serverPath: _webDavServerPath ?? '/', maxVersions: _webDavMaxVersions, + deviceId: await _deviceIdentityService.getOrCreateDeviceId(), + deviceName: await _deviceIdentityService.deviceName(), ); _lastExportedAt = exportedAt; await _libraryBackupPreferencesService.setLastExportedAt(exportedAt); @@ -1053,12 +1077,14 @@ class AppSessionController extends ChangeNotifier { _webDavSyncStatus = WebDavSyncStatus.notConfigured; _pendingWebDavImport = false; _pendingImportRemoteModifiedAt = null; + _pendingImportDeviceName = null; return; } if (!_webDavAutoImportEnabled) { _webDavSyncStatus = WebDavSyncStatus.disabled; _pendingWebDavImport = false; _pendingImportRemoteModifiedAt = null; + _pendingImportDeviceName = null; return; } @@ -1069,6 +1095,7 @@ class AppSessionController extends ChangeNotifier { _webDavSyncStatus = WebDavSyncStatus.notConfigured; _pendingWebDavImport = false; _pendingImportRemoteModifiedAt = null; + _pendingImportDeviceName = null; return; } @@ -1089,6 +1116,7 @@ class AppSessionController extends ChangeNotifier { _webDavSyncStatus = WebDavSyncStatus.current; _pendingWebDavImport = false; _pendingImportRemoteModifiedAt = null; + _pendingImportDeviceName = null; return; } @@ -1099,6 +1127,7 @@ class AppSessionController extends ChangeNotifier { _webDavSyncStatus = WebDavSyncStatus.current; _pendingWebDavImport = false; _pendingImportRemoteModifiedAt = null; + _pendingImportDeviceName = null; return; } @@ -1142,6 +1171,18 @@ class AppSessionController extends ChangeNotifier { ? WebDavSyncStatus.behind : WebDavSyncStatus.current; _pendingImportRemoteModifiedAt = isNewer ? backupModified : null; + _pendingImportDeviceName = null; + if (isNewer) { + final deviceInfo = await _libraryBackupService + .getRemoteBackupDeviceInfo( + client: client, + remotePath: LibraryBackupService.remoteBackupPath( + _webDavServerPath ?? '/', + currentDatabasePath, + ), + ); + _pendingImportDeviceName = deviceInfo.deviceName; + } } catch (error, stackTrace) { _logUnexpectedError( operation: 'check WebDAV backup availability', @@ -1151,6 +1192,7 @@ class AppSessionController extends ChangeNotifier { _webDavSyncStatus = WebDavSyncStatus.offline; _pendingWebDavImport = false; _pendingImportRemoteModifiedAt = null; + _pendingImportDeviceName = null; } } diff --git a/lib/core/storage/library_backup_service.dart b/lib/core/storage/library_backup_service.dart index c4437c5..2cd00a0 100644 --- a/lib/core/storage/library_backup_service.dart +++ b/lib/core/storage/library_backup_service.dart @@ -21,6 +21,8 @@ class WebDavBackupEntry { required this.remotePath, this.modifiedAt, this.sizeBytes, + this.deviceId, + this.deviceName, }); final String fileName; @@ -28,6 +30,20 @@ class WebDavBackupEntry { final String remotePath; final DateTime? modifiedAt; final int? sizeBytes; + + /// The device that uploaded this backup, if known. `null` for backups + /// exported before device attribution was introduced, or when the sidecar + /// metadata file could not be read. + final String? deviceId; + final String? deviceName; +} + +/// Device attribution read from a backup's `.meta.json` sidecar. +class WebDavBackupDeviceInfo { + const WebDavBackupDeviceInfo({this.deviceId, this.deviceName}); + + final String? deviceId; + final String? deviceName; } class LibraryBackupService { @@ -39,6 +55,8 @@ class LibraryBackupService { Future buildBackupArchive( String sourceDatabasePath, { DateTime? exportedAt, + String? deviceId, + String? deviceName, }) async { final normalizedSourcePath = p.normalize(sourceDatabasePath); developer.log( @@ -53,6 +71,8 @@ class LibraryBackupService { 'formatVersion': _backupFormatVersion, 'libraryName': _libraryNameForPath(normalizedSourcePath), 'exportedAt': (exportedAt ?? DateTime.now().toUtc()).toIso8601String(), + if (deviceId != null) 'deviceId': deviceId, + if (deviceName != null) 'deviceName': deviceName, }), ), ); @@ -157,11 +177,15 @@ class LibraryBackupService { required String sourceDatabasePath, required String serverPath, int maxVersions = 3, + String? deviceId, + String? deviceName, }) async { final exportedAt = DateTime.now().toUtc(); final bytes = await buildBackupArchive( sourceDatabasePath, exportedAt: exportedAt, + deviceId: deviceId, + deviceName: deviceName, ); final canonicalName = backupFileNameForDatabasePath(sourceDatabasePath); final canonicalPath = _joinServerPath(serverPath, canonicalName); @@ -172,6 +196,13 @@ class LibraryBackupService { ); await client.mkdirAll(serverPath); await client.write(tmpPath, bytes); + await _writeMetaSidecar( + client: client, + backupPath: tmpPath, + deviceId: deviceId, + deviceName: deviceName, + exportedAt: exportedAt, + ); // Archive the current canonical backup before replacing it. await _archiveExistingBackup( @@ -187,6 +218,11 @@ class LibraryBackupService { name: 'classi.backup', ); await client.rename(tmpPath, canonicalPath, true); + await _renameMetaSidecar( + client: client, + fromBackupPath: tmpPath, + toBackupPath: canonicalPath, + ); // Prune old archived versions. await _pruneArchivedVersions( @@ -219,6 +255,11 @@ class LibraryBackupService { name: 'classi.backup', ); await client.rename(canonicalPath, archivedPath, true); + await _renameMetaSidecar( + client: client, + fromBackupPath: canonicalPath, + toBackupPath: archivedPath, + ); } catch (_) { // No existing file or server does not support rename — proceed. } @@ -266,6 +307,11 @@ class LibraryBackupService { try { await client.remove(_joinServerPath(serverPath, name)); } catch (_) {} + try { + await client.remove( + _metaSidecarPath(_joinServerPath(serverPath, name)), + ); + } catch (_) {} } } } catch (_) { @@ -298,6 +344,14 @@ class LibraryBackupService { } } + /// Reads the device attribution sidecar for the backup at [remotePath], + /// or an empty [WebDavBackupDeviceInfo] if none exists or it cannot be + /// read (e.g. an older backup exported before device attribution existed). + Future getRemoteBackupDeviceInfo({ + required webdav.Client client, + required String remotePath, + }) => _readMetaSidecar(client: client, backupPath: remotePath); + /// Lists backup archives available on the WebDAV server path. /// /// Both canonical (`name.classi-backup`) and archived @@ -308,7 +362,8 @@ class LibraryBackupService { required String serverPath, }) async { final files = await client.readDir(serverPath); - final backups = []; + final candidates = + <({String fileName, DateTime? modifiedAt, int? sizeBytes})>[]; for (final file in files) { if (file.isDir == true) { @@ -320,16 +375,34 @@ class LibraryBackupService { continue; } - backups.add( + candidates.add(( + fileName: fileName, + modifiedAt: file.mTime, + sizeBytes: file.size, + )); + } + + final remotePaths = [ + for (final candidate in candidates) + _joinServerPath(serverPath, candidate.fileName), + ]; + final deviceInfos = await Future.wait([ + for (final remotePath in remotePaths) + _readMetaSidecar(client: client, backupPath: remotePath), + ]); + + final backups = [ + for (var index = 0; index < candidates.length; index++) WebDavBackupEntry( - fileName: fileName, - libraryName: libraryNameForBackupFile(fileName), - remotePath: _joinServerPath(serverPath, fileName), - modifiedAt: file.mTime, - sizeBytes: file.size, + fileName: candidates[index].fileName, + libraryName: libraryNameForBackupFile(candidates[index].fileName), + remotePath: remotePaths[index], + modifiedAt: candidates[index].modifiedAt, + sizeBytes: candidates[index].sizeBytes, + deviceId: deviceInfos[index].deviceId, + deviceName: deviceInfos[index].deviceName, ), - ); - } + ]; backups.sort((left, right) { final leftModified = left.modifiedAt; @@ -378,6 +451,67 @@ class LibraryBackupService { static String _libraryNameForPath(String path) => p.basenameWithoutExtension(p.normalize(path)); + static String _metaSidecarPath(String backupPath) => '$backupPath.meta.json'; + + /// Uploads the device-attribution sidecar for [backupPath]. Best-effort: + /// device attribution is metadata for display purposes only, so a failure + /// here must never break the backup export itself. + Future _writeMetaSidecar({ + required webdav.Client client, + required String backupPath, + required String? deviceId, + required String? deviceName, + required DateTime exportedAt, + }) async { + try { + final metaBytes = utf8.encode( + jsonEncode({ + if (deviceId != null) 'deviceId': deviceId, + if (deviceName != null) 'deviceName': deviceName, + 'exportedAt': exportedAt.toIso8601String(), + }), + ); + await client.write(_metaSidecarPath(backupPath), metaBytes); + } catch (_) { + // Best-effort; the backup itself already succeeded. + } + } + + /// Renames the device-attribution sidecar alongside a backup rename. + /// Best-effort: an older backup may not have a sidecar to rename. + Future _renameMetaSidecar({ + required webdav.Client client, + required String fromBackupPath, + required String toBackupPath, + }) async { + try { + await client.rename( + _metaSidecarPath(fromBackupPath), + _metaSidecarPath(toBackupPath), + true, + ); + } catch (_) { + // No sidecar to rename — proceed. + } + } + + Future _readMetaSidecar({ + required webdav.Client client, + required String backupPath, + }) async { + try { + final metaBytes = await client.read(_metaSidecarPath(backupPath)); + final metaJson = + jsonDecode(utf8.decode(metaBytes)) as Map; + return WebDavBackupDeviceInfo( + deviceId: metaJson['deviceId'] as String?, + deviceName: metaJson['deviceName'] as String?, + ); + } catch (_) { + return const WebDavBackupDeviceInfo(); + } + } + Map _artifactEntryNamesFor(String databasePath) { final databaseFilePath = DatabasePathService.databaseFilePathFor( databasePath, diff --git a/lib/core/sync/device_identity_service.dart b/lib/core/sync/device_identity_service.dart new file mode 100644 index 0000000..804f31a --- /dev/null +++ b/lib/core/sync/device_identity_service.dart @@ -0,0 +1,77 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// Identifies this installation of Classi to other devices syncing the same +/// WebDAV backup, so a remote backup or a pending-import prompt can say +/// which device it came from. +/// +/// The identity is device-scoped (stored via [SharedPreferences], not the +/// per-library project settings) since it should stay the same across every +/// `.classi` library opened on this device. +class DeviceIdentityService { + static const String _deviceIdKey = 'device.id'; + static const String _deviceNameKey = 'device.name'; + static const String _idAlphabet = '0123456789abcdef'; + static const int _idLength = 16; + + /// Returns the stable random identifier for this device, generating and + /// persisting one on first use. + Future getOrCreateDeviceId() async { + final prefs = await SharedPreferences.getInstance(); + final existing = prefs.getString(_deviceIdKey); + if (existing != null && existing.isNotEmpty) { + return existing; + } + + final generated = _generateDeviceId(); + await prefs.setString(_deviceIdKey, generated); + return generated; + } + + /// The user-chosen device label, or `null` if none has been set. + Future storedDeviceName() async { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString(_deviceNameKey); + return (stored == null || stored.isEmpty) ? null : stored; + } + + /// The device label to embed in exported backups: the user's chosen name, + /// falling back to a generic platform-based label. + Future deviceName() async { + return await storedDeviceName() ?? defaultDeviceName(); + } + + Future setDeviceName(String? name) async { + final prefs = await SharedPreferences.getInstance(); + final trimmed = name?.trim(); + if (trimmed == null || trimmed.isEmpty) { + await prefs.remove(_deviceNameKey); + return; + } + await prefs.setString(_deviceNameKey, trimmed); + } + + /// A generic, non-identifying label derived from the current platform. + /// + /// Deliberately avoids the OS hostname, which often contains the owner's + /// real name (e.g. "Alice's MacBook") and would otherwise be baked into + /// every backup uploaded to a third-party WebDAV server. + static String defaultDeviceName() { + if (Platform.isIOS) return 'iPhone/iPad'; + if (Platform.isAndroid) return 'Android device'; + if (Platform.isMacOS) return 'Mac'; + if (Platform.isWindows) return 'Windows PC'; + if (Platform.isLinux) return 'Linux PC'; + return 'Device'; + } + + String _generateDeviceId() { + final random = Random.secure(); + return List.generate( + _idLength, + (_) => _idAlphabet[random.nextInt(_idAlphabet.length)], + ).join(); + } +} diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 1b1b424..99e5c09 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:developer' as developer; import 'package:easy_localization/easy_localization.dart'; @@ -9,6 +10,7 @@ import '../../core/providers/app_providers.dart'; import '../../core/security/security_preferences_service.dart'; import '../../core/session/app_session_controller.dart'; import '../../core/storage/library_backup_service.dart'; +import '../../core/sync/device_identity_service.dart'; import '../../core/update/app_update_controller.dart'; import '../../shared/utils/formatting.dart'; import '../../shared/widgets/app_updater.dart'; @@ -665,6 +667,7 @@ class _BackupsSectionState extends ConsumerState<_BackupsSection> { final _usernameController = TextEditingController(); final _passwordController = TextEditingController(); final _serverPathController = TextEditingController(); + final _deviceNameController = TextEditingController(); bool _testingConnection = false; bool? _connectionOk; @@ -683,6 +686,7 @@ class _BackupsSectionState extends ConsumerState<_BackupsSection> { if (widget.session.isWebDavConfigured) { WidgetsBinding.instance.addPostFrameCallback((_) => _loadBackups()); } + unawaited(_loadDeviceName()); } @override @@ -691,9 +695,15 @@ class _BackupsSectionState extends ConsumerState<_BackupsSection> { _usernameController.dispose(); _passwordController.dispose(); _serverPathController.dispose(); + _deviceNameController.dispose(); super.dispose(); } + Future _loadDeviceName() async { + final name = await ref.read(appSessionProvider).storedDeviceName(); + if (mounted) setState(() => _deviceNameController.text = name ?? ''); + } + Future _saveSettings() async { final session = ref.read(appSessionProvider); await session.setWebDavUrl(_urlController.text); @@ -703,6 +713,7 @@ class _BackupsSectionState extends ConsumerState<_BackupsSection> { } final path = _serverPathController.text.trim(); await session.setWebDavServerPath(path.isEmpty ? '/' : path); + await session.setDeviceName(_deviceNameController.text); if (mounted) { setState(() => _connectionOk = null); _loadBackups(); @@ -820,6 +831,19 @@ class _BackupsSectionState extends ConsumerState<_BackupsSection> { children: [ Text('webdav_settings'.tr()), const SizedBox(height: 16), + TextField( + controller: _deviceNameController, + decoration: InputDecoration( + labelText: 'device_name'.tr(), + hintText: DeviceIdentityService.defaultDeviceName(), + ), + ), + const SizedBox(height: 4), + Text( + 'device_name_hint'.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), TextField( controller: _urlController, decoration: InputDecoration( @@ -1120,7 +1144,7 @@ class _BackupListTile extends StatelessWidget { final sizeStr = backup.sizeBytes != null ? _formatBytes(backup.sizeBytes!) : null; - final subtitleParts = [?dateStr, ?sizeStr]; + final subtitleParts = [?backup.deviceName, ?dateStr, ?sizeStr]; return ListTile( contentPadding: EdgeInsets.zero, diff --git a/lib/features/setup/auto_import_prompt_card.dart b/lib/features/setup/auto_import_prompt_card.dart index 4446c0e..d9b1e87 100644 --- a/lib/features/setup/auto_import_prompt_card.dart +++ b/lib/features/setup/auto_import_prompt_card.dart @@ -23,6 +23,7 @@ class _AutoImportPromptCardState extends ConsumerState { } final remoteModifiedAt = session.pendingImportRemoteModifiedAt; + final deviceName = session.pendingImportDeviceName; final lastExportedAt = session.lastExportedAt; final localeTag = Localizations.localeOf(context).toLanguageTag(); @@ -57,13 +58,17 @@ class _AutoImportPromptCardState extends ConsumerState { const SizedBox(height: 8), if (remoteModifiedAt != null) Text( - 'newer_backup_exported_at'.tr( - namedArgs: { - 'datetime': DateFormat.yMd(localeTag) - .add_Hm() - .format(remoteModifiedAt.toLocal()), - }, - ), + (deviceName != null + ? 'newer_backup_exported_from_at' + : 'newer_backup_exported_at') + .tr( + namedArgs: { + if (deviceName != null) 'device': deviceName, + 'datetime': DateFormat.yMd(localeTag) + .add_Hm() + .format(remoteModifiedAt.toLocal()), + }, + ), ) else Text('newer_backup_available_hint'.tr()), @@ -118,6 +123,7 @@ class _AutoImportPromptCardState extends ConsumerState { Future _restoreBackup() async { final session = ref.read(appSessionProvider); final remoteModifiedAt = session.pendingImportRemoteModifiedAt; + final deviceName = session.pendingImportDeviceName; final lastExportedAt = session.lastExportedAt; final localeTag = Localizations.localeOf(context).toLanguageTag(); @@ -134,13 +140,17 @@ class _AutoImportPromptCardState extends ConsumerState { const SizedBox(height: 12), if (remoteModifiedAt != null) Text( - 'newer_backup_exported_at'.tr( - namedArgs: { - 'datetime': DateFormat.yMd(localeTag) - .add_Hm() - .format(remoteModifiedAt.toLocal()), - }, - ), + (deviceName != null + ? 'newer_backup_exported_from_at' + : 'newer_backup_exported_at') + .tr( + namedArgs: { + if (deviceName != null) 'device': deviceName, + 'datetime': DateFormat.yMd(localeTag) + .add_Hm() + .format(remoteModifiedAt.toLocal()), + }, + ), ), if (lastExportedAt != null) Padding( diff --git a/lib/features/setup/webdav_restore_flow.dart b/lib/features/setup/webdav_restore_flow.dart index c6fe0ba..ad75e11 100644 --- a/lib/features/setup/webdav_restore_flow.dart +++ b/lib/features/setup/webdav_restore_flow.dart @@ -368,6 +368,11 @@ class _WebDavRestoreCredentials { String _backupSubtitle(BuildContext context, WebDavBackupEntry backup) { final parts = [backup.fileName]; + final deviceName = backup.deviceName; + if (deviceName != null && deviceName.isNotEmpty) { + parts.add(deviceName); + } + final modifiedAt = backup.modifiedAt; if (modifiedAt != null) { final localeTag = context.locale.toLanguageTag(); diff --git a/test/app_session_controller_test.dart b/test/app_session_controller_test.dart index e3807cf..9d24609 100644 --- a/test/app_session_controller_test.dart +++ b/test/app_session_controller_test.dart @@ -494,6 +494,71 @@ void main() { expect(controller.pendingImportRemoteModifiedAt, isNull); }, ); + + test('auto-export uploads this device\'s identity with the backup', () async { + final exportService = _DeviceCapturingLibraryBackupService(); + controller.dispose(); + final databasePathService = _TestDatabasePathService( + '${tempDirectory.path}/test.classi', + ); + controller = _WebDavAppSessionController( + keyService: keyService, + databasePathService: databasePathService, + securityPreferencesService: _securityPreferencesServiceFor( + databasePathService, + ), + libraryBackupPreferencesService: _libraryBackupPreferencesServiceFor( + databasePathService, + ), + libraryBackupService: exportService, + biometricService: BiometricService(), + ); + + await controller.initialize(); + await controller.createDatabase('test'); + await controller.setWebDavUrl('https://example.invalid/remote.php/dav'); + await controller.setWebDavAutoExportEnabled(true); + + expect(await controller.exportNow(), isNull); + + expect(exportService.lastDeviceId, isNotNull); + expect(exportService.lastDeviceId, isNotEmpty); + expect(exportService.lastDeviceName, isNotEmpty); + }); + + test( + 'a pending auto-import surfaces the uploading device\'s name', + () async { + final backupService = _PendingImportDeviceLibraryBackupService( + remoteModifiedAt: DateTime.now().toUtc().add(const Duration(days: 1)), + deviceName: 'Kitchen iPad', + ); + controller.dispose(); + final databasePathService = _TestDatabasePathService( + '${tempDirectory.path}/test.classi', + ); + controller = _WebDavAppSessionController( + keyService: keyService, + databasePathService: databasePathService, + securityPreferencesService: _securityPreferencesServiceFor( + databasePathService, + ), + libraryBackupPreferencesService: _libraryBackupPreferencesServiceFor( + databasePathService, + ), + libraryBackupService: backupService, + biometricService: BiometricService(), + ); + + await controller.initialize(); + await controller.createDatabase('test'); + await controller.setWebDavUrl('https://example.invalid/remote.php/dav'); + await controller.setWebDavAutoImportEnabled(true); + + expect(controller.hasPendingAutoImport, isTrue); + expect(controller.pendingImportDeviceName, 'Kitchen iPad'); + }, + ); } class _TestDatabasePathService extends DatabasePathService { @@ -549,6 +614,8 @@ class _DelayingLibraryBackupService extends LibraryBackupService { required String sourceDatabasePath, required String serverPath, int maxVersions = 3, + String? deviceId, + String? deviceName, }) async { if (!started.isCompleted) { started.complete(); @@ -610,6 +677,8 @@ class _RestoringSelfLibraryBackupService extends LibraryBackupService { required String sourceDatabasePath, required String serverPath, int maxVersions = 3, + String? deviceId, + String? deviceName, }) async { exportCalled = true; return DateTime.now().toUtc(); @@ -670,6 +739,8 @@ class _ExportingLibraryBackupService extends LibraryBackupService { required String sourceDatabasePath, required String serverPath, int maxVersions = 3, + String? deviceId, + String? deviceName, }) async { return exportedAt; } @@ -683,3 +754,62 @@ class _ExportingLibraryBackupService extends LibraryBackupService { return remoteModifiedAt; } } + +class _DeviceCapturingLibraryBackupService extends LibraryBackupService { + String? lastDeviceId; + String? lastDeviceName; + + @override + Future exportBackupToWebDav({ + required webdav.Client client, + required String sourceDatabasePath, + required String serverPath, + int maxVersions = 3, + String? deviceId, + String? deviceName, + }) async { + lastDeviceId = deviceId; + lastDeviceName = deviceName; + return DateTime.now().toUtc(); + } + + // Avoid a real network round-trip: _runAutoExportIfConfigured calls this + // right after exportBackupToWebDav to timestamp the pending-import + // dismissal, and the fake client points at a non-existent host. + @override + Future getRemoteBackupModifiedAt({ + required webdav.Client client, + required String serverPath, + required String backupFileName, + }) async => DateTime.now().toUtc(); +} + +class _PendingImportDeviceLibraryBackupService extends LibraryBackupService { + _PendingImportDeviceLibraryBackupService({ + required this.remoteModifiedAt, + required this.deviceName, + }); + + final DateTime remoteModifiedAt; + final String deviceName; + + @override + Future getRemoteBackupModifiedAt({ + required webdav.Client client, + required String serverPath, + required String backupFileName, + }) async { + return remoteModifiedAt; + } + + @override + Future getRemoteBackupDeviceInfo({ + required webdav.Client client, + required String remotePath, + }) async { + return WebDavBackupDeviceInfo( + deviceId: 'remote-device', + deviceName: deviceName, + ); + } +} diff --git a/test/device_identity_service_test.dart b/test/device_identity_service_test.dart new file mode 100644 index 0000000..9f7d4e2 --- /dev/null +++ b/test/device_identity_service_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:classi/core/sync/device_identity_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('getOrCreateDeviceId persists the same id across calls', () async { + final service = DeviceIdentityService(); + + final first = await service.getOrCreateDeviceId(); + final second = await service.getOrCreateDeviceId(); + + expect(first, second); + expect(first, isNotEmpty); + }); + + test('a fresh service instance reads back the persisted device id', () async { + final id = await DeviceIdentityService().getOrCreateDeviceId(); + final reloaded = await DeviceIdentityService().getOrCreateDeviceId(); + + expect(reloaded, id); + }); + + test( + 'deviceName falls back to a generic platform label until set', + () async { + final service = DeviceIdentityService(); + + expect(await service.storedDeviceName(), isNull); + expect( + await service.deviceName(), + DeviceIdentityService.defaultDeviceName(), + ); + + await service.setDeviceName('Kitchen iPad'); + expect(await service.storedDeviceName(), 'Kitchen iPad'); + expect(await service.deviceName(), 'Kitchen iPad'); + + await service.setDeviceName(' '); + expect( + await service.storedDeviceName(), + isNull, + reason: 'a blank name clears the stored label', + ); + }, + ); +} diff --git a/test/library_backup_service_test.dart b/test/library_backup_service_test.dart index 58293dc..31d76d3 100644 --- a/test/library_backup_service_test.dart +++ b/test/library_backup_service_test.dart @@ -1,5 +1,7 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:archive/archive.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:classi/core/storage/library_backup_service.dart'; @@ -46,6 +48,41 @@ void main() { expect(archiveBytes, isNotEmpty); }); + test('buildBackupArchive embeds device attribution in the manifest', () async { + final archiveBytes = await service.buildBackupArchive( + sourceLibraryDirectory.path, + deviceId: 'device-123', + deviceName: 'Kitchen iPad', + ); + + final archive = ZipDecoder().decodeBytes(archiveBytes); + final manifestFile = archive.findFile('backup.json')!; + final manifestJson = + jsonDecode(utf8.decode(manifestFile.content as List)) + as Map; + + expect(manifestJson['deviceId'], 'device-123'); + expect(manifestJson['deviceName'], 'Kitchen iPad'); + }); + + test( + 'buildBackupArchive omits device attribution when not provided', + () async { + final archiveBytes = await service.buildBackupArchive( + sourceLibraryDirectory.path, + ); + + final archive = ZipDecoder().decodeBytes(archiveBytes); + final manifestFile = archive.findFile('backup.json')!; + final manifestJson = + jsonDecode(utf8.decode(manifestFile.content as List)) + as Map; + + expect(manifestJson.containsKey('deviceId'), isFalse); + expect(manifestJson.containsKey('deviceName'), isFalse); + }, + ); + test( 'restoreBackupFromBytes restores database artifacts into a package folder', () async { From f0d64f3bc5cbf3d65ef5db48f7a747d3e48e0f97 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 20:03:59 +0000 Subject: [PATCH 03/10] feat: add a best-effort WebDAV sync lock around export Two devices exporting at the same moment can interleave the upload/archive/rename/prune steps in exportBackupToWebDav and corrupt the backup version history. Add a lock file (/.classi-sync.lock, holding {deviceId, acquiredAt} JSON) that exportBackupToWebDav acquires before touching any backup files and releases in a finally block. WebDAV servers vary in support for native LOCK/UNLOCK (RFC 4918), so this avoids that entirely and uses a plain file any WebDAV server can store: write our claim, wait a short random jitter, then read back to confirm we're still the recorded owner. It's advisory, not a true compare-and-swap, but catches the common case of two devices syncing within moments of each other. A lease expires an abandoned lock left by a crashed app rather than wedging the folder permanently. Callers get a distinct WebDavSyncBusyException (surfaced as 'backup_export_busy') instead of a generic export failure when another device currently holds the lock. --- assets/translations/de.json | 1 + assets/translations/en.json | 1 + lib/core/session/app_session_controller.dart | 6 + lib/core/storage/library_backup_service.dart | 240 +++++++++++++++---- test/library_backup_service_test.dart | 20 ++ 5 files changed, 221 insertions(+), 47 deletions(-) diff --git a/assets/translations/de.json b/assets/translations/de.json index e0d1337..7c681f5 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -42,6 +42,7 @@ "class_average": "Klassendurchschnitt", "back": "Zurück", "back_to_unlock": "Zurück zum Entsperren", + "backup_export_busy": "Ein anderes Gerät synchronisiert dieses Backup gerade. Versuche es gleich noch einmal.", "backup_export_failed": "Classi konnte das Backup nicht exportieren.", "backup_exported": "Backup exportiert.", "backup_import_failed": "Classi konnte das Backup nicht importieren.", diff --git a/assets/translations/en.json b/assets/translations/en.json index f64f9c8..5d92314 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -42,6 +42,7 @@ "class_average": "Class average", "back": "Back", "back_to_unlock": "Back to unlock", + "backup_export_busy": "Another device is syncing this backup right now. Try again in a moment.", "backup_export_failed": "Classi could not export the backup.", "backup_exported": "Backup exported.", "backup_import_failed": "Classi could not import the backup.", diff --git a/lib/core/session/app_session_controller.dart b/lib/core/session/app_session_controller.dart index 9272d95..b228b80 100644 --- a/lib/core/session/app_session_controller.dart +++ b/lib/core/session/app_session_controller.dart @@ -1062,6 +1062,12 @@ class AppSessionController extends ChangeNotifier { ); developer.log('WebDAV export succeeded', name: 'classi.backup'); _setBackupMessage('backup_exported'); + } on WebDavSyncBusyException catch (error) { + developer.log( + 'WebDAV export skipped: ${error.message}', + name: 'classi.backup', + ); + _setBackupMessage('backup_export_busy', isError: true); } catch (error, stackTrace) { _logUnexpectedError( operation: 'run auto WebDAV export', diff --git a/lib/core/storage/library_backup_service.dart b/lib/core/storage/library_backup_service.dart index 2cd00a0..fcf4d9a 100644 --- a/lib/core/storage/library_backup_service.dart +++ b/lib/core/storage/library_backup_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:developer' as developer; import 'dart:io'; +import 'dart:math'; import 'dart:typed_data'; import 'package:archive/archive.dart'; @@ -13,6 +14,24 @@ const String classiBackupExtension = '.classi-backup'; const String _backupManifestFileName = 'backup.json'; const int _backupFormatVersion = 1; const String _canonicalDatabaseFileName = 'data.db'; +const String _syncLockFileName = '.classi-sync.lock'; +const Duration _syncLockLease = Duration(minutes: 2); +const int _syncLockVerifyJitterMs = 300; + +/// Thrown by [LibraryBackupService.exportBackupToWebDav] when another device +/// currently holds the sync lock for the target folder. +/// +/// This is expected, transient contention — not a corruption or connection +/// failure — so callers should surface it as "try again in a moment" rather +/// than a generic export error. +class WebDavSyncBusyException implements Exception { + const WebDavSyncBusyException(this.message); + + final String message; + + @override + String toString() => 'WebDavSyncBusyException: $message'; +} class WebDavBackupEntry { const WebDavBackupEntry({ @@ -169,7 +188,12 @@ class LibraryBackupService { /// The upload goes to a `.tmp` file first, then the existing backup (if any) /// is archived as a timestamped copy, and the `.tmp` file is renamed to the /// canonical backup name. Older archived versions beyond [maxVersions] are - /// pruned. + /// pruned. The whole sequence runs under a best-effort sync lock (see + /// [_trySyncLockAcquire]) so two devices exporting at the same moment don't + /// interleave their archive/rename/prune steps. + /// + /// Throws [WebDavSyncBusyException] if another device currently holds the + /// lock. /// /// Returns the UTC [DateTime] embedded as `exportedAt` in the manifest. Future exportBackupToWebDav({ @@ -180,60 +204,81 @@ class LibraryBackupService { String? deviceId, String? deviceName, }) async { - final exportedAt = DateTime.now().toUtc(); - final bytes = await buildBackupArchive( - sourceDatabasePath, - exportedAt: exportedAt, - deviceId: deviceId, - deviceName: deviceName, - ); - final canonicalName = backupFileNameForDatabasePath(sourceDatabasePath); - final canonicalPath = _joinServerPath(serverPath, canonicalName); - final tmpPath = '$canonicalPath.tmp'; - developer.log( - 'exportBackupToWebDav: uploading to $tmpPath (${bytes.length} bytes)', - name: 'classi.backup', - ); await client.mkdirAll(serverPath); - await client.write(tmpPath, bytes); - await _writeMetaSidecar( - client: client, - backupPath: tmpPath, - deviceId: deviceId, - deviceName: deviceName, - exportedAt: exportedAt, - ); - // Archive the current canonical backup before replacing it. - await _archiveExistingBackup( + final lockOwnerId = deviceId ?? _fallbackSyncLockOwnerId(); + final lockAcquired = await _trySyncLockAcquire( client: client, serverPath: serverPath, - canonicalName: canonicalName, - canonicalPath: canonicalPath, + ownerId: lockOwnerId, ); + if (!lockAcquired) { + throw const WebDavSyncBusyException( + 'Another device is syncing this library right now.', + ); + } - // Atomically promote the tmp file to the canonical name. - developer.log( - 'exportBackupToWebDav: renaming $tmpPath → $canonicalPath', - name: 'classi.backup', - ); - await client.rename(tmpPath, canonicalPath, true); - await _renameMetaSidecar( - client: client, - fromBackupPath: tmpPath, - toBackupPath: canonicalPath, - ); + try { + final exportedAt = DateTime.now().toUtc(); + final bytes = await buildBackupArchive( + sourceDatabasePath, + exportedAt: exportedAt, + deviceId: deviceId, + deviceName: deviceName, + ); + final canonicalName = backupFileNameForDatabasePath(sourceDatabasePath); + final canonicalPath = _joinServerPath(serverPath, canonicalName); + final tmpPath = '$canonicalPath.tmp'; + developer.log( + 'exportBackupToWebDav: uploading to $tmpPath (${bytes.length} bytes)', + name: 'classi.backup', + ); + await client.write(tmpPath, bytes); + await _writeMetaSidecar( + client: client, + backupPath: tmpPath, + deviceId: deviceId, + deviceName: deviceName, + exportedAt: exportedAt, + ); - // Prune old archived versions. - await _pruneArchivedVersions( - client: client, - serverPath: serverPath, - canonicalName: canonicalName, - maxVersions: maxVersions, - ); + // Archive the current canonical backup before replacing it. + await _archiveExistingBackup( + client: client, + serverPath: serverPath, + canonicalName: canonicalName, + canonicalPath: canonicalPath, + ); + + // Atomically promote the tmp file to the canonical name. + developer.log( + 'exportBackupToWebDav: renaming $tmpPath → $canonicalPath', + name: 'classi.backup', + ); + await client.rename(tmpPath, canonicalPath, true); + await _renameMetaSidecar( + client: client, + fromBackupPath: tmpPath, + toBackupPath: canonicalPath, + ); + + // Prune old archived versions. + await _pruneArchivedVersions( + client: client, + serverPath: serverPath, + canonicalName: canonicalName, + maxVersions: maxVersions, + ); - developer.log('exportBackupToWebDav: done', name: 'classi.backup'); - return exportedAt; + developer.log('exportBackupToWebDav: done', name: 'classi.backup'); + return exportedAt; + } finally { + await _syncLockRelease( + client: client, + serverPath: serverPath, + ownerId: lockOwnerId, + ); + } } Future _archiveExistingBackup({ @@ -512,6 +557,107 @@ class LibraryBackupService { } } + // --- Sync lock ------------------------------------------------------- + // + // WebDAV servers vary in their support for the native LOCK/UNLOCK methods + // (RFC 4918), so rather than depend on that this uses a plain lock file + // any WebDAV server can store: `/.classi-sync.lock`, holding + // `{deviceId, acquiredAt}` as JSON. + // + // Acquisition is "write, then read back and check we're still the + // recorded owner" rather than a true atomic compare-and-swap — this is a + // best-effort, advisory lock, not a hard guarantee. It's good enough to + // catch the common case (two devices syncing within moments of each + // other) without depending on WebDAV features not every server offers. + // A lease (see [_syncLockLease]) makes a lock left behind by a crashed or + // killed app expire automatically rather than wedging the folder forever. + + static String _syncLockPath(String serverPath) => + _joinServerPath(serverPath, _syncLockFileName); + + /// Whether a lock acquired at [acquiredAt] has outlived its lease and + /// should be treated as abandoned. Exposed for testing the time math + /// without needing a WebDAV round-trip. + static bool isSyncLockExpired(DateTime acquiredAt, {DateTime? now}) => + (now ?? DateTime.now().toUtc()).difference(acquiredAt) > _syncLockLease; + + Future _trySyncLockAcquire({ + required webdav.Client client, + required String serverPath, + required String ownerId, + }) async { + final lockPath = _syncLockPath(serverPath); + + final existing = await _readSyncLock(client: client, lockPath: lockPath); + if (existing != null && + existing.deviceId != ownerId && + !isSyncLockExpired(existing.acquiredAt)) { + return false; + } + + await client.write( + lockPath, + utf8.encode( + jsonEncode({ + 'deviceId': ownerId, + 'acquiredAt': DateTime.now().toUtc().toIso8601String(), + }), + ), + ); + + // Give a concurrent writer a moment to finish its own write, then check + // whether we're actually the one left recorded as the owner. + await Future.delayed( + Duration(milliseconds: Random().nextInt(_syncLockVerifyJitterMs)), + ); + final afterWrite = await _readSyncLock(client: client, lockPath: lockPath); + return afterWrite?.deviceId == ownerId; + } + + /// Releases the sync lock, but only if [ownerId] still holds it — never + /// removes a lock another device has since (legitimately) acquired. + Future _syncLockRelease({ + required webdav.Client client, + required String serverPath, + required String ownerId, + }) async { + final lockPath = _syncLockPath(serverPath); + final existing = await _readSyncLock(client: client, lockPath: lockPath); + if (existing == null || existing.deviceId != ownerId) return; + + try { + await client.remove(lockPath); + } catch (_) { + // Best-effort; the lease will expire it regardless. + } + } + + Future<({String deviceId, DateTime acquiredAt})?> _readSyncLock({ + required webdav.Client client, + required String lockPath, + }) async { + try { + final bytes = await client.read(lockPath); + final json = jsonDecode(utf8.decode(bytes)) as Map; + final deviceId = json['deviceId'] as String?; + final acquiredAt = DateTime.tryParse( + json['acquiredAt'] as String? ?? '', + ); + if (deviceId == null || acquiredAt == null) return null; + return (deviceId: deviceId, acquiredAt: acquiredAt); + } catch (_) { + return null; + } + } + + String _fallbackSyncLockOwnerId() { + final random = Random.secure(); + return List.generate( + 8, + (_) => random.nextInt(256), + ).map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); + } + Map _artifactEntryNamesFor(String databasePath) { final databaseFilePath = DatabasePathService.databaseFilePathFor( databasePath, diff --git a/test/library_backup_service_test.dart b/test/library_backup_service_test.dart index 31d76d3..f191d19 100644 --- a/test/library_backup_service_test.dart +++ b/test/library_backup_service_test.dart @@ -109,4 +109,24 @@ void main() { ); }, ); + + test('isSyncLockExpired is false for a freshly acquired lock', () { + final now = DateTime.utc(2026, 5, 7, 8, 0); + final acquiredAt = now.subtract(const Duration(seconds: 5)); + + expect( + LibraryBackupService.isSyncLockExpired(acquiredAt, now: now), + isFalse, + ); + }); + + test('isSyncLockExpired is true once the lease has passed', () { + final now = DateTime.utc(2026, 5, 7, 8, 0); + final acquiredAt = now.subtract(const Duration(minutes: 3)); + + expect( + LibraryBackupService.isSyncLockExpired(acquiredAt, now: now), + isTrue, + ); + }); } From 739a19187a2ba385712cacbf6d4d0ec2efe2db42 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 20:14:42 +0000 Subject: [PATCH 04/10] feat: detect conflicting WebDAV backups instead of silently overwriting Add revision tracking so exportBackupToWebDav can tell whether the remote backup moved on since this device last synced. Each export embeds a fresh revision token (plus the parentRevision it was based on) in the manifest and .meta.json sidecar. AppSessionController persists the last known revision per library (new LibraryBackupPreferencesService.lastKnownRevision) and passes it as parentRevision on every export, updating it after a successful export or restore. If the remote's current revision doesn't match parentRevision, another device pushed a change this device never saw. Rather than clobbering it, the export is written as a separate `_CONFLICT_` copy and a WebDavSyncConflictException is thrown; the canonical backup is left untouched. AppSessionController surfaces this as a distinct 'backup_export_conflict' message pointing the user at the backup list to reconcile manually, rather than a generic export failure. A remote backup with no revision at all (nothing uploaded yet, or a legacy backup predating revision tracking) is not treated as a conflict, so this doesn't block export against pre-existing backups. --- assets/translations/de.json | 1 + assets/translations/en.json | 1 + lib/core/session/app_session_controller.dart | 49 ++++- .../library_backup_preferences_service.dart | 30 +++ lib/core/storage/library_backup_service.dart | 151 +++++++++++++- test/app_session_controller_test.dart | 191 +++++++++++++++++- ...brary_backup_preferences_service_test.dart | 12 ++ test/library_backup_service_test.dart | 27 +++ 8 files changed, 444 insertions(+), 18 deletions(-) diff --git a/assets/translations/de.json b/assets/translations/de.json index 7c681f5..f867459 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -43,6 +43,7 @@ "back": "Zurück", "back_to_unlock": "Zurück zum Entsperren", "backup_export_busy": "Ein anderes Gerät synchronisiert dieses Backup gerade. Versuche es gleich noch einmal.", + "backup_export_conflict": "Ein anderes Gerät hat diese Bibliothek geändert, seit du zuletzt synchronisiert hast. Deine Änderungen wurden als separates Backup gespeichert – prüfe \"Verfügbare Backups\", um sie zu vergleichen und zusammenzuführen.", "backup_export_failed": "Classi konnte das Backup nicht exportieren.", "backup_exported": "Backup exportiert.", "backup_import_failed": "Classi konnte das Backup nicht importieren.", diff --git a/assets/translations/en.json b/assets/translations/en.json index 5d92314..3f0e3b1 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -43,6 +43,7 @@ "back": "Back", "back_to_unlock": "Back to unlock", "backup_export_busy": "Another device is syncing this backup right now. Try again in a moment.", + "backup_export_conflict": "Another device changed this library since you last synced. Your changes were saved as a separate backup — check \"Available backups\" to review and merge.", "backup_export_failed": "Classi could not export the backup.", "backup_exported": "Backup exported.", "backup_import_failed": "Classi could not import the backup.", diff --git a/lib/core/session/app_session_controller.dart b/lib/core/session/app_session_controller.dart index b228b80..425d466 100644 --- a/lib/core/session/app_session_controller.dart +++ b/lib/core/session/app_session_controller.dart @@ -94,6 +94,7 @@ class AppSessionController extends ChangeNotifier { String? _pendingImportDeviceName; DateTime? _lastExportedAt; DateTime? _lastImportedAt; + String? _lastKnownRevision; bool _isExporting = false; WebDavSyncStatus _webDavSyncStatus = WebDavSyncStatus.notConfigured; Future? _pendingLockCleanup; @@ -128,6 +129,11 @@ class AppSessionController extends ChangeNotifier { DateTime? get lastExportedAt => _lastExportedAt; DateTime? get lastImportedAt => _lastImportedAt; + + /// The revision token this device last synced (via export or import), or + /// `null` before the first sync. Used to detect conflicting changes from + /// another device on the next export. + String? get lastKnownRevision => _lastKnownRevision; bool get isExporting => _isExporting; WebDavSyncStatus get webDavSyncStatus => _webDavSyncStatus; String? get lastBackupMessageCode => _lastBackupMessageCode; @@ -919,6 +925,8 @@ class AppSessionController extends ChangeNotifier { _webDavMaxVersions = await _libraryBackupPreferencesService.maxVersions(); _lastExportedAt = await _libraryBackupPreferencesService.lastExportedAt(); _lastImportedAt = await _libraryBackupPreferencesService.lastImportedAt(); + _lastKnownRevision = await _libraryBackupPreferencesService + .lastKnownRevision(); } Future _resolveCurrentDatabaseState() async { @@ -1042,6 +1050,7 @@ class AppSessionController extends ChangeNotifier { maxVersions: _webDavMaxVersions, deviceId: await _deviceIdentityService.getOrCreateDeviceId(), deviceName: await _deviceIdentityService.deviceName(), + parentRevision: _lastKnownRevision, ); _lastExportedAt = exportedAt; await _libraryBackupPreferencesService.setLastExportedAt(exportedAt); @@ -1060,8 +1069,32 @@ class AppSessionController extends ChangeNotifier { await _libraryBackupPreferencesService.setPendingImportDismissedAt( remoteModifiedAt ?? exportedAt, ); + + // Record what we just uploaded as the revision this device knows + // about, so the next export can tell whether another device has + // pushed a change in the meantime. + final uploadedInfo = await _libraryBackupService.getRemoteBackupDeviceInfo( + client: client, + remotePath: LibraryBackupService.remoteBackupPath( + _webDavServerPath ?? '/', + currentDatabasePath, + ), + ); + if (uploadedInfo.revision != null) { + _lastKnownRevision = uploadedInfo.revision; + await _libraryBackupPreferencesService.setLastKnownRevision( + _lastKnownRevision, + ); + } + developer.log('WebDAV export succeeded', name: 'classi.backup'); _setBackupMessage('backup_exported'); + } on WebDavSyncConflictException catch (error) { + developer.log( + 'WebDAV export conflict: ${error.message}', + name: 'classi.backup', + ); + _setBackupMessage('backup_export_conflict', isError: true); } on WebDavSyncBusyException catch (error) { developer.log( 'WebDAV export skipped: ${error.message}', @@ -1252,15 +1285,23 @@ class AppSessionController extends ChangeNotifier { await _databasePathService.setDatabaseFilePath(destinationPath); await _refreshDatabasePath(); } - await _libraryBackupService.restoreBackupFromBytes( - bytes: bytes, - destinationDatabasePath: destinationPath, - ); + final restoredRevision = await _libraryBackupService + .restoreBackupFromBytes( + bytes: bytes, + destinationDatabasePath: destinationPath, + ); final importedAt = DateTime.now().toUtc(); _lastImportedAt = importedAt; await _libraryBackupPreferencesService.setLastImportedAt(importedAt); await _libraryBackupPreferencesService.setPendingImportDismissedAt(null); + // Adopt the imported backup's revision as our own so the next export + // is recognized as building on top of what we just restored, rather + // than looking like a conflict with it. + _lastKnownRevision = restoredRevision; + await _libraryBackupPreferencesService.setLastKnownRevision( + restoredRevision, + ); await _loadSecurityPreferences(); await _loadBackupPreferences(); diff --git a/lib/core/storage/library_backup_preferences_service.dart b/lib/core/storage/library_backup_preferences_service.dart index bb5cb11..5107f60 100644 --- a/lib/core/storage/library_backup_preferences_service.dart +++ b/lib/core/storage/library_backup_preferences_service.dart @@ -17,6 +17,7 @@ class LibraryBackupPreferencesService { static const String _pendingImportDismissedAtKey = 'backup.pending_import_dismissed_at'; static const String _maxVersionsKey = 'backup.max_versions'; + static const String _lastKnownRevisionKey = 'backup.last_known_revision'; static const List _autoExportEnabledPath = [ 'backup', 'autoExportEnabled', @@ -38,6 +39,10 @@ class LibraryBackupPreferencesService { 'pendingImportDismissedAt', ]; static const List _maxVersionsPath = ['backup', 'maxVersions']; + static const List _lastKnownRevisionPath = [ + 'backup', + 'lastKnownRevision', + ]; static const int defaultMaxVersions = 3; @@ -135,6 +140,31 @@ class LibraryBackupPreferencesService { ); } + /// The revision token of the remote backup this device last synced with + /// (via export or import), used to detect whether another device has + /// pushed a conflicting change since. `null` before the first sync. + Future lastKnownRevision() async { + return _readString( + path: _lastKnownRevisionPath, + legacyKey: _lastKnownRevisionKey, + ); + } + + Future setLastKnownRevision(String? revision) async { + if (revision == null || revision.isEmpty) { + await _removePath( + path: _lastKnownRevisionPath, + removeLegacyKey: _lastKnownRevisionKey, + ); + return; + } + await _writeString( + path: _lastKnownRevisionPath, + value: revision, + removeLegacyKey: _lastKnownRevisionKey, + ); + } + Future lastExportedAt() async { return _readDateTime( path: _lastExportedAtPath, diff --git a/lib/core/storage/library_backup_service.dart b/lib/core/storage/library_backup_service.dart index fcf4d9a..c06e2a3 100644 --- a/lib/core/storage/library_backup_service.dart +++ b/lib/core/storage/library_backup_service.dart @@ -33,6 +33,30 @@ class WebDavSyncBusyException implements Exception { String toString() => 'WebDavSyncBusyException: $message'; } +/// Thrown by [LibraryBackupService.exportBackupToWebDav] when the remote +/// backup has moved on to a revision this device never saw — i.e. another +/// device pushed a change since this device last synced. +/// +/// The canonical backup is left untouched; this device's changes are +/// uploaded as a separate `_CONFLICT_` copy instead, so nothing is lost on +/// either side. The user needs to reconcile the two manually (the conflict +/// copy shows up alongside the canonical one in the backup list). +class WebDavSyncConflictException implements Exception { + const WebDavSyncConflictException({ + required this.message, + this.conflictingDeviceName, + }); + + final String message; + + /// The device that produced the remote revision this export conflicts + /// with, if known. + final String? conflictingDeviceName; + + @override + String toString() => 'WebDavSyncConflictException: $message'; +} + class WebDavBackupEntry { const WebDavBackupEntry({ required this.fileName, @@ -57,12 +81,22 @@ class WebDavBackupEntry { final String? deviceName; } -/// Device attribution read from a backup's `.meta.json` sidecar. +/// Metadata read from a backup's `.meta.json` sidecar: which device +/// produced it and its place in the revision chain (see +/// [LibraryBackupService.exportBackupToWebDav]'s conflict detection). class WebDavBackupDeviceInfo { - const WebDavBackupDeviceInfo({this.deviceId, this.deviceName}); + const WebDavBackupDeviceInfo({ + this.deviceId, + this.deviceName, + this.revision, + }); final String? deviceId; final String? deviceName; + + /// This backup's own revision token, or `null` for backups exported + /// before revision tracking existed. + final String? revision; } class LibraryBackupService { @@ -76,6 +110,8 @@ class LibraryBackupService { DateTime? exportedAt, String? deviceId, String? deviceName, + String? revision, + String? parentRevision, }) async { final normalizedSourcePath = p.normalize(sourceDatabasePath); developer.log( @@ -92,6 +128,8 @@ class LibraryBackupService { 'exportedAt': (exportedAt ?? DateTime.now().toUtc()).toIso8601String(), if (deviceId != null) 'deviceId': deviceId, if (deviceName != null) 'deviceName': deviceName, + if (revision != null) 'revision': revision, + if (parentRevision != null) 'parentRevision': parentRevision, }), ), ); @@ -122,7 +160,13 @@ class LibraryBackupService { } /// Restores a backup archive from [bytes] into [destinationDatabasePath]. - Future restoreBackupFromBytes({ + /// + /// Returns the restored backup's `revision` token from its manifest, or + /// `null` for a backup exported before revision tracking existed. Callers + /// should record this as the new "last known revision" for the library so + /// the next export can detect whether the remote moved on in the + /// meantime. + Future restoreBackupFromBytes({ required Uint8List bytes, required String destinationDatabasePath, }) async { @@ -180,6 +224,8 @@ class LibraryBackupService { if (!restoredDatabase || !restoredSecurityMetadata) { throw StateError('Backup archive is incomplete.'); } + + return manifestJson['revision'] as String?; } /// Builds a backup archive in memory and uploads it atomically to the @@ -192,8 +238,17 @@ class LibraryBackupService { /// [_trySyncLockAcquire]) so two devices exporting at the same moment don't /// interleave their archive/rename/prune steps. /// + /// [parentRevision] should be the revision this device last saw for this + /// library (its own last export, or whatever it last restored) — pass + /// `null` if this device has never synced this library before. If the + /// remote's current revision doesn't match, another device pushed a + /// change this device never saw: rather than silently overwriting it, + /// this uploads the local changes as a separate `_CONFLICT_` copy and + /// throws [WebDavSyncConflictException], leaving the canonical backup + /// untouched. + /// /// Throws [WebDavSyncBusyException] if another device currently holds the - /// lock. + /// sync lock. /// /// Returns the UTC [DateTime] embedded as `exportedAt` in the manifest. Future exportBackupToWebDav({ @@ -203,6 +258,7 @@ class LibraryBackupService { int maxVersions = 3, String? deviceId, String? deviceName, + String? parentRevision, }) async { await client.mkdirAll(serverPath); @@ -219,15 +275,67 @@ class LibraryBackupService { } try { + final canonicalName = backupFileNameForDatabasePath(sourceDatabasePath); + final canonicalPath = _joinServerPath(serverPath, canonicalName); + + // Detect whether the remote moved on to a revision we never saw. + // A remote with no revision at all (nothing uploaded yet, or an old + // backup that predates revision tracking) is not a conflict — there's + // nothing to compare against, so fail open rather than block export + // forever on a pre-existing legacy backup. + final existingRemote = await _readMetaSidecar( + client: client, + backupPath: canonicalPath, + ); + final remoteRevision = existingRemote.revision; + if (remoteRevision != null && remoteRevision != parentRevision) { + final exportedAt = DateTime.now().toUtc(); + final revision = _randomToken(); + final bytes = await buildBackupArchive( + sourceDatabasePath, + exportedAt: exportedAt, + deviceId: deviceId, + deviceName: deviceName, + revision: revision, + parentRevision: parentRevision, + ); + final stem = p.basenameWithoutExtension(canonicalName); + final conflictName = + '${stem}_CONFLICT_${_timestampStamp(exportedAt)}$classiBackupExtension'; + final conflictPath = _joinServerPath(serverPath, conflictName); + developer.log( + 'exportBackupToWebDav: conflict detected, uploading as $conflictName', + name: 'classi.backup', + ); + await client.write(conflictPath, bytes); + await _writeMetaSidecar( + client: client, + backupPath: conflictPath, + deviceId: deviceId, + deviceName: deviceName, + exportedAt: exportedAt, + revision: revision, + parentRevision: parentRevision, + ); + throw WebDavSyncConflictException( + message: + 'Another device changed this library since this device last ' + 'synced. Saved local changes as a separate copy instead of ' + 'overwriting the newer backup.', + conflictingDeviceName: existingRemote.deviceName, + ); + } + final exportedAt = DateTime.now().toUtc(); + final revision = _randomToken(); final bytes = await buildBackupArchive( sourceDatabasePath, exportedAt: exportedAt, deviceId: deviceId, deviceName: deviceName, + revision: revision, + parentRevision: parentRevision, ); - final canonicalName = backupFileNameForDatabasePath(sourceDatabasePath); - final canonicalPath = _joinServerPath(serverPath, canonicalName); final tmpPath = '$canonicalPath.tmp'; developer.log( 'exportBackupToWebDav: uploading to $tmpPath (${bytes.length} bytes)', @@ -240,6 +348,8 @@ class LibraryBackupService { deviceId: deviceId, deviceName: deviceName, exportedAt: exportedAt, + revision: revision, + parentRevision: parentRevision, ); // Archive the current canonical backup before replacing it. @@ -291,7 +401,7 @@ class LibraryBackupService { final props = await client.readProps(canonicalPath); final mTime = props.mTime; if (mTime == null) return; - final stamp = mTime.toUtc().toIso8601String().replaceAll(':', '').replaceAll('-', '').split('.').first; + final stamp = _timestampStamp(mTime); final stem = p.basenameWithoutExtension(canonicalName); final archivedName = '${stem}_$stamp$classiBackupExtension'; final archivedPath = _joinServerPath(serverPath, archivedName); @@ -498,15 +608,27 @@ class LibraryBackupService { static String _metaSidecarPath(String backupPath) => '$backupPath.meta.json'; - /// Uploads the device-attribution sidecar for [backupPath]. Best-effort: - /// device attribution is metadata for display purposes only, so a failure - /// here must never break the backup export itself. + /// Formats [dt] as the compact UTC timestamp used in archived/conflict + /// backup file names, e.g. `20260507T080000Z`. + static String _timestampStamp(DateTime dt) => dt + .toUtc() + .toIso8601String() + .replaceAll(':', '') + .replaceAll('-', '') + .split('.') + .first; + + /// Uploads the attribution/revision sidecar for [backupPath]. Best-effort: + /// this metadata is for display and conflict detection, so a failure here + /// must never break the backup export itself. Future _writeMetaSidecar({ required webdav.Client client, required String backupPath, required String? deviceId, required String? deviceName, required DateTime exportedAt, + String? revision, + String? parentRevision, }) async { try { final metaBytes = utf8.encode( @@ -514,6 +636,8 @@ class LibraryBackupService { if (deviceId != null) 'deviceId': deviceId, if (deviceName != null) 'deviceName': deviceName, 'exportedAt': exportedAt.toIso8601String(), + if (revision != null) 'revision': revision, + if (parentRevision != null) 'parentRevision': parentRevision, }), ); await client.write(_metaSidecarPath(backupPath), metaBytes); @@ -551,6 +675,7 @@ class LibraryBackupService { return WebDavBackupDeviceInfo( deviceId: metaJson['deviceId'] as String?, deviceName: metaJson['deviceName'] as String?, + revision: metaJson['revision'] as String?, ); } catch (_) { return const WebDavBackupDeviceInfo(); @@ -650,7 +775,11 @@ class LibraryBackupService { } } - String _fallbackSyncLockOwnerId() { + String _fallbackSyncLockOwnerId() => _randomToken(); + + /// A short random opaque hex token, used for the sync lock's fallback + /// owner id and for revision tokens. + String _randomToken() { final random = Random.secure(); return List.generate( 8, diff --git a/test/app_session_controller_test.dart b/test/app_session_controller_test.dart index 9d24609..d68ed62 100644 --- a/test/app_session_controller_test.dart +++ b/test/app_session_controller_test.dart @@ -314,6 +314,53 @@ void main() { }, ); + test( + 'restoring a backup adopts its revision as the local sync baseline', + () async { + final sourceLibraryDirectory = Directory( + '${tempDirectory.path}/revision-source.classi', + ); + await sourceLibraryDirectory.create(recursive: true); + await File('${sourceLibraryDirectory.path}/data.db').writeAsString('db'); + await File( + '${sourceLibraryDirectory.path}/data.db.security.json', + ).writeAsString('security'); + + final archiveBytes = await LibraryBackupService().buildBackupArchive( + sourceLibraryDirectory.path, + revision: 'remote-revision-42', + ); + final restoreService = _RestoringLibraryBackupService(archiveBytes); + controller.dispose(); + final databasePathService = _TestDatabasePathService( + '${tempDirectory.path}/blank2.classi', + ); + controller = _RestoringAppSessionController( + keyService: keyService, + databasePathService: databasePathService, + securityPreferencesService: _securityPreferencesServiceFor( + databasePathService, + ), + libraryBackupPreferencesService: _libraryBackupPreferencesServiceFor( + databasePathService, + ), + libraryBackupService: restoreService, + biometricService: BiometricService(), + ); + + await controller.initialize(); + + final destinationPath = '${tempDirectory.path}/revision-restored.classi'; + final errorCode = await controller.restoreWebDavBackup( + remotePath: '/backups/remote.classi-backup', + destinationPath: destinationPath, + ); + + expect(errorCode, isNull); + expect(controller.lastKnownRevision, 'remote-revision-42'); + }, + ); + test( 'restoring into the currently open library does not let a pre-restore ' 'auto-export clobber the remote backup being restored', @@ -559,6 +606,89 @@ void main() { expect(controller.pendingImportDeviceName, 'Kitchen iPad'); }, ); + + test( + 'each export is based on the revision this device last saw', + () async { + final exportService = _DeviceCapturingLibraryBackupService(); + controller.dispose(); + final databasePathService = _TestDatabasePathService( + '${tempDirectory.path}/test.classi', + ); + controller = _WebDavAppSessionController( + keyService: keyService, + databasePathService: databasePathService, + securityPreferencesService: _securityPreferencesServiceFor( + databasePathService, + ), + libraryBackupPreferencesService: _libraryBackupPreferencesServiceFor( + databasePathService, + ), + libraryBackupService: exportService, + biometricService: BiometricService(), + ); + + await controller.initialize(); + await controller.createDatabase('test'); + await controller.setWebDavUrl('https://example.invalid/remote.php/dav'); + await controller.setWebDavAutoExportEnabled(true); + + expect(await controller.exportNow(), isNull); + expect( + exportService.lastParentRevision, + isNull, + reason: 'first export for this device has nothing to build on', + ); + + expect(await controller.exportNow(), isNull); + expect( + exportService.lastParentRevision, + 'revision-1', + reason: + 'the second export should be based on the revision recorded ' + 'after the first export succeeded', + ); + }, + ); + + test( + 'a sync conflict is surfaced distinctly and does not update the local ' + 'export bookkeeping', + () async { + final conflictService = _ConflictingLibraryBackupService(); + controller.dispose(); + final databasePathService = _TestDatabasePathService( + '${tempDirectory.path}/test.classi', + ); + controller = _WebDavAppSessionController( + keyService: keyService, + databasePathService: databasePathService, + securityPreferencesService: _securityPreferencesServiceFor( + databasePathService, + ), + libraryBackupPreferencesService: _libraryBackupPreferencesServiceFor( + databasePathService, + ), + libraryBackupService: conflictService, + biometricService: BiometricService(), + ); + + await controller.initialize(); + await controller.createDatabase('test'); + await controller.setWebDavUrl('https://example.invalid/remote.php/dav'); + await controller.setWebDavAutoExportEnabled(true); + + final errorCode = await controller.exportNow(); + + // exportNow() only returns non-null for a pre-flight check (e.g. not + // configured); a failure during the export itself surfaces through + // lastBackupMessageCode instead. + expect(errorCode, isNull); + expect(controller.lastBackupMessageCode, 'backup_export_conflict'); + expect(controller.lastBackupMessageIsError, isTrue); + expect(controller.lastExportedAt, isNull); + }, + ); } class _TestDatabasePathService extends DatabasePathService { @@ -616,6 +746,7 @@ class _DelayingLibraryBackupService extends LibraryBackupService { int maxVersions = 3, String? deviceId, String? deviceName, + String? parentRevision, }) async { if (!started.isCompleted) { started.complete(); @@ -623,6 +754,21 @@ class _DelayingLibraryBackupService extends LibraryBackupService { await finish.future; return DateTime.now().toUtc(); } + + // Avoid real network round-trips for the post-export bookkeeping calls; + // the fake client points at a non-existent host. + @override + Future getRemoteBackupModifiedAt({ + required webdav.Client client, + required String serverPath, + required String backupFileName, + }) async => DateTime.now().toUtc(); + + @override + Future getRemoteBackupDeviceInfo({ + required webdav.Client client, + required String remotePath, + }) async => const WebDavBackupDeviceInfo(revision: 'revision-1'); } class _CloseDelayingAppSessionController extends AppSessionController { @@ -679,6 +825,7 @@ class _RestoringSelfLibraryBackupService extends LibraryBackupService { int maxVersions = 3, String? deviceId, String? deviceName, + String? parentRevision, }) async { exportCalled = true; return DateTime.now().toUtc(); @@ -741,6 +888,7 @@ class _ExportingLibraryBackupService extends LibraryBackupService { int maxVersions = 3, String? deviceId, String? deviceName, + String? parentRevision, }) async { return exportedAt; } @@ -753,11 +901,20 @@ class _ExportingLibraryBackupService extends LibraryBackupService { }) async { return remoteModifiedAt; } + + // Avoid a real network round-trip for the post-export revision lookup; + // the fake client points at a non-existent host. + @override + Future getRemoteBackupDeviceInfo({ + required webdav.Client client, + required String remotePath, + }) async => const WebDavBackupDeviceInfo(revision: 'revision-1'); } class _DeviceCapturingLibraryBackupService extends LibraryBackupService { String? lastDeviceId; String? lastDeviceName; + String? lastParentRevision; @override Future exportBackupToWebDav({ @@ -767,21 +924,31 @@ class _DeviceCapturingLibraryBackupService extends LibraryBackupService { int maxVersions = 3, String? deviceId, String? deviceName, + String? parentRevision, }) async { lastDeviceId = deviceId; lastDeviceName = deviceName; + lastParentRevision = parentRevision; return DateTime.now().toUtc(); } - // Avoid a real network round-trip: _runAutoExportIfConfigured calls this - // right after exportBackupToWebDav to timestamp the pending-import - // dismissal, and the fake client points at a non-existent host. + // Avoid a real network round-trip: _runAutoExportIfConfigured calls these + // right after exportBackupToWebDav (to timestamp the pending-import + // dismissal, and to record the newly uploaded revision), and the fake + // client points at a non-existent host. @override Future getRemoteBackupModifiedAt({ required webdav.Client client, required String serverPath, required String backupFileName, }) async => DateTime.now().toUtc(); + + @override + Future getRemoteBackupDeviceInfo({ + required webdav.Client client, + required String remotePath, + }) async => + WebDavBackupDeviceInfo(deviceId: lastDeviceId, revision: 'revision-1'); } class _PendingImportDeviceLibraryBackupService extends LibraryBackupService { @@ -813,3 +980,21 @@ class _PendingImportDeviceLibraryBackupService extends LibraryBackupService { ); } } + +class _ConflictingLibraryBackupService extends LibraryBackupService { + @override + Future exportBackupToWebDav({ + required webdav.Client client, + required String sourceDatabasePath, + required String serverPath, + int maxVersions = 3, + String? deviceId, + String? deviceName, + String? parentRevision, + }) async { + throw const WebDavSyncConflictException( + message: 'Another device changed this library.', + conflictingDeviceName: 'Other Device', + ); + } +} diff --git a/test/library_backup_preferences_service_test.dart b/test/library_backup_preferences_service_test.dart index 4f91ce0..d869799 100644 --- a/test/library_backup_preferences_service_test.dart +++ b/test/library_backup_preferences_service_test.dart @@ -55,6 +55,18 @@ void main() { expect(await service.webDavServerPath(), '/classi-backups/'); }); + test('stores and clears the last known sync revision', () async { + final service = _serviceFor(projectPath); + + expect(await service.lastKnownRevision(), isNull); + + await service.setLastKnownRevision('device-a-1'); + expect(await service.lastKnownRevision(), 'device-a-1'); + + await service.setLastKnownRevision(null); + expect(await service.lastKnownRevision(), isNull); + }); + test('backup preferences do not leak across projects', () async { final firstProject = _serviceFor(projectPath); await firstProject.setAutoExportEnabled(true); diff --git a/test/library_backup_service_test.dart b/test/library_backup_service_test.dart index f191d19..b18c564 100644 --- a/test/library_backup_service_test.dart +++ b/test/library_backup_service_test.dart @@ -83,6 +83,33 @@ void main() { }, ); + test( + 'buildBackupArchive embeds revision and parentRevision in the manifest, ' + 'and restoreBackupFromBytes returns the restored revision', + () async { + final archiveBytes = await service.buildBackupArchive( + sourceLibraryDirectory.path, + revision: 'revision-2', + parentRevision: 'revision-1', + ); + + final archive = ZipDecoder().decodeBytes(archiveBytes); + final manifestFile = archive.findFile('backup.json')!; + final manifestJson = + jsonDecode(utf8.decode(manifestFile.content as List)) + as Map; + + expect(manifestJson['revision'], 'revision-2'); + expect(manifestJson['parentRevision'], 'revision-1'); + + final restoredRevision = await service.restoreBackupFromBytes( + bytes: archiveBytes, + destinationDatabasePath: '${tempDirectory.path}/revision-check.classi', + ); + expect(restoredRevision, 'revision-2'); + }, + ); + test( 'restoreBackupFromBytes restores database artifacts into a package folder', () async { From 08d206caa78767a1b50c895dcce2be315e53a362 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 06:47:35 +0000 Subject: [PATCH 05/10] feat: add a foreground safety-net export timer and a visual backup status button The background/lock-triggered auto-export isn't reliable on every platform: Android can suspend the process shortly after it's backgrounded, cutting off the in-flight export (checkpoint, sync lock, zip, upload, archive, prune) before it finishes, while desktop platforms keep running normally when unfocused. Add a periodic timer (default 10 min, injectable via AppSessionController's new periodicExportInterval param) that opportunistically re-exports while the app is open and unlocked, independent of backgrounding, so the same code path works identically on every platform and the backup is never more than one interval stale even when the background trigger gets killed. The timer starts/stops alongside WebDAV auto-export eligibility (configured + enabled) and the session's ready/not-ready transitions. Also add a persistent backup status indicator to the main app shell (NavigationRail trailing slot on desktop, a strip above the bottom NavigationBar on mobile) showing at a glance whether the backup is current, syncing, behind, or failed (busy/conflict/generic), with a tap-to-export-now action reusing the existing exportNow() flow. Only shown once WebDAV auto-export is configured and enabled, matching the existing Settings screen's "Export now" gating. --- assets/translations/de.json | 1 + assets/translations/en.json | 1 + lib/core/session/app_session_controller.dart | 58 +++++++- lib/shared/widgets/app_scaffold.dart | 144 +++++++++++++++++-- test/app_session_controller_test.dart | 87 +++++++++++ 5 files changed, 279 insertions(+), 12 deletions(-) diff --git a/assets/translations/de.json b/assets/translations/de.json index f867459..14e6ca2 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -135,6 +135,7 @@ "export_library": "Backup exportieren", "export_library_hint": "Erstellt eine portable Backup-Datei für Sync oder Transfer.", "export_now": "Jetzt exportieren", + "exporting_backup": "Backup wird exportiert…", "first_name": "Vorname", "generic_error": "Etwas ist schiefgelaufen.", "generic_error_hint": "Bitte versuche es erneut.", diff --git a/assets/translations/en.json b/assets/translations/en.json index 3f0e3b1..397d224 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -135,6 +135,7 @@ "export_library": "Export backup", "export_library_hint": "Create a portable backup file for sync or transfer.", "export_now": "Export now", + "exporting_backup": "Exporting backup…", "first_name": "First name", "generic_error": "Something went wrong.", "generic_error_hint": "Please try again.", diff --git a/lib/core/session/app_session_controller.dart b/lib/core/session/app_session_controller.dart index 425d466..dd655d0 100644 --- a/lib/core/session/app_session_controller.dart +++ b/lib/core/session/app_session_controller.dart @@ -53,13 +53,15 @@ class AppSessionController extends ChangeNotifier { required LibraryBackupService libraryBackupService, required BiometricService biometricService, DeviceIdentityService? deviceIdentityService, + Duration periodicExportInterval = const Duration(minutes: 10), }) : _keyService = keyService, _databasePathService = databasePathService, _securityPreferencesService = securityPreferencesService, _libraryBackupPreferencesService = libraryBackupPreferencesService, _libraryBackupService = libraryBackupService, _biometricService = biometricService, - _deviceIdentityService = deviceIdentityService ?? DeviceIdentityService(); + _deviceIdentityService = deviceIdentityService ?? DeviceIdentityService(), + _periodicExportInterval = periodicExportInterval; final KeyService _keyService; final DatabasePathService _databasePathService; @@ -81,6 +83,20 @@ class AppSessionController extends ChangeNotifier { Duration _inactivityTimeout = SecurityPreferencesService.defaultInactivityTimeout; Timer? _inactivityTimer; + + /// How often to opportunistically re-export while the app is open and + /// unlocked, independent of backgrounding. + /// + /// The background/lock-triggered export (see [handleAppBackgrounded]) is + /// not reliable on every platform: on Android the OS can suspend the + /// process shortly after it's backgrounded, cutting off the in-flight + /// export before it finishes, whereas desktop platforms keep running + /// normally when unfocused. This periodic timer runs entirely in the + /// foreground, so it works the same way everywhere, and bounds how stale + /// the WebDAV backup can get even when a background export never + /// completes. + final Duration _periodicExportInterval; + Timer? _periodicExportTimer; String? _currentPassphrase; String? _pendingRecoveryKey; bool _webDavAutoExportEnabled = false; @@ -239,6 +255,7 @@ class AppSessionController extends ChangeNotifier { } _status = AppSessionStatus.ready; _resetInactivityTimer(); + _startPeriodicExportTimerIfNeeded(); return true; } catch (error, stackTrace) { await _handleFatalError( @@ -315,6 +332,7 @@ class AppSessionController extends ChangeNotifier { if (_status == AppSessionStatus.ready) { await refreshIfChanged(); _resetInactivityTimer(); + _startPeriodicExportTimerIfNeeded(); } } @@ -360,6 +378,7 @@ class AppSessionController extends ChangeNotifier { return; } _resetInactivityTimer(); + _startPeriodicExportTimerIfNeeded(); } Future refreshIfChanged() async { @@ -598,6 +617,11 @@ class AppSessionController extends ChangeNotifier { Future setWebDavUrl(String? url) async { _webDavUrl = (url == null || url.trim().isEmpty) ? null : url.trim(); await _libraryBackupPreferencesService.setWebDavUrl(_webDavUrl); + if (_webDavUrl == null) { + _cancelPeriodicExportTimer(); + } else { + _startPeriodicExportTimerIfNeeded(); + } notifyListeners(); } @@ -631,6 +655,11 @@ class AppSessionController extends ChangeNotifier { Future setWebDavAutoExportEnabled(bool value) async { _webDavAutoExportEnabled = value; await _libraryBackupPreferencesService.setAutoExportEnabled(value); + if (value) { + _startPeriodicExportTimerIfNeeded(); + } else { + _cancelPeriodicExportTimer(); + } notifyListeners(); } @@ -828,6 +857,7 @@ class AppSessionController extends ChangeNotifier { _pendingRecoveryKey = null; _status = AppSessionStatus.ready; _resetInactivityTimer(); + _startPeriodicExportTimerIfNeeded(); return true; } catch (error, stackTrace) { await _handleFatalError( @@ -867,6 +897,7 @@ class AppSessionController extends ChangeNotifier { AppDatabase? _detachDatabase() { _cancelInactivityTimer(); + _cancelPeriodicExportTimer(); final database = _database; _database = null; _currentPassphrase = null; @@ -1388,6 +1419,30 @@ class AppSessionController extends ChangeNotifier { _inactivityTimer = null; } + /// Starts the periodic foreground export timer if it isn't already + /// running and the session is currently eligible (ready, WebDAV + /// configured, auto-export enabled). Safe to call speculatively — + /// idempotent and a no-op when not eligible. + void _startPeriodicExportTimerIfNeeded() { + if (_periodicExportTimer != null) return; + if (_status != AppSessionStatus.ready) return; + if (!_webDavAutoExportEnabled || !isWebDavConfigured) return; + + _periodicExportTimer = Timer.periodic(_periodicExportInterval, (_) { + unawaited(_runPeriodicExportTick()); + }); + } + + void _cancelPeriodicExportTimer() { + _periodicExportTimer?.cancel(); + _periodicExportTimer = null; + } + + Future _runPeriodicExportTick() async { + if (_status != AppSessionStatus.ready || _isExporting || _isBusy) return; + await _flushAndAutoExport(); + } + void _setBackupMessage(String code, {bool isError = false}) { _lastBackupMessageCode = code; _lastBackupMessageIsError = isError; @@ -1396,6 +1451,7 @@ class AppSessionController extends ChangeNotifier { @override void dispose() { _cancelInactivityTimer(); + _cancelPeriodicExportTimer(); unawaited(_closeDatabase()); super.dispose(); } diff --git a/lib/shared/widgets/app_scaffold.dart b/lib/shared/widgets/app_scaffold.dart index 7ace2a9..ce0c5b8 100644 --- a/lib/shared/widgets/app_scaffold.dart +++ b/lib/shared/widgets/app_scaffold.dart @@ -68,6 +68,15 @@ class AppScaffold extends ConsumerWidget { label: Text(destination.label.tr()), ), ], + trailing: const Expanded( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.only(bottom: 12), + child: _BackupStatusIndicator(dense: true), + ), + ), + ), ), const VerticalDivider(width: 1), Expanded(child: child), @@ -77,17 +86,23 @@ class AppScaffold extends ConsumerWidget { } else { scaffold = Scaffold( body: child, - bottomNavigationBar: NavigationBar( - selectedIndex: selectedIndex, - onDestinationSelected: (index) => - context.go(destinations[index].path), - destinations: [ - for (final destination in destinations) - NavigationDestination( - icon: Icon(destination.icon), - selectedIcon: Icon(destination.selectedIcon), - label: destination.label.tr(), - ), + bottomNavigationBar: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const _BackupStatusIndicator(dense: false), + NavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: (index) => + context.go(destinations[index].path), + destinations: [ + for (final destination in destinations) + NavigationDestination( + icon: Icon(destination.icon), + selectedIcon: Icon(destination.selectedIcon), + label: destination.label.tr(), + ), + ], + ), ], ), ); @@ -140,3 +155,110 @@ class _NavigationItem { final IconData selectedIcon; final String label; } + +/// A small, always-visible WebDAV backup status indicator, shown next to +/// the main navigation on every screen. Tapping it triggers an immediate +/// manual export. +/// +/// Only shown once WebDAV auto-export is configured and enabled — this +/// mirrors the "Export now" button already gated the same way in Settings, +/// and stays out of the way for users who haven't set up sync. +class _BackupStatusIndicator extends ConsumerWidget { + const _BackupStatusIndicator({required this.dense}); + + /// `true` renders a compact icon button (for the desktop navigation + /// rail); `false` renders a full-width tappable strip with a status + /// label (for the mobile bottom navigation bar). + final bool dense; + + static const Set _exportErrorCodes = { + 'backup_export_failed', + 'backup_export_busy', + 'backup_export_conflict', + }; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(appSessionProvider); + if (!session.isWebDavConfigured || !session.webDavAutoExportEnabled) { + return const SizedBox.shrink(); + } + + final colorScheme = Theme.of(context).colorScheme; + final isExporting = session.isExporting; + final hasExportError = + session.lastBackupMessageIsError && + _exportErrorCodes.contains(session.lastBackupMessageCode); + + final IconData icon; + final String labelKey; + final Color color; + if (isExporting) { + icon = Icons.cloud_sync_outlined; + labelKey = 'exporting_backup'; + color = colorScheme.onSurfaceVariant; + } else if (hasExportError) { + icon = Icons.cloud_off_outlined; + labelKey = session.lastBackupMessageCode!; + color = colorScheme.error; + } else if (session.hasPendingAutoImport) { + icon = Icons.cloud_sync_outlined; + labelKey = 'webdav_sync_behind'; + color = colorScheme.primary; + } else { + icon = Icons.cloud_done_outlined; + labelKey = 'webdav_sync_current'; + color = colorScheme.primary; + } + + final iconWidget = isExporting + ? SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: color), + ) + : Icon(icon, color: color); + + if (dense) { + return IconButton( + onPressed: isExporting ? null : () => _exportNow(context, ref), + tooltip: labelKey.tr(), + icon: iconWidget, + ); + } + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: isExporting ? null : () => _exportNow(context, ref), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + iconWidget, + const SizedBox(width: 12), + Expanded( + child: Text( + labelKey.tr(), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: color), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } + + Future _exportNow(BuildContext context, WidgetRef ref) async { + final session = ref.read(appSessionProvider); + final errorCode = await session.exportNow(); + if (!context.mounted) return; + final code = errorCode ?? session.lastBackupMessageCode ?? 'backup_exported'; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(code.tr()))); + } +} diff --git a/test/app_session_controller_test.dart b/test/app_session_controller_test.dart index d68ed62..d63a36e 100644 --- a/test/app_session_controller_test.dart +++ b/test/app_session_controller_test.dart @@ -607,6 +607,90 @@ void main() { }, ); + test( + 'a periodic timer re-exports while the app stays open, independent of ' + 'backgrounding', + () async { + final exportService = _DeviceCapturingLibraryBackupService(); + controller.dispose(); + final databasePathService = _TestDatabasePathService( + '${tempDirectory.path}/test.classi', + ); + controller = _WebDavAppSessionController( + keyService: keyService, + databasePathService: databasePathService, + securityPreferencesService: _securityPreferencesServiceFor( + databasePathService, + ), + libraryBackupPreferencesService: _libraryBackupPreferencesServiceFor( + databasePathService, + ), + libraryBackupService: exportService, + biometricService: BiometricService(), + periodicExportInterval: const Duration(milliseconds: 20), + ); + + await controller.initialize(); + await controller.createDatabase('test'); + controller.clearPendingRecoveryKey(); + await controller.setWebDavUrl('https://example.invalid/remote.php/dav'); + await controller.setWebDavAutoExportEnabled(true); + + expect(exportService.exportCount, 0); + + // Give the periodic timer a chance to fire at least once, without + // backgrounding or locking the app. + await Future.delayed(const Duration(milliseconds: 150)); + + expect( + exportService.exportCount, + greaterThan(0), + reason: + 'the periodic timer should trigger an export on its own, not ' + 'just on background/lock', + ); + }, + ); + + test( + 'the periodic export timer stops once auto-export is disabled', + () async { + final exportService = _DeviceCapturingLibraryBackupService(); + controller.dispose(); + final databasePathService = _TestDatabasePathService( + '${tempDirectory.path}/test.classi', + ); + controller = _WebDavAppSessionController( + keyService: keyService, + databasePathService: databasePathService, + securityPreferencesService: _securityPreferencesServiceFor( + databasePathService, + ), + libraryBackupPreferencesService: _libraryBackupPreferencesServiceFor( + databasePathService, + ), + libraryBackupService: exportService, + biometricService: BiometricService(), + periodicExportInterval: const Duration(milliseconds: 20), + ); + + await controller.initialize(); + await controller.createDatabase('test'); + controller.clearPendingRecoveryKey(); + await controller.setWebDavUrl('https://example.invalid/remote.php/dav'); + await controller.setWebDavAutoExportEnabled(true); + await controller.setWebDavAutoExportEnabled(false); + + await Future.delayed(const Duration(milliseconds: 150)); + + expect( + exportService.exportCount, + 0, + reason: 'disabling auto-export must stop the periodic timer too', + ); + }, + ); + test( 'each export is based on the revision this device last saw', () async { @@ -864,6 +948,7 @@ class _WebDavAppSessionController extends AppSessionController { required super.libraryBackupPreferencesService, required super.libraryBackupService, required super.biometricService, + super.periodicExportInterval, }); @override @@ -915,6 +1000,7 @@ class _DeviceCapturingLibraryBackupService extends LibraryBackupService { String? lastDeviceId; String? lastDeviceName; String? lastParentRevision; + int exportCount = 0; @override Future exportBackupToWebDav({ @@ -929,6 +1015,7 @@ class _DeviceCapturingLibraryBackupService extends LibraryBackupService { lastDeviceId = deviceId; lastDeviceName = deviceName; lastParentRevision = parentRevision; + exportCount++; return DateTime.now().toUtc(); } From 2d2cb8d4ec21a1162b6ebd199922bf992cc384c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 07:33:00 +0000 Subject: [PATCH 06/10] style: use null-aware collection elements (dart fix) Applied the analyzer's own suggested fix for use_null_aware_elements in the new WebDAV device/revision manifest code, replacing if (x != null) 'key': x with 'key': ?x. No behavior change. --- lib/core/storage/library_backup_service.dart | 16 ++++++++-------- lib/features/setup/auto_import_prompt_card.dart | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/core/storage/library_backup_service.dart b/lib/core/storage/library_backup_service.dart index c06e2a3..6054e80 100644 --- a/lib/core/storage/library_backup_service.dart +++ b/lib/core/storage/library_backup_service.dart @@ -126,10 +126,10 @@ class LibraryBackupService { 'formatVersion': _backupFormatVersion, 'libraryName': _libraryNameForPath(normalizedSourcePath), 'exportedAt': (exportedAt ?? DateTime.now().toUtc()).toIso8601String(), - if (deviceId != null) 'deviceId': deviceId, - if (deviceName != null) 'deviceName': deviceName, - if (revision != null) 'revision': revision, - if (parentRevision != null) 'parentRevision': parentRevision, + 'deviceId': ?deviceId, + 'deviceName': ?deviceName, + 'revision': ?revision, + 'parentRevision': ?parentRevision, }), ), ); @@ -633,11 +633,11 @@ class LibraryBackupService { try { final metaBytes = utf8.encode( jsonEncode({ - if (deviceId != null) 'deviceId': deviceId, - if (deviceName != null) 'deviceName': deviceName, + 'deviceId': ?deviceId, + 'deviceName': ?deviceName, 'exportedAt': exportedAt.toIso8601String(), - if (revision != null) 'revision': revision, - if (parentRevision != null) 'parentRevision': parentRevision, + 'revision': ?revision, + 'parentRevision': ?parentRevision, }), ); await client.write(_metaSidecarPath(backupPath), metaBytes); diff --git a/lib/features/setup/auto_import_prompt_card.dart b/lib/features/setup/auto_import_prompt_card.dart index d9b1e87..e797d65 100644 --- a/lib/features/setup/auto_import_prompt_card.dart +++ b/lib/features/setup/auto_import_prompt_card.dart @@ -63,7 +63,7 @@ class _AutoImportPromptCardState extends ConsumerState { : 'newer_backup_exported_at') .tr( namedArgs: { - if (deviceName != null) 'device': deviceName, + 'device': ?deviceName, 'datetime': DateFormat.yMd(localeTag) .add_Hm() .format(remoteModifiedAt.toLocal()), @@ -145,7 +145,7 @@ class _AutoImportPromptCardState extends ConsumerState { : 'newer_backup_exported_at') .tr( namedArgs: { - if (deviceName != null) 'device': deviceName, + 'device': ?deviceName, 'datetime': DateFormat.yMd(localeTag) .add_Hm() .format(remoteModifiedAt.toLocal()), From fc6d0360821fd227d34dd7b7cade7669003d09df Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 11:39:49 +0000 Subject: [PATCH 07/10] fix: make page width consistent across screens and remove horizontal table scrolling Wrap timeframe grades, student detail, and student summary screens in ContentConstraints so their content width matches the rest of the app instead of stretching full-bleed. Convert the group detail screen's timeframe and session DataTables to ellipsis-safe Row/Expanded layouts so tables never scroll horizontally, and add defensive maxLines/ellipsis to labels and table headers that could otherwise wrap to a second line. Also fix a ChangeNotifier-used-after-dispose crash in the periodic export timer: an in-flight export tick could call notifyListeners() after the session controller was disposed. --- lib/core/session/app_session_controller.dart | 5 +- lib/features/groups/group_detail_screen.dart | 452 +++++++++++++----- .../groups/timeframe_grades_screen.dart | 145 +++--- .../students/student_detail_screen.dart | 204 ++++---- .../students/student_summary_screen.dart | 120 +++-- 5 files changed, 554 insertions(+), 372 deletions(-) diff --git a/lib/core/session/app_session_controller.dart b/lib/core/session/app_session_controller.dart index dd655d0..392efff 100644 --- a/lib/core/session/app_session_controller.dart +++ b/lib/core/session/app_session_controller.dart @@ -77,6 +77,7 @@ class AppSessionController extends ChangeNotifier { AppSessionErrorCode? _errorCode; DateTime? _openedAt; bool _isBusy = false; + bool _disposed = false; bool _lockOnBackground = true; int _backgroundLockSuspendCount = 0; bool _biometricEnabled = false; @@ -1043,6 +1044,7 @@ class AppSessionController extends ChangeNotifier { ); return; } + if (_disposed) return; _isExporting = true; notifyListeners(); @@ -1051,7 +1053,7 @@ class AppSessionController extends ChangeNotifier { await _updatePendingAutoImportAvailability(); } finally { _isExporting = false; - notifyListeners(); + if (!_disposed) notifyListeners(); } } @@ -1450,6 +1452,7 @@ class AppSessionController extends ChangeNotifier { @override void dispose() { + _disposed = true; _cancelInactivityTimer(); _cancelPeriodicExportTimer(); unawaited(_closeDatabase()); diff --git a/lib/features/groups/group_detail_screen.dart b/lib/features/groups/group_detail_screen.dart index 1ee96f0..f39e9dc 100644 --- a/lib/features/groups/group_detail_screen.dart +++ b/lib/features/groups/group_detail_screen.dart @@ -1214,30 +1214,19 @@ class _TimeframesTable extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: DataTable( - showCheckboxColumn: false, - columnSpacing: 16, - horizontalMargin: 0, - columns: [ - DataColumn(label: Text('timeframe_label'.tr())), - DataColumn(label: Text('start_date'.tr())), - DataColumn(label: Text('end_date'.tr())), - DataColumn(label: Text('average'.tr()), numeric: true), - DataColumn(label: Text('attendance'.tr()), numeric: true), - DataColumn(label: Text('material'.tr()), numeric: true), - DataColumn(label: Text('homework'.tr()), numeric: true), - ], - rows: [ - for (final timeframe in timeframes) - _buildRow(context, ref, timeframe), + return Column( + children: [ + const _TimeframesTableHeader(), + const Divider(height: 1), + for (var i = 0; i < timeframes.length; i++) ...[ + _buildRow(context, ref, timeframes[i]), + if (i < timeframes.length - 1) const Divider(height: 1), ], - ), + ], ); } - DataRow _buildRow(BuildContext context, WidgetRef ref, Timeframe timeframe) { + Widget _buildRow(BuildContext context, WidgetRef ref, Timeframe timeframe) { final params = ( groupId: timeframe.groupId, startDate: timeframe.startDate, @@ -1264,47 +1253,102 @@ class _TimeframesTable extends ConsumerWidget { final overlaps = _findOverlaps(timeframe, allTimeframes); final hasOverlap = overlaps.isNotEmpty; - final labelWidget = Row( - children: [ - Expanded( - child: Text( - timeframe.label, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - if (hasOverlap) - Tooltip( - message: 'overlaps_with'.tr(namedArgs: { - 'timeframes': overlaps - .map((t) => - '${t.label} (${formatShortDate(t.startDate)} – ${formatShortDate(t.endDate)})') - .join(', '), - }), - child: Icon( - Icons.warning_amber_outlined, - color: Theme.of(context).colorScheme.error, - size: 16, + return InkWell( + onTap: () => _viewTimeframeGrades(context, timeframe), + child: Container( + color: hasOverlap + ? Theme.of( + context, + ).colorScheme.errorContainer.withValues(alpha: 0.15) + : null, + padding: const EdgeInsets.symmetric(vertical: AppSpacing.small), + child: Row( + children: [ + _TimeframesTableCell( + flex: 3, + child: Row( + children: [ + Expanded( + child: Text( + timeframe.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (hasOverlap) + Tooltip( + message: 'overlaps_with'.tr(namedArgs: { + 'timeframes': overlaps + .map((t) => + '${t.label} (${formatShortDate(t.startDate)} – ${formatShortDate(t.endDate)})') + .join(', '), + }), + child: Icon( + Icons.warning_amber_outlined, + color: Theme.of(context).colorScheme.error, + size: 16, + ), + ), + ], + ), ), - ), - ], - ); - - return DataRow( - onSelectChanged: (_) => _viewTimeframeGrades(context, timeframe), - color: hasOverlap - ? WidgetStatePropertyAll( - Theme.of(context).colorScheme.errorContainer.withValues(alpha: 0.15)) - : null, - cells: [ - DataCell(labelWidget), - DataCell(Text(formatShortDate(timeframe.startDate))), - DataCell(Text(formatShortDate(timeframe.endDate))), - DataCell(Text(_avgFinalGrade(grades))), - DataCell(Text(_formatPercent(presentCount, allAttendance.length))), - DataCell(Text(_formatPercent(hadMaterialCount, allMaterial.length))), - DataCell(Text(_formatPercent(hadHomeworkCount, allHomework.length))), - ], + _TimeframesTableCell( + flex: 2, + child: Text( + formatShortDate(timeframe.startDate), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + _TimeframesTableCell( + flex: 2, + child: Text( + formatShortDate(timeframe.endDate), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + _TimeframesTableCell( + flex: 1, + child: Text( + _avgFinalGrade(grades), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + _TimeframesTableCell( + flex: 1, + child: Text( + _formatPercent(presentCount, allAttendance.length), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + _TimeframesTableCell( + flex: 1, + child: Text( + _formatPercent(hadMaterialCount, allMaterial.length), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + _TimeframesTableCell( + flex: 1, + child: Text( + _formatPercent(hadHomeworkCount, allHomework.length), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), ); } @@ -1320,6 +1364,66 @@ class _TimeframesTable extends ConsumerWidget { } } +/// Header row for [_TimeframesTable]. A plain [Row] of [Expanded] cells +/// instead of [DataTable] so it never needs to scroll horizontally: cells +/// share the available width and truncate with an ellipsis rather than +/// wrapping or forcing the table wider than its container. +class _TimeframesTableHeader extends StatelessWidget { + const _TimeframesTableHeader(); + + @override + Widget build(BuildContext context) { + final style = Theme.of( + context, + ).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700); + Widget cell(String key, int flex, {TextAlign align = TextAlign.center}) { + return _TimeframesTableCell( + flex: flex, + child: Text( + key.tr(), + style: style, + textAlign: align, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.small), + child: Row( + children: [ + cell('timeframe_label', 3, align: TextAlign.start), + cell('start_date', 2), + cell('end_date', 2), + cell('average', 1), + cell('attendance', 1), + cell('material', 1), + cell('homework', 1), + ], + ), + ); + } +} + +class _TimeframesTableCell extends StatelessWidget { + const _TimeframesTableCell({required this.flex, required this.child}); + + final int flex; + final Widget child; + + @override + Widget build(BuildContext context) { + return Expanded( + flex: flex, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: child, + ), + ); + } +} + class _LessonCalendarCard extends ConsumerStatefulWidget { const _LessonCalendarCard({ required this.groupId, @@ -2579,90 +2683,180 @@ class _SessionsTable extends StatelessWidget { @override Widget build(BuildContext context) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: DataTable( - showCheckboxColumn: false, - columnSpacing: 16, - horizontalMargin: 0, - columns: [ - DataColumn(label: Text('date'.tr())), - DataColumn(label: Text('session_label'.tr())), - DataColumn(label: Text('grade_category'.tr())), - DataColumn(label: Text('attendance'.tr()), numeric: true), - DataColumn(label: Text('homework'.tr()), numeric: true), - DataColumn(label: Text('material'.tr()), numeric: true), - DataColumn(label: Text('grade'.tr()), numeric: true), - const DataColumn(label: SizedBox.shrink()), - ], - rows: [ - for (final summary in summaries) - _buildRow(context, summary), + return Column( + children: [ + const _SessionsTableHeader(), + const Divider(height: 1), + for (var i = 0; i < summaries.length; i++) ...[ + _buildRow(context, summaries[i]), + if (i < summaries.length - 1) const Divider(height: 1), ], - ), + ], ); } - DataRow _buildRow(BuildContext context, SessionSummary summary) { + Widget _buildRow(BuildContext context, SessionSummary summary) { final session = summary.session; final locale = context.locale.toLanguageTag(); - return DataRow( - onSelectChanged: (_) => context.push( + return InkWell( + onTap: () => context.push( _lessonModeLocation( groupId: groupId, date: session.date, categoryId: session.categoryId, ), ), - cells: [ - DataCell( - Text(DateFormat.yMMMd(locale).format(session.date)), - ), - DataCell( - Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (session.label.isNotEmpty) - Text(session.label) - else - Text( - 'no_label'.tr(), - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - if (session.description != null && session.description!.isNotEmpty) - Text( - session.description!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - DataCell(Text(session.categoryName)), - DataCell(_PercentCell(value: summary.attendancePercent)), - DataCell(_PercentCell(value: summary.homeworkPercent)), - DataCell(_PercentCell(value: summary.materialPercent)), - DataCell( - summary.gradeMean != null - ? Text(formatNumber(summary.gradeMean!)) - : const Text('–'), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.small), + child: Row( + children: [ + _SessionsTableCell( + flex: 2, + child: Text( + DateFormat.yMMMd(locale).format(session.date), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + _SessionsTableCell( + flex: 3, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (session.label.isNotEmpty) + Text( + session.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + else + Text( + 'no_label'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + if (session.description != null && + session.description!.isNotEmpty) + Text( + session.description!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + _SessionsTableCell( + flex: 2, + child: Text( + session.categoryName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + _SessionsTableCell( + flex: 1, + child: _PercentCell(value: summary.attendancePercent), + ), + _SessionsTableCell( + flex: 1, + child: _PercentCell(value: summary.homeworkPercent), + ), + _SessionsTableCell( + flex: 1, + child: _PercentCell(value: summary.materialPercent), + ), + _SessionsTableCell( + flex: 1, + child: summary.gradeMean != null + ? Text( + formatNumber(summary.gradeMean!), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : const Text('–', textAlign: TextAlign.center), + ), + SizedBox( + width: 40, + child: _SessionRowActions( + summary: summary, + onEdit: onEdit, + onDelete: onDelete, + ), + ), + ], ), - DataCell( - _SessionRowActions( - summary: summary, - onEdit: onEdit, - onDelete: onDelete, - ), + ), + ); + } +} + +/// Header row for [_SessionsTable]. A plain [Row] of [Expanded] cells +/// instead of [DataTable] so it never needs to scroll horizontally: cells +/// share the available width and truncate with an ellipsis rather than +/// wrapping or forcing the table wider than its container. +class _SessionsTableHeader extends StatelessWidget { + const _SessionsTableHeader(); + + @override + Widget build(BuildContext context) { + final style = Theme.of( + context, + ).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700); + Widget cell(String key, int flex, {TextAlign align = TextAlign.center}) { + return _SessionsTableCell( + flex: flex, + child: Text( + key.tr(), + style: style, + textAlign: align, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ], + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.small), + child: Row( + children: [ + cell('date', 2, align: TextAlign.start), + cell('session_label', 3, align: TextAlign.start), + cell('grade_category', 2, align: TextAlign.start), + cell('attendance', 1), + cell('homework', 1), + cell('material', 1), + cell('grade', 1), + const SizedBox(width: 40), + ], + ), + ); + } +} + +class _SessionsTableCell extends StatelessWidget { + const _SessionsTableCell({required this.flex, required this.child}); + + final int flex; + final Widget child; + + @override + Widget build(BuildContext context) { + return Expanded( + flex: flex, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: child, + ), ); } } diff --git a/lib/features/groups/timeframe_grades_screen.dart b/lib/features/groups/timeframe_grades_screen.dart index 97403a4..144e9c2 100644 --- a/lib/features/groups/timeframe_grades_screen.dart +++ b/lib/features/groups/timeframe_grades_screen.dart @@ -9,14 +9,15 @@ import '../../shared/theme/app_ui.dart'; import '../../shared/utils/formatting.dart'; import '../../shared/utils/grade_categories.dart' show - GradeCategory, - colorForCategory, - colorFromHex, - defaultGradeCategories, - onColorForBackground, - parseGradeCategories; + GradeCategory, + colorForCategory, + colorFromHex, + defaultGradeCategories, + onColorForBackground, + parseGradeCategories; import '../../shared/widgets/app_error_state.dart'; import '../../shared/widgets/confirm_dialog.dart'; +import '../../shared/widgets/content_constraints.dart'; import '../../shared/widgets/student_avatar.dart'; import '../grades/grade_picker_dialog.dart'; import '../notes/note_links.dart'; @@ -41,10 +42,12 @@ class TimeframeGradesScreen extends ConsumerWidget { final studentsValue = ref.watch(groupStudentsProvider(groupId)); final notesValue = ref.watch(groupNotesProvider(groupId)); final groupValue = ref.watch(groupProvider(groupId)); - final categoryAveragesValue = - ref.watch(timeframeCategoryAveragesProvider(params)); - final timeframeGradesValue = - ref.watch(timeframeGradesProvider(timeframe.id)); + final categoryAveragesValue = ref.watch( + timeframeCategoryAveragesProvider(params), + ); + final timeframeGradesValue = ref.watch( + timeframeGradesProvider(timeframe.id), + ); final attendanceValue = ref.watch(timeframeAttendanceProvider(params)); final materialValue = ref.watch(timeframeMaterialProvider(params)); final homeworkValue = ref.watch(timeframeHomeworkProvider(params)); @@ -94,8 +97,7 @@ class TimeframeGradesScreen extends ConsumerWidget { final gradeScaleEntries = group != null ? parseGradeScaleEntries(group.gradeScaleJson) : defaultGradeScaleEntries; - final gradeScale = - gradeScaleEntries.map((e) => e.label).toList(); + final gradeScale = gradeScaleEntries.map((e) => e.label).toList(); final categories = group != null ? parseGradeCategories(group.gradeCategoriesJson) : defaultGradeCategories; @@ -108,14 +110,12 @@ class TimeframeGradesScreen extends ConsumerWidget { } } - final categoryAverages = - categoryAveragesValue.asData?.value ?? {}; + final categoryAverages = categoryAveragesValue.asData?.value ?? {}; final finalGrades = { for (final g in timeframeGradesValue.asData?.value ?? []) g.studentId: g.grade, }; - final attendanceLogs = - attendanceValue.asData?.value ?? {}; + final attendanceLogs = attendanceValue.asData?.value ?? {}; final materialLogs = materialValue.asData?.value ?? {}; final homeworkLogs = homeworkValue.asData?.value ?? {}; @@ -152,7 +152,9 @@ class TimeframeGradesScreen extends ConsumerWidget { timeframeId: timeframe.id, ); if (result != null) { - await ref.read(timeframeRepositoryProvider).updateTimeframe( + await ref + .read(timeframeRepositoryProvider) + .updateTimeframe( id: timeframe.id, label: result.label, startDate: result.startDate, @@ -213,32 +215,32 @@ class _TimeframeTable extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { return SingleChildScrollView( padding: appScreenPadding, - child: Card( - clipBehavior: Clip.antiAlias, - child: Padding( - padding: appCardPadding, - child: Column( - children: [ - _TableHeader(), - for (var i = 0; i < students.length; i++) ...[ - _TimeframeStudentRow( - student: students[i], - timeframe: timeframe, - categories: categories, - gradeScaleEntries: gradeScaleEntries, - gradeScale: gradeScale, - notes: notesByStudent[students[i].id] ?? [], - perCategoryAverages: - categoryAverages[students[i].id] ?? {}, - finalGrade: finalGrades[students[i].id], - attendanceLogs: - attendanceLogs[students[i].id] ?? [], - materialLogs: materialLogs[students[i].id] ?? [], - homeworkLogs: homeworkLogs[students[i].id] ?? [], - ), - if (i < students.length - 1) const Divider(height: 1), + child: ContentConstraints( + child: Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: appCardPadding, + child: Column( + children: [ + _TableHeader(), + for (var i = 0; i < students.length; i++) ...[ + _TimeframeStudentRow( + student: students[i], + timeframe: timeframe, + categories: categories, + gradeScaleEntries: gradeScaleEntries, + gradeScale: gradeScale, + notes: notesByStudent[students[i].id] ?? [], + perCategoryAverages: categoryAverages[students[i].id] ?? {}, + finalGrade: finalGrades[students[i].id], + attendanceLogs: attendanceLogs[students[i].id] ?? [], + materialLogs: materialLogs[students[i].id] ?? [], + homeworkLogs: homeworkLogs[students[i].id] ?? [], + ), + if (i < students.length - 1) const Divider(height: 1), + ], ], - ], + ), ), ), ), @@ -332,20 +334,23 @@ class _TimeframeStudentRow extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final sortField = ref.watch(studentSortFieldProvider); - final presentCount = - attendanceLogs.where((l) => !l.isAbsent).length; - final attendancePercent = - _formatPercent(presentCount, attendanceLogs.length); + final presentCount = attendanceLogs.where((l) => !l.isAbsent).length; + final attendancePercent = _formatPercent( + presentCount, + attendanceLogs.length, + ); - final hadMaterialCount = - materialLogs.where((l) => l.hadMaterial).length; - final materialPercent = - _formatPercent(hadMaterialCount, materialLogs.length); + final hadMaterialCount = materialLogs.where((l) => l.hadMaterial).length; + final materialPercent = _formatPercent( + hadMaterialCount, + materialLogs.length, + ); - final hadHomeworkCount = - homeworkLogs.where((l) => l.hadHomework).length; - final homeworkPercent = - _formatPercent(hadHomeworkCount, homeworkLogs.length); + final hadHomeworkCount = homeworkLogs.where((l) => l.hadHomework).length; + final homeworkPercent = _formatPercent( + hadHomeworkCount, + homeworkLogs.length, + ); return Padding( padding: const EdgeInsets.symmetric( @@ -388,7 +393,8 @@ class _TimeframeStudentRow extends ConsumerWidget { runSpacing: AppSpacing.xSmall, children: [ for (final category in categories) - if (perCategoryAverages[category.id] case final v?) + if (perCategoryAverages[category.id] + case final v?) _CategoryChip( label: '${category.name}: ${gradeLabelForNumericValue(v, gradeScaleEntries)}', @@ -406,7 +412,9 @@ class _TimeframeStudentRow extends ConsumerWidget { Expanded( flex: 2, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xSmall), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xSmall, + ), child: OutlinedButton( onPressed: () => _pickFinalGrade(context, ref), style: OutlinedButton.styleFrom( @@ -415,10 +423,9 @@ class _TimeframeStudentRow extends ConsumerWidget { horizontal: AppSpacing.small, ), backgroundColor: finalGrade == null - ? Theme.of(context) - .colorScheme - .secondaryContainer - .withValues(alpha: 0.35) + ? Theme.of( + context, + ).colorScheme.secondaryContainer.withValues(alpha: 0.35) : null, ), child: Text( @@ -430,18 +437,9 @@ class _TimeframeStudentRow extends ConsumerWidget { ), ), ), - Expanded( - flex: 2, - child: _StatCell(value: attendancePercent), - ), - Expanded( - flex: 2, - child: _StatCell(value: materialPercent), - ), - Expanded( - flex: 2, - child: _StatCell(value: homeworkPercent), - ), + Expanded(flex: 2, child: _StatCell(value: attendancePercent)), + Expanded(flex: 2, child: _StatCell(value: materialPercent)), + Expanded(flex: 2, child: _StatCell(value: homeworkPercent)), Expanded( flex: 2, child: Center( @@ -477,8 +475,7 @@ class _TimeframeStudentRow extends ConsumerWidget { final repo = ref.read(timeframeGradeRepositoryProvider); if (result.value == null) { - final existing = - await repo.getGrade(timeframe.id, student.id); + final existing = await repo.getGrade(timeframe.id, student.id); if (existing != null) await repo.deleteGrade(existing.id); } else { await repo.saveGrade( diff --git a/lib/features/students/student_detail_screen.dart b/lib/features/students/student_detail_screen.dart index d5a04d4..fb64d6d 100644 --- a/lib/features/students/student_detail_screen.dart +++ b/lib/features/students/student_detail_screen.dart @@ -23,6 +23,7 @@ import '../../shared/theme/app_ui.dart'; import '../../shared/widgets/app_bar_title.dart'; import '../../shared/widgets/app_error_state.dart'; import '../../shared/widgets/confirm_dialog.dart'; +import '../../shared/widgets/content_constraints.dart'; import '../../shared/widgets/empty_state.dart'; import '../../shared/widgets/student_avatar.dart'; import '../../shared/widgets/swipe_action_background.dart'; @@ -64,9 +65,8 @@ final studentListItemsProvider = StreamProvider.autoDispose final studentAttendanceProvider = StreamProvider.autoDispose .family, int>( - (ref, studentId) => ref - .watch(attendanceRepositoryProvider) - .watchStudentLogs(studentId), + (ref, studentId) => + ref.watch(attendanceRepositoryProvider).watchStudentLogs(studentId), ); final availableGroupsProvider = FutureProvider.autoDispose>( @@ -90,7 +90,11 @@ class DateRangeFilter { }) : _preset = preset; /// No filter — show all data. - const DateRangeFilter.all() : start = null, end = null, _preset = null, timeframeId = null; + const DateRangeFilter.all() + : start = null, + end = null, + _preset = null, + timeframeId = null; /// One of the built-in quick filters. factory DateRangeFilter.fromPreset(QuickFilter preset) { @@ -163,10 +167,8 @@ class DateRangeFilter { enum QuickFilter { oneMonth, threeMonths, sixMonths, thisYear } -final studentDateFilterProvider = - StateProvider.autoDispose.family( - (ref, _) => const DateRangeFilter.all(), - ); +final studentDateFilterProvider = StateProvider.autoDispose + .family((ref, _) => const DateRangeFilter.all()); class StudentDetailScreen extends ConsumerWidget { const StudentDetailScreen({required this.studentId, super.key}); @@ -190,9 +192,7 @@ class StudentDetailScreen extends ConsumerWidget { final homeworkValue = ref.watch(studentHomeworkProvider(studentId)); final notesValue = ref.watch(studentNotesProvider(studentId)); final listItemsValue = ref.watch(studentListItemsProvider(studentId)); - final attendanceValue = ref.watch( - studentAttendanceProvider(studentId), - ); + final attendanceValue = ref.watch(studentAttendanceProvider(studentId)); final groupsValue = ref.watch(availableGroupsProvider); final allStudentsValue = ref.watch(availableStudentsProvider); final group = groupValue.value; @@ -202,29 +202,27 @@ class StudentDetailScreen extends ConsumerWidget { : onColorForBackground(groupColor); final attendanceLogs = attendanceValue.value ?? const []; final totalLogs = attendanceLogs.length; - final presentCount = - attendanceLogs.where((l) => !l.isAbsent).length; - final attendancePercent = - totalLogs > 0 ? (presentCount / totalLogs * 100).round() : null; + final presentCount = attendanceLogs.where((l) => !l.isAbsent).length; + final attendancePercent = totalLogs > 0 + ? (presentCount / totalLogs * 100).round() + : null; final attendanceSubtitle = attendancePercent != null ? '$attendancePercent% ${'present'.tr()}' : null; - final groupAveragesMap = ref.watch( - groupAveragesProvider(student.groupId), - ).value; + final groupAveragesMap = ref + .watch(groupAveragesProvider(student.groupId)) + .value; final classAverage = groupAveragesMap != null && groupAveragesMap.isNotEmpty - ? groupAveragesMap.values.reduce((a, b) => a + b) / - groupAveragesMap.length - : null; + ? groupAveragesMap.values.reduce((a, b) => a + b) / + groupAveragesMap.length + : null; final appBarSubtitle = [ group?.name, attendanceSubtitle, ].nonNulls.join(' · '); - final dateFilter = ref.watch( - studentDateFilterProvider(studentId), - ); + final dateFilter = ref.watch(studentDateFilterProvider(studentId)); final filteredGradesValue = gradesValue.whenData( (list) => list.where((g) => dateFilter.includes(g.date)).toList(), @@ -239,7 +237,8 @@ class StudentDetailScreen extends ConsumerWidget { (list) => list.where((a) => dateFilter.includes(a.date)).toList(), ); final filteredNotesValue = notesValue.whenData( - (list) => list.where((n) => dateFilter.includes(n.createdAt)).toList(), + (list) => + list.where((n) => dateFilter.includes(n.createdAt)).toList(), ); return DefaultTabController( @@ -276,9 +275,8 @@ class StudentDetailScreen extends ConsumerWidget { ), actions: [ IconButton( - onPressed: () => context.push( - '/students/${student.id}/summary', - ), + onPressed: () => + context.push('/students/${student.id}/summary'), icon: const Icon(Icons.summarize_outlined), tooltip: 'parent_summary'.tr(), ), @@ -303,39 +301,41 @@ class StudentDetailScreen extends ConsumerWidget { children: [ _DateFilterChips(studentId: studentId), Expanded( - child: TabBarView( - children: [ - _AttendanceTab( - studentId: student.id, - attendanceValue: filteredAttendanceValue, - ), - _GradesTab( - studentId: student.id, - gradesValue: filteredGradesValue, - gradeScaleJson: groupValue.value?.gradeScaleJson, - gradeCategoriesJson: - groupValue.value?.gradeCategoriesJson, - classAverage: classAverage, - ), - _MaterialTab( - studentId: student.id, - materialValue: filteredMaterialValue, - ), - _HomeworkTab( - studentId: student.id, - homeworkValue: filteredHomeworkValue, - ), - _StudentNotesTab( - studentId: student.id, - notesValue: filteredNotesValue, - groups: groupsValue.value ?? const [], - students: allStudentsValue.value ?? const [], - ), - _ListsTab( - studentId: student.id, - listItemsValue: listItemsValue, - ), - ], + child: ContentConstraints( + child: TabBarView( + children: [ + _AttendanceTab( + studentId: student.id, + attendanceValue: filteredAttendanceValue, + ), + _GradesTab( + studentId: student.id, + gradesValue: filteredGradesValue, + gradeScaleJson: groupValue.value?.gradeScaleJson, + gradeCategoriesJson: + groupValue.value?.gradeCategoriesJson, + classAverage: classAverage, + ), + _MaterialTab( + studentId: student.id, + materialValue: filteredMaterialValue, + ), + _HomeworkTab( + studentId: student.id, + homeworkValue: filteredHomeworkValue, + ), + _StudentNotesTab( + studentId: student.id, + notesValue: filteredNotesValue, + groups: groupsValue.value ?? const [], + students: allStudentsValue.value ?? const [], + ), + _ListsTab( + studentId: student.id, + listItemsValue: listItemsValue, + ), + ], + ), ), ), ], @@ -462,9 +462,7 @@ class _DateFilterChips extends ConsumerWidget { Padding( padding: const EdgeInsets.only(right: 8), child: FilterChip( - label: Text( - DateRangeFilter.fromPreset(preset).label(context), - ), + label: Text(DateRangeFilter.fromPreset(preset).label(context)), selected: current.activePreset == preset, onSelected: (_) => notifier.state = DateRangeFilter.fromPreset(preset), @@ -481,7 +479,8 @@ class _DateFilterChips extends ConsumerWidget { selected: current.timeframeId == timeframe.id, onSelected: (_) => notifier.state = DateRangeFilter.fromTimeframe(timeframe), - tooltip: '${formatShortDate(timeframe.startDate)} – ${formatShortDate(timeframe.endDate)}', + tooltip: + '${formatShortDate(timeframe.startDate)} – ${formatShortDate(timeframe.endDate)}', ), ), ], @@ -531,10 +530,7 @@ class _DateFilterChips extends ConsumerWidget { firstDate: DateTime(2000), lastDate: DateTime(now.year + 1), initialDateRange: current.start != null - ? DateTimeRange( - start: current.start!, - end: current.end ?? now, - ) + ? DateTimeRange(start: current.start!, end: current.end ?? now) : null, ); if (picked == null) return; @@ -570,8 +566,12 @@ class _GradesTabState extends ConsumerState<_GradesTab> { @override Widget build(BuildContext context) { final dateFilter = ref.watch(studentDateFilterProvider(widget.studentId)); - final timeframesValue = ref.watch(studentTimeframesProvider(widget.studentId)); - final timeframeGradesValue = ref.watch(studentTimeframeGradesProvider(widget.studentId)); + final timeframesValue = ref.watch( + studentTimeframesProvider(widget.studentId), + ); + final timeframeGradesValue = ref.watch( + studentTimeframeGradesProvider(widget.studentId), + ); return widget.gradesValue.when( data: (grades) { @@ -604,13 +604,15 @@ class _GradesTabState extends ConsumerState<_GradesTab> { loading: () => const [], error: (_, __) => const [], ); - + final timeframeMap = {for (final t in timeframes) t.id: t}; final joinedGrades = timeframeGrades .where((g) => timeframeMap.containsKey(g.timeframeId)) - .map((g) => (timeframe: timeframeMap[g.timeframeId]!, grade: g.grade)) + .map( + (g) => (timeframe: timeframeMap[g.timeframeId]!, grade: g.grade), + ) .toList(); - + final filteredTimeframeGrades = dateFilter.isAll ? joinedGrades : joinedGrades.where((tg) { @@ -620,9 +622,10 @@ class _GradesTabState extends ConsumerState<_GradesTab> { } final filterStart = dateFilter.start ?? DateTime(2000); final filterEnd = dateFilter.end ?? DateTime.now(); - return !(tf.endDate.isBefore(filterStart) || tf.startDate.isAfter(filterEnd)); + return !(tf.endDate.isBefore(filterStart) || + tf.startDate.isAfter(filterEnd)); }).toList(); - + // Add timeframe grades to chart data final numericGrades = <({GradeEntry grade, double value})>[]; for (final grade in chartGrades) { @@ -631,7 +634,7 @@ class _GradesTabState extends ConsumerState<_GradesTab> { numericGrades.add((grade: grade, value: parsed)); } } - + // Add timeframe grades as chart data points // Use the end date of the timeframe and a special category for (final tg in filteredTimeframeGrades) { @@ -652,7 +655,7 @@ class _GradesTabState extends ConsumerState<_GradesTab> { numericGrades.add((grade: syntheticGrade, value: parsed)); } } - + // Sort all grades by date for the chart numericGrades.sort((a, b) => a.grade.date.compareTo(b.grade.date)); final categorySums = {}; @@ -669,7 +672,10 @@ class _GradesTabState extends ConsumerState<_GradesTab> { final average = entry.value / categoryCounts[categoryId]!; categoryAverages.add((value: average, categoryId: categoryId)); } - final average = calculateWeightedAverage(categoryAverages, gradeCategories); + final average = calculateWeightedAverage( + categoryAverages, + gradeCategories, + ); return ListView( padding: const EdgeInsets.all(16), @@ -733,8 +739,8 @@ class _GradesTabState extends ConsumerState<_GradesTab> { style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), - ...filteredTimeframeGrades.map((tg) => - ListTile( + ...filteredTimeframeGrades.map( + (tg) => ListTile( contentPadding: EdgeInsets.zero, title: Text(tg.timeframe.label), subtitle: Text( @@ -744,7 +750,8 @@ class _GradesTabState extends ConsumerState<_GradesTab> { tg.grade, style: Theme.of(context).textTheme.titleMedium, ), - onTap: () => context.push('/groups/${tg.timeframe.groupId}'), + onTap: () => + context.push('/groups/${tg.timeframe.groupId}'), ), ), ], @@ -944,6 +951,8 @@ class _GradeTableHeader extends StatelessWidget { child: Text( 'grades'.tr(), style: Theme.of(context).textTheme.labelLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 12), @@ -961,6 +970,8 @@ class _GradeTableHeader extends StatelessWidget { child: Text( 'label'.tr(), style: Theme.of(context).textTheme.labelLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 12), @@ -1096,8 +1107,8 @@ class _GradeChartCard extends StatelessWidget { : average; final plottedClassAverage = classAverage != null ? (shouldInvert - ? _flipValue(minRaw, maxRaw, classAverage!) - : classAverage) + ? _flipValue(minRaw, maxRaw, classAverage!) + : classAverage) : null; final minPlot = [ ...plottedValues, @@ -2157,9 +2168,7 @@ class _AttendanceTab extends ConsumerWidget { confirmDismiss: (_) => showConfirmDialog( context: context, title: 'confirm_delete'.tr( - namedArgs: { - 'name': dateFormat.format(log.date), - }, + namedArgs: {'name': dateFormat.format(log.date)}, ), body: log.isAbsent ? (log.isExcused ? 'excused' : 'unexcused').tr() @@ -2167,19 +2176,13 @@ class _AttendanceTab extends ConsumerWidget { ), onDismissed: (_) => ref .read(attendanceRepositoryProvider) - .clearAbsence( - studentId: studentId, - date: log.date, - ), + .clearAbsence(studentId: studentId, date: log.date), child: _AttendanceLogTile( log: log, dateFormat: dateFormat, onDelete: () => ref .read(attendanceRepositoryProvider) - .clearAbsence( - studentId: studentId, - date: log.date, - ), + .clearAbsence(studentId: studentId, date: log.date), onToggleExcused: (excused) => ref .read(attendanceRepositoryProvider) .setExcused( @@ -2207,10 +2210,9 @@ class _AttendanceTab extends ConsumerWidget { ); if (selected == null) return; - await ref.read(attendanceRepositoryProvider).markAbsent( - studentId: studentId, - date: DateUtils.dateOnly(selected), - ); + await ref + .read(attendanceRepositoryProvider) + .markAbsent(studentId: studentId, date: DateUtils.dateOnly(selected)); } } @@ -2371,9 +2373,7 @@ class _AttendanceBarChart extends StatelessWidget { padding: const EdgeInsets.only(top: 8), child: Text( DateFormat.MMM( - Localizations.localeOf( - context, - ).toLanguageTag(), + Localizations.localeOf(context).toLanguageTag(), ).format(DateTime(month.$1, month.$2)), style: Theme.of(context).textTheme.labelSmall, ), diff --git a/lib/features/students/student_summary_screen.dart b/lib/features/students/student_summary_screen.dart index 66cecc3..0250832 100644 --- a/lib/features/students/student_summary_screen.dart +++ b/lib/features/students/student_summary_screen.dart @@ -11,6 +11,7 @@ import '../../shared/utils/formatting.dart'; import '../../shared/utils/grade_categories.dart'; import '../../shared/widgets/app_bar_title.dart'; import '../../shared/widgets/app_error_state.dart'; +import '../../shared/widgets/content_constraints.dart'; import '../../shared/widgets/student_avatar.dart'; /// A read-only overview of a student's performance for parent meetings. @@ -39,10 +40,10 @@ class StudentSummaryScreen extends ConsumerWidget { final dateFilter = ref.watch(studentDateFilterProvider(studentId)); final group = groupValue.value; - final groupColor = - group == null ? null : colorFromHex(group.colorHex); - final groupForeground = - groupColor == null ? null : onColorForBackground(groupColor); + final groupColor = group == null ? null : colorFromHex(group.colorHex); + final groupForeground = groupColor == null + ? null + : onColorForBackground(groupColor); final gradeScaleEntries = parseGradeScaleEntries( group?.gradeScaleJson ?? '', ); @@ -59,9 +60,7 @@ class StudentSummaryScreen extends ConsumerWidget { : allGrades.where((g) => dateFilter.includes(g.date)).toList(); final attendance = dateFilter.isAll ? allAttendance - : allAttendance - .where((a) => dateFilter.includes(a.date)) - .toList(); + : allAttendance.where((a) => dateFilter.includes(a.date)).toList(); final homework = dateFilter.isAll ? allHomework : allHomework.where((h) => dateFilter.includes(h.date)).toList(); @@ -70,9 +69,7 @@ class StudentSummaryScreen extends ConsumerWidget { : allMaterial.where((m) => dateFilter.includes(m.date)).toList(); final notes = dateFilter.isAll ? allNotes - : allNotes - .where((n) => dateFilter.includes(n.createdAt)) - .toList(); + : allNotes.where((n) => dateFilter.includes(n.createdAt)).toList(); // Compute category averages first, then calculate weighted average. final categoryAverages = _computeCategoryAverages( @@ -101,35 +98,34 @@ class StudentSummaryScreen extends ConsumerWidget { ), ), ), - body: ListView( - padding: appScreenPadding, - children: [ - _SummaryHeader( - student: student, - group: group, - dateFilter: dateFilter, - ), - const SizedBox(height: AppSpacing.large), - _SummaryAttendanceCard(attendance: attendance), - const SizedBox(height: AppSpacing.large), - _SummaryGradesCard( - studentAverage: studentAverage, - categoryAverages: categoryAverages, - categories: categories, - gradeScaleEntries: gradeScaleEntries, - totalGrades: grades.length, - ), - const SizedBox(height: AppSpacing.large), - _SummaryWorkHabitsCard( - homework: homework, - material: material, - ), - if (notes.isNotEmpty) ...[ + body: ContentConstraints( + child: ListView( + padding: appScreenPadding, + children: [ + _SummaryHeader( + student: student, + group: group, + dateFilter: dateFilter, + ), + const SizedBox(height: AppSpacing.large), + _SummaryAttendanceCard(attendance: attendance), const SizedBox(height: AppSpacing.large), - _SummaryNotesCard(notes: notes), + _SummaryGradesCard( + studentAverage: studentAverage, + categoryAverages: categoryAverages, + categories: categories, + gradeScaleEntries: gradeScaleEntries, + totalGrades: grades.length, + ), + const SizedBox(height: AppSpacing.large), + _SummaryWorkHabitsCard(homework: homework, material: material), + if (notes.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.large), + _SummaryNotesCard(notes: notes), + ], + const SizedBox(height: AppSpacing.xxLarge), ], - const SizedBox(height: AppSpacing.xxLarge), - ], + ), ), floatingActionButton: FloatingActionButton.extended( onPressed: () async { @@ -145,12 +141,14 @@ class StudentSummaryScreen extends ConsumerWidget { ), ); if (body == null || body.trim().isEmpty) return; - await ref.read(noteRepositoryProvider).saveNote( - body: body.trim(), - groupId: student.groupId, - studentIds: [studentId], - isTodo: false, - ); + await ref + .read(noteRepositoryProvider) + .saveNote( + body: body.trim(), + groupId: student.groupId, + studentIds: [studentId], + isTodo: false, + ); }, icon: const Icon(Icons.note_add_outlined), label: Text('add_note'.tr()), @@ -176,10 +174,7 @@ class StudentSummaryScreen extends ConsumerWidget { sums[g.categoryId] = (sums[g.categoryId] ?? 0) + v; counts[g.categoryId] = (counts[g.categoryId] ?? 0) + 1; } - return { - for (final e in sums.entries) - e.key: e.value / counts[e.key]!, - }; + return {for (final e in sums.entries) e.key: e.value / counts[e.key]!}; } } @@ -200,8 +195,9 @@ class _SummaryHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final today = DateFormat.yMMMMd(context.locale.toLanguageTag()) - .format(DateTime.now()); + final today = DateFormat.yMMMMd( + context.locale.toLanguageTag(), + ).format(DateTime.now()); return Row( children: [ StudentAvatar(student: student, size: 56), @@ -216,6 +212,8 @@ class _SummaryHeader extends StatelessWidget { lastName: student.lastName, ), style: Theme.of(context).textTheme.titleLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), if (group != null) Text( @@ -223,6 +221,8 @@ class _SummaryHeader extends StatelessWidget { style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), Text( today, @@ -377,10 +377,7 @@ class _SummaryGradesCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'grades'.tr(), - style: Theme.of(context).textTheme.titleMedium, - ), + Text('grades'.tr(), style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: AppSpacing.medium), if (studentAverage == null || totalGrades == 0) Text( @@ -488,8 +485,7 @@ class _SummaryWorkHabitsCard extends StatelessWidget { final matTotal = material.length; final matDone = material.where((m) => m.hadMaterial).length; - final matPercent = - matTotal > 0 ? (matDone / matTotal * 100).round() : null; + final matPercent = matTotal > 0 ? (matDone / matTotal * 100).round() : null; if (hwTotal == 0 && matTotal == 0) return const SizedBox.shrink(); @@ -608,10 +604,7 @@ class _SummaryNotesCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'notes'.tr(), - style: Theme.of(context).textTheme.titleMedium, - ), + Text('notes'.tr(), style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: AppSpacing.small), for (final note in recent) ...[ const Divider(), @@ -634,9 +627,7 @@ class _SummaryNotesCard extends StatelessWidget { Text( dateFormat.format(note.createdAt), style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], @@ -744,10 +735,7 @@ class _StatChip extends StatelessWidget { // --------------------------------------------------------------------------- class _QuickNoteDialog extends StatelessWidget { - const _QuickNoteDialog({ - required this.studentName, - required this.controller, - }); + const _QuickNoteDialog({required this.studentName, required this.controller}); final String studentName; final TextEditingController controller; From 616bfa95489dc1eda16f27c78f3484c44b12864f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 12:49:58 +0000 Subject: [PATCH 08/10] fix: prevent dropdown overflow with long labels Add isExpanded: true to DropdownButtonFormFields whose selected-item text can exceed the field width (grade scale, list scope, grade category, inactivity timeout) so the dropdown properly constrains to the available width instead of overflowing its RenderFlex. Also add maxLines/ellipsis to the underlying item labels so long group, category, or list names truncate instead of wrapping or overflowing. --- lib/features/groups/group_form.dart | 1 + lib/features/lists/list_editor.dart | 13 +++++++++++-- lib/features/sessions/session_form.dart | 9 ++++++++- lib/features/settings/settings_screen.dart | 3 +++ lib/features/setup/setup_screen.dart | 3 +++ 5 files changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/features/groups/group_form.dart b/lib/features/groups/group_form.dart index 3f7d15b..df1f059 100644 --- a/lib/features/groups/group_form.dart +++ b/lib/features/groups/group_form.dart @@ -127,6 +127,7 @@ class _GroupFormSheetState extends State<_GroupFormSheet> { const SizedBox(height: 16), DropdownButtonFormField( initialValue: _selectedGradeSystemId, + isExpanded: true, decoration: InputDecoration( labelText: 'grade_scale'.tr(), helperText: 'grade_scale_hint'.tr(), diff --git a/lib/features/lists/list_editor.dart b/lib/features/lists/list_editor.dart index 7d305c4..fbd38ca 100644 --- a/lib/features/lists/list_editor.dart +++ b/lib/features/lists/list_editor.dart @@ -84,16 +84,25 @@ class _ListEditorDialogState extends State<_ListEditorDialog> { if (widget.allowGroupSelection) DropdownButtonFormField( initialValue: _selectedGroupId, + isExpanded: true, decoration: InputDecoration(labelText: 'list_scope'.tr()), items: [ DropdownMenuItem( value: null, - child: Text('global_list'.tr()), + child: Text( + 'global_list'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), for (final group in widget.groups) DropdownMenuItem( value: group.id, - child: Text(group.name), + child: Text( + group.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), ], onChanged: (value) => setState(() { diff --git a/lib/features/sessions/session_form.dart b/lib/features/sessions/session_form.dart index ceee159..77d9a41 100644 --- a/lib/features/sessions/session_form.dart +++ b/lib/features/sessions/session_form.dart @@ -133,6 +133,7 @@ class _SessionFormSheetState extends State<_SessionFormSheet> { if (widget.gradeCategories.isNotEmpty) DropdownButtonFormField( initialValue: _selectedCategoryId, + isExpanded: true, decoration: InputDecoration( labelText: 'grade_category'.tr(), ), @@ -147,7 +148,13 @@ class _SessionFormSheetState extends State<_SessionFormSheet> { backgroundColor: colorForCategory(category), ), const SizedBox(width: 8), - Text(category.name), + Expanded( + child: Text( + category.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), ], ), ), diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 99e5c09..205a4f7 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -548,6 +548,7 @@ class _SecuritySection extends ConsumerWidget { (value) => value == session.inactivityTimeout, orElse: () => SecurityPreferencesService.defaultInactivityTimeout, ), + isExpanded: true, decoration: InputDecoration(labelText: 'inactivity_timeout'.tr()), items: [ for (final entry in _timeoutOptions.entries) @@ -557,6 +558,8 @@ class _SecuritySection extends ConsumerWidget { 'minutes_count'.tr( namedArgs: {'count': entry.key.toString()}, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), ], diff --git a/lib/features/setup/setup_screen.dart b/lib/features/setup/setup_screen.dart index 7a8380d..799d085 100644 --- a/lib/features/setup/setup_screen.dart +++ b/lib/features/setup/setup_screen.dart @@ -647,6 +647,7 @@ class _LockingStep extends StatelessWidget { const SizedBox(height: 12), DropdownButtonFormField( initialValue: inactivityTimeout, + isExpanded: true, decoration: InputDecoration(labelText: 'inactivity_timeout'.tr()), items: [ for (final entry in timeoutOptions.entries) @@ -656,6 +657,8 @@ class _LockingStep extends StatelessWidget { 'minutes_count'.tr( namedArgs: {'count': entry.key.toString()}, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), ], From 90d9b35ca9447004ac8f49bff8e95591f2650a15 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 13:00:33 +0000 Subject: [PATCH 09/10] chore: remove dead code and fix broken lesson repository test Delete grade_entry_screen.dart and group_tracking_screen.dart, both unreachable from any route and unreferenced anywhere else in the app. Fix the failing lesson_repository_test: watchGroupEntryCategories reads from sessions_table, but the test only ever wrote to grade_entries_table via GradeRepository.saveEntry, so the query returned nothing. The real app creates the corresponding session row via SessionRepository.upsertSession before entering grades (see lesson_mode_screen.dart); the test now does the same. --- lib/features/grades/grade_entry_screen.dart | 427 ------------------ .../tracking/group_tracking_screen.dart | 399 ---------------- test/lesson_repository_test.dart | 24 + 3 files changed, 24 insertions(+), 826 deletions(-) delete mode 100644 lib/features/grades/grade_entry_screen.dart delete mode 100644 lib/features/tracking/group_tracking_screen.dart diff --git a/lib/features/grades/grade_entry_screen.dart b/lib/features/grades/grade_entry_screen.dart deleted file mode 100644 index a1c9fd6..0000000 --- a/lib/features/grades/grade_entry_screen.dart +++ /dev/null @@ -1,427 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../core/database/app_database.dart'; -import '../../core/providers/app_providers.dart'; -import '../../shared/utils/grade_categories.dart'; -import '../../shared/utils/formatting.dart'; -import '../../shared/widgets/app_bar_title.dart'; -import '../../shared/widgets/app_error_state.dart'; -import '../../shared/widgets/student_avatar.dart'; -import 'grade_picker_dialog.dart'; - -final gradeEntryGroupProvider = StreamProvider.autoDispose.family( - (ref, groupId) => ref.watch(groupRepositoryProvider).watchGroup(groupId), -); - -final gradeEntryStudentsProvider = StreamProvider.autoDispose - .family, int>( - (ref, groupId) => ref - .watch(studentRepositoryProvider) - .watchByGroup( - groupId, - sortField: ref.watch(studentSortFieldProvider), - ), - ); - -final gradeSelectionsProvider = FutureProvider.autoDispose - .family, (int, DateTime, String, String)>( - (ref, args) => ref - .watch(gradeRepositoryProvider) - .getSessionSelections( - groupId: args.$1, - date: args.$2, - sessionLabel: args.$3, - categoryId: args.$4, - ), - ); - -class GradeEntryScreen extends ConsumerStatefulWidget { - const GradeEntryScreen({required this.groupId, super.key}); - - final int groupId; - - @override - ConsumerState createState() => _GradeEntryScreenState(); -} - -class _GradeEntryScreenState extends ConsumerState { - static const String _noGradeSelectionValue = '__classi_no_grade__'; - - late final TextEditingController _sessionController; - DateTime _selectedDate = DateUtils.dateOnly(DateTime.now()); - String? _selectedCategoryId; - - @override - void initState() { - super.initState(); - _sessionController = TextEditingController(); - } - - @override - void dispose() { - _sessionController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final sortField = ref.watch(studentSortFieldProvider); - final groupValue = ref.watch(gradeEntryGroupProvider(widget.groupId)); - final studentsValue = ref.watch(gradeEntryStudentsProvider(widget.groupId)); - - return groupValue.when( - data: (group) { - if (group == null) { - return const Scaffold(body: SizedBox.shrink()); - } - - final gradeScaleEntries = parseGradeScaleEntries(group.gradeScaleJson); - final gradeScale = [for (final entry in gradeScaleEntries) entry.label]; - final gradeCategories = parseGradeCategories(group.gradeCategoriesJson); - _selectedCategoryId ??= gradeCategories.first.id; - final sessionLabel = _sessionController.text.trim(); - final selectionsValue = ref.watch( - gradeSelectionsProvider(( - widget.groupId, - _selectedDate, - sessionLabel, - _selectedCategoryId ?? defaultGradeCategoryId, - )), - ); - final selectedCategory = gradeCategories.firstWhere( - (category) => category.id == _selectedCategoryId, - orElse: () => gradeCategories.first, - ); - - return Scaffold( - appBar: AppBar( - backgroundColor: colorFromHex(group.colorHex), - foregroundColor: onColorForBackground(colorFromHex(group.colorHex)), - title: AppBarTitle(title: 'grade_entry'.tr(), subtitle: group.name), - ), - body: studentsValue.when( - data: (students) { - final selections = selectionsValue.value ?? const {}; - final done = selections.length; - - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - controller: _sessionController, - decoration: InputDecoration( - labelText: 'session_label'.tr(), - ), - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: _selectedCategoryId, - items: [ - for (final category in gradeCategories) - DropdownMenuItem( - value: category.id, - child: Text( - '${category.name} (${formatNumber(category.weight)})', - ), - ), - ], - onChanged: (value) => - setState(() => _selectedCategoryId = value), - decoration: InputDecoration( - labelText: 'grade_category'.tr(), - ), - ), - const SizedBox(height: 16), - ListTile( - contentPadding: EdgeInsets.zero, - title: Text('date'.tr()), - subtitle: Text( - MaterialLocalizations.of( - context, - ).formatMediumDate(_selectedDate), - ), - trailing: IconButton( - onPressed: _pickDate, - icon: const Icon(Icons.calendar_today), - ), - ), - const SizedBox(height: 8), - LinearProgressIndicator( - value: students.isEmpty - ? 0 - : done / students.length, - ), - const SizedBox(height: 8), - Text( - 'progress'.tr( - namedArgs: { - 'done': done.toString(), - 'total': students.length.toString(), - }, - ), - ), - ], - ), - ), - ), - const SizedBox(height: 16), - Card( - clipBehavior: Clip.antiAlias, - child: Table( - columnWidths: const { - 0: FlexColumnWidth(3.6), - 1: FlexColumnWidth(2.1), - }, - defaultVerticalAlignment: - TableCellVerticalAlignment.middle, - children: [ - TableRow( - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), - children: [ - _GradeHeaderCell(label: 'name'.tr()), - _GradeHeaderCell(label: 'grade'.tr()), - ], - ), - for (final student in students) - TableRow( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - child: Row( - children: [ - StudentAvatar(student: student, size: 28), - const SizedBox(width: 12), - Expanded( - child: Text( - studentDisplayName( - firstName: student.firstName, - lastName: student.lastName, - sortField: sortField, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.titleSmall, - ), - ), - ], - ), - ), - _GradeSelectionCell( - key: ValueKey( - '${student.id}:${selections[student.id] ?? _noGradeSelectionValue}', - ), - value: - selections[student.id] ?? - _noGradeSelectionValue, - gradeScale: gradeScale, - noGradeValue: _noGradeSelectionValue, - onTap: () => _pickGrade( - studentId: student.id, - category: selectedCategory, - currentValue: - selections[student.id] ?? - _noGradeSelectionValue, - gradeScale: gradeScale, - ), - ), - ], - ), - ], - ), - ), - ], - ); - }, - error: (error, _) => const AppErrorState(), - loading: () => const Center(child: CircularProgressIndicator()), - ), - ); - }, - error: (error, _) => const AppErrorScaffold(), - loading: () => - const Scaffold(body: Center(child: CircularProgressIndicator())), - ); - } - - Future _pickDate() async { - final selected = await showDatePicker( - context: context, - initialDate: _selectedDate, - firstDate: DateTime(2020), - lastDate: DateTime(2100), - ); - - if (selected != null) { - setState(() => _selectedDate = DateUtils.dateOnly(selected)); - } - } - - Future _saveGrade({ - required int studentId, - required GradeCategory category, - required String value, - }) async { - final sessionLabel = _sessionController.text.trim(); - await ref - .read(gradeRepositoryProvider) - .saveEntry( - studentId: studentId, - date: _selectedDate, - sessionLabel: sessionLabel, - value: value, - categoryId: category.id, - categoryName: category.name, - ); - ref.invalidate( - gradeSelectionsProvider(( - widget.groupId, - _selectedDate, - sessionLabel, - category.id, - )), - ); - } - - Future _setGradeValue({ - required int studentId, - required GradeCategory category, - required String? value, - }) { - if (value == null || value == _noGradeSelectionValue) { - return _clearGrade(studentId: studentId, category: category); - } - return _saveGrade(studentId: studentId, category: category, value: value); - } - - Future _pickGrade({ - required int studentId, - required GradeCategory category, - required String currentValue, - required List gradeScale, - }) async { - final result = await showGradePickerDialog( - context: context, - gradeScale: gradeScale, - initialValue: currentValue == _noGradeSelectionValue - ? null - : currentValue, - ); - if (result == null || !result.confirmed) { - return; - } - - await _setGradeValue( - studentId: studentId, - category: category, - value: result.value ?? _noGradeSelectionValue, - ); - } - - Future _clearGrade({ - required int studentId, - required GradeCategory category, - }) async { - final sessionLabel = _sessionController.text.trim(); - await ref - .read(gradeRepositoryProvider) - .clearSessionSelection( - studentId: studentId, - date: _selectedDate, - sessionLabel: sessionLabel, - categoryId: category.id, - ); - ref.invalidate( - gradeSelectionsProvider(( - widget.groupId, - _selectedDate, - sessionLabel, - category.id, - )), - ); - } -} - -class _GradeHeaderCell extends StatelessWidget { - const _GradeHeaderCell({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Text( - label, - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700), - ), - ); - } -} - -class _GradeSelectionCell extends StatelessWidget { - const _GradeSelectionCell({ - required this.value, - required this.gradeScale, - required this.noGradeValue, - required this.onTap, - super.key, - }); - - final String value; - final List gradeScale; - final String noGradeValue; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final hasGrade = value != noGradeValue; - return Padding( - padding: const EdgeInsets.all(12), - child: OutlinedButton( - onPressed: onTap, - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - alignment: Alignment.centerLeft, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Row( - children: [ - Expanded( - child: Text( - hasGrade ? value : 'no_grade'.tr(), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - Icon( - Icons.arrow_drop_down, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/tracking/group_tracking_screen.dart b/lib/features/tracking/group_tracking_screen.dart deleted file mode 100644 index d462191..0000000 --- a/lib/features/tracking/group_tracking_screen.dart +++ /dev/null @@ -1,399 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../core/database/app_database.dart'; -import '../../core/providers/app_providers.dart'; -import '../../shared/utils/grade_categories.dart'; -import '../../shared/utils/formatting.dart'; -import '../../shared/widgets/app_bar_title.dart'; -import '../../shared/widgets/app_error_state.dart'; -import '../../shared/widgets/student_avatar.dart'; - -final groupTrackingGroupProvider = StreamProvider.autoDispose - .family( - (ref, groupId) => ref.watch(groupRepositoryProvider).watchGroup(groupId), - ); - -final groupTrackingStudentsProvider = StreamProvider.autoDispose - .family, int>( - (ref, groupId) => ref - .watch(studentRepositoryProvider) - .watchByGroup( - groupId, - sortField: ref.watch(studentSortFieldProvider), - ), - ); - -final groupMaterialSelectionsProvider = StreamProvider.autoDispose - .family, (int, DateTime)>( - (ref, args) => ref - .watch(materialRepositoryProvider) - .watchGroupSelections(groupId: args.$1, date: args.$2), - ); - -final groupHomeworkSelectionsProvider = StreamProvider.autoDispose - .family, (int, DateTime)>( - (ref, args) => ref - .watch(homeworkRepositoryProvider) - .watchGroupSelections(groupId: args.$1, date: args.$2), - ); - -final groupAbsenceSelectionsProvider = StreamProvider.autoDispose - .family, (int, DateTime)>( - (ref, args) => ref - .watch(attendanceRepositoryProvider) - .watchGroupSelections(groupId: args.$1, date: args.$2), - ); - -class GroupTrackingScreen extends ConsumerStatefulWidget { - const GroupTrackingScreen({required this.groupId, super.key}); - - final int groupId; - - @override - ConsumerState createState() => - _GroupTrackingScreenState(); -} - -class _GroupTrackingScreenState extends ConsumerState { - DateTime _selectedDate = DateUtils.dateOnly(DateTime.now()); - - @override - Widget build(BuildContext context) { - final sortField = ref.watch(studentSortFieldProvider); - final groupValue = ref.watch(groupTrackingGroupProvider(widget.groupId)); - final studentsValue = ref.watch( - groupTrackingStudentsProvider(widget.groupId), - ); - final materialSelectionsValue = ref.watch( - groupMaterialSelectionsProvider((widget.groupId, _selectedDate)), - ); - final homeworkSelectionsValue = ref.watch( - groupHomeworkSelectionsProvider((widget.groupId, _selectedDate)), - ); - final absenceSelectionsValue = ref.watch( - groupAbsenceSelectionsProvider((widget.groupId, _selectedDate)), - ); - - return groupValue.when( - data: (group) { - if (group == null) { - return const Scaffold(body: SizedBox.shrink()); - } - - return Scaffold( - appBar: AppBar( - backgroundColor: colorFromHex(group.colorHex), - foregroundColor: onColorForBackground(colorFromHex(group.colorHex)), - title: AppBarTitle( - title: 'batch_tracking'.tr(), - subtitle: group.name, - ), - ), - body: studentsValue.when( - data: (students) { - final materialSelections = - materialSelectionsValue.value ?? const {}; - final homeworkSelections = - homeworkSelectionsValue.value ?? const {}; - final absentStudents = - absenceSelectionsValue.value ?? const {}; - - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ListTile( - contentPadding: EdgeInsets.zero, - title: Text('date'.tr()), - subtitle: Text( - MaterialLocalizations.of( - context, - ).formatMediumDate(_selectedDate), - ), - trailing: IconButton( - onPressed: _pickDate, - icon: const Icon(Icons.calendar_today_outlined), - ), - ), - const SizedBox(height: 8), - Text( - 'batch_tracking_hint'.tr(), - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - ), - const SizedBox(height: 16), - Card( - clipBehavior: Clip.antiAlias, - child: Table( - columnWidths: const { - 0: FlexColumnWidth(3.4), - 1: FlexColumnWidth(1.2), - 2: FlexColumnWidth(1.6), - 3: FlexColumnWidth(1.2), - }, - defaultVerticalAlignment: - TableCellVerticalAlignment.middle, - children: [ - TableRow( - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), - children: [ - _TrackingHeaderCell(label: 'name'.tr()), - _TrackingHeaderCell(label: 'material'.tr()), - _TrackingHeaderCell(label: 'homework'.tr()), - _TrackingHeaderCell(label: 'absent'.tr()), - ], - ), - for (final student in students) - TableRow( - decoration: BoxDecoration( - color: absentStudents.contains(student.id) - ? Theme.of(context).colorScheme.errorContainer - .withValues(alpha: 0.24) - : null, - ), - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - child: Row( - children: [ - StudentAvatar(student: student, size: 28), - const SizedBox(width: 12), - Expanded( - child: Text( - studentDisplayName( - firstName: student.firstName, - lastName: student.lastName, - sortField: sortField, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.titleSmall, - ), - ), - ], - ), - ), - _TrackingCheckboxCell( - value: materialSelections[student.id], - enabled: !absentStudents.contains(student.id), - onChanged: (value) => _setMaterialValue( - studentId: student.id, - value: value, - ), - ), - _TrackingCheckboxCell( - value: homeworkSelections[student.id], - enabled: !absentStudents.contains(student.id), - onChanged: (value) => _setHomeworkValue( - studentId: student.id, - value: value, - ), - ), - _TrackingBinaryCheckboxCell( - value: absentStudents.contains(student.id), - onChanged: (value) => _setAbsent( - studentId: student.id, - absent: value, - ), - ), - ], - ), - ], - ), - ), - ], - ); - }, - error: (error, _) => const AppErrorState(), - loading: () => const Center(child: CircularProgressIndicator()), - ), - ); - }, - error: (error, _) => const AppErrorScaffold(), - loading: () => - const Scaffold(body: Center(child: CircularProgressIndicator())), - ); - } - - Future _pickDate() async { - final selected = await showDatePicker( - context: context, - initialDate: _selectedDate, - firstDate: DateTime(2020), - lastDate: DateTime(2100), - ); - if (selected == null) { - return; - } - - setState(() => _selectedDate = DateUtils.dateOnly(selected)); - } - - Future _setMaterialValue({ - required int studentId, - required bool? value, - }) async { - await ref - .read(attendanceRepositoryProvider) - .clearAbsence(studentId: studentId, date: _selectedDate); - if (value == null) { - await _clearMaterial(studentId); - return; - } - - await _saveMaterial(studentId: studentId, hadMaterial: value); - } - - Future _setHomeworkValue({ - required int studentId, - required bool? value, - }) async { - await ref - .read(attendanceRepositoryProvider) - .clearAbsence(studentId: studentId, date: _selectedDate); - if (value == null) { - await _clearHomework(studentId); - return; - } - - await _saveHomework(studentId: studentId, hadHomework: value); - } - - Future _setAbsent({required int studentId, required bool absent}) { - if (absent) { - return ref - .read(attendanceRepositoryProvider) - .markAbsent(studentId: studentId, date: _selectedDate); - } - - return ref - .read(attendanceRepositoryProvider) - .clearAbsence(studentId: studentId, date: _selectedDate); - } - - Future _saveMaterial({ - required int studentId, - required bool hadMaterial, - }) { - return ref - .read(materialRepositoryProvider) - .saveLog( - studentId: studentId, - date: _selectedDate, - hadMaterial: hadMaterial, - ); - } - - Future _clearMaterial(int studentId) { - return ref - .read(materialRepositoryProvider) - .clearLog(studentId: studentId, date: _selectedDate); - } - - Future _saveHomework({ - required int studentId, - required bool hadHomework, - }) { - return ref - .read(homeworkRepositoryProvider) - .saveLog( - studentId: studentId, - date: _selectedDate, - hadHomework: hadHomework, - ); - } - - Future _clearHomework(int studentId) { - return ref - .read(homeworkRepositoryProvider) - .clearLog(studentId: studentId, date: _selectedDate); - } -} - -class _TrackingHeaderCell extends StatelessWidget { - const _TrackingHeaderCell({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Text( - label, - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700), - ), - ); - } -} - -class _TrackingCheckboxCell extends StatelessWidget { - const _TrackingCheckboxCell({ - required this.value, - required this.enabled, - required this.onChanged, - }); - - final bool? value; - final bool enabled; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return Center( - child: Checkbox( - tristate: true, - value: value, - onChanged: enabled ? (_) => onChanged(_nextValue(value)) : null, - ), - ); - } - - bool? _nextValue(bool? currentValue) { - if (currentValue == null) { - return true; - } - if (currentValue) { - return false; - } - return null; - } -} - -class _TrackingBinaryCheckboxCell extends StatelessWidget { - const _TrackingBinaryCheckboxCell({ - required this.value, - required this.onChanged, - }); - - final bool value; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return Center( - child: Checkbox(value: value, onChanged: (value) => onChanged(value!)), - ); - } -} diff --git a/test/lesson_repository_test.dart b/test/lesson_repository_test.dart index 00ea1d4..1d035f7 100644 --- a/test/lesson_repository_test.dart +++ b/test/lesson_repository_test.dart @@ -6,6 +6,7 @@ import 'package:classi/features/homework/homework_repository.dart'; import 'package:classi/features/lessons/lesson_repository.dart'; import 'package:classi/features/material_tracking/material_repository.dart'; import 'package:classi/features/notes/note_repository.dart'; +import 'package:classi/features/sessions/session_repository.dart'; import 'package:classi/features/students/student_repository.dart'; import 'package:classi/shared/utils/formatting.dart'; import 'package:drift/native.dart'; @@ -21,6 +22,7 @@ void main() { late AttendanceRepository attendanceRepository; late NoteRepository noteRepository; late LessonRepository lessonRepository; + late SessionRepository sessionRepository; setUp(() { database = AppDatabase.test(NativeDatabase.memory()); @@ -32,6 +34,7 @@ void main() { attendanceRepository = AttendanceRepository(database); noteRepository = NoteRepository(database); lessonRepository = LessonRepository(database); + sessionRepository = SessionRepository(database); }); tearDown(() async { @@ -65,6 +68,27 @@ void main() { date: DateTime(2026, 5, 7), hadMaterial: true, ); + await sessionRepository.upsertSession( + groupId: groupId, + date: DateTime(2026, 5, 8), + categoryId: 'oral', + categoryName: 'Oral', + label: 'Mitarbeit', + ); + await sessionRepository.upsertSession( + groupId: groupId, + date: DateTime(2026, 5, 8), + categoryId: 'quiz', + categoryName: 'Quiz', + label: 'Quiz', + ); + await sessionRepository.upsertSession( + groupId: groupId, + date: DateTime(2026, 5, 9), + categoryId: 'oral', + categoryName: 'Oral', + label: 'Mitarbeit', + ); await gradeRepository.saveEntry( studentId: studentId, date: DateTime(2026, 5, 8), From 629766f90a676d01338fb1e21ed4113d23469bb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 15:04:49 +0000 Subject: [PATCH 10/10] fix: silence unnecessary_underscores lint in student_detail_screen Replace (_, __) error-callback parameters with (_, _) using Dart's wildcard pattern support, matching the codebase's lint rules. --- lib/features/students/student_detail_screen.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/features/students/student_detail_screen.dart b/lib/features/students/student_detail_screen.dart index fb64d6d..e613e7b 100644 --- a/lib/features/students/student_detail_screen.dart +++ b/lib/features/students/student_detail_screen.dart @@ -494,7 +494,7 @@ class _DateFilterChips extends ConsumerWidget { ), ), ], - error: (_, __) => [], + error: (_, _) => [], ), // Custom range Padding( @@ -597,12 +597,12 @@ class _GradesTabState extends ConsumerState<_GradesTab> { final timeframeGrades = timeframeGradesValue.when( data: (tfGrades) => tfGrades, loading: () => const [], - error: (_, __) => const [], + error: (_, _) => const [], ); final timeframes = timeframesValue.when( data: (tfs) => tfs, loading: () => const [], - error: (_, __) => const [], + error: (_, _) => const [], ); final timeframeMap = {for (final t in timeframes) t.id: t};