diff --git a/assets/translations/de.json b/assets/translations/de.json index 018da9c..14e6ca2 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -42,6 +42,8 @@ "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_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.", @@ -98,6 +100,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", @@ -131,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.", @@ -223,6 +228,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..397d224 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -42,6 +42,8 @@ "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_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.", @@ -98,6 +100,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", @@ -131,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.", @@ -223,6 +228,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 9b4d5c8..392efff 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; @@ -13,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 } @@ -50,12 +52,16 @@ class AppSessionController extends ChangeNotifier { required LibraryBackupPreferencesService libraryBackupPreferencesService, 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; + _biometricService = biometricService, + _deviceIdentityService = deviceIdentityService ?? DeviceIdentityService(), + _periodicExportInterval = periodicExportInterval; final KeyService _keyService; final DatabasePathService _databasePathService; @@ -63,6 +69,7 @@ class AppSessionController extends ChangeNotifier { final LibraryBackupPreferencesService _libraryBackupPreferencesService; final LibraryBackupService _libraryBackupService; final BiometricService _biometricService; + final DeviceIdentityService _deviceIdentityService; AppDatabase? _database; String? _databasePath; @@ -70,12 +77,27 @@ class AppSessionController extends ChangeNotifier { AppSessionErrorCode? _errorCode; DateTime? _openedAt; bool _isBusy = false; + bool _disposed = false; bool _lockOnBackground = true; int _backgroundLockSuspendCount = 0; bool _biometricEnabled = false; 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; @@ -86,8 +108,10 @@ class AppSessionController extends ChangeNotifier { int _webDavMaxVersions = LibraryBackupPreferencesService.defaultMaxVersions; bool _pendingWebDavImport = false; DateTime? _pendingImportRemoteModifiedAt; + String? _pendingImportDeviceName; DateTime? _lastExportedAt; DateTime? _lastImportedAt; + String? _lastKnownRevision; bool _isExporting = false; WebDavSyncStatus _webDavSyncStatus = WebDavSyncStatus.notConfigured; Future? _pendingLockCleanup; @@ -116,8 +140,17 @@ 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; + + /// 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; @@ -223,6 +256,7 @@ class AppSessionController extends ChangeNotifier { } _status = AppSessionStatus.ready; _resetInactivityTimer(); + _startPeriodicExportTimerIfNeeded(); return true; } catch (error, stackTrace) { await _handleFatalError( @@ -299,6 +333,7 @@ class AppSessionController extends ChangeNotifier { if (_status == AppSessionStatus.ready) { await refreshIfChanged(); _resetInactivityTimer(); + _startPeriodicExportTimerIfNeeded(); } } @@ -344,6 +379,7 @@ class AppSessionController extends ChangeNotifier { return; } _resetInactivityTimer(); + _startPeriodicExportTimerIfNeeded(); } Future refreshIfChanged() async { @@ -582,6 +618,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(); } @@ -615,6 +656,11 @@ class AppSessionController extends ChangeNotifier { Future setWebDavAutoExportEnabled(bool value) async { _webDavAutoExportEnabled = value; await _libraryBackupPreferencesService.setAutoExportEnabled(value); + if (value) { + _startPeriodicExportTimerIfNeeded(); + } else { + _cancelPeriodicExportTimer(); + } notifyListeners(); } @@ -631,6 +677,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(); @@ -799,6 +858,7 @@ class AppSessionController extends ChangeNotifier { _pendingRecoveryKey = null; _status = AppSessionStatus.ready; _resetInactivityTimer(); + _startPeriodicExportTimerIfNeeded(); return true; } catch (error, stackTrace) { await _handleFatalError( @@ -838,6 +898,7 @@ class AppSessionController extends ChangeNotifier { AppDatabase? _detachDatabase() { _cancelInactivityTimer(); + _cancelPeriodicExportTimer(); final database = _database; _database = null; _currentPassphrase = null; @@ -896,6 +957,8 @@ class AppSessionController extends ChangeNotifier { _webDavMaxVersions = await _libraryBackupPreferencesService.maxVersions(); _lastExportedAt = await _libraryBackupPreferencesService.lastExportedAt(); _lastImportedAt = await _libraryBackupPreferencesService.lastImportedAt(); + _lastKnownRevision = await _libraryBackupPreferencesService + .lastKnownRevision(); } Future _resolveCurrentDatabaseState() async { @@ -923,6 +986,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 +999,9 @@ class AppSessionController extends ChangeNotifier { await _prepareDatabaseSnapshot( closeDatabaseAfterSnapshot: closeDatabaseAfterSnapshot, ); - await _runAutoExportIfConfigured(); + if (runAutoExport) { + await _runAutoExportIfConfigured(); + } await _securityPreferencesService.setSessionDirty(!markSessionClean); await _updatePendingAutoImportAvailability(); } @@ -978,6 +1044,7 @@ class AppSessionController extends ChangeNotifier { ); return; } + if (_disposed) return; _isExporting = true; notifyListeners(); @@ -986,7 +1053,7 @@ class AppSessionController extends ChangeNotifier { await _updatePendingAutoImportAvailability(); } finally { _isExporting = false; - notifyListeners(); + if (!_disposed) notifyListeners(); } } @@ -1014,6 +1081,9 @@ class AppSessionController extends ChangeNotifier { sourceDatabasePath: currentDatabasePath, serverPath: _webDavServerPath ?? '/', maxVersions: _webDavMaxVersions, + deviceId: await _deviceIdentityService.getOrCreateDeviceId(), + deviceName: await _deviceIdentityService.deviceName(), + parentRevision: _lastKnownRevision, ); _lastExportedAt = exportedAt; await _libraryBackupPreferencesService.setLastExportedAt(exportedAt); @@ -1032,8 +1102,38 @@ 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}', + name: 'classi.backup', + ); + _setBackupMessage('backup_export_busy', isError: true); } catch (error, stackTrace) { _logUnexpectedError( operation: 'run auto WebDAV export', @@ -1049,12 +1149,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; } @@ -1065,6 +1167,7 @@ class AppSessionController extends ChangeNotifier { _webDavSyncStatus = WebDavSyncStatus.notConfigured; _pendingWebDavImport = false; _pendingImportRemoteModifiedAt = null; + _pendingImportDeviceName = null; return; } @@ -1085,6 +1188,7 @@ class AppSessionController extends ChangeNotifier { _webDavSyncStatus = WebDavSyncStatus.current; _pendingWebDavImport = false; _pendingImportRemoteModifiedAt = null; + _pendingImportDeviceName = null; return; } @@ -1095,6 +1199,7 @@ class AppSessionController extends ChangeNotifier { _webDavSyncStatus = WebDavSyncStatus.current; _pendingWebDavImport = false; _pendingImportRemoteModifiedAt = null; + _pendingImportDeviceName = null; return; } @@ -1138,6 +1243,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', @@ -1147,6 +1264,7 @@ class AppSessionController extends ChangeNotifier { _webDavSyncStatus = WebDavSyncStatus.offline; _pendingWebDavImport = false; _pendingImportRemoteModifiedAt = null; + _pendingImportDeviceName = null; } } @@ -1172,7 +1290,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.'); @@ -1185,15 +1318,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(); @@ -1280,6 +1421,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; @@ -1287,7 +1452,9 @@ class AppSessionController extends ChangeNotifier { @override void dispose() { + _disposed = true; _cancelInactivityTimer(); + _cancelPeriodicExportTimer(); unawaited(_closeDatabase()); super.dispose(); } 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 c4437c5..6054e80 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,48 @@ 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'; +} + +/// 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({ @@ -21,6 +64,8 @@ class WebDavBackupEntry { required this.remotePath, this.modifiedAt, this.sizeBytes, + this.deviceId, + this.deviceName, }); final String fileName; @@ -28,6 +73,30 @@ 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; +} + +/// 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, + 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 { @@ -39,6 +108,10 @@ class LibraryBackupService { Future buildBackupArchive( String sourceDatabasePath, { DateTime? exportedAt, + String? deviceId, + String? deviceName, + String? revision, + String? parentRevision, }) async { final normalizedSourcePath = p.normalize(sourceDatabasePath); developer.log( @@ -53,6 +126,10 @@ class LibraryBackupService { 'formatVersion': _backupFormatVersion, 'libraryName': _libraryNameForPath(normalizedSourcePath), 'exportedAt': (exportedAt ?? DateTime.now().toUtc()).toIso8601String(), + 'deviceId': ?deviceId, + 'deviceName': ?deviceName, + 'revision': ?revision, + 'parentRevision': ?parentRevision, }), ), ); @@ -83,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 { @@ -141,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 @@ -149,7 +234,21 @@ 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. + /// + /// [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 + /// sync lock. /// /// Returns the UTC [DateTime] embedded as `exportedAt` in the manifest. Future exportBackupToWebDav({ @@ -157,47 +256,139 @@ class LibraryBackupService { required String sourceDatabasePath, required String serverPath, int maxVersions = 3, + String? deviceId, + String? deviceName, + String? parentRevision, }) async { - final exportedAt = DateTime.now().toUtc(); - final bytes = await buildBackupArchive( - sourceDatabasePath, - exportedAt: exportedAt, - ); - 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); - // 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); + 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, + ); + } - // Prune old archived versions. - await _pruneArchivedVersions( - client: client, - serverPath: serverPath, - canonicalName: canonicalName, - maxVersions: maxVersions, - ); + final exportedAt = DateTime.now().toUtc(); + final revision = _randomToken(); + final bytes = await buildBackupArchive( + sourceDatabasePath, + exportedAt: exportedAt, + deviceId: deviceId, + deviceName: deviceName, + revision: revision, + parentRevision: parentRevision, + ); + 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, + revision: revision, + parentRevision: parentRevision, + ); + + // Archive the current canonical backup before replacing it. + await _archiveExistingBackup( + client: client, + serverPath: serverPath, + canonicalName: canonicalName, + canonicalPath: canonicalPath, + ); - developer.log('exportBackupToWebDav: done', name: 'classi.backup'); - return exportedAt; + // 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; + } finally { + await _syncLockRelease( + client: client, + serverPath: serverPath, + ownerId: lockOwnerId, + ); + } } Future _archiveExistingBackup({ @@ -210,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); @@ -219,6 +410,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 +462,11 @@ class LibraryBackupService { try { await client.remove(_joinServerPath(serverPath, name)); } catch (_) {} + try { + await client.remove( + _metaSidecarPath(_joinServerPath(serverPath, name)), + ); + } catch (_) {} } } } catch (_) { @@ -298,6 +499,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 +517,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 +530,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 +606,187 @@ class LibraryBackupService { static String _libraryNameForPath(String path) => p.basenameWithoutExtension(p.normalize(path)); + static String _metaSidecarPath(String backupPath) => '$backupPath.meta.json'; + + /// 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( + jsonEncode({ + 'deviceId': ?deviceId, + 'deviceName': ?deviceName, + 'exportedAt': exportedAt.toIso8601String(), + 'revision': ?revision, + 'parentRevision': ?parentRevision, + }), + ); + 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?, + revision: metaJson['revision'] as String?, + ); + } catch (_) { + return const WebDavBackupDeviceInfo(); + } + } + + // --- 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() => _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, + (_) => random.nextInt(256), + ).map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); + } + 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/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/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/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/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/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 1b1b424..205a4f7 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'; @@ -546,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) @@ -555,6 +558,8 @@ class _SecuritySection extends ConsumerWidget { 'minutes_count'.tr( namedArgs: {'count': entry.key.toString()}, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), ], @@ -665,6 +670,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 +689,7 @@ class _BackupsSectionState extends ConsumerState<_BackupsSection> { if (widget.session.isWebDavConfigured) { WidgetsBinding.instance.addPostFrameCallback((_) => _loadBackups()); } + unawaited(_loadDeviceName()); } @override @@ -691,9 +698,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 +716,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 +834,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 +1147,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..e797d65 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: { + '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: { + 'device': ?deviceName, + 'datetime': DateFormat.yMd(localeTag) + .add_Hm() + .format(remoteModifiedAt.toLocal()), + }, + ), ), if (lastExportedAt != null) Padding( 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, ), ), ], 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/lib/features/students/student_detail_screen.dart b/lib/features/students/student_detail_screen.dart index d5a04d4..e613e7b 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)}', ), ), ], @@ -495,7 +494,7 @@ class _DateFilterChips extends ConsumerWidget { ), ), ], - error: (_, __) => [], + error: (_, _) => [], ), // Custom range Padding( @@ -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) { @@ -597,20 +597,22 @@ 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}; 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; 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/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 b8b35ee..d63a36e 100644 --- a/test/app_session_controller_test.dart +++ b/test/app_session_controller_test.dart @@ -314,6 +314,124 @@ 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', + () 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'); @@ -423,6 +541,238 @@ 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'); + }, + ); + + 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 { + 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 { @@ -478,6 +828,9 @@ class _DelayingLibraryBackupService extends LibraryBackupService { required String sourceDatabasePath, required String serverPath, int maxVersions = 3, + String? deviceId, + String? deviceName, + String? parentRevision, }) async { if (!started.isCompleted) { started.complete(); @@ -485,6 +838,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 { @@ -526,6 +894,37 @@ 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, + String? deviceId, + String? deviceName, + String? parentRevision, + }) 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, @@ -549,6 +948,7 @@ class _WebDavAppSessionController extends AppSessionController { required super.libraryBackupPreferencesService, required super.libraryBackupService, required super.biometricService, + super.periodicExportInterval, }); @override @@ -571,6 +971,9 @@ class _ExportingLibraryBackupService extends LibraryBackupService { required String sourceDatabasePath, required String serverPath, int maxVersions = 3, + String? deviceId, + String? deviceName, + String? parentRevision, }) async { return exportedAt; } @@ -583,4 +986,102 @@ 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; + int exportCount = 0; + + @override + Future exportBackupToWebDav({ + required webdav.Client client, + required String sourceDatabasePath, + required String serverPath, + int maxVersions = 3, + String? deviceId, + String? deviceName, + String? parentRevision, + }) async { + lastDeviceId = deviceId; + lastDeviceName = deviceName; + lastParentRevision = parentRevision; + exportCount++; + return DateTime.now().toUtc(); + } + + // 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 { + _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, + ); + } +} + +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/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/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), 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 58293dc..b18c564 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,68 @@ 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( + '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 { @@ -72,4 +136,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, + ); + }); }