diff --git a/assets/translations/de.json b/assets/translations/de.json index 14e6ca2..0e89a54 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -375,5 +375,20 @@ "filter_custom": "Eigener Zeitraum", "parent_summary": "Elterngespräch", "work_habits": "Arbeitsverhalten", - "empty_grades": "Noch keine Noten erfasst" + "empty_grades": "Noch keine Noten erfasst", + "lesson_not_started": "Noch nicht begonnen", + "start_lesson": "Stunde starten", + "continue_lesson": "Stunde fortsetzen", + "resolve_conflict": "Konflikt lösen", + "backup_conflict_detected": "Sicherungskonflikt", + "backup_conflict_detected_hint": "Ein anderes Gerät hat Änderungen an dieser Bibliothek gesichert, die diesem Gerät noch nicht bekannt sind.", + "backup_conflict_explanation": "Ein anderes Gerät hat nach der letzten Synchronisierung dieses Geräts Änderungen an dieser Bibliothek hochgeladen. Es wird nichts gelöscht — die gewählte Version wird zur aktiven Bibliothek, die andere bleibt in der Sicherungsliste verfügbar.", + "this_device_version": "Version dieses Geräts", + "server_version": "Version auf dem Server", + "keep_this_device_version": "Version dieses Geräts behalten", + "use_server_version": "Version auf dem Server verwenden", + "conflict_resolved": "Konflikt gelöst.", + "unknown_device": "Unbekanntes Gerät", + "call_name": "Rufname", + "call_name_hint": "Falls abweichend vom Vornamen" } diff --git a/assets/translations/en.json b/assets/translations/en.json index 397d224..0d90290 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -375,5 +375,20 @@ "filter_custom": "Custom range", "parent_summary": "Parent meeting", "work_habits": "Work habits", - "empty_grades": "No grades recorded yet" + "empty_grades": "No grades recorded yet", + "lesson_not_started": "Not started yet", + "start_lesson": "Start lesson", + "continue_lesson": "Continue lesson", + "resolve_conflict": "Resolve conflict", + "backup_conflict_detected": "Backup conflict", + "backup_conflict_detected_hint": "Another device backed up changes to this library that this device hasn't synced yet. Resolve it to keep backups in sync.", + "backup_conflict_explanation": "Another device uploaded changes to this library after this device's last sync. Nothing has been deleted — pick which version becomes your active library. The version you don't choose stays available in your backup list.", + "this_device_version": "This device's version", + "server_version": "Server version", + "keep_this_device_version": "Keep this device's version", + "use_server_version": "Use the server version", + "conflict_resolved": "Conflict resolved.", + "unknown_device": "Unknown device", + "call_name": "Call name", + "call_name_hint": "If different from the first name" } diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index a10b2e5..27ab601 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -67,7 +67,7 @@ class AppDatabase extends _$AppDatabase { final String databasePath; @override - int get schemaVersion => 21; + int get schemaVersion => 22; @override MigrationStrategy get migration => MigrationStrategy( @@ -197,6 +197,9 @@ class AppDatabase extends _$AppDatabase { if (from < 21) { await migrator.createTable(timeframeGradesTable); } + if (from < 22) { + await migrator.addColumn(studentsTable, studentsTable.callName); + } }, ); diff --git a/lib/core/database/app_database.g.dart b/lib/core/database/app_database.g.dart index 38f0f74..6785dd8 100644 --- a/lib/core/database/app_database.g.dart +++ b/lib/core/database/app_database.g.dart @@ -520,6 +520,17 @@ class $StudentsTableTable extends StudentsTable type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _callNameMeta = const VerificationMeta( + 'callName', + ); + @override + late final GeneratedColumn callName = GeneratedColumn( + 'call_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _groupIdMeta = const VerificationMeta( 'groupId', ); @@ -584,6 +595,7 @@ class $StudentsTableTable extends StudentsTable id, firstName, lastName, + callName, groupId, originNote, createdAt, @@ -621,6 +633,12 @@ class $StudentsTableTable extends StudentsTable } else if (isInserting) { context.missing(_lastNameMeta); } + if (data.containsKey('call_name')) { + context.handle( + _callNameMeta, + callName.isAcceptableOrUnknown(data['call_name']!, _callNameMeta), + ); + } if (data.containsKey('group_id')) { context.handle( _groupIdMeta, @@ -674,6 +692,10 @@ class $StudentsTableTable extends StudentsTable DriftSqlType.string, data['${effectivePrefix}last_name'], )!, + callName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}call_name'], + ), groupId: attachedDatabase.typeMapping.read( DriftSqlType.int, data['${effectivePrefix}group_id'], @@ -708,6 +730,11 @@ class StudentsTableData extends DataClass final int id; final String firstName; final String lastName; + + /// Informal name a teacher actually calls the student by (German + /// "Rufname"), shown instead of [firstName] wherever the surname is + /// still displayed alongside it. `null` means "same as firstName". + final String? callName; final int groupId; final String? originNote; final DateTime createdAt; @@ -717,6 +744,7 @@ class StudentsTableData extends DataClass required this.id, required this.firstName, required this.lastName, + this.callName, required this.groupId, this.originNote, required this.createdAt, @@ -729,6 +757,9 @@ class StudentsTableData extends DataClass map['id'] = Variable(id); map['first_name'] = Variable(firstName); map['last_name'] = Variable(lastName); + if (!nullToAbsent || callName != null) { + map['call_name'] = Variable(callName); + } map['group_id'] = Variable(groupId); if (!nullToAbsent || originNote != null) { map['origin_note'] = Variable(originNote); @@ -748,6 +779,9 @@ class StudentsTableData extends DataClass id: Value(id), firstName: Value(firstName), lastName: Value(lastName), + callName: callName == null && nullToAbsent + ? const Value.absent() + : Value(callName), groupId: Value(groupId), originNote: originNote == null && nullToAbsent ? const Value.absent() @@ -771,6 +805,7 @@ class StudentsTableData extends DataClass id: serializer.fromJson(json['id']), firstName: serializer.fromJson(json['firstName']), lastName: serializer.fromJson(json['lastName']), + callName: serializer.fromJson(json['callName']), groupId: serializer.fromJson(json['groupId']), originNote: serializer.fromJson(json['originNote']), createdAt: serializer.fromJson(json['createdAt']), @@ -785,6 +820,7 @@ class StudentsTableData extends DataClass 'id': serializer.toJson(id), 'firstName': serializer.toJson(firstName), 'lastName': serializer.toJson(lastName), + 'callName': serializer.toJson(callName), 'groupId': serializer.toJson(groupId), 'originNote': serializer.toJson(originNote), 'createdAt': serializer.toJson(createdAt), @@ -797,6 +833,7 @@ class StudentsTableData extends DataClass int? id, String? firstName, String? lastName, + Value callName = const Value.absent(), int? groupId, Value originNote = const Value.absent(), DateTime? createdAt, @@ -806,6 +843,7 @@ class StudentsTableData extends DataClass id: id ?? this.id, firstName: firstName ?? this.firstName, lastName: lastName ?? this.lastName, + callName: callName.present ? callName.value : this.callName, groupId: groupId ?? this.groupId, originNote: originNote.present ? originNote.value : this.originNote, createdAt: createdAt ?? this.createdAt, @@ -817,6 +855,7 @@ class StudentsTableData extends DataClass id: data.id.present ? data.id.value : this.id, firstName: data.firstName.present ? data.firstName.value : this.firstName, lastName: data.lastName.present ? data.lastName.value : this.lastName, + callName: data.callName.present ? data.callName.value : this.callName, groupId: data.groupId.present ? data.groupId.value : this.groupId, originNote: data.originNote.present ? data.originNote.value @@ -835,6 +874,7 @@ class StudentsTableData extends DataClass ..write('id: $id, ') ..write('firstName: $firstName, ') ..write('lastName: $lastName, ') + ..write('callName: $callName, ') ..write('groupId: $groupId, ') ..write('originNote: $originNote, ') ..write('createdAt: $createdAt, ') @@ -849,6 +889,7 @@ class StudentsTableData extends DataClass id, firstName, lastName, + callName, groupId, originNote, createdAt, @@ -862,6 +903,7 @@ class StudentsTableData extends DataClass other.id == this.id && other.firstName == this.firstName && other.lastName == this.lastName && + other.callName == this.callName && other.groupId == this.groupId && other.originNote == this.originNote && other.createdAt == this.createdAt && @@ -873,6 +915,7 @@ class StudentsTableCompanion extends UpdateCompanion { final Value id; final Value firstName; final Value lastName; + final Value callName; final Value groupId; final Value originNote; final Value createdAt; @@ -882,6 +925,7 @@ class StudentsTableCompanion extends UpdateCompanion { this.id = const Value.absent(), this.firstName = const Value.absent(), this.lastName = const Value.absent(), + this.callName = const Value.absent(), this.groupId = const Value.absent(), this.originNote = const Value.absent(), this.createdAt = const Value.absent(), @@ -892,6 +936,7 @@ class StudentsTableCompanion extends UpdateCompanion { this.id = const Value.absent(), required String firstName, required String lastName, + this.callName = const Value.absent(), required int groupId, this.originNote = const Value.absent(), this.createdAt = const Value.absent(), @@ -904,6 +949,7 @@ class StudentsTableCompanion extends UpdateCompanion { Expression? id, Expression? firstName, Expression? lastName, + Expression? callName, Expression? groupId, Expression? originNote, Expression? createdAt, @@ -914,6 +960,7 @@ class StudentsTableCompanion extends UpdateCompanion { if (id != null) 'id': id, if (firstName != null) 'first_name': firstName, if (lastName != null) 'last_name': lastName, + if (callName != null) 'call_name': callName, if (groupId != null) 'group_id': groupId, if (originNote != null) 'origin_note': originNote, if (createdAt != null) 'created_at': createdAt, @@ -926,6 +973,7 @@ class StudentsTableCompanion extends UpdateCompanion { Value? id, Value? firstName, Value? lastName, + Value? callName, Value? groupId, Value? originNote, Value? createdAt, @@ -936,6 +984,7 @@ class StudentsTableCompanion extends UpdateCompanion { id: id ?? this.id, firstName: firstName ?? this.firstName, lastName: lastName ?? this.lastName, + callName: callName ?? this.callName, groupId: groupId ?? this.groupId, originNote: originNote ?? this.originNote, createdAt: createdAt ?? this.createdAt, @@ -956,6 +1005,9 @@ class StudentsTableCompanion extends UpdateCompanion { if (lastName.present) { map['last_name'] = Variable(lastName.value); } + if (callName.present) { + map['call_name'] = Variable(callName.value); + } if (groupId.present) { map['group_id'] = Variable(groupId.value); } @@ -980,6 +1032,7 @@ class StudentsTableCompanion extends UpdateCompanion { ..write('id: $id, ') ..write('firstName: $firstName, ') ..write('lastName: $lastName, ') + ..write('callName: $callName, ') ..write('groupId: $groupId, ') ..write('originNote: $originNote, ') ..write('createdAt: $createdAt, ') @@ -7171,6 +7224,7 @@ typedef $$StudentsTableTableCreateCompanionBuilder = Value id, required String firstName, required String lastName, + Value callName, required int groupId, Value originNote, Value createdAt, @@ -7182,6 +7236,7 @@ typedef $$StudentsTableTableUpdateCompanionBuilder = Value id, Value firstName, Value lastName, + Value callName, Value groupId, Value originNote, Value createdAt, @@ -7448,6 +7503,11 @@ class $$StudentsTableTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get callName => $composableBuilder( + column: $table.callName, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get originNote => $composableBuilder( column: $table.originNote, builder: (column) => ColumnFilters(column), @@ -7718,6 +7778,11 @@ class $$StudentsTableTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get callName => $composableBuilder( + column: $table.callName, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get originNote => $composableBuilder( column: $table.originNote, builder: (column) => ColumnOrderings(column), @@ -7780,6 +7845,9 @@ class $$StudentsTableTableAnnotationComposer GeneratedColumn get lastName => $composableBuilder(column: $table.lastName, builder: (column) => column); + GeneratedColumn get callName => + $composableBuilder(column: $table.callName, builder: (column) => column); + GeneratedColumn get originNote => $composableBuilder( column: $table.originNote, builder: (column) => column, @@ -8068,6 +8136,7 @@ class $$StudentsTableTableTableManager Value id = const Value.absent(), Value firstName = const Value.absent(), Value lastName = const Value.absent(), + Value callName = const Value.absent(), Value groupId = const Value.absent(), Value originNote = const Value.absent(), Value createdAt = const Value.absent(), @@ -8077,6 +8146,7 @@ class $$StudentsTableTableTableManager id: id, firstName: firstName, lastName: lastName, + callName: callName, groupId: groupId, originNote: originNote, createdAt: createdAt, @@ -8088,6 +8158,7 @@ class $$StudentsTableTableTableManager Value id = const Value.absent(), required String firstName, required String lastName, + Value callName = const Value.absent(), required int groupId, Value originNote = const Value.absent(), Value createdAt = const Value.absent(), @@ -8097,6 +8168,7 @@ class $$StudentsTableTableTableManager id: id, firstName: firstName, lastName: lastName, + callName: callName, groupId: groupId, originNote: originNote, createdAt: createdAt, diff --git a/lib/core/database/tables/students_table.dart b/lib/core/database/tables/students_table.dart index 045744c..a98418b 100644 --- a/lib/core/database/tables/students_table.dart +++ b/lib/core/database/tables/students_table.dart @@ -9,6 +9,11 @@ class StudentsTable extends Table { TextColumn get lastName => text().withLength(min: 1, max: 100)(); + /// Informal name a teacher actually calls the student by (German + /// "Rufname"), shown instead of [firstName] wherever the surname is + /// still displayed alongside it. `null` means "same as firstName". + TextColumn get callName => text().nullable()(); + IntColumn get groupId => integer().references(GroupsTable, #id, onDelete: KeyAction.cascade)(); diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index fefb8ab..95de6b6 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -22,6 +22,7 @@ import '../../features/settings/student_sort_controller.dart'; import '../../features/settings/theme_controller.dart'; import '../../features/students/student_repository.dart'; import '../../features/students/student_sorting.dart'; +import '../../features/today/today_repository.dart'; import '../database/app_database.dart'; import '../security/biometric_service.dart'; import '../security/key_service.dart'; @@ -200,6 +201,15 @@ final groupExportServiceProvider = Provider( (ref) => GroupExportService(ref.watch(databaseProvider)), ); +final todayRepositoryProvider = Provider( + (ref) => TodayRepository(ref.watch(databaseProvider)), +); + +final todayOverviewProvider = StreamProvider.autoDispose.family< + List, DateTime>( + (ref, date) => ref.watch(todayRepositoryProvider).watchTodayOverview(date), +); + final timeframeRepositoryProvider = Provider( (ref) => TimeframeRepository(ref.watch(databaseProvider)), ); diff --git a/lib/core/session/app_session_controller.dart b/lib/core/session/app_session_controller.dart index 392efff..e05c8d0 100644 --- a/lib/core/session/app_session_controller.dart +++ b/lib/core/session/app_session_controller.dart @@ -735,6 +735,60 @@ class AppSessionController extends ChangeNotifier { } } + /// Resolves a sync conflict by keeping this device's current local content + /// and overwriting the canonical backup with it. + /// + /// [canonicalRevision] is the revision token currently on the server (from + /// the canonical [WebDavBackupEntry] the conflict was detected against). + /// Adopting it as this device's "last known revision" tells the next + /// export it has now acknowledged that remote state and intends to + /// supersede it, so the export proceeds instead of raising another + /// conflict. The conflict copy itself is left on the server untouched — + /// nothing is deleted by resolving this way. + /// + /// Adopting the remote revision is only safe for the duration of the + /// export it enables: if that upload does not land, the adopted revision + /// must not survive, or the *next* auto-export would silently overwrite + /// the server copy without ever surfacing the conflict again. So it is + /// rolled back unless the export actually succeeds. + /// + /// Returns a translation key on error or `null` on success. + Future keepThisDeviceVersionAfterConflict({ + required String? canonicalRevision, + }) async { + if (_isBusy || _isExporting) return 'database_busy'; + if (_database == null || _currentPassphrase == null) { + return 'database_busy'; + } + if (!isWebDavConfigured) return 'webdav_not_configured'; + + final previousRevision = _lastKnownRevision; + final previousExportedAt = _lastExportedAt; + + _lastKnownRevision = canonicalRevision; + await _libraryBackupPreferencesService.setLastKnownRevision( + canonicalRevision, + ); + + final errorCode = await exportNow(); + + // exportNow reports upload failures through the backup status message + // rather than its return value, so a null return is not proof the + // export landed. _lastExportedAt only advances on a completed upload, + // which makes it the authoritative signal here. + final exported = + _lastExportedAt != null && _lastExportedAt != previousExportedAt; + if (errorCode != null || !exported) { + _lastKnownRevision = previousRevision; + await _libraryBackupPreferencesService.setLastKnownRevision( + previousRevision, + ); + return errorCode ?? _lastBackupMessageCode ?? 'backup_export_failed'; + } + + return null; + } + /// Returns `true` if the WebDAV connection test succeeded. Future testWebDavConnection({ String? url, diff --git a/lib/core/storage/library_backup_service.dart b/lib/core/storage/library_backup_service.dart index 6054e80..631cb0e 100644 --- a/lib/core/storage/library_backup_service.dart +++ b/lib/core/storage/library_backup_service.dart @@ -66,6 +66,7 @@ class WebDavBackupEntry { this.sizeBytes, this.deviceId, this.deviceName, + this.revision, }); final String fileName; @@ -79,6 +80,18 @@ class WebDavBackupEntry { /// metadata file could not be read. final String? deviceId; final String? deviceName; + + /// This backup's own revision token from its sidecar, if known. Used to + /// reconcile a sync conflict (see [WebDavSyncConflictException]) by + /// telling the exporter which remote revision it is now superseding. + final String? revision; + + /// Whether this is a `_CONFLICT_` copy uploaded when a device's export + /// found the canonical backup had moved on to a revision it never saw. + bool get isConflict => + LibraryBackupService._conflictTimestampPattern.hasMatch( + p.basenameWithoutExtension(fileName), + ); } /// Metadata read from a backup's `.meta.json` sidecar: which device @@ -556,6 +569,7 @@ class LibraryBackupService { sizeBytes: candidates[index].sizeBytes, deviceId: deviceInfos[index].deviceId, deviceName: deviceInfos[index].deviceName, + revision: deviceInfos[index].revision, ), ]; @@ -588,11 +602,26 @@ class LibraryBackupService { static String backupFileNameForDatabasePath(String databasePath) => '${_libraryNameForPath(databasePath)}$classiBackupExtension'; + /// Matches the `_CONFLICT_` suffix appended to the canonical + /// library name for conflict copies, e.g. `MyClass_CONFLICT_20260507T080000Z`. + static final RegExp _conflictTimestampPattern = RegExp( + r'_CONFLICT_\d{8}T\d{6}Z$', + ); + + /// Matches the `_` suffix appended to the canonical library name + /// for archived copies, e.g. `MyClass_20260506T143200Z`. + static final RegExp _archivedTimestampPattern = RegExp(r'_\d{8}T\d{6}Z$'); + static String libraryNameForBackupFile(String backupFilePath) { final stem = _libraryNameForPath(backupFilePath); - // Archived files look like "name_20260506T143200Z". Strip the timestamp. - final timestampPattern = RegExp(r'_\d{8}T\d{6}Z$'); - return stem.replaceFirst(timestampPattern, ''); + final withoutConflictSuffix = stem.replaceFirst( + _conflictTimestampPattern, + '', + ); + if (withoutConflictSuffix != stem) { + return withoutConflictSuffix; + } + return stem.replaceFirst(_archivedTimestampPattern, ''); } static bool isBackupFilePath(String path) => diff --git a/lib/features/avatar/avatar_editor_sheet.dart b/lib/features/avatar/avatar_editor_sheet.dart index 6733b0f..8f1fd14 100644 --- a/lib/features/avatar/avatar_editor_sheet.dart +++ b/lib/features/avatar/avatar_editor_sheet.dart @@ -96,6 +96,7 @@ class _AvatarEditorSheetState extends ConsumerState<_AvatarEditorSheet> { studentDisplayName( firstName: widget.student.firstName, lastName: widget.student.lastName, + callName: widget.student.callName, sortField: sortField, ), style: Theme.of(context).textTheme.titleLarge, diff --git a/lib/features/groups/group_detail_screen.dart b/lib/features/groups/group_detail_screen.dart index f39e9dc..c76c761 100644 --- a/lib/features/groups/group_detail_screen.dart +++ b/lib/features/groups/group_detail_screen.dart @@ -502,6 +502,7 @@ class GroupDetailScreen extends ConsumerWidget { groupId: groupId, firstName: result.firstName, lastName: result.lastName, + callName: result.callName, originNote: result.originNote, avatarJson: result.avatarJson, ); @@ -2378,6 +2379,7 @@ class _StudentsSectionState extends ConsumerState<_StudentsSection> { studentDisplayName( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, sortField: widget.sortField, ), ), diff --git a/lib/features/groups/group_repository.dart b/lib/features/groups/group_repository.dart index 6893909..1b47e5d 100644 --- a/lib/features/groups/group_repository.dart +++ b/lib/features/groups/group_repository.dart @@ -142,6 +142,7 @@ class GroupRepository { firstName: student.firstName, lastName: student.lastName, groupId: clonedGroupId, + callName: Value(student.callName), originNote: Value(student.originNote), avatarJson: Value(student.avatarJson), ), diff --git a/lib/features/groups/timeframe_grades_screen.dart b/lib/features/groups/timeframe_grades_screen.dart index 144e9c2..d017aab 100644 --- a/lib/features/groups/timeframe_grades_screen.dart +++ b/lib/features/groups/timeframe_grades_screen.dart @@ -380,6 +380,7 @@ class _TimeframeStudentRow extends ConsumerWidget { studentDisplayName( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, sortField: sortField, ), maxLines: 2, @@ -494,6 +495,7 @@ class _TimeframeStudentRow extends ConsumerWidget { studentDisplayName( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, ), ), content: ConstrainedBox( diff --git a/lib/features/lessons/lesson_mode_screen.dart b/lib/features/lessons/lesson_mode_screen.dart index 8884223..dc5f342 100644 --- a/lib/features/lessons/lesson_mode_screen.dart +++ b/lib/features/lessons/lesson_mode_screen.dart @@ -514,6 +514,7 @@ class _LessonModeScreenState extends ConsumerState { studentDisplayName( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, sortField: sortField, ), ), @@ -700,6 +701,7 @@ class _LessonModeScreenState extends ConsumerState { final name = studentDisplayName( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, sortField: sortField, ); final body = await _showQuickNoteDialog( diff --git a/lib/features/lessons/lesson_sections.dart b/lib/features/lessons/lesson_sections.dart index 29f0501..72f01f6 100644 --- a/lib/features/lessons/lesson_sections.dart +++ b/lib/features/lessons/lesson_sections.dart @@ -302,7 +302,7 @@ class LessonStudentNotesSheet extends ConsumerWidget { children: [ Expanded( child: Text( - '${studentDisplayName(firstName: student.firstName, lastName: student.lastName, sortField: sortField)} · ${'notes'.tr()}', + '${studentDisplayName(firstName: student.firstName, lastName: student.lastName, callName: student.callName, sortField: sortField)} · ${'notes'.tr()}', style: Theme.of(context).textTheme.titleLarge, ), ), @@ -379,7 +379,9 @@ class _SummaryChip extends StatelessWidget { children: [ Icon(icon, size: 18, color: colorScheme.onSurfaceVariant), const SizedBox(width: AppSpacing.small), - Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + Flexible( + child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + ), if (onTap != null) ...[ const SizedBox(width: AppSpacing.xSmall), Icon( @@ -599,6 +601,7 @@ class _LessonNameCell extends ConsumerWidget { studentDisplayName( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, sortField: sortField, ), maxLines: 2, diff --git a/lib/features/lists/list_detail_screen.dart b/lib/features/lists/list_detail_screen.dart index 94503e3..60a703f 100644 --- a/lib/features/lists/list_detail_screen.dart +++ b/lib/features/lists/list_detail_screen.dart @@ -350,6 +350,7 @@ class _ChecklistItemTile extends ConsumerWidget { : studentDisplayName( firstName: singleLinkedStudent.firstName, lastName: singleLinkedStudent.lastName, + callName: singleLinkedStudent.callName, sortField: sortField, ); final displayLabel = @@ -358,6 +359,7 @@ class _ChecklistItemTile extends ConsumerWidget { value: item.label, firstName: singleLinkedStudent.firstName, lastName: singleLinkedStudent.lastName, + callName: singleLinkedStudent.callName, ) ? singleLinkedStudentName! : item.label; diff --git a/lib/features/lists/list_item_editor.dart b/lib/features/lists/list_item_editor.dart index 321dbc8..67d6785 100644 --- a/lib/features/lists/list_item_editor.dart +++ b/lib/features/lists/list_item_editor.dart @@ -137,6 +137,7 @@ class _ListItemEditorSheetState extends ConsumerState<_ListItemEditorSheet> { studentDisplayName( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, sortField: sortField, ), ), diff --git a/lib/features/lists/list_repository.dart b/lib/features/lists/list_repository.dart index b40c394..2ec3ec2 100644 --- a/lib/features/lists/list_repository.dart +++ b/lib/features/lists/list_repository.dart @@ -309,6 +309,7 @@ class ListRepository { label: studentDisplayName( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, sortField: sortField, ), ), diff --git a/lib/features/notes/note_editor.dart b/lib/features/notes/note_editor.dart index 70faad3..09363b5 100644 --- a/lib/features/notes/note_editor.dart +++ b/lib/features/notes/note_editor.dart @@ -181,6 +181,7 @@ class _NoteEditorSheetState extends ConsumerState<_NoteEditorSheet> { studentDisplayName( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, sortField: sortField, ), ), @@ -422,6 +423,7 @@ class _StudentSelectionSheetState studentDisplayName( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, sortField: sortField, ), ), @@ -484,6 +486,7 @@ class _StudentSelectionSheetState studentDisplayName( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, sortField: sortField, ), ), diff --git a/lib/features/seating_plan/lesson_seating_view.dart b/lib/features/seating_plan/lesson_seating_view.dart index f7dd8d2..3578bdf 100644 --- a/lib/features/seating_plan/lesson_seating_view.dart +++ b/lib/features/seating_plan/lesson_seating_view.dart @@ -490,6 +490,7 @@ class _StudentActionSheetState extends ConsumerState<_StudentActionSheet> { studentDisplayName( firstName: widget.student.firstName, lastName: widget.student.lastName, + callName: widget.student.callName, sortField: sortField, ), style: Theme.of(context).textTheme.titleLarge, diff --git a/lib/features/seating_plan/seating_plan_chip.dart b/lib/features/seating_plan/seating_plan_chip.dart index 7d739fe..9f402f9 100644 --- a/lib/features/seating_plan/seating_plan_chip.dart +++ b/lib/features/seating_plan/seating_plan_chip.dart @@ -37,6 +37,7 @@ class SeatingPlanChip extends ConsumerWidget { final name = studentDisplayName( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, sortField: sortField, ); diff --git a/lib/features/settings/backup_conflict_screen.dart b/lib/features/settings/backup_conflict_screen.dart new file mode 100644 index 0000000..159b3bf --- /dev/null +++ b/lib/features/settings/backup_conflict_screen.dart @@ -0,0 +1,244 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/providers/app_providers.dart'; +import '../../core/storage/library_backup_service.dart'; +import '../../shared/theme/app_ui.dart'; +import '../../shared/widgets/confirm_dialog.dart'; +import '../../shared/widgets/content_constraints.dart'; + +/// Guided resolution for a WebDAV sync conflict: shown when this device's +/// export found the canonical backup had moved on to a revision it never +/// saw, so it uploaded its own changes as a separate `_CONFLICT_` copy +/// instead of overwriting the newer backup (see [WebDavSyncConflictException]). +/// +/// Both versions are shown side by side with their device and time so the +/// teacher can tell which one is theirs and pick which becomes the active +/// library. Nothing is deleted by resolving here — the version not chosen +/// stays available in "Available backups" afterward. +class BackupConflictScreen extends ConsumerStatefulWidget { + const BackupConflictScreen({ + required this.canonical, + required this.conflict, + super.key, + }); + + /// The current backup on the server that this device's export conflicted + /// with. + final WebDavBackupEntry canonical; + + /// The `_CONFLICT_` copy this device uploaded of its own local content. + final WebDavBackupEntry conflict; + + @override + ConsumerState createState() => + _BackupConflictScreenState(); +} + +class _BackupConflictScreenState extends ConsumerState { + bool _resolving = false; + + @override + Widget build(BuildContext context) { + final localeTag = Localizations.localeOf(context).toLanguageTag(); + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar(title: Text('resolve_conflict'.tr())), + body: ContentConstraints( + child: ListView( + padding: appScreenPadding, + children: [ + Card( + color: colorScheme.errorContainer, + child: Padding( + padding: appCardPadding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.warning_amber_outlined, + color: colorScheme.onErrorContainer, + ), + const SizedBox(width: AppSpacing.medium), + Expanded( + child: Text( + 'backup_conflict_explanation'.tr(), + style: TextStyle(color: colorScheme.onErrorContainer), + ), + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.large), + _ConflictOptionCard( + icon: Icons.smartphone_outlined, + title: 'this_device_version'.tr(), + entry: widget.conflict, + localeTag: localeTag, + actionLabel: 'keep_this_device_version'.tr(), + busy: _resolving, + onSelect: _keepThisDevice, + ), + const SizedBox(height: AppSpacing.medium), + _ConflictOptionCard( + icon: Icons.cloud_outlined, + title: 'server_version'.tr(), + entry: widget.canonical, + localeTag: localeTag, + actionLabel: 'use_server_version'.tr(), + busy: _resolving, + onSelect: _useServerVersion, + ), + ], + ), + ), + ); + } + + Future _keepThisDevice() async { + setState(() => _resolving = true); + try { + final errorCode = await ref + .read(appSessionProvider) + .keepThisDeviceVersionAfterConflict( + canonicalRevision: widget.canonical.revision, + ); + if (!mounted) return; + if (errorCode == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('conflict_resolved'.tr()))); + Navigator.of(context).pop(true); + } else { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(errorCode.tr()))); + } + } finally { + if (mounted) setState(() => _resolving = false); + } + } + + Future _useServerVersion() async { + final confirmed = await showConfirmDialog( + context: context, + title: 'use_server_version'.tr(), + body: 'restore_backup_overwrite_warning'.tr(), + confirmKey: 'restore_backup', + ); + if (!confirmed || !mounted) return; + + setState(() => _resolving = true); + try { + final session = ref.read(appSessionProvider); + final dbPath = await session.currentDatabasePath(); + if (!mounted) return; + final errorCode = await session.restoreWebDavBackup( + remotePath: widget.canonical.remotePath, + destinationPath: dbPath, + createNew: false, + ); + if (!mounted) return; + if (errorCode == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('backup_restored'.tr()))); + Navigator.of(context).pop(true); + } else { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(errorCode.tr()))); + } + } finally { + if (mounted) setState(() => _resolving = false); + } + } +} + +class _ConflictOptionCard extends StatelessWidget { + const _ConflictOptionCard({ + required this.icon, + required this.title, + required this.entry, + required this.localeTag, + required this.actionLabel, + required this.busy, + required this.onSelect, + }); + + final IconData icon; + final String title; + final WebDavBackupEntry entry; + final String localeTag; + final String actionLabel; + final bool busy; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + final modifiedAt = entry.modifiedAt; + final dateStr = modifiedAt != null + ? DateFormat.yMd(localeTag).add_Hm().format(modifiedAt.toLocal()) + : null; + final sizeStr = entry.sizeBytes != null + ? _formatBytes(entry.sizeBytes!) + : null; + final subtitleParts = [entry.deviceName ?? 'unknown_device'.tr(), ?dateStr, ?sizeStr]; + + return Card( + child: Padding( + padding: appCardPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: AppSpacing.medium), + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.small), + Text( + subtitleParts.join(' • '), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.medium), + Align( + alignment: Alignment.centerRight, + child: FilledButton( + onPressed: busy ? null : onSelect, + child: busy + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(actionLabel), + ), + ), + ], + ), + ), + ); + } + + String _formatBytes(int bytes) { + if (bytes >= 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + if (bytes >= 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + return '$bytes B'; + } +} diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 205a4f7..9d48e5b 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -17,6 +17,7 @@ import '../../shared/widgets/app_updater.dart'; import '../../shared/widgets/app_error_state.dart'; import '../../shared/widgets/content_constraints.dart'; import '../setup/database_selection_sheet.dart'; +import 'backup_conflict_screen.dart'; import 'grade_system_controller.dart'; import 'grade_system_editor.dart'; import '../students/student_sorting.dart'; @@ -745,6 +746,57 @@ class _BackupsSectionState extends ConsumerState<_BackupsSection> { } } + /// Backups excluding `_CONFLICT_` copies, which are surfaced separately + /// through [_pendingConflicts] instead of appearing as plain list entries. + List get _nonConflictBackups => [ + for (final backup in _backups ?? const []) + if (!backup.isConflict) backup, + ]; + + /// Pairs each conflict copy with its canonical counterpart, newest + /// conflict per library only (older conflict copies for the same library + /// are superseded once the newest one is resolved). + List<(WebDavBackupEntry canonical, WebDavBackupEntry conflict)> + get _pendingConflicts { + final backups = _backups; + if (backups == null) return const []; + + final seenLibraries = {}; + final pairs = <(WebDavBackupEntry, WebDavBackupEntry)>[]; + for (final entry in backups) { + if (!entry.isConflict || !seenLibraries.add(entry.libraryName)) { + continue; + } + final canonicalFileName = '${entry.libraryName}$classiBackupExtension'; + WebDavBackupEntry? canonical; + for (final candidate in backups) { + if (!candidate.isConflict && candidate.fileName == canonicalFileName) { + canonical = candidate; + break; + } + } + if (canonical != null) { + pairs.add((canonical, entry)); + } + } + return pairs; + } + + Future _resolveConflict( + WebDavBackupEntry canonical, + WebDavBackupEntry conflict, + ) async { + final resolved = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + BackupConflictScreen(canonical: canonical, conflict: conflict), + ), + ); + if (resolved == true) { + _loadBackups(); + } + } + Future _loadBackups() async { if (_loadingBackups) return; setState(() { @@ -977,6 +1029,12 @@ class _BackupsSectionState extends ConsumerState<_BackupsSection> { const SizedBox(height: 16), const Divider(height: 1), const SizedBox(height: 12), + for (final (canonical, conflict) in _pendingConflicts) ...[ + _ConflictBanner( + onResolve: () => _resolveConflict(canonical, conflict), + ), + const SizedBox(height: 12), + ], Row( children: [ Expanded( @@ -1008,7 +1066,7 @@ class _BackupsSectionState extends ConsumerState<_BackupsSection> { ) else if (_backups == null || _loadingBackups && _backups!.isEmpty) const SizedBox.shrink() - else if (_backups!.isEmpty) + else if (_nonConflictBackups.isEmpty) Text( 'no_webdav_backups_found'.tr(), style: Theme.of(context).textTheme.bodySmall, @@ -1017,10 +1075,10 @@ class _BackupsSectionState extends ConsumerState<_BackupsSection> { ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemCount: _backups!.length, + itemCount: _nonConflictBackups.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { - final backup = _backups![index]; + final backup = _nonConflictBackups[index]; return _BackupListTile( backup: backup, localeTag: localeTag, @@ -1125,6 +1183,59 @@ class _MaxVersionsPicker extends StatelessWidget { } } +class _ConflictBanner extends StatelessWidget { + const _ConflictBanner({required this.onResolve}); + + final VoidCallback onResolve; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + color: colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning_amber_outlined, color: colorScheme.onErrorContainer), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'backup_conflict_detected'.tr(), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onErrorContainer, + ), + ), + const SizedBox(height: 4), + Text( + 'backup_conflict_detected_hint'.tr(), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onErrorContainer, + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: FilledButton( + onPressed: onResolve, + child: Text('resolve_conflict'.tr()), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + class _BackupListTile extends StatelessWidget { const _BackupListTile({ required this.backup, diff --git a/lib/features/setup/recover_access_screen.dart b/lib/features/setup/recover_access_screen.dart index 64b4f5f..478f23b 100644 --- a/lib/features/setup/recover_access_screen.dart +++ b/lib/features/setup/recover_access_screen.dart @@ -182,7 +182,7 @@ class _RecoverAccessScreenState extends ConsumerState { if (!mounted || !success) { return; } - context.go('/groups'); + context.go('/today'); } finally { if (mounted) { setState(() => _isRecovering = false); diff --git a/lib/features/setup/recovery_key_screen.dart b/lib/features/setup/recovery_key_screen.dart index 5920104..3c24306 100644 --- a/lib/features/setup/recovery_key_screen.dart +++ b/lib/features/setup/recovery_key_screen.dart @@ -23,7 +23,7 @@ class _RecoveryKeyScreenState extends ConsumerState { if (recoveryKey == null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { - context.go('/groups'); + context.go('/today'); } }); } @@ -135,6 +135,6 @@ class _RecoveryKeyScreenState extends ConsumerState { void _continue() { ref.read(appSessionProvider).clearPendingRecoveryKey(); - context.go('/groups'); + context.go('/today'); } } diff --git a/lib/features/students/student_detail_screen.dart b/lib/features/students/student_detail_screen.dart index e613e7b..1da76eb 100644 --- a/lib/features/students/student_detail_screen.dart +++ b/lib/features/students/student_detail_screen.dart @@ -265,6 +265,7 @@ class StudentDetailScreen extends ConsumerWidget { title: studentDisplayNameWithOrigin( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, originNote: student.originNote, sortField: sortField, ), @@ -359,6 +360,7 @@ class StudentDetailScreen extends ConsumerWidget { context: context, initialFirstName: student.firstName, initialLastName: student.lastName, + initialCallName: student.callName, initialOriginNote: student.originNote, initialAvatarJson: student.avatarJson, title: 'edit'.tr(), @@ -400,6 +402,7 @@ class StudentDetailScreen extends ConsumerWidget { id: student.id, firstName: result.firstName, lastName: result.lastName, + callName: result.callName, originNote: result.originNote, avatarJson: result.avatarJson, ); @@ -1937,6 +1940,7 @@ class _StudentNotesTab extends ConsumerWidget { firstName: linkedStudent.firstName, lastName: linkedStudent.lastName, + callName: linkedStudent.callName, sortField: sortField, ), ), diff --git a/lib/features/students/student_draft.dart b/lib/features/students/student_draft.dart index cfc3ba2..0dd9306 100644 --- a/lib/features/students/student_draft.dart +++ b/lib/features/students/student_draft.dart @@ -1,6 +1,7 @@ typedef StudentDraft = ({ String firstName, String lastName, + String? callName, String? originNote, String? avatarJson, }); diff --git a/lib/features/students/student_form.dart b/lib/features/students/student_form.dart index ff55883..6b3c4d6 100644 --- a/lib/features/students/student_form.dart +++ b/lib/features/students/student_form.dart @@ -16,6 +16,7 @@ Future showStudentFormSheet({ required BuildContext context, String? initialFirstName, String? initialLastName, + String? initialCallName, String? initialOriginNote, String? initialAvatarJson, String? title, @@ -29,6 +30,7 @@ Future showStudentFormSheet({ builder: (context) => _StudentFormSheet( initialFirstName: initialFirstName, initialLastName: initialLastName, + initialCallName: initialCallName, initialOriginNote: initialOriginNote, initialAvatarJson: initialAvatarJson, title: title, @@ -41,6 +43,7 @@ class _StudentFormSheet extends ConsumerStatefulWidget { const _StudentFormSheet({ this.initialFirstName, this.initialLastName, + this.initialCallName, this.initialOriginNote, this.initialAvatarJson, this.title, @@ -49,6 +52,7 @@ class _StudentFormSheet extends ConsumerStatefulWidget { final String? initialFirstName; final String? initialLastName; + final String? initialCallName; final String? initialOriginNote; final String? initialAvatarJson; final String? title; @@ -62,6 +66,7 @@ class _StudentFormSheetState extends ConsumerState<_StudentFormSheet> { final _formKey = GlobalKey(); late final TextEditingController _firstNameController; late final TextEditingController _lastNameController; + late final TextEditingController _callNameController; late final TextEditingController _originNoteController; late String? _avatarJson; @@ -70,20 +75,24 @@ class _StudentFormSheetState extends ConsumerState<_StudentFormSheet> { super.initState(); _firstNameController = TextEditingController(text: widget.initialFirstName); _lastNameController = TextEditingController(text: widget.initialLastName); + _callNameController = TextEditingController(text: widget.initialCallName); _originNoteController = TextEditingController( text: widget.initialOriginNote, ); _avatarJson = widget.initialAvatarJson; _firstNameController.addListener(_handleNameChanged); _lastNameController.addListener(_handleNameChanged); + _callNameController.addListener(_handleNameChanged); } @override void dispose() { _firstNameController.removeListener(_handleNameChanged); _lastNameController.removeListener(_handleNameChanged); + _callNameController.removeListener(_handleNameChanged); _firstNameController.dispose(); _lastNameController.dispose(); + _callNameController.dispose(); _originNoteController.dispose(); super.dispose(); } @@ -122,6 +131,7 @@ class _StudentFormSheetState extends ConsumerState<_StudentFormSheet> { lastName: _previewStudent.lastName.isEmpty ? '...' : _previewStudent.lastName, + callName: _previewStudent.callName, originNote: _previewStudent.originNote, sortField: sortField, ), @@ -158,6 +168,14 @@ class _StudentFormSheetState extends ConsumerState<_StudentFormSheet> { : null, ), const SizedBox(height: 16), + TextFormField( + controller: _callNameController, + decoration: InputDecoration( + labelText: 'call_name'.tr(), + hintText: 'call_name_hint'.tr(), + ), + ), + const SizedBox(height: 16), TextFormField( controller: _originNoteController, decoration: InputDecoration( @@ -202,6 +220,9 @@ class _StudentFormSheetState extends ConsumerState<_StudentFormSheet> { firstName: _firstNameController.text.trim(), lastName: _lastNameController.text.trim(), groupId: 0, + callName: _callNameController.text.trim().isEmpty + ? null + : _callNameController.text.trim(), originNote: _originNoteController.text.trim().isEmpty ? null : _originNoteController.text.trim(), @@ -231,6 +252,9 @@ class _StudentFormSheetState extends ConsumerState<_StudentFormSheet> { Navigator.of(context).pop(( firstName: _firstNameController.text.trim(), lastName: _lastNameController.text.trim(), + callName: _callNameController.text.trim().isEmpty + ? null + : _callNameController.text.trim(), originNote: _originNoteController.text.trim().isEmpty ? null : _originNoteController.text.trim(), @@ -246,6 +270,7 @@ class _StudentFormSheetState extends ConsumerState<_StudentFormSheet> { 'name': studentDisplayName( firstName: _firstNameController.text.trim(), lastName: _lastNameController.text.trim(), + callName: _callNameController.text.trim(), ), }, ), diff --git a/lib/features/students/student_import_parser.dart b/lib/features/students/student_import_parser.dart index c386cbc..0129746 100644 --- a/lib/features/students/student_import_parser.dart +++ b/lib/features/students/student_import_parser.dart @@ -62,6 +62,7 @@ List parseWebUntisStudentText(String source) { drafts.add(( firstName: firstName, lastName: lastName, + callName: null, originNote: null, avatarJson: null, )); @@ -84,6 +85,7 @@ StudentDraft? _parseBatchLine(String line) { return ( firstName: firstName, lastName: lastName, + callName: null, originNote: null, avatarJson: null, ); @@ -101,6 +103,7 @@ StudentDraft? _parseBatchLine(String line) { return ( firstName: parts.first, lastName: parts.sublist(1).join(' '), + callName: null, originNote: null, avatarJson: null, ); @@ -119,6 +122,7 @@ StudentDraft? _parseBatchLine(String line) { return ( firstName: parts.first, lastName: parts.sublist(1).join(' '), + callName: null, originNote: null, avatarJson: null, ); diff --git a/lib/features/students/student_repository.dart b/lib/features/students/student_repository.dart index 9456985..255f93b 100644 --- a/lib/features/students/student_repository.dart +++ b/lib/features/students/student_repository.dart @@ -58,6 +58,7 @@ class StudentRepository { required int groupId, required String firstName, required String lastName, + String? callName, String? originNote, String? avatarJson, }) { @@ -68,6 +69,9 @@ class StudentRepository { firstName: firstName.trim(), lastName: lastName.trim(), groupId: groupId, + callName: Value( + callName?.trim().isEmpty ?? true ? null : callName!.trim(), + ), originNote: Value( originNote?.trim().isEmpty ?? true ? null : originNote!.trim(), ), @@ -98,6 +102,7 @@ class StudentRepository { required int id, required String firstName, required String lastName, + String? callName, String? originNote, String? avatarJson, }) { @@ -107,6 +112,9 @@ class StudentRepository { StudentsTableCompanion( firstName: Value(firstName.trim()), lastName: Value(lastName.trim()), + callName: Value( + callName?.trim().isEmpty ?? true ? null : callName!.trim(), + ), originNote: Value( originNote?.trim().isEmpty ?? true ? null : originNote!.trim(), ), @@ -137,6 +145,11 @@ class StudentRepository { firstName: student.firstName.trim(), lastName: student.lastName.trim(), groupId: groupId, + callName: Value( + student.callName?.trim().isEmpty ?? true + ? null + : student.callName!.trim(), + ), originNote: Value( student.originNote?.trim().isEmpty ?? true ? null diff --git a/lib/features/students/student_summary_screen.dart b/lib/features/students/student_summary_screen.dart index 0250832..b35211a 100644 --- a/lib/features/students/student_summary_screen.dart +++ b/lib/features/students/student_summary_screen.dart @@ -93,6 +93,7 @@ class StudentSummaryScreen extends ConsumerWidget { subtitle: studentDisplayNameWithOrigin( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, originNote: student.originNote, sortField: sortField, ), @@ -136,6 +137,7 @@ class StudentSummaryScreen extends ConsumerWidget { studentName: studentDisplayName( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, ), controller: controller, ), @@ -210,6 +212,7 @@ class _SummaryHeader extends StatelessWidget { studentDisplayName( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, ), style: Theme.of(context).textTheme.titleLarge, maxLines: 1, diff --git a/lib/features/today/today_dashboard_screen.dart b/lib/features/today/today_dashboard_screen.dart new file mode 100644 index 0000000..8850112 --- /dev/null +++ b/lib/features/today/today_dashboard_screen.dart @@ -0,0 +1,294 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/providers/app_providers.dart'; +import '../../shared/theme/app_ui.dart'; +import '../../shared/utils/grade_categories.dart'; +import '../../shared/widgets/app_error_state.dart'; +import '../../shared/widgets/content_constraints.dart'; +import '../../shared/widgets/empty_state.dart'; +import '../lessons/lesson_support.dart'; +import 'today_repository.dart'; + +/// Landing screen after unlock: every active group's status for a chosen +/// date in one place, with a single tap into that group's lesson for that +/// date. Defaults to today; a date picker lets a teacher browse into the +/// past (e.g. to catch up on missed entries), with a quick way back to +/// today. Replaces having to open each group individually to see whether a +/// day's lesson has already been logged. +class TodayDashboardScreen extends ConsumerStatefulWidget { + const TodayDashboardScreen({this.initialDate, super.key}); + + /// The date to show initially. Defaults to today when omitted; browsing + /// to a different date afterwards is local screen state, not reflected + /// back into the route (matching Lesson Mode's own date picker). + final DateTime? initialDate; + + @override + ConsumerState createState() => + _TodayDashboardScreenState(); +} + +class _TodayDashboardScreenState extends ConsumerState { + late DateTime _selectedDate = normalizeLessonDate( + widget.initialDate ?? DateTime.now(), + ); + + @override + Widget build(BuildContext context) { + final today = normalizeLessonDate(DateTime.now()); + final isToday = _selectedDate == today; + final overviewValue = ref.watch(todayOverviewProvider(_selectedDate)); + + return Scaffold( + appBar: AppBar( + title: Text('today'.tr()), + actions: [ + if (!isToday) + IconButton( + onPressed: _goToToday, + icon: const Icon(Icons.today_outlined), + tooltip: 'today'.tr(), + ), + IconButton( + onPressed: _pickDate, + icon: const Icon(Icons.calendar_today_outlined), + tooltip: 'date'.tr(), + ), + ], + ), + body: ContentConstraints( + child: overviewValue.when( + data: (overviews) { + if (overviews.isEmpty) { + return EmptyState( + icon: Icons.groups_outlined, + title: 'empty_groups'.tr(), + ); + } + + return ListView( + padding: appScreenPadding, + children: [ + InkWell( + onTap: _pickDate, + borderRadius: BorderRadius.circular(AppRadii.large), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.small, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.calendar_today_outlined, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: AppSpacing.small), + Text( + MaterialLocalizations.of( + context, + ).formatFullDate(_selectedDate), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.large), + for (final overview in overviews) ...[ + _TodayGroupCard( + overview: overview, + onOpenLesson: () => _openLesson(overview.group.id), + ), + const SizedBox(height: AppSpacing.medium), + ], + ], + ); + }, + error: (error, _) => const AppErrorState(), + loading: () => const 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 = normalizeLessonDate(selected)); + } + + void _goToToday() { + setState(() => _selectedDate = normalizeLessonDate(DateTime.now())); + } + + void _openLesson(int groupId) { + context.push( + Uri( + path: '/groups/$groupId/lesson', + queryParameters: {'date': encodeLessonDate(_selectedDate)}, + ).toString(), + ); + } +} + +class _TodayGroupCard extends StatelessWidget { + const _TodayGroupCard({required this.overview, required this.onOpenLesson}); + + final TodayGroupOverview overview; + final VoidCallback onOpenLesson; + + @override + Widget build(BuildContext context) { + final group = overview.group; + final groupColor = colorFromHex(group.colorHex); + final colorScheme = Theme.of(context).colorScheme; + + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onOpenLesson, + child: Padding( + padding: appCardPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 10, + backgroundColor: groupColor.withValues(alpha: 0.18), + child: CircleAvatar(radius: 5, backgroundColor: groupColor), + ), + const SizedBox(width: AppSpacing.medium), + Expanded( + child: Text( + group.name, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + const SizedBox(height: AppSpacing.medium), + if (overview.hasActivity) + Wrap( + spacing: AppSpacing.small, + runSpacing: AppSpacing.small, + children: [ + _TodayStatChip( + icon: Icons.person_off_outlined, + label: + '${'absent'.tr()}: ${overview.absentCount}/${overview.totalStudents}', + emphasize: overview.absentCount > 0, + ), + _TodayStatChip( + icon: Icons.edit_note_outlined, + label: + '${'grades'.tr()}: ${overview.gradeCount}/${overview.totalStudents}', + ), + _TodayStatChip( + icon: Icons.fact_check_outlined, + label: + '${'homework'.tr()}: ${overview.homeworkCount}/${overview.totalStudents}', + ), + _TodayStatChip( + icon: Icons.backpack_outlined, + label: + '${'material'.tr()}: ${overview.materialCount}/${overview.totalStudents}', + ), + ], + ) + else + Text( + 'lesson_not_started'.tr(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.medium), + Align( + alignment: Alignment.centerRight, + child: FilledButton.tonalIcon( + onPressed: onOpenLesson, + icon: Icon( + overview.hasActivity + ? Icons.arrow_forward + : Icons.play_arrow_outlined, + ), + label: Text( + (overview.hasActivity ? 'continue_lesson' : 'start_lesson') + .tr(), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _TodayStatChip extends StatelessWidget { + const _TodayStatChip({ + required this.icon, + required this.label, + this.emphasize = false, + }); + + final IconData icon; + final String label; + final bool emphasize; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final color = emphasize ? colorScheme.error : colorScheme.onSurfaceVariant; + + return DecoratedBox( + decoration: BoxDecoration( + color: emphasize + ? colorScheme.errorContainer.withValues(alpha: 0.4) + : colorScheme.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(AppRadii.large), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.medium, + vertical: AppSpacing.small, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: AppSpacing.small), + Flexible( + child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/today/today_repository.dart b/lib/features/today/today_repository.dart new file mode 100644 index 0000000..de3c7ae --- /dev/null +++ b/lib/features/today/today_repository.dart @@ -0,0 +1,134 @@ +import 'package:drift/drift.dart'; + +import '../../core/database/app_database.dart'; + +/// Snapshot of a single active group's activity for one calendar date, used +/// by the Today dashboard to show what still needs attention at a glance. +class TodayGroupOverview { + const TodayGroupOverview({ + required this.group, + required this.totalStudents, + required this.attendanceRecordedCount, + required this.absentCount, + required this.gradeCount, + required this.homeworkCount, + required this.materialCount, + }); + + final Group group; + + /// Total number of students currently in the group. + final int totalStudents; + + /// Students with an attendance entry (present or absent) for the date. + final int attendanceRecordedCount; + + final int absentCount; + + /// Students with at least one grade entry recorded on the date. + final int gradeCount; + + /// Students with a homework check recorded on the date. + final int homeworkCount; + + /// Students with a material check recorded on the date. + final int materialCount; + + /// Whether any lesson data has been logged for this group on the date. + /// Used to distinguish "not started yet" from "already covered today". + bool get hasActivity => + attendanceRecordedCount > 0 || + gradeCount > 0 || + homeworkCount > 0 || + materialCount > 0; +} + +class TodayRepository { + TodayRepository(this._database); + + final AppDatabase _database; + + /// Watches every active group enriched with [date]'s lesson activity, + /// ordered by name. Recomputes whenever groups, students, or any of the + /// tracked logs for [date] change. + Stream> watchTodayOverview(DateTime date) { + final normalizedDate = DateTime(date.year, date.month, date.day); + return _database + .customSelect( + ''' + SELECT + g.id AS g_id, + g.name AS g_name, + g.color_hex AS g_color_hex, + g.grade_scale_json AS g_grade_scale_json, + g.grade_categories_json AS g_grade_categories_json, + g.created_at AS g_created_at, + (SELECT COUNT(*) FROM students_table st WHERE st.group_id = g.id) + AS total_students, + (SELECT COUNT(*) FROM attendance_logs_table a + JOIN students_table st ON st.id = a.student_id + WHERE st.group_id = g.id AND a.date = ?) + AS attendance_recorded_count, + (SELECT COUNT(*) FROM attendance_logs_table a + JOIN students_table st ON st.id = a.student_id + WHERE st.group_id = g.id AND a.date = ? AND a.is_absent = 1) + AS absent_count, + (SELECT COUNT(DISTINCT ge.student_id) FROM grade_entries_table ge + JOIN students_table st ON st.id = ge.student_id + WHERE st.group_id = g.id AND ge.date = ?) + AS grade_count, + (SELECT COUNT(*) FROM homework_logs_table h + JOIN students_table st ON st.id = h.student_id + WHERE st.group_id = g.id AND h.date = ?) + AS homework_count, + (SELECT COUNT(*) FROM material_logs_table m + JOIN students_table st ON st.id = m.student_id + WHERE st.group_id = g.id AND m.date = ?) + AS material_count + FROM groups_table g + WHERE g.archived_at IS NULL + ORDER BY g.name ASC + ''', + variables: [ + Variable.withDateTime(normalizedDate), + Variable.withDateTime(normalizedDate), + Variable.withDateTime(normalizedDate), + Variable.withDateTime(normalizedDate), + Variable.withDateTime(normalizedDate), + ], + readsFrom: { + _database.groupsTable, + _database.studentsTable, + _database.attendanceLogsTable, + _database.gradeEntriesTable, + _database.homeworkLogsTable, + _database.materialLogsTable, + }, + ) + .watch() + .map((rows) => [for (final row in rows) _rowToOverview(row)]); + } + + TodayGroupOverview _rowToOverview(QueryRow row) { + final group = Group( + id: row.read('g_id'), + name: row.read('g_name'), + colorHex: row.read('g_color_hex'), + gradeScaleJson: row.read('g_grade_scale_json'), + gradeCategoriesJson: row.read('g_grade_categories_json'), + createdAt: DateTime.fromMillisecondsSinceEpoch( + row.read('g_created_at') * 1000, + ), + ); + + return TodayGroupOverview( + group: group, + totalStudents: row.read('total_students'), + attendanceRecordedCount: row.read('attendance_recorded_count'), + absentCount: row.read('absent_count'), + gradeCount: row.read('grade_count'), + homeworkCount: row.read('homework_count'), + materialCount: row.read('material_count'), + ); + } +} diff --git a/lib/shared/router/app_router.dart b/lib/shared/router/app_router.dart index 6e4da78..3f0d5c5 100644 --- a/lib/shared/router/app_router.dart +++ b/lib/shared/router/app_router.dart @@ -17,6 +17,7 @@ import '../../features/setup/setup_screen.dart'; import '../../features/setup/unlock_screen.dart'; import '../../features/students/student_detail_screen.dart'; import '../../features/students/student_summary_screen.dart'; +import '../../features/today/today_dashboard_screen.dart'; import '../widgets/app_scaffold.dart'; import '../widgets/startup_screen.dart'; @@ -75,7 +76,7 @@ final routerProvider = Provider((ref) { return from; } } - return '/groups'; + return '/today'; } return null; case AppSessionStatus.error: @@ -104,6 +105,14 @@ final routerProvider = Provider((ref) { ShellRoute( builder: (context, state, child) => AppScaffold(child: child), routes: [ + GoRoute( + path: '/today', + builder: (context, state) => TodayDashboardScreen( + initialDate: parseLessonDateOrToday( + state.uri.queryParameters['date'], + ), + ), + ), GoRoute( path: '/groups', builder: (context, state) => const GroupsScreen(), diff --git a/lib/shared/utils/formatting.dart b/lib/shared/utils/formatting.dart index 6e9b36c..852385b 100644 --- a/lib/shared/utils/formatting.dart +++ b/lib/shared/utils/formatting.dart @@ -248,26 +248,43 @@ bool _sameLabels(List labels, List entries) { return true; } +/// The name shown for a student in place of [firstName]: [callName] (the +/// informal "Rufname" a teacher actually calls them by) when set, otherwise +/// [firstName] itself. +String effectiveFirstName({required String firstName, String? callName}) { + final trimmedCallName = callName?.trim(); + return trimmedCallName == null || trimmedCallName.isEmpty + ? firstName + : trimmedCallName; +} + String studentDisplayName({ required String firstName, required String lastName, + String? callName, StudentSortField sortField = StudentSortField.lastName, }) { + final displayFirstName = effectiveFirstName( + firstName: firstName, + callName: callName, + ); return switch (sortField) { - StudentSortField.firstName => '$firstName $lastName', - StudentSortField.lastName => '$lastName, $firstName', + StudentSortField.firstName => '$displayFirstName $lastName', + StudentSortField.lastName => '$lastName, $displayFirstName', }; } String studentDisplayNameWithOrigin({ required String firstName, required String lastName, + String? callName, String? originNote, StudentSortField sortField = StudentSortField.lastName, }) { final displayName = studentDisplayName( firstName: firstName, lastName: lastName, + callName: callName, sortField: sortField, ); final trimmedOriginNote = originNote?.trim(); @@ -281,20 +298,31 @@ bool isGeneratedStudentDisplayName({ required String value, required String firstName, required String lastName, + String? callName, }) { final trimmedValue = value.trim(); - return trimmedValue == - studentDisplayName( - firstName: firstName, - lastName: lastName, - sortField: StudentSortField.firstName, - ) || - trimmedValue == + // Match against the call name *and* the plain first name: a label stored + // before a call name was set (e.g. a checklist populated from the group) + // must still count as auto-generated afterwards, so it re-renders with the + // new name instead of freezing as a custom label. The set collapses to one + // candidate when no call name is set. + final candidateFirstNames = { + effectiveFirstName(firstName: firstName, callName: callName), + firstName, + }; + for (final candidateFirstName in candidateFirstNames) { + for (final sortField in StudentSortField.values) { + if (trimmedValue == studentDisplayName( - firstName: firstName, + firstName: candidateFirstName, lastName: lastName, - sortField: StudentSortField.lastName, - ); + sortField: sortField, + )) { + return true; + } + } + } + return false; } String studentInitials({required String firstName, required String lastName}) { diff --git a/lib/shared/widgets/app_scaffold.dart b/lib/shared/widgets/app_scaffold.dart index ce0c5b8..ba79da1 100644 --- a/lib/shared/widgets/app_scaffold.dart +++ b/lib/shared/widgets/app_scaffold.dart @@ -21,6 +21,12 @@ class AppScaffold extends ConsumerWidget { final isExtended = MediaQuery.sizeOf(context).width > 1200; final selectedIndex = _selectedIndex(context); final destinations = [ + _NavigationItem( + path: '/today', + icon: Icons.today_outlined, + selectedIcon: Icons.today, + label: 'today', + ), _NavigationItem( path: '/groups', icon: Icons.groups_outlined, @@ -129,15 +135,18 @@ class AppScaffold extends ConsumerWidget { int _selectedIndex(BuildContext context) { final location = GoRouterState.of(context).uri.path; - if (location.startsWith('/lists')) { + if (location.startsWith('/groups') || location.startsWith('/students')) { return 1; } - if (location.startsWith('/notes')) { + if (location.startsWith('/lists')) { return 2; } - if (location.startsWith('/settings')) { + if (location.startsWith('/notes')) { return 3; } + if (location.startsWith('/settings')) { + return 4; + } return 0; } } diff --git a/lib/shared/widgets/student_avatar.dart b/lib/shared/widgets/student_avatar.dart index f73e52b..558df15 100644 --- a/lib/shared/widgets/student_avatar.dart +++ b/lib/shared/widgets/student_avatar.dart @@ -67,6 +67,7 @@ class StudentAvatar extends ConsumerWidget { semanticsLabel: studentDisplayName( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, sortField: sortField, ), ), diff --git a/lib/shared/widgets/student_link_chip.dart b/lib/shared/widgets/student_link_chip.dart index 7acadaa..d5bc1c6 100644 --- a/lib/shared/widgets/student_link_chip.dart +++ b/lib/shared/widgets/student_link_chip.dart @@ -26,6 +26,7 @@ class StudentLinkChip extends ConsumerWidget { studentDisplayName( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, sortField: sortField, ), ), diff --git a/lib/shared/widgets/student_selection_sheet.dart b/lib/shared/widgets/student_selection_sheet.dart index 6f283b1..fd1c968 100644 --- a/lib/shared/widgets/student_selection_sheet.dart +++ b/lib/shared/widgets/student_selection_sheet.dart @@ -136,6 +136,7 @@ class _StudentSelectionSheetState studentDisplayName( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, sortField: sortField, ), ), @@ -198,6 +199,7 @@ class _StudentSelectionSheetState studentDisplayName( firstName: student.firstName, lastName: student.lastName, + callName: student.callName, sortField: sortField, ), ), diff --git a/test/app_session_controller_test.dart b/test/app_session_controller_test.dart index d63a36e..aeea9fb 100644 --- a/test/app_session_controller_test.dart +++ b/test/app_session_controller_test.dart @@ -773,6 +773,53 @@ void main() { expect(controller.lastExportedAt, isNull); }, ); + + test( + 'a failed keep-this-device resolution reports the failure and does not ' + 'leave the remote revision adopted', + () async { + final conflictService = _ConflictingLibraryBackupService(); + controller.dispose(); + final databasePathService = _TestDatabasePathService( + '${tempDirectory.path}/test.classi', + ); + final backupPreferences = _libraryBackupPreferencesServiceFor( + databasePathService, + ); + controller = _WebDavAppSessionController( + keyService: keyService, + databasePathService: databasePathService, + securityPreferencesService: _securityPreferencesServiceFor( + databasePathService, + ), + libraryBackupPreferencesService: backupPreferences, + 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 revisionBefore = controller.lastKnownRevision; + + final errorCode = await controller.keepThisDeviceVersionAfterConflict( + canonicalRevision: 'server-revision', + ); + + // The upload never landed, so the caller has to hear about it — + // otherwise the conflict screen tells the teacher it was resolved. + expect(errorCode, isNotNull); + expect(controller.lastExportedAt, isNull); + + // Adopting the server's revision is only licensed by a successful + // export. Left in place after a failure, the next auto-export would + // overwrite the server copy without ever prompting again. + expect(controller.lastKnownRevision, revisionBefore); + expect(await backupPreferences.lastKnownRevision(), revisionBefore); + }, + ); } class _TestDatabasePathService extends DatabasePathService { diff --git a/test/grade_scale_formatting_test.dart b/test/grade_scale_formatting_test.dart index 557c24e..5f9d5b1 100644 --- a/test/grade_scale_formatting_test.dart +++ b/test/grade_scale_formatting_test.dart @@ -37,6 +37,46 @@ void main() { ); }); + test('call name replaces first name when set, surname always shown', () { + expect( + studentDisplayName( + firstName: 'Alexander', + lastName: 'Mustermann', + callName: 'Alex', + sortField: StudentSortField.firstName, + ), + 'Alex Mustermann', + ); + expect( + studentDisplayName( + firstName: 'Alexander', + lastName: 'Mustermann', + callName: 'Alex', + sortField: StudentSortField.lastName, + ), + 'Mustermann, Alex', + ); + }); + + test('a blank or unset call name falls back to the first name', () { + expect( + studentDisplayName( + firstName: 'Alexander', + lastName: 'Mustermann', + callName: null, + ), + 'Mustermann, Alexander', + ); + expect( + studentDisplayName( + firstName: 'Alexander', + lastName: 'Mustermann', + callName: ' ', + ), + 'Mustermann, Alexander', + ); + }); + test('generated student names are recognized in both supported orders', () { expect( isGeneratedStudentDisplayName( @@ -63,4 +103,50 @@ void main() { isFalse, ); }); + + test('generated name recognition uses the call name when set', () { + expect( + isGeneratedStudentDisplayName( + value: 'Mustermann, Alex', + firstName: 'Alexander', + lastName: 'Mustermann', + callName: 'Alex', + ), + isTrue, + ); + expect( + isGeneratedStudentDisplayName( + value: 'Presentation topic', + firstName: 'Alexander', + lastName: 'Mustermann', + callName: 'Alex', + ), + isFalse, + ); + }); + + test( + 'a label generated before a call name was set is still recognized', + () { + // A checklist populated from the group stores the name as its label. + // Setting a call name afterwards must not turn that stored label into + // an untracked custom one — otherwise it freezes at the old name and + // the item redundantly grows a linked-student chip. + for (final storedLabel in const [ + 'Mustermann, Alexander', + 'Alexander Mustermann', + ]) { + expect( + isGeneratedStudentDisplayName( + value: storedLabel, + firstName: 'Alexander', + lastName: 'Mustermann', + callName: 'Alex', + ), + isTrue, + reason: '$storedLabel should still count as auto-generated', + ); + } + }, + ); } diff --git a/test/library_backup_conflict_test.dart b/test/library_backup_conflict_test.dart new file mode 100644 index 0000000..9218f01 --- /dev/null +++ b/test/library_backup_conflict_test.dart @@ -0,0 +1,50 @@ +import 'package:classi/core/storage/library_backup_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('canonical backups are not conflicts', () { + const entry = WebDavBackupEntry( + fileName: 'class-8a.classi-backup', + libraryName: 'class-8a', + remotePath: '/backups/class-8a.classi-backup', + ); + expect(entry.isConflict, isFalse); + }); + + test('archived backups are not conflicts', () { + const entry = WebDavBackupEntry( + fileName: 'class-8a_20260506T143200Z.classi-backup', + libraryName: 'class-8a', + remotePath: '/backups/class-8a_20260506T143200Z.classi-backup', + ); + expect(entry.isConflict, isFalse); + }); + + test('conflict copies are recognized', () { + const entry = WebDavBackupEntry( + fileName: 'class-8a_CONFLICT_20260507T080000Z.classi-backup', + libraryName: 'class-8a', + remotePath: '/backups/class-8a_CONFLICT_20260507T080000Z.classi-backup', + ); + expect(entry.isConflict, isTrue); + }); + + test('libraryNameForBackupFile strips conflict and archive suffixes', () { + expect( + LibraryBackupService.libraryNameForBackupFile( + 'class-8a_CONFLICT_20260507T080000Z.classi-backup', + ), + 'class-8a', + ); + expect( + LibraryBackupService.libraryNameForBackupFile( + 'class-8a_20260506T143200Z.classi-backup', + ), + 'class-8a', + ); + expect( + LibraryBackupService.libraryNameForBackupFile('class-8a.classi-backup'), + 'class-8a', + ); + }); +} diff --git a/test/student_data_repository_test.dart b/test/student_data_repository_test.dart index 649a99a..5b04fb9 100644 --- a/test/student_data_repository_test.dart +++ b/test/student_data_repository_test.dart @@ -78,12 +78,14 @@ void main() { ( firstName: 'Ada', lastName: 'Lovelace', + callName: null, originNote: null, avatarJson: null, ), ( firstName: 'Alan', lastName: 'Turing', + callName: null, originNote: 'Imported', avatarJson: null, ),