From af99b68fa778eac1b11a8e77e7d3905a184445af Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 17:29:25 +0000 Subject: [PATCH 1/7] feat: add Today dashboard and backup conflict resolution UI Add a Today landing screen showing every active group's attendance, grade, homework, and material status for the current date with a one-tap entry into that group's lesson, replacing the flat Groups list as the post-unlock destination. Add a guided conflict resolution screen for WebDAV sync conflicts, reachable from a new warning banner in Settings > Backups. Lets the teacher compare the canonical and conflicting backups (device, time, size) and either keep this device's version (re-exports it as the new canonical) or adopt the server version, instead of having to spot and interpret raw `_CONFLICT_` files in the flat backup list. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LJRKq8YRXgS65F9dAcQxNi --- assets/translations/de.json | 15 +- assets/translations/en.json | 15 +- lib/core/providers/app_providers.dart | 10 + lib/core/session/app_session_controller.dart | 29 +++ lib/core/storage/library_backup_service.dart | 35 ++- .../settings/backup_conflict_screen.dart | 245 ++++++++++++++++++ lib/features/settings/settings_screen.dart | 117 ++++++++- lib/features/setup/recover_access_screen.dart | 2 +- lib/features/setup/recovery_key_screen.dart | 4 +- .../today/today_dashboard_screen.dart | 217 ++++++++++++++++ lib/features/today/today_repository.dart | 134 ++++++++++ lib/shared/router/app_router.dart | 7 +- lib/shared/widgets/app_scaffold.dart | 15 +- test/library_backup_conflict_test.dart | 50 ++++ 14 files changed, 880 insertions(+), 15 deletions(-) create mode 100644 lib/features/settings/backup_conflict_screen.dart create mode 100644 lib/features/today/today_dashboard_screen.dart create mode 100644 lib/features/today/today_repository.dart create mode 100644 test/library_backup_conflict_test.dart diff --git a/assets/translations/de.json b/assets/translations/de.json index 14e6ca2..104f607 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -375,5 +375,18 @@ "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" } diff --git a/assets/translations/en.json b/assets/translations/en.json index 397d224..e38d3bc 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -375,5 +375,18 @@ "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" } 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..b291f08 100644 --- a/lib/core/session/app_session_controller.dart +++ b/lib/core/session/app_session_controller.dart @@ -735,6 +735,35 @@ 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. + /// + /// 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'; + + _lastKnownRevision = canonicalRevision; + await _libraryBackupPreferencesService.setLastKnownRevision( + canonicalRevision, + ); + + return exportNow(); + } + /// 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/settings/backup_conflict_screen.dart b/lib/features/settings/backup_conflict_screen.dart new file mode 100644 index 0000000..5341166 --- /dev/null +++ b/lib/features/settings/backup_conflict_screen.dart @@ -0,0 +1,245 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.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/today/today_dashboard_screen.dart b/lib/features/today/today_dashboard_screen.dart new file mode 100644 index 0000000..e060a5c --- /dev/null +++ b/lib/features/today/today_dashboard_screen.dart @@ -0,0 +1,217 @@ +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/formatting.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 today in one +/// place, with a single tap into that group's lesson for today. Replaces +/// having to open each group individually to see whether today's lesson has +/// already been logged. +class TodayDashboardScreen extends ConsumerWidget { + const TodayDashboardScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final today = normalizeLessonDate(DateTime.now()); + final overviewValue = ref.watch(todayOverviewProvider(today)); + + return Scaffold( + appBar: AppBar(title: Text('today'.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: [ + Text( + MaterialLocalizations.of(context).formatFullDate(today), + 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(context, overview.group.id, today), + ), + const SizedBox(height: AppSpacing.medium), + ], + ], + ); + }, + error: (error, _) => const AppErrorState(), + loading: () => const Center(child: CircularProgressIndicator()), + ), + ), + ); + } + + void _openLesson(BuildContext context, int groupId, DateTime date) { + context.push( + Uri( + path: '/groups/$groupId/lesson', + queryParameters: {'date': encodeLessonDate(date)}, + ).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), + 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..8220bde 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,10 @@ final routerProvider = Provider((ref) { ShellRoute( builder: (context, state, child) => AppScaffold(child: child), routes: [ + GoRoute( + path: '/today', + builder: (context, state) => const TodayDashboardScreen(), + ), GoRoute( path: '/groups', builder: (context, state) => const GroupsScreen(), 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/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', + ); + }); +} From e9e3bc24b898c12a5eb796e61d314804badb10d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 18:42:10 +0000 Subject: [PATCH 2/7] fix: prevent chip label overflow and drop unused imports flutter analyze and a widget-test render pass turned up two real bugs: an unbounded Text inside a mainAxisSize.min Row overflows instead of ellipsizing once the available width is narrower than the label (hit by the new Today dashboard's stat chips, and latent in the pre-existing LessonSummaryCard chip they were modeled on). Wrap both in Flexible so they actually shrink. Also drop two now-unused imports flagged by analyze after fixing the wrong colorFromHex import and an intl import already covered by easy_localization. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LJRKq8YRXgS65F9dAcQxNi --- lib/features/lessons/lesson_sections.dart | 4 +++- lib/features/settings/backup_conflict_screen.dart | 1 - lib/features/today/today_dashboard_screen.dart | 6 ++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/features/lessons/lesson_sections.dart b/lib/features/lessons/lesson_sections.dart index 29f0501..f73f2bf 100644 --- a/lib/features/lessons/lesson_sections.dart +++ b/lib/features/lessons/lesson_sections.dart @@ -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( diff --git a/lib/features/settings/backup_conflict_screen.dart b/lib/features/settings/backup_conflict_screen.dart index 5341166..159b3bf 100644 --- a/lib/features/settings/backup_conflict_screen.dart +++ b/lib/features/settings/backup_conflict_screen.dart @@ -1,7 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:intl/intl.dart'; import '../../core/providers/app_providers.dart'; import '../../core/storage/library_backup_service.dart'; diff --git a/lib/features/today/today_dashboard_screen.dart b/lib/features/today/today_dashboard_screen.dart index e060a5c..9b516fc 100644 --- a/lib/features/today/today_dashboard_screen.dart +++ b/lib/features/today/today_dashboard_screen.dart @@ -5,7 +5,7 @@ import 'package:go_router/go_router.dart'; import '../../core/providers/app_providers.dart'; import '../../shared/theme/app_ui.dart'; -import '../../shared/utils/formatting.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'; @@ -208,7 +208,9 @@ class _TodayStatChip extends StatelessWidget { children: [ Icon(icon, size: 16, color: color), const SizedBox(width: AppSpacing.small), - Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + Flexible( + child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + ), ], ), ), From 2750447a13c5310d66ea4d9dc539bc7d5b66558f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 19:07:21 +0000 Subject: [PATCH 3/7] feat: let the Today dashboard browse other dates Turn the screen stateful so a teacher can pick any date via a date picker (tap the date header or the calendar icon in the app bar) to review or catch up on a past day's attendance/grades/homework/material across all groups, the same way as for today. A "jump to today" icon appears in the app bar whenever browsing away from the current date. No new repository work needed: TodayRepository.watchTodayOverview was already parameterized by date. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LJRKq8YRXgS65F9dAcQxNi --- .../today/today_dashboard_screen.dart | 100 +++++++++++++++--- 1 file changed, 84 insertions(+), 16 deletions(-) diff --git a/lib/features/today/today_dashboard_screen.dart b/lib/features/today/today_dashboard_screen.dart index 9b516fc..e227676 100644 --- a/lib/features/today/today_dashboard_screen.dart +++ b/lib/features/today/today_dashboard_screen.dart @@ -12,20 +12,46 @@ 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 today in one -/// place, with a single tap into that group's lesson for today. Replaces -/// having to open each group individually to see whether today's lesson has -/// already been logged. -class TodayDashboardScreen extends ConsumerWidget { +/// 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({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => + _TodayDashboardScreenState(); +} + +class _TodayDashboardScreenState extends ConsumerState { + late DateTime _selectedDate = normalizeLessonDate(DateTime.now()); + + @override + Widget build(BuildContext context) { final today = normalizeLessonDate(DateTime.now()); - final overviewValue = ref.watch(todayOverviewProvider(today)); + final isToday = _selectedDate == today; + final overviewValue = ref.watch(todayOverviewProvider(_selectedDate)); return Scaffold( - appBar: AppBar(title: Text('today'.tr())), + 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) { @@ -39,18 +65,42 @@ class TodayDashboardScreen extends ConsumerWidget { return ListView( padding: appScreenPadding, children: [ - Text( - MaterialLocalizations.of(context).formatFullDate(today), - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + 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(context, overview.group.id, today), + onOpenLesson: () => _openLesson(overview.group.id), ), const SizedBox(height: AppSpacing.medium), ], @@ -64,11 +114,29 @@ class TodayDashboardScreen extends ConsumerWidget { ); } - void _openLesson(BuildContext context, int groupId, DateTime date) { + 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(date)}, + queryParameters: {'date': encodeLessonDate(_selectedDate)}, ).toString(), ); } From 6a2440828b4d96515fb52951bdd800880acc7545 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 19:37:17 +0000 Subject: [PATCH 4/7] feat: make the Today dashboard's date deep-linkable Accept an optional initialDate on TodayDashboardScreen and read it from a ?date= route query parameter in /today, the same convention Lesson Mode already uses for its own initialDate. Falls back to today when absent, so existing links and the nav bar destination are unaffected; this only adds the ability to open the dashboard directly onto a specific date from a URL. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LJRKq8YRXgS65F9dAcQxNi --- lib/features/today/today_dashboard_screen.dart | 11 +++++++++-- lib/shared/router/app_router.dart | 6 +++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/features/today/today_dashboard_screen.dart b/lib/features/today/today_dashboard_screen.dart index e227676..8850112 100644 --- a/lib/features/today/today_dashboard_screen.dart +++ b/lib/features/today/today_dashboard_screen.dart @@ -19,7 +19,12 @@ import 'today_repository.dart'; /// 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({super.key}); + 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() => @@ -27,7 +32,9 @@ class TodayDashboardScreen extends ConsumerStatefulWidget { } class _TodayDashboardScreenState extends ConsumerState { - late DateTime _selectedDate = normalizeLessonDate(DateTime.now()); + late DateTime _selectedDate = normalizeLessonDate( + widget.initialDate ?? DateTime.now(), + ); @override Widget build(BuildContext context) { diff --git a/lib/shared/router/app_router.dart b/lib/shared/router/app_router.dart index 8220bde..3f0d5c5 100644 --- a/lib/shared/router/app_router.dart +++ b/lib/shared/router/app_router.dart @@ -107,7 +107,11 @@ final routerProvider = Provider((ref) { routes: [ GoRoute( path: '/today', - builder: (context, state) => const TodayDashboardScreen(), + builder: (context, state) => TodayDashboardScreen( + initialDate: parseLessonDateOrToday( + state.uri.queryParameters['date'], + ), + ), ), GoRoute( path: '/groups', From 41ef46ae0c5d9ea90f6d0efabfc7d6e6aee67729 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 20:13:14 +0000 Subject: [PATCH 5/7] feat: add a call name (Rufname) for students Teachers often call a student by a name that isn't their official first name. Add an optional callName field to Student, shown in place of firstName everywhere a name is displayed while the surname always stays visible alongside it (both sort orders). The official firstName and lastName are unchanged and still what CSV exports and WebUntis import matching use. - New nullable students_table.callName column (schema v22, migrated). - studentDisplayName/studentDisplayNameWithOrigin/ isGeneratedStudentDisplayName in formatting.dart prefer callName, falling back to firstName when unset or blank. - Student form gains an optional "Call name" field; StudentDraft, StudentRepository.addStudent/updateStudent, and GroupRepository's clone-group student copy all carry it through. - Every existing display call site (lesson roster, seating plan, notes, lists, group/student detail and summary screens, avatar widgets) now passes the student's callName. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LJRKq8YRXgS65F9dAcQxNi --- assets/translations/de.json | 4 +- assets/translations/en.json | 4 +- lib/core/database/app_database.dart | 5 +- lib/core/database/app_database.g.dart | 72 +++++++++++++++++++ lib/core/database/tables/students_table.dart | 5 ++ lib/features/avatar/avatar_editor_sheet.dart | 1 + lib/features/groups/group_detail_screen.dart | 2 + lib/features/groups/group_repository.dart | 1 + .../groups/timeframe_grades_screen.dart | 2 + lib/features/lessons/lesson_mode_screen.dart | 2 + lib/features/lessons/lesson_sections.dart | 3 +- lib/features/lists/list_detail_screen.dart | 2 + lib/features/lists/list_item_editor.dart | 1 + lib/features/lists/list_repository.dart | 1 + lib/features/notes/note_editor.dart | 3 + .../seating_plan/lesson_seating_view.dart | 1 + .../seating_plan/seating_plan_chip.dart | 1 + .../students/student_detail_screen.dart | 4 ++ lib/features/students/student_draft.dart | 1 + lib/features/students/student_form.dart | 25 +++++++ .../students/student_import_parser.dart | 4 ++ lib/features/students/student_repository.dart | 13 ++++ .../students/student_summary_screen.dart | 3 + lib/shared/utils/formatting.dart | 24 ++++++- lib/shared/widgets/student_avatar.dart | 1 + lib/shared/widgets/student_link_chip.dart | 1 + .../widgets/student_selection_sheet.dart | 2 + test/grade_scale_formatting_test.dart | 61 ++++++++++++++++ test/student_data_repository_test.dart | 2 + 29 files changed, 245 insertions(+), 6 deletions(-) diff --git a/assets/translations/de.json b/assets/translations/de.json index 104f607..0e89a54 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -388,5 +388,7 @@ "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" + "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 e38d3bc..0d90290 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -388,5 +388,7 @@ "keep_this_device_version": "Keep this device's version", "use_server_version": "Use the server version", "conflict_resolved": "Conflict resolved.", - "unknown_device": "Unknown device" + "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/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 f73f2bf..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, ), ), @@ -601,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/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/shared/utils/formatting.dart b/lib/shared/utils/formatting.dart index 6e9b36c..6152ca6 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,18 +298,21 @@ bool isGeneratedStudentDisplayName({ required String value, required String firstName, required String lastName, + String? callName, }) { final trimmedValue = value.trim(); return trimmedValue == studentDisplayName( firstName: firstName, lastName: lastName, + callName: callName, sortField: StudentSortField.firstName, ) || trimmedValue == studentDisplayName( firstName: firstName, lastName: lastName, + callName: callName, sortField: StudentSortField.lastName, ); } 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/grade_scale_formatting_test.dart b/test/grade_scale_formatting_test.dart index 557c24e..7bcc021 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,25 @@ 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: 'Mustermann, Alexander', + firstName: 'Alexander', + lastName: 'Mustermann', + callName: 'Alex', + ), + isFalse, + ); + }); } 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, ), From 1e8767fc55f4a97009c5d85f87f7a8ed0f318b8f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 20:24:35 +0000 Subject: [PATCH 6/7] fix: keep pre-existing list labels tied to their student isGeneratedStudentDisplayName only compared the stored label against the name built from the call name, so a checklist label written before a call name was set (e.g. "Mustermann, Alexander") stopped being recognized as auto-generated the moment one was added. The item then froze at the old name and grew a redundant linked-student chip, because the screen treated it as a custom label. Match against the plain first name as well as the call name, so labels generated either side of the change still re-render with the current name. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LJRKq8YRXgS65F9dAcQxNi --- lib/shared/utils/formatting.dart | 32 +++++++++++++++++---------- test/grade_scale_formatting_test.dart | 27 +++++++++++++++++++++- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/lib/shared/utils/formatting.dart b/lib/shared/utils/formatting.dart index 6152ca6..852385b 100644 --- a/lib/shared/utils/formatting.dart +++ b/lib/shared/utils/formatting.dart @@ -301,20 +301,28 @@ bool isGeneratedStudentDisplayName({ String? callName, }) { final trimmedValue = value.trim(); - return trimmedValue == - studentDisplayName( - firstName: firstName, - lastName: lastName, - callName: callName, - 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, - callName: callName, - sortField: StudentSortField.lastName, - ); + sortField: sortField, + )) { + return true; + } + } + } + return false; } String studentInitials({required String firstName, required String lastName}) { diff --git a/test/grade_scale_formatting_test.dart b/test/grade_scale_formatting_test.dart index 7bcc021..5f9d5b1 100644 --- a/test/grade_scale_formatting_test.dart +++ b/test/grade_scale_formatting_test.dart @@ -116,7 +116,7 @@ void main() { ); expect( isGeneratedStudentDisplayName( - value: 'Mustermann, Alexander', + value: 'Presentation topic', firstName: 'Alexander', lastName: 'Mustermann', callName: 'Alex', @@ -124,4 +124,29 @@ void main() { 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', + ); + } + }, + ); } From 682b58b56ccc53cdb7d0835e38b48ab2813684f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 05:57:03 +0000 Subject: [PATCH 7/7] fix: roll back the adopted revision when conflict re-export fails Resolving a sync conflict with "keep this device's version" adopted the server's revision and then re-exported. But exportNow() reports upload failures through the backup status message, not its return value, so a failed upload looked like success: the conflict screen told the teacher it was resolved, and the adopted revision stayed persisted. The next auto-export would then overwrite the server copy without ever raising the conflict again. Treat the export as successful only when lastExportedAt actually advances, restore the previous revision otherwise, and return a real error code so the screen reports the failure instead of dismissing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LJRKq8YRXgS65F9dAcQxNi --- lib/core/session/app_session_controller.dart | 27 ++++++++++- test/app_session_controller_test.dart | 47 ++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/lib/core/session/app_session_controller.dart b/lib/core/session/app_session_controller.dart index b291f08..e05c8d0 100644 --- a/lib/core/session/app_session_controller.dart +++ b/lib/core/session/app_session_controller.dart @@ -746,6 +746,12 @@ class AppSessionController extends ChangeNotifier { /// 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, @@ -756,12 +762,31 @@ class AppSessionController extends ChangeNotifier { } if (!isWebDavConfigured) return 'webdav_not_configured'; + final previousRevision = _lastKnownRevision; + final previousExportedAt = _lastExportedAt; + _lastKnownRevision = canonicalRevision; await _libraryBackupPreferencesService.setLastKnownRevision( canonicalRevision, ); - return exportNow(); + 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. 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 {