From 439ea33ffbaf12e3c29ba1ac305a745a54c61c7d Mon Sep 17 00:00:00 2001 From: Mike Barkmin Date: Tue, 28 Apr 2026 10:13:08 +0200 Subject: [PATCH 1/2] feat: better lists --- assets/translations/de.json | 9 +- assets/translations/en.json | 9 +- lib/core/database/app_database.dart | 16 +- lib/core/database/app_database.g.dart | 138 ++++++-- .../database/tables/list_items_table.dart | 2 + lib/core/database/tables/lists_table.dart | 7 +- lib/features/groups/group_detail_screen.dart | 19 +- lib/features/lists/list_detail_screen.dart | 218 +++++++++--- lib/features/lists/list_editor.dart | 153 ++++++++ lib/features/lists/list_item_editor.dart | 206 +++++++++++ lib/features/lists/list_item_links.dart | 58 +++ lib/features/lists/list_repository.dart | 125 ++++++- lib/features/lists/lists_screen.dart | 149 +++----- .../widgets/student_selection_sheet.dart | 334 ++++++++++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 4 + test/list_repository_test.dart | 153 ++++++++ .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 18 files changed, 1397 insertions(+), 207 deletions(-) create mode 100644 lib/features/lists/list_editor.dart create mode 100644 lib/features/lists/list_item_editor.dart create mode 100644 lib/features/lists/list_item_links.dart create mode 100644 lib/shared/widgets/student_selection_sheet.dart create mode 100644 test/list_repository_test.dart diff --git a/assets/translations/de.json b/assets/translations/de.json index 0336832..b46c271 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -73,6 +73,7 @@ "create_database": "Bibliothek erstellen", "create_another_database": "Weitere Bibliothek erstellen", "create_another_database_hint": "Wähle einen Ordner für ein neues verschlüsseltes Classi-Bibliothekspaket.", + "create_items_for_group_students": "Für jede Schüler:in einen Eintrag anlegen", "continue_to_app": "Weiter zu Classi", "copy_recovery_key": "Recovery-Key kopieren", "current_passphrase": "Aktuelle Passphrase", @@ -101,7 +102,7 @@ "empty_groups": "Noch keine Gruppen. Lege deine erste Gruppe an.", "empty_homework": "Noch keine Hausaufgabeneinträge.", "empty_list_items": "Noch keine Listeneinträge.", - "empty_lists": "Für diese Gruppe gibt es noch keine Listen.", + "empty_lists": "Es gibt noch keine Listen.", "empty_material": "Noch keine Materialeinträge.", "empty_notes": "Noch keine Notizen.", "empty_students": "Noch keine Schüler:innen in dieser Gruppe.", @@ -197,11 +198,13 @@ "ok": "OK", "save": "Speichern", "search_students": "Schüler:innen suchen", + "select_all_students": "Alle auswählen", "sessions": "Einträge", "session_label": "Bezeichnung", "session_notes": "Stundennotizen", "settings": "Einstellungen", "selected_students": "{count} ausgewählt", + "clear_selection": "Auswahl aufheben", "sort_by_first_name": "Vorname", "sort_by_last_name": "Nachname", "sort_students": "Schüler:innen sortieren", @@ -242,5 +245,7 @@ "biometric_unlock": "Biometrisch entsperren", "biometric_unlock_hint": "Fingerabdruck oder Gesichtserkennung statt Passphrase verwenden.", "biometric_reason": "Bestätige deine Identität, um Classi zu entsperren.", - "unlock_with_biometrics": "Biometrie verwenden" + "unlock_with_biometrics": "Biometrie verwenden", + "list_scope": "Listenbereich", + "global_list": "Globale Liste" } diff --git a/assets/translations/en.json b/assets/translations/en.json index dda5e5a..43091a7 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -73,6 +73,7 @@ "create_database": "Create library", "create_another_database": "Create another library", "create_another_database_hint": "Pick a folder for a new encrypted Classi library package.", + "create_items_for_group_students": "Create one item per student", "continue_to_app": "Continue to Classi", "copy_recovery_key": "Copy recovery key", "current_passphrase": "Current passphrase", @@ -101,7 +102,7 @@ "empty_groups": "No groups yet. Add your first group to get started.", "empty_homework": "No homework logs yet.", "empty_list_items": "No checklist items yet.", - "empty_lists": "No lists for this group yet.", + "empty_lists": "No lists yet.", "empty_material": "No material logs yet.", "empty_notes": "No notes yet.", "empty_students": "No students in this group yet.", @@ -197,11 +198,13 @@ "ok": "OK", "save": "Save", "search_students": "Search students", + "select_all_students": "Select all", "sessions": "sessions", "session_label": "Session label", "session_notes": "Lesson notes", "settings": "Settings", "selected_students": "{count} selected", + "clear_selection": "Clear selection", "sort_by_first_name": "First name", "sort_by_last_name": "Last name", "sort_students": "Student sorting", @@ -242,5 +245,7 @@ "biometric_unlock": "Unlock with biometrics", "biometric_unlock_hint": "Use fingerprint or face recognition instead of your passphrase.", "biometric_reason": "Confirm your identity to unlock Classi.", - "unlock_with_biometrics": "Use biometrics" + "unlock_with_biometrics": "Use biometrics", + "list_scope": "List scope", + "global_list": "Global list" } diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index a523b6f..7e8eaf4 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -38,7 +38,10 @@ typedef AttendanceLog = AttendanceLogsTableData; class AppDatabase extends _$AppDatabase { AppDatabase._(super.executor, {required this.databasePath}); - factory AppDatabase.open({required File dbFile, required String databaseKey}) { + factory AppDatabase.open({ + required File dbFile, + required String databaseKey, + }) { return AppDatabase._( openEncryptedDatabase(dbFile, databaseKey), databasePath: dbFile.path, @@ -51,7 +54,7 @@ class AppDatabase extends _$AppDatabase { final String databasePath; @override - int get schemaVersion => 10; + int get schemaVersion => 11; @override MigrationStrategy get migration => MigrationStrategy( @@ -93,6 +96,15 @@ class AppDatabase extends _$AppDatabase { if (from < 10) { await migrator.addColumn(studentsTable, studentsTable.seatIndex); } + if (from < 11) { + await migrator.alterTable(TableMigration(listsTable)); + await migrator.addColumn(listItemsTable, listItemsTable.studentIdsJson); + await customStatement(''' + UPDATE list_items_table + SET student_ids_json = json_array(student_id) + WHERE student_id IS NOT NULL AND student_ids_json IS NULL + '''); + } }, ); diff --git a/lib/core/database/app_database.g.dart b/lib/core/database/app_database.g.dart index 5ffa577..4ad078d 100644 --- a/lib/core/database/app_database.g.dart +++ b/lib/core/database/app_database.g.dart @@ -2555,9 +2555,9 @@ class $ListsTableTable extends ListsTable late final GeneratedColumn groupId = GeneratedColumn( 'group_id', aliasedName, - false, + true, type: DriftSqlType.int, - requiredDuringInsert: true, + requiredDuringInsert: false, defaultConstraints: GeneratedColumn.constraintIsAlways( 'REFERENCES groups_table (id) ON DELETE CASCADE', ), @@ -2626,8 +2626,6 @@ class $ListsTableTable extends ListsTable _groupIdMeta, groupId.isAcceptableOrUnknown(data['group_id']!, _groupIdMeta), ); - } else if (isInserting) { - context.missing(_groupIdMeta); } if (data.containsKey('name')) { context.handle( @@ -2665,7 +2663,7 @@ class $ListsTableTable extends ListsTable groupId: attachedDatabase.typeMapping.read( DriftSqlType.int, data['${effectivePrefix}group_id'], - )!, + ), name: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}name'], @@ -2689,13 +2687,13 @@ class $ListsTableTable extends ListsTable class Checklist extends DataClass implements Insertable { final int id; - final int groupId; + final int? groupId; final String name; final DateTime createdAt; final DateTime? archivedAt; const Checklist({ required this.id, - required this.groupId, + this.groupId, required this.name, required this.createdAt, this.archivedAt, @@ -2704,7 +2702,9 @@ class Checklist extends DataClass implements Insertable { Map toColumns(bool nullToAbsent) { final map = {}; map['id'] = Variable(id); - map['group_id'] = Variable(groupId); + if (!nullToAbsent || groupId != null) { + map['group_id'] = Variable(groupId); + } map['name'] = Variable(name); map['created_at'] = Variable(createdAt); if (!nullToAbsent || archivedAt != null) { @@ -2716,7 +2716,9 @@ class Checklist extends DataClass implements Insertable { ListsTableCompanion toCompanion(bool nullToAbsent) { return ListsTableCompanion( id: Value(id), - groupId: Value(groupId), + groupId: groupId == null && nullToAbsent + ? const Value.absent() + : Value(groupId), name: Value(name), createdAt: Value(createdAt), archivedAt: archivedAt == null && nullToAbsent @@ -2732,7 +2734,7 @@ class Checklist extends DataClass implements Insertable { serializer ??= driftRuntimeOptions.defaultSerializer; return Checklist( id: serializer.fromJson(json['id']), - groupId: serializer.fromJson(json['groupId']), + groupId: serializer.fromJson(json['groupId']), name: serializer.fromJson(json['name']), createdAt: serializer.fromJson(json['createdAt']), archivedAt: serializer.fromJson(json['archivedAt']), @@ -2743,7 +2745,7 @@ class Checklist extends DataClass implements Insertable { serializer ??= driftRuntimeOptions.defaultSerializer; return { 'id': serializer.toJson(id), - 'groupId': serializer.toJson(groupId), + 'groupId': serializer.toJson(groupId), 'name': serializer.toJson(name), 'createdAt': serializer.toJson(createdAt), 'archivedAt': serializer.toJson(archivedAt), @@ -2752,13 +2754,13 @@ class Checklist extends DataClass implements Insertable { Checklist copyWith({ int? id, - int? groupId, + Value groupId = const Value.absent(), String? name, DateTime? createdAt, Value archivedAt = const Value.absent(), }) => Checklist( id: id ?? this.id, - groupId: groupId ?? this.groupId, + groupId: groupId.present ? groupId.value : this.groupId, name: name ?? this.name, createdAt: createdAt ?? this.createdAt, archivedAt: archivedAt.present ? archivedAt.value : this.archivedAt, @@ -2802,7 +2804,7 @@ class Checklist extends DataClass implements Insertable { class ListsTableCompanion extends UpdateCompanion { final Value id; - final Value groupId; + final Value groupId; final Value name; final Value createdAt; final Value archivedAt; @@ -2815,12 +2817,11 @@ class ListsTableCompanion extends UpdateCompanion { }); ListsTableCompanion.insert({ this.id = const Value.absent(), - required int groupId, + this.groupId = const Value.absent(), required String name, this.createdAt = const Value.absent(), this.archivedAt = const Value.absent(), - }) : groupId = Value(groupId), - name = Value(name); + }) : name = Value(name); static Insertable custom({ Expression? id, Expression? groupId, @@ -2839,7 +2840,7 @@ class ListsTableCompanion extends UpdateCompanion { ListsTableCompanion copyWith({ Value? id, - Value? groupId, + Value? groupId, Value? name, Value? createdAt, Value? archivedAt, @@ -2932,6 +2933,17 @@ class $ListItemsTableTable extends ListItemsTable 'REFERENCES students_table (id) ON DELETE SET NULL', ), ); + static const VerificationMeta _studentIdsJsonMeta = const VerificationMeta( + 'studentIdsJson', + ); + @override + late final GeneratedColumn studentIdsJson = GeneratedColumn( + 'student_ids_json', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _labelMeta = const VerificationMeta('label'); @override late final GeneratedColumn label = GeneratedColumn( @@ -2973,6 +2985,7 @@ class $ListItemsTableTable extends ListItemsTable id, listId, studentId, + studentIdsJson, label, checkedAt, createdAt, @@ -3006,6 +3019,15 @@ class $ListItemsTableTable extends ListItemsTable studentId.isAcceptableOrUnknown(data['student_id']!, _studentIdMeta), ); } + if (data.containsKey('student_ids_json')) { + context.handle( + _studentIdsJsonMeta, + studentIdsJson.isAcceptableOrUnknown( + data['student_ids_json']!, + _studentIdsJsonMeta, + ), + ); + } if (data.containsKey('label')) { context.handle( _labelMeta, @@ -3047,6 +3069,10 @@ class $ListItemsTableTable extends ListItemsTable DriftSqlType.int, data['${effectivePrefix}student_id'], ), + studentIdsJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}student_ids_json'], + ), label: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}label'], @@ -3072,6 +3098,7 @@ class ChecklistItem extends DataClass implements Insertable { final int id; final int listId; final int? studentId; + final String? studentIdsJson; final String label; final DateTime? checkedAt; final DateTime createdAt; @@ -3079,6 +3106,7 @@ class ChecklistItem extends DataClass implements Insertable { required this.id, required this.listId, this.studentId, + this.studentIdsJson, required this.label, this.checkedAt, required this.createdAt, @@ -3091,6 +3119,9 @@ class ChecklistItem extends DataClass implements Insertable { if (!nullToAbsent || studentId != null) { map['student_id'] = Variable(studentId); } + if (!nullToAbsent || studentIdsJson != null) { + map['student_ids_json'] = Variable(studentIdsJson); + } map['label'] = Variable(label); if (!nullToAbsent || checkedAt != null) { map['checked_at'] = Variable(checkedAt); @@ -3106,6 +3137,9 @@ class ChecklistItem extends DataClass implements Insertable { studentId: studentId == null && nullToAbsent ? const Value.absent() : Value(studentId), + studentIdsJson: studentIdsJson == null && nullToAbsent + ? const Value.absent() + : Value(studentIdsJson), label: Value(label), checkedAt: checkedAt == null && nullToAbsent ? const Value.absent() @@ -3123,6 +3157,7 @@ class ChecklistItem extends DataClass implements Insertable { id: serializer.fromJson(json['id']), listId: serializer.fromJson(json['listId']), studentId: serializer.fromJson(json['studentId']), + studentIdsJson: serializer.fromJson(json['studentIdsJson']), label: serializer.fromJson(json['label']), checkedAt: serializer.fromJson(json['checkedAt']), createdAt: serializer.fromJson(json['createdAt']), @@ -3135,6 +3170,7 @@ class ChecklistItem extends DataClass implements Insertable { 'id': serializer.toJson(id), 'listId': serializer.toJson(listId), 'studentId': serializer.toJson(studentId), + 'studentIdsJson': serializer.toJson(studentIdsJson), 'label': serializer.toJson(label), 'checkedAt': serializer.toJson(checkedAt), 'createdAt': serializer.toJson(createdAt), @@ -3145,6 +3181,7 @@ class ChecklistItem extends DataClass implements Insertable { int? id, int? listId, Value studentId = const Value.absent(), + Value studentIdsJson = const Value.absent(), String? label, Value checkedAt = const Value.absent(), DateTime? createdAt, @@ -3152,6 +3189,9 @@ class ChecklistItem extends DataClass implements Insertable { id: id ?? this.id, listId: listId ?? this.listId, studentId: studentId.present ? studentId.value : this.studentId, + studentIdsJson: studentIdsJson.present + ? studentIdsJson.value + : this.studentIdsJson, label: label ?? this.label, checkedAt: checkedAt.present ? checkedAt.value : this.checkedAt, createdAt: createdAt ?? this.createdAt, @@ -3161,6 +3201,9 @@ class ChecklistItem extends DataClass implements Insertable { id: data.id.present ? data.id.value : this.id, listId: data.listId.present ? data.listId.value : this.listId, studentId: data.studentId.present ? data.studentId.value : this.studentId, + studentIdsJson: data.studentIdsJson.present + ? data.studentIdsJson.value + : this.studentIdsJson, label: data.label.present ? data.label.value : this.label, checkedAt: data.checkedAt.present ? data.checkedAt.value : this.checkedAt, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, @@ -3173,6 +3216,7 @@ class ChecklistItem extends DataClass implements Insertable { ..write('id: $id, ') ..write('listId: $listId, ') ..write('studentId: $studentId, ') + ..write('studentIdsJson: $studentIdsJson, ') ..write('label: $label, ') ..write('checkedAt: $checkedAt, ') ..write('createdAt: $createdAt') @@ -3181,8 +3225,15 @@ class ChecklistItem extends DataClass implements Insertable { } @override - int get hashCode => - Object.hash(id, listId, studentId, label, checkedAt, createdAt); + int get hashCode => Object.hash( + id, + listId, + studentId, + studentIdsJson, + label, + checkedAt, + createdAt, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -3190,6 +3241,7 @@ class ChecklistItem extends DataClass implements Insertable { other.id == this.id && other.listId == this.listId && other.studentId == this.studentId && + other.studentIdsJson == this.studentIdsJson && other.label == this.label && other.checkedAt == this.checkedAt && other.createdAt == this.createdAt); @@ -3199,6 +3251,7 @@ class ListItemsTableCompanion extends UpdateCompanion { final Value id; final Value listId; final Value studentId; + final Value studentIdsJson; final Value label; final Value checkedAt; final Value createdAt; @@ -3206,6 +3259,7 @@ class ListItemsTableCompanion extends UpdateCompanion { this.id = const Value.absent(), this.listId = const Value.absent(), this.studentId = const Value.absent(), + this.studentIdsJson = const Value.absent(), this.label = const Value.absent(), this.checkedAt = const Value.absent(), this.createdAt = const Value.absent(), @@ -3214,6 +3268,7 @@ class ListItemsTableCompanion extends UpdateCompanion { this.id = const Value.absent(), required int listId, this.studentId = const Value.absent(), + this.studentIdsJson = const Value.absent(), required String label, this.checkedAt = const Value.absent(), this.createdAt = const Value.absent(), @@ -3223,6 +3278,7 @@ class ListItemsTableCompanion extends UpdateCompanion { Expression? id, Expression? listId, Expression? studentId, + Expression? studentIdsJson, Expression? label, Expression? checkedAt, Expression? createdAt, @@ -3231,6 +3287,7 @@ class ListItemsTableCompanion extends UpdateCompanion { if (id != null) 'id': id, if (listId != null) 'list_id': listId, if (studentId != null) 'student_id': studentId, + if (studentIdsJson != null) 'student_ids_json': studentIdsJson, if (label != null) 'label': label, if (checkedAt != null) 'checked_at': checkedAt, if (createdAt != null) 'created_at': createdAt, @@ -3241,6 +3298,7 @@ class ListItemsTableCompanion extends UpdateCompanion { Value? id, Value? listId, Value? studentId, + Value? studentIdsJson, Value? label, Value? checkedAt, Value? createdAt, @@ -3249,6 +3307,7 @@ class ListItemsTableCompanion extends UpdateCompanion { id: id ?? this.id, listId: listId ?? this.listId, studentId: studentId ?? this.studentId, + studentIdsJson: studentIdsJson ?? this.studentIdsJson, label: label ?? this.label, checkedAt: checkedAt ?? this.checkedAt, createdAt: createdAt ?? this.createdAt, @@ -3267,6 +3326,9 @@ class ListItemsTableCompanion extends UpdateCompanion { if (studentId.present) { map['student_id'] = Variable(studentId.value); } + if (studentIdsJson.present) { + map['student_ids_json'] = Variable(studentIdsJson.value); + } if (label.present) { map['label'] = Variable(label.value); } @@ -3285,6 +3347,7 @@ class ListItemsTableCompanion extends UpdateCompanion { ..write('id: $id, ') ..write('listId: $listId, ') ..write('studentId: $studentId, ') + ..write('studentIdsJson: $studentIdsJson, ') ..write('label: $label, ') ..write('checkedAt: $checkedAt, ') ..write('createdAt: $createdAt') @@ -6937,7 +7000,7 @@ typedef $$HomeworkLogsTableTableProcessedTableManager = typedef $$ListsTableTableCreateCompanionBuilder = ListsTableCompanion Function({ Value id, - required int groupId, + Value groupId, required String name, Value createdAt, Value archivedAt, @@ -6945,7 +7008,7 @@ typedef $$ListsTableTableCreateCompanionBuilder = typedef $$ListsTableTableUpdateCompanionBuilder = ListsTableCompanion Function({ Value id, - Value groupId, + Value groupId, Value name, Value createdAt, Value archivedAt, @@ -6960,9 +7023,9 @@ final class $$ListsTableTableReferences $_aliasNameGenerator(db.listsTable.groupId, db.groupsTable.id), ); - $$GroupsTableTableProcessedTableManager get groupId { - final $_column = $_itemColumn('group_id')!; - + $$GroupsTableTableProcessedTableManager? get groupId { + final $_column = $_itemColumn('group_id'); + if ($_column == null) return null; final manager = $$GroupsTableTableTableManager( $_db, $_db.groupsTable, @@ -7225,7 +7288,7 @@ class $$ListsTableTableTableManager updateCompanionCallback: ({ Value id = const Value.absent(), - Value groupId = const Value.absent(), + Value groupId = const Value.absent(), Value name = const Value.absent(), Value createdAt = const Value.absent(), Value archivedAt = const Value.absent(), @@ -7239,7 +7302,7 @@ class $$ListsTableTableTableManager createCompanionCallback: ({ Value id = const Value.absent(), - required int groupId, + Value groupId = const Value.absent(), required String name, Value createdAt = const Value.absent(), Value archivedAt = const Value.absent(), @@ -7348,6 +7411,7 @@ typedef $$ListItemsTableTableCreateCompanionBuilder = Value id, required int listId, Value studentId, + Value studentIdsJson, required String label, Value checkedAt, Value createdAt, @@ -7357,6 +7421,7 @@ typedef $$ListItemsTableTableUpdateCompanionBuilder = Value id, Value listId, Value studentId, + Value studentIdsJson, Value label, Value checkedAt, Value createdAt, @@ -7423,6 +7488,11 @@ class $$ListItemsTableTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get studentIdsJson => $composableBuilder( + column: $table.studentIdsJson, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get label => $composableBuilder( column: $table.label, builder: (column) => ColumnFilters(column), @@ -7499,6 +7569,11 @@ class $$ListItemsTableTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get studentIdsJson => $composableBuilder( + column: $table.studentIdsJson, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get label => $composableBuilder( column: $table.label, builder: (column) => ColumnOrderings(column), @@ -7573,6 +7648,11 @@ class $$ListItemsTableTableAnnotationComposer GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); + GeneratedColumn get studentIdsJson => $composableBuilder( + column: $table.studentIdsJson, + builder: (column) => column, + ); + GeneratedColumn get label => $composableBuilder(column: $table.label, builder: (column) => column); @@ -7662,6 +7742,7 @@ class $$ListItemsTableTableTableManager Value id = const Value.absent(), Value listId = const Value.absent(), Value studentId = const Value.absent(), + Value studentIdsJson = const Value.absent(), Value label = const Value.absent(), Value checkedAt = const Value.absent(), Value createdAt = const Value.absent(), @@ -7669,6 +7750,7 @@ class $$ListItemsTableTableTableManager id: id, listId: listId, studentId: studentId, + studentIdsJson: studentIdsJson, label: label, checkedAt: checkedAt, createdAt: createdAt, @@ -7678,6 +7760,7 @@ class $$ListItemsTableTableTableManager Value id = const Value.absent(), required int listId, Value studentId = const Value.absent(), + Value studentIdsJson = const Value.absent(), required String label, Value checkedAt = const Value.absent(), Value createdAt = const Value.absent(), @@ -7685,6 +7768,7 @@ class $$ListItemsTableTableTableManager id: id, listId: listId, studentId: studentId, + studentIdsJson: studentIdsJson, label: label, checkedAt: checkedAt, createdAt: createdAt, diff --git a/lib/core/database/tables/list_items_table.dart b/lib/core/database/tables/list_items_table.dart index 8a02b5c..dcbbb51 100644 --- a/lib/core/database/tables/list_items_table.dart +++ b/lib/core/database/tables/list_items_table.dart @@ -16,6 +16,8 @@ class ListItemsTable extends Table { onDelete: KeyAction.setNull, )(); + TextColumn get studentIdsJson => text().nullable()(); + TextColumn get label => text().withLength(min: 1, max: 150)(); DateTimeColumn get checkedAt => dateTime().nullable()(); diff --git a/lib/core/database/tables/lists_table.dart b/lib/core/database/tables/lists_table.dart index 16cb023..44f2dc6 100644 --- a/lib/core/database/tables/lists_table.dart +++ b/lib/core/database/tables/lists_table.dart @@ -6,8 +6,11 @@ import 'groups_table.dart'; class ListsTable extends Table { IntColumn get id => integer().autoIncrement()(); - IntColumn get groupId => - integer().references(GroupsTable, #id, onDelete: KeyAction.cascade)(); + IntColumn get groupId => integer().nullable().references( + GroupsTable, + #id, + onDelete: KeyAction.cascade, + )(); TextColumn get name => text().withLength(min: 1, max: 120)(); diff --git a/lib/features/groups/group_detail_screen.dart b/lib/features/groups/group_detail_screen.dart index 3cd1752..90a7ddc 100644 --- a/lib/features/groups/group_detail_screen.dart +++ b/lib/features/groups/group_detail_screen.dart @@ -22,6 +22,7 @@ import '../../shared/widgets/student_avatar.dart'; import '../../shared/widgets/swipe_action_background.dart'; import '../../shared/theme/app_ui.dart'; import '../lists/list_repository.dart'; +import '../lists/list_editor.dart'; import '../lessons/lesson_support.dart'; import '../notes/note_editor.dart'; import '../notes/note_links.dart'; @@ -287,7 +288,7 @@ class GroupDetailScreen extends ConsumerWidget { listProgressValue.value ?? const {}, onAddList: archived ? null - : () => _createList(context, ref, group.id), + : () => _createList(context, ref, group), onEditList: (list) => _editList(context, ref, list), onArchiveList: (list) => ref.read(listRepositoryProvider).archiveList(list.id), @@ -440,20 +441,28 @@ class GroupDetailScreen extends ConsumerWidget { Future _createList( BuildContext context, WidgetRef ref, - int groupId, + Group group, ) async { - final name = await _showListNameDialog( + final result = await showListEditorDialog( context: context, + groups: [group], + initialGroupId: group.id, + allowGroupSelection: false, + fixedGroupName: group.name, title: 'new_list'.tr(), actionLabel: 'add'.tr(), ); - if (name == null || name.isEmpty) { + if (result == null) { return; } await ref .read(listRepositoryProvider) - .createList(groupId: groupId, name: name); + .createListWithOptions( + groupId: group.id, + name: result.name, + populateFromGroupStudents: result.populateFromGroupStudents, + ); } Future _addGroupNote( diff --git a/lib/features/lists/list_detail_screen.dart b/lib/features/lists/list_detail_screen.dart index 3bcd75c..de95b85 100644 --- a/lib/features/lists/list_detail_screen.dart +++ b/lib/features/lists/list_detail_screen.dart @@ -10,6 +10,9 @@ import '../../shared/widgets/app_bar_title.dart'; import '../../shared/widgets/app_error_state.dart'; import '../../shared/widgets/empty_state.dart'; import '../../shared/widgets/student_avatar.dart'; +import '../../shared/widgets/student_link_chip.dart'; +import 'list_item_editor.dart'; +import 'list_item_links.dart'; final checklistProvider = StreamProvider.family( (ref, listId) => ref.watch(listRepositoryProvider).watchList(listId), @@ -23,12 +26,22 @@ final listDetailGroupProvider = StreamProvider.family( (ref, groupId) => ref.watch(groupRepositoryProvider).watchGroup(groupId), ); +final listDetailGroupsProvider = StreamProvider>( + (ref) => ref.watch(groupRepositoryProvider).watchActiveGroups(), +); + final listDetailStudentsProvider = StreamProvider.family, int>( (ref, groupId) => ref .watch(studentRepositoryProvider) .watchByGroup(groupId, sortField: ref.watch(studentSortFieldProvider)), ); +final listDetailAllStudentsProvider = StreamProvider>( + (ref) => ref + .watch(studentRepositoryProvider) + .watchAllStudents(sortField: ref.watch(studentSortFieldProvider)), +); + class ListDetailScreen extends ConsumerStatefulWidget { const ListDetailScreen({required this.listId, super.key}); @@ -52,11 +65,14 @@ class _ListDetailScreenState extends ConsumerState { return const Scaffold(body: SizedBox.shrink()); } - final groupValue = ref.watch(listDetailGroupProvider(list.groupId)); - final studentsValue = ref.watch( - listDetailStudentsProvider(list.groupId), - ); - final group = groupValue.value; + final groupsValue = ref.watch(listDetailGroupsProvider); + final groupValue = list.groupId == null + ? null + : ref.watch(listDetailGroupProvider(list.groupId!)); + final studentsValue = list.groupId == null + ? ref.watch(listDetailAllStudentsProvider) + : ref.watch(listDetailStudentsProvider(list.groupId!)); + final group = groupValue?.value; final appBarColor = group == null ? null : colorFromHex(group.colorHex); final appBarForeground = appBarColor == null ? null @@ -66,7 +82,12 @@ class _ListDetailScreenState extends ConsumerState { appBar: AppBar( backgroundColor: appBarColor, foregroundColor: appBarForeground, - title: AppBarTitle(title: list.name, subtitle: group?.name), + title: AppBarTitle( + title: list.name, + subtitle: + group?.name ?? + (list.groupId == null ? 'global_list'.tr() : null), + ), actions: [ IconButton( icon: const Icon(Icons.edit_outlined), @@ -75,12 +96,18 @@ class _ListDetailScreenState extends ConsumerState { ], ), floatingActionButton: FloatingActionButton.extended( - onPressed: () => _addItem(context), + onPressed: () => _addItem( + context, + list: list, + groups: groupsValue.value ?? const [], + students: studentsValue.value ?? const [], + ), icon: const Icon(Icons.add), label: Text('add_item'.tr()), ), body: itemsValue.when( data: (items) { + final groups = groupsValue.value ?? const []; final studentsById = { for (final student in studentsValue.value ?? const []) student.id: student, @@ -135,15 +162,17 @@ class _ListDetailScreenState extends ConsumerState { onSelected: (value) => setState(() => _showUncheckedOnly = value), ), - if (items.isEmpty) + if (items.isEmpty && list.groupId != null) OutlinedButton( onPressed: () => ref .read(listRepositoryProvider) .populateFromGroup( listId: list.id, - groupId: list.groupId, + groupId: list.groupId!, ), - child: Text('populate_from_group'.tr()), + child: Text( + 'create_items_for_group_students'.tr(), + ), ), ], ), @@ -159,12 +188,21 @@ class _ListDetailScreenState extends ConsumerState { for (final item in visibleItems) _ChecklistItemTile( item: item, - student: item.studentId == null - ? null - : studentsById[item.studentId], + linkedStudents: [ + for (final studentId in listItemStudentIds(item)) + if (studentsById[studentId] != null) + studentsById[studentId]!, + ], onChanged: (checked) => ref .read(listRepositoryProvider) .toggleItem(itemId: item.id, checked: checked), + onEdit: () => _editItem( + context, + list: list, + item: item, + groups: groups, + students: studentsValue.value ?? const [], + ), onDelete: () => ref .read(listRepositoryProvider) .deleteItem(item.id), @@ -216,76 +254,128 @@ class _ListDetailScreenState extends ConsumerState { .renameList(listId: list.id, name: name); } - Future _addItem(BuildContext context) async { - final controller = TextEditingController(); - final label = await showDialog( + Future _addItem( + BuildContext context, { + required Checklist list, + required List groups, + required List students, + }) async { + final result = await showListItemEditorSheet( context: context, - builder: (context) => AlertDialog( - title: Text('add_item'.tr()), - content: TextField( - controller: controller, - decoration: InputDecoration(labelText: 'add_item'.tr()), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text('cancel'.tr()), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(controller.text.trim()), - child: Text('add'.tr()), - ), - ], - ), + groups: groups, + students: students, + preferredGroupId: list.groupId, + allowSelectAllStudents: list.groupId == null, + title: 'add_item'.tr(), ); + if (result == null) { + return; + } - controller.dispose(); - if (label == null || label.isEmpty) { + await ref + .read(listRepositoryProvider) + .addItem( + listId: widget.listId, + label: result.label, + studentIds: result.studentIds, + ); + } + + Future _editItem( + BuildContext context, { + required Checklist list, + required ChecklistItem item, + required List groups, + required List students, + }) async { + final result = await showListItemEditorSheet( + context: context, + groups: groups, + students: students, + preferredGroupId: list.groupId, + allowSelectAllStudents: list.groupId == null, + title: 'edit'.tr(), + initialLabel: item.label, + initialStudentIds: listItemStudentIds(item), + ); + if (result == null) { return; } await ref .read(listRepositoryProvider) - .addItem(listId: widget.listId, label: label); + .updateItem( + item: item, + label: result.label, + studentIds: result.studentIds, + ); } } class _ChecklistItemTile extends StatelessWidget { const _ChecklistItemTile({ required this.item, - required this.student, + required this.linkedStudents, required this.onChanged, + required this.onEdit, required this.onDelete, }); final ChecklistItem item; - final Student? student; + final List linkedStudents; final ValueChanged onChanged; + final VoidCallback onEdit; final VoidCallback onDelete; @override Widget build(BuildContext context) { final checked = item.checkedAt != null; - final title = student == null - ? item.label + final singleLinkedStudent = linkedStudents.length == 1 + ? linkedStudents.single + : null; + final singleLinkedStudentName = singleLinkedStudent == null + ? null : studentDisplayName( - firstName: student!.firstName, - lastName: student!.lastName, + firstName: singleLinkedStudent.firstName, + lastName: singleLinkedStudent.lastName, ); + final showLinkedStudents = + linkedStudents.length > 1 || + (singleLinkedStudentName != null && + singleLinkedStudentName != item.label); + final subtitleChildren = [ + if (item.checkedAt != null) + Text( + MaterialLocalizations.of(context).formatMediumDate(item.checkedAt!), + ), + if (showLinkedStudents) ...[ + if (item.checkedAt != null) const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final student in linkedStudents) + StudentLinkChip(student: student, avatarSize: 24), + ], + ), + ], + ]; return Card( child: ListTile( onTap: () => onChanged(!checked), - leading: student == null + leading: singleLinkedStudent == null + ? linkedStudents.length > 1 + ? CircleAvatar(child: Text('${linkedStudents.length}')) + : null + : StudentAvatar(student: singleLinkedStudent, size: 36), + title: Text(item.label), + subtitle: subtitleChildren.isEmpty ? null - : StudentAvatar(student: student!, size: 36), - title: Text(title), - subtitle: item.checkedAt == null - ? null - : Text( - MaterialLocalizations.of( - context, - ).formatMediumDate(item.checkedAt!), + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: subtitleChildren, ), trailing: Row( mainAxisSize: MainAxisSize.min, @@ -294,9 +384,27 @@ class _ChecklistItemTile extends StatelessWidget { value: checked, onChanged: (value) => onChanged(value ?? false), ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: onDelete, + PopupMenuButton<_ChecklistItemAction>( + onSelected: (action) { + switch (action) { + case _ChecklistItemAction.edit: + onEdit(); + return; + case _ChecklistItemAction.delete: + onDelete(); + return; + } + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: _ChecklistItemAction.edit, + child: Text('edit'.tr()), + ), + PopupMenuItem( + value: _ChecklistItemAction.delete, + child: Text('delete'.tr()), + ), + ], ), ], ), @@ -304,3 +412,5 @@ class _ChecklistItemTile extends StatelessWidget { ); } } + +enum _ChecklistItemAction { edit, delete } diff --git a/lib/features/lists/list_editor.dart b/lib/features/lists/list_editor.dart new file mode 100644 index 0000000..7d305c4 --- /dev/null +++ b/lib/features/lists/list_editor.dart @@ -0,0 +1,153 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../../core/database/app_database.dart'; + +typedef ListEditorResult = ({ + String name, + int? groupId, + bool populateFromGroupStudents, +}); + +Future showListEditorDialog({ + required BuildContext context, + required List groups, + required String title, + required String actionLabel, + int? initialGroupId, + String? initialName, + bool allowGroupSelection = true, + String? fixedGroupName, +}) { + return showDialog( + context: context, + builder: (context) => _ListEditorDialog( + groups: groups, + title: title, + actionLabel: actionLabel, + initialGroupId: initialGroupId, + initialName: initialName, + allowGroupSelection: allowGroupSelection, + fixedGroupName: fixedGroupName, + ), + ); +} + +class _ListEditorDialog extends StatefulWidget { + const _ListEditorDialog({ + required this.groups, + required this.title, + required this.actionLabel, + required this.allowGroupSelection, + this.initialGroupId, + this.initialName, + this.fixedGroupName, + }); + + final List groups; + final String title; + final String actionLabel; + final int? initialGroupId; + final String? initialName; + final bool allowGroupSelection; + final String? fixedGroupName; + + @override + State<_ListEditorDialog> createState() => _ListEditorDialogState(); +} + +class _ListEditorDialogState extends State<_ListEditorDialog> { + late final TextEditingController _nameController; + late int? _selectedGroupId; + bool _populateFromGroupStudents = false; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.initialName); + _selectedGroupId = widget.initialGroupId; + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.allowGroupSelection) + DropdownButtonFormField( + initialValue: _selectedGroupId, + decoration: InputDecoration(labelText: 'list_scope'.tr()), + items: [ + DropdownMenuItem( + value: null, + child: Text('global_list'.tr()), + ), + for (final group in widget.groups) + DropdownMenuItem( + value: group.id, + child: Text(group.name), + ), + ], + onChanged: (value) => setState(() { + _selectedGroupId = value; + if (value == null) { + _populateFromGroupStudents = false; + } + }), + ) + else if (widget.fixedGroupName != null) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.groups_outlined), + title: Text(widget.fixedGroupName!), + subtitle: Text('groups'.tr()), + ), + if (!widget.allowGroupSelection && widget.fixedGroupName != null) + const SizedBox(height: 16), + TextField( + controller: _nameController, + decoration: InputDecoration(labelText: 'new_list'.tr()), + ), + if (_selectedGroupId != null) ...[ + const SizedBox(height: 16), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + value: _populateFromGroupStudents, + onChanged: (value) => + setState(() => _populateFromGroupStudents = value ?? false), + title: Text('create_items_for_group_students'.tr()), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('cancel'.tr()), + ), + FilledButton( + onPressed: () { + final name = _nameController.text.trim(); + if (name.isEmpty) { + return; + } + Navigator.of(context).pop(( + name: name, + groupId: _selectedGroupId, + populateFromGroupStudents: _populateFromGroupStudents, + )); + }, + child: Text(widget.actionLabel), + ), + ], + ); + } +} diff --git a/lib/features/lists/list_item_editor.dart b/lib/features/lists/list_item_editor.dart new file mode 100644 index 0000000..89255ed --- /dev/null +++ b/lib/features/lists/list_item_editor.dart @@ -0,0 +1,206 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../../core/database/app_database.dart'; +import '../../shared/utils/formatting.dart'; +import '../../shared/widgets/student_avatar.dart'; +import '../../shared/widgets/student_selection_sheet.dart'; + +typedef ListItemEditorResult = ({String label, List studentIds}); + +Future showListItemEditorSheet({ + required BuildContext context, + required List groups, + required List students, + required String title, + String? initialLabel, + List? initialStudentIds, + int? preferredGroupId, + bool allowSelectAllStudents = false, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, + builder: (context) => _ListItemEditorSheet( + groups: groups, + students: students, + title: title, + initialLabel: initialLabel, + initialStudentIds: initialStudentIds, + preferredGroupId: preferredGroupId, + allowSelectAllStudents: allowSelectAllStudents, + ), + ); +} + +class _ListItemEditorSheet extends StatefulWidget { + const _ListItemEditorSheet({ + required this.groups, + required this.students, + required this.title, + required this.allowSelectAllStudents, + this.initialLabel, + this.initialStudentIds, + this.preferredGroupId, + }); + + final List groups; + final List students; + final String title; + final String? initialLabel; + final List? initialStudentIds; + final int? preferredGroupId; + final bool allowSelectAllStudents; + + @override + State<_ListItemEditorSheet> createState() => _ListItemEditorSheetState(); +} + +class _ListItemEditorSheetState extends State<_ListItemEditorSheet> { + final _formKey = GlobalKey(); + late final TextEditingController _labelController; + late final Set _studentIds; + + @override + void initState() { + super.initState(); + _labelController = TextEditingController(text: widget.initialLabel); + _studentIds = {...?widget.initialStudentIds}; + } + + @override + void dispose() { + _labelController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 24, + bottom: MediaQuery.viewInsetsOf(context).bottom + 24, + ), + child: SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.title, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextFormField( + controller: _labelController, + decoration: InputDecoration(labelText: 'label'.tr()), + validator: (value) => value == null || value.trim().isEmpty + ? 'label'.tr() + : null, + ), + ), + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_studentIds.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final student in widget.students) + if (_studentIds.contains(student.id)) + InputChip( + avatar: StudentAvatar( + student: student, + size: 24, + ), + label: Text( + studentDisplayName( + firstName: student.firstName, + lastName: student.lastName, + ), + ), + onDeleted: () => setState( + () => _studentIds.remove(student.id), + ), + ), + ], + ), + if (_studentIds.isNotEmpty) const SizedBox(height: 16), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.people_outline), + title: Text('link_to_students'.tr()), + subtitle: Text( + 'selected_students'.tr( + namedArgs: {'count': '${_studentIds.length}'}, + ), + ), + trailing: const Icon(Icons.chevron_right), + onTap: _pickStudents, + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('cancel'.tr()), + ), + const SizedBox(width: 12), + FilledButton(onPressed: _save, child: Text('save'.tr())), + ], + ), + ], + ), + ), + ), + ); + } + + Future _pickStudents() async { + final selectedStudentIds = await showStudentSelectionSheet( + context: context, + groups: widget.groups, + students: widget.students, + selectedStudentIds: _studentIds, + preferredGroupId: widget.preferredGroupId, + allowSelectAll: widget.allowSelectAllStudents, + ); + if (selectedStudentIds == null) { + return; + } + + setState(() { + _studentIds + ..clear() + ..addAll(selectedStudentIds); + }); + } + + void _save() { + if (!_formKey.currentState!.validate()) { + return; + } + + Navigator.of(context).pop(( + label: _labelController.text.trim(), + studentIds: _studentIds.toList(growable: false), + )); + } +} diff --git a/lib/features/lists/list_item_links.dart b/lib/features/lists/list_item_links.dart new file mode 100644 index 0000000..19bb42f --- /dev/null +++ b/lib/features/lists/list_item_links.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; + +import '../../core/database/app_database.dart'; + +List normalizeListItemStudentIds(Iterable studentIds) { + final normalized = []; + final seen = {}; + for (final studentId in studentIds) { + if (studentId > 0 && seen.add(studentId)) { + normalized.add(studentId); + } + } + return normalized; +} + +String? encodeListItemStudentIds(Iterable studentIds) { + final normalized = normalizeListItemStudentIds(studentIds); + if (normalized.isEmpty) { + return null; + } + return jsonEncode(normalized); +} + +List decodeListItemStudentIds( + String? encodedStudentIds, { + int? fallbackStudentId, +}) { + final fallback = fallbackStudentId == null + ? const [] + : [fallbackStudentId]; + if (encodedStudentIds == null || encodedStudentIds.trim().isEmpty) { + return fallback; + } + + try { + final decoded = jsonDecode(encodedStudentIds); + if (decoded is! List) { + return fallback; + } + final normalized = normalizeListItemStudentIds([ + for (final studentId in decoded) + if (studentId is int) + studentId + else + int.tryParse(studentId.toString()) ?? -1, + ]); + return normalized.isEmpty ? fallback : normalized; + } on FormatException { + return fallback; + } +} + +List listItemStudentIds(ChecklistItem item) { + return decodeListItemStudentIds( + item.studentIdsJson, + fallbackStudentId: item.studentId, + ); +} diff --git a/lib/features/lists/list_repository.dart b/lib/features/lists/list_repository.dart index e88ff13..0754070 100644 --- a/lib/features/lists/list_repository.dart +++ b/lib/features/lists/list_repository.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart'; import '../../core/database/app_database.dart'; import '../../shared/utils/formatting.dart'; +import 'list_item_links.dart'; class ListProgress { const ListProgress({required this.checked, required this.total}); @@ -100,11 +101,36 @@ class ListRepository { } Future createList({required int groupId, required String name}) { - return _database - .into(_database.listsTable) - .insert( - ListsTableCompanion.insert(groupId: groupId, name: name.trim()), - ); + return createListWithOptions(groupId: groupId, name: name); + } + + Future createListWithOptions({ + int? groupId, + required String name, + bool populateFromGroupStudents = false, + }) { + if (populateFromGroupStudents && groupId == null) { + throw ArgumentError.value( + groupId, + 'groupId', + 'Only group lists can create one item per student.', + ); + } + + return _database.transaction(() async { + final listId = await _database + .into(_database.listsTable) + .insert( + ListsTableCompanion.insert( + groupId: Value(groupId), + name: name.trim(), + ), + ); + if (populateFromGroupStudents && groupId != null) { + await populateFromGroup(listId: listId, groupId: groupId); + } + return listId; + }); } Future renameList({required int listId, required String name}) { @@ -133,20 +159,55 @@ class ListRepository { Future addItem({ required int listId, - int? studentId, + List studentIds = const [], required String label, - }) { - return _database + }) async { + final list = await _requireList(listId); + final normalizedStudentIds = await _validatedStudentIdsForList( + list: list, + studentIds: studentIds, + ); + + await _database .into(_database.listItemsTable) .insert( ListItemsTableCompanion.insert( listId: listId, - studentId: Value(studentId), + studentId: Value( + normalizedStudentIds.isEmpty ? null : normalizedStudentIds.first, + ), + studentIdsJson: Value( + encodeListItemStudentIds(normalizedStudentIds), + ), label: label.trim(), ), ); } + Future updateItem({ + required ChecklistItem item, + required String label, + List studentIds = const [], + }) async { + final list = await _requireList(item.listId); + final normalizedStudentIds = await _validatedStudentIdsForList( + list: list, + studentIds: studentIds, + ); + + await (_database.update( + _database.listItemsTable, + )..where((table) => table.id.equals(item.id))).write( + ListItemsTableCompanion( + studentId: Value( + normalizedStudentIds.isEmpty ? null : normalizedStudentIds.first, + ), + studentIdsJson: Value(encodeListItemStudentIds(normalizedStudentIds)), + label: Value(label.trim()), + ), + ); + } + Future toggleItem({required int itemId, required bool checked}) { return (_database.update( _database.listItemsTable, @@ -167,6 +228,15 @@ class ListRepository { required int listId, required int groupId, }) async { + final list = await _requireList(listId); + if (list.groupId != groupId) { + throw ArgumentError.value( + groupId, + 'groupId', + 'List does not belong to the requested group.', + ); + } + final students = await (_database.select(_database.studentsTable) ..where((table) => table.groupId.equals(groupId)) @@ -183,6 +253,7 @@ class ListRepository { ListItemsTableCompanion.insert( listId: listId, studentId: Value(student.id), + studentIdsJson: Value(encodeListItemStudentIds([student.id])), label: studentDisplayName( firstName: student.firstName, lastName: student.lastName, @@ -192,4 +263,40 @@ class ListRepository { } }); } + + Future _requireList(int listId) { + return (_database.select( + _database.listsTable, + )..where((table) => table.id.equals(listId))).getSingle(); + } + + Future> _validatedStudentIdsForList({ + required Checklist list, + required Iterable studentIds, + }) async { + final normalizedStudentIds = normalizeListItemStudentIds(studentIds); + if (normalizedStudentIds.isEmpty) { + return normalizedStudentIds; + } + + final query = _database.select(_database.studentsTable) + ..where((table) => table.id.isIn(normalizedStudentIds)); + if (list.groupId != null) { + query.where((table) => table.groupId.equals(list.groupId!)); + } + final students = await query.get(); + final availableStudentIds = {for (final student in students) student.id}; + final invalidStudentIds = [ + for (final studentId in normalizedStudentIds) + if (!availableStudentIds.contains(studentId)) studentId, + ]; + if (invalidStudentIds.isNotEmpty) { + throw ArgumentError.value( + invalidStudentIds, + 'studentIds', + 'Students are not available for this list.', + ); + } + return normalizedStudentIds; + } } diff --git a/lib/features/lists/lists_screen.dart b/lib/features/lists/lists_screen.dart index aa7b76a..c439967 100644 --- a/lib/features/lists/lists_screen.dart +++ b/lib/features/lists/lists_screen.dart @@ -10,6 +10,7 @@ import '../../shared/widgets/app_error_state.dart'; import '../../shared/widgets/confirm_dialog.dart'; import '../../shared/widgets/empty_state.dart'; import '../../shared/widgets/swipe_action_background.dart'; +import 'list_editor.dart'; import 'list_repository.dart'; final listsProvider = StreamProvider.family, int>( @@ -169,13 +170,11 @@ class _ListsScreenState extends ConsumerState { return; } - if (groups.isEmpty) { - return; - } - - final result = await showDialog<({int groupId, String name})>( + final result = await showListEditorDialog( context: context, - builder: (context) => _CreateListDialog(groups: groups), + groups: groups, + title: 'new_list'.tr(), + actionLabel: 'add'.tr(), ); if (result == null) { return; @@ -183,7 +182,11 @@ class _ListsScreenState extends ConsumerState { await ref .read(listRepositoryProvider) - .createList(groupId: result.groupId, name: result.name); + .createListWithOptions( + groupId: result.groupId, + name: result.name, + populateFromGroupStudents: result.populateFromGroupStudents, + ); } Future _createListForGroup( @@ -191,36 +194,30 @@ class _ListsScreenState extends ConsumerState { WidgetRef ref, int groupId, ) async { - final controller = TextEditingController(); - final name = await showDialog( + final group = (await ref.read(listsGroupProvider(groupId).future))!; + if (!context.mounted) { + return; + } + final result = await showListEditorDialog( context: context, - builder: (context) => AlertDialog( - title: Text('new_list'.tr()), - content: TextField( - controller: controller, - decoration: InputDecoration(labelText: 'new_list'.tr()), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text('cancel'.tr()), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(controller.text.trim()), - child: Text('add'.tr()), - ), - ], - ), + groups: [group], + initialGroupId: groupId, + allowGroupSelection: false, + fixedGroupName: group.name, + title: 'new_list'.tr(), + actionLabel: 'add'.tr(), ); - - controller.dispose(); - if (name == null || name.isEmpty) { + if (result == null) { return; } await ref .read(listRepositoryProvider) - .createList(groupId: groupId, name: name); + .createListWithOptions( + groupId: groupId, + name: result.name, + populateFromGroupStudents: result.populateFromGroupStudents, + ); } Future _deleteList( @@ -291,7 +288,9 @@ class _ListsList extends StatelessWidget { title: Text( '${list.name} (${(progress[list.id]?.checked ?? 0)}/${(progress[list.id]?.total ?? 0)})', ), - subtitle: group == null ? null : _ListGroupChip(group: group), + subtitle: group == null + ? const _GlobalListChip() + : _ListGroupChip(group: group), trailing: PopupMenuButton<_GlobalListAction>( onSelected: (action) { switch (action) { @@ -405,6 +404,21 @@ class _ListsList extends StatelessWidget { enum _GlobalListAction { archive, unarchive, delete } +class _GlobalListChip extends StatelessWidget { + const _GlobalListChip(); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.centerLeft, + child: Chip( + avatar: const Icon(Icons.public_outlined, size: 18), + label: Text('global_list'.tr()), + ), + ); + } +} + class _ListGroupChip extends StatelessWidget { const _ListGroupChip({required this.group}); @@ -429,76 +443,3 @@ class _ListGroupChip extends StatelessWidget { ); } } - -class _CreateListDialog extends StatefulWidget { - const _CreateListDialog({required this.groups}); - - final List groups; - - @override - State<_CreateListDialog> createState() => _CreateListDialogState(); -} - -class _CreateListDialogState extends State<_CreateListDialog> { - late final TextEditingController _nameController; - late int _selectedGroupId; - - @override - void initState() { - super.initState(); - _nameController = TextEditingController(); - _selectedGroupId = widget.groups.first.id; - } - - @override - void dispose() { - _nameController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text('new_list'.tr()), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - DropdownButtonFormField( - initialValue: _selectedGroupId, - decoration: InputDecoration(labelText: 'groups'.tr()), - items: [ - for (final group in widget.groups) - DropdownMenuItem(value: group.id, child: Text(group.name)), - ], - onChanged: (value) { - if (value != null) { - setState(() => _selectedGroupId = value); - } - }, - ), - const SizedBox(height: 16), - TextField( - controller: _nameController, - decoration: InputDecoration(labelText: 'new_list'.tr()), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text('cancel'.tr()), - ), - FilledButton( - onPressed: () { - final name = _nameController.text.trim(); - if (name.isEmpty) { - return; - } - Navigator.of(context).pop((groupId: _selectedGroupId, name: name)); - }, - child: Text('add'.tr()), - ), - ], - ); - } -} diff --git a/lib/shared/widgets/student_selection_sheet.dart b/lib/shared/widgets/student_selection_sheet.dart new file mode 100644 index 0000000..5dc6c21 --- /dev/null +++ b/lib/shared/widgets/student_selection_sheet.dart @@ -0,0 +1,334 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../../core/database/app_database.dart'; +import '../utils/grade_categories.dart'; +import '../utils/formatting.dart'; +import 'student_avatar.dart'; + +Future?> showStudentSelectionSheet({ + required BuildContext context, + required List groups, + required List students, + required Set selectedStudentIds, + int? preferredGroupId, + bool allowSelectAll = false, +}) { + return showModalBottomSheet>( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, + builder: (context) => _StudentSelectionSheet( + groups: groups, + students: students, + selectedStudentIds: selectedStudentIds, + preferredGroupId: preferredGroupId, + allowSelectAll: allowSelectAll, + ), + ); +} + +class _StudentSelectionSheet extends StatefulWidget { + const _StudentSelectionSheet({ + required this.groups, + required this.students, + required this.selectedStudentIds, + required this.allowSelectAll, + this.preferredGroupId, + }); + + final List groups; + final List students; + final Set selectedStudentIds; + final int? preferredGroupId; + final bool allowSelectAll; + + @override + State<_StudentSelectionSheet> createState() => _StudentSelectionSheetState(); +} + +class _StudentSelectionSheetState extends State<_StudentSelectionSheet> { + late final TextEditingController _searchController; + late final Set _selectedStudentIds; + String _query = ''; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(); + _selectedStudentIds = {...widget.selectedStudentIds}; + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final sections = _sections; + + return SizedBox( + height: MediaQuery.sizeOf(context).height * 0.8, + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'link_to_students'.tr(), + style: Theme.of(context).textTheme.titleLarge, + ), + ), + if (widget.allowSelectAll && widget.students.isNotEmpty) + TextButton( + onPressed: + _selectedStudentIds.length == widget.students.length + ? _clearSelection + : _selectAllStudents, + child: Text( + _selectedStudentIds.length == widget.students.length + ? 'clear_selection'.tr() + : 'select_all_students'.tr(), + ), + ), + ], + ), + const SizedBox(height: 16), + TextField( + controller: _searchController, + onChanged: (value) => setState(() => _query = value.trim()), + decoration: InputDecoration( + labelText: 'search_students'.tr(), + prefixIcon: const Icon(Icons.search), + ), + ), + const SizedBox(height: 16), + if (_selectedStudentIds.isNotEmpty) ...[ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final student in widget.students) + if (_selectedStudentIds.contains(student.id)) + InputChip( + avatar: StudentAvatar(student: student, size: 24), + label: Text( + studentDisplayName( + firstName: student.firstName, + lastName: student.lastName, + ), + ), + onDeleted: () => _toggleStudent(student.id, false), + ), + ], + ), + const SizedBox(height: 16), + ], + Expanded( + child: sections.isEmpty + ? Center(child: Text('no_students_found'.tr())) + : ListView.separated( + itemCount: sections.length, + separatorBuilder: (context, index) => + const SizedBox(height: 12), + itemBuilder: (context, index) { + final section = sections[index]; + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (section.color != null) + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: section.color, + shape: BoxShape.circle, + ), + ), + if (section.color != null) + const SizedBox(width: 12), + Expanded( + child: Text( + section.title, + style: Theme.of( + context, + ).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 8), + for (final student in section.students) + CheckboxListTile( + value: _selectedStudentIds.contains( + student.id, + ), + contentPadding: EdgeInsets.zero, + secondary: StudentAvatar( + student: student, + size: 32, + ), + title: Text( + studentDisplayName( + firstName: student.firstName, + lastName: student.lastName, + ), + ), + controlAffinity: + ListTileControlAffinity.trailing, + onChanged: (value) => _toggleStudent( + student.id, + value ?? false, + ), + ), + ], + ), + ), + ); + }, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('cancel'.tr()), + ), + const SizedBox(width: 12), + FilledButton( + onPressed: () => Navigator.of( + context, + ).pop(_selectedStudentIds.toList(growable: false)), + child: Text('save'.tr()), + ), + ], + ), + ], + ), + ), + ); + } + + List<_StudentSelectionSection> get _sections { + final groupsById = {for (final group in widget.groups) group.id: group}; + final studentsByGroupId = >{}; + for (final student in widget.students) { + studentsByGroupId.putIfAbsent(student.groupId, () => []).add(student); + } + + final orderedGroupIds = + [ + ...{ + ...widget.groups.map((group) => group.id), + ...studentsByGroupId.keys.whereType(), + }, + ]..sort((left, right) { + final leftPreferred = left == widget.preferredGroupId; + final rightPreferred = right == widget.preferredGroupId; + if (leftPreferred != rightPreferred) { + return leftPreferred ? -1 : 1; + } + final leftGroup = groupsById[left]; + final rightGroup = groupsById[right]; + if (leftGroup == null && rightGroup == null) { + return left.compareTo(right); + } + if (leftGroup == null) { + return 1; + } + if (rightGroup == null) { + return -1; + } + return leftGroup.name.compareTo(rightGroup.name); + }); + + final normalizedQuery = _query.toLowerCase(); + final sections = <_StudentSelectionSection>[]; + + for (final groupId in orderedGroupIds) { + final group = groupsById[groupId]; + final students = [...?studentsByGroupId[groupId]] + ..sort((left, right) { + final leftSelected = _selectedStudentIds.contains(left.id); + final rightSelected = _selectedStudentIds.contains(right.id); + if (leftSelected != rightSelected) { + return leftSelected ? -1 : 1; + } + final lastNameCompare = left.lastName.compareTo(right.lastName); + if (lastNameCompare != 0) { + return lastNameCompare; + } + return left.firstName.compareTo(right.firstName); + }); + + final visibleStudents = [ + for (final student in students) + if (normalizedQuery.isEmpty || + student.firstName.toLowerCase().contains(normalizedQuery) || + student.lastName.toLowerCase().contains(normalizedQuery) || + (group?.name.toLowerCase().contains(normalizedQuery) ?? false)) + student, + ]; + if (visibleStudents.isEmpty) { + continue; + } + + sections.add( + _StudentSelectionSection( + title: group?.name ?? 'unknown_group'.tr(), + color: group == null ? null : colorFromHex(group.colorHex), + students: visibleStudents, + ), + ); + } + + return sections; + } + + void _toggleStudent(int studentId, bool selected) { + setState(() { + if (selected) { + _selectedStudentIds.add(studentId); + } else { + _selectedStudentIds.remove(studentId); + } + }); + } + + void _selectAllStudents() { + setState(() { + _selectedStudentIds + ..clear() + ..addAll(widget.students.map((student) => student.id)); + }); + } + + void _clearSelection() { + setState(_selectedStudentIds.clear); + } +} + +class _StudentSelectionSection { + const _StudentSelectionSection({ + required this.title, + required this.students, + this.color, + }); + + final String title; + final Color? color; + final List students; +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 9e8f8a2..807a721 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,10 +7,14 @@ import Foundation import file_picker import flutter_secure_storage_darwin +import local_auth_darwin +import package_info_plus import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/test/list_repository_test.dart b/test/list_repository_test.dart new file mode 100644 index 0000000..d3020e1 --- /dev/null +++ b/test/list_repository_test.dart @@ -0,0 +1,153 @@ +import 'package:classi/core/database/app_database.dart'; +import 'package:classi/features/groups/group_repository.dart'; +import 'package:classi/features/lists/list_item_links.dart'; +import 'package:classi/features/lists/list_repository.dart'; +import 'package:classi/features/students/student_repository.dart'; +import 'package:classi/shared/utils/formatting.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late AppDatabase database; + late GroupRepository groupRepository; + late StudentRepository studentRepository; + late ListRepository repository; + + setUp(() { + database = AppDatabase.test(NativeDatabase.memory()); + groupRepository = GroupRepository(database); + studentRepository = StudentRepository(database); + repository = ListRepository(database); + }); + + tearDown(() async { + await database.close(); + }); + + test('global lists appear in overview but not in group streams', () async { + final groupId = await groupRepository.createGroup( + name: '8A', + gradeScale: defaultGradeScaleEntries, + ); + + final globalListId = await repository.createListWithOptions( + name: 'General', + ); + final groupListId = await repository.createList( + groupId: groupId, + name: 'HW', + ); + + final allLists = await repository.watchAllLists().first; + expect( + allLists.map((list) => list.id), + containsAll([globalListId, groupListId]), + ); + expect( + allLists.firstWhere((list) => list.id == globalListId).groupId, + isNull, + ); + + final groupLists = await repository.watchLists(groupId).first; + expect(groupLists.map((list) => list.id).toList(), [groupListId]); + }); + + test('global list items can link multiple students across groups', () async { + final firstGroupId = await groupRepository.createGroup( + name: '8A', + gradeScale: defaultGradeScaleEntries, + ); + final secondGroupId = await groupRepository.createGroup( + name: '8B', + gradeScale: defaultGradeScaleEntries, + ); + final firstStudentId = await studentRepository.addStudent( + groupId: firstGroupId, + firstName: 'Ada', + lastName: 'Lovelace', + ); + final secondStudentId = await studentRepository.addStudent( + groupId: secondGroupId, + firstName: 'Alan', + lastName: 'Turing', + ); + final listId = await repository.createListWithOptions(name: 'Forms'); + + await repository.addItem( + listId: listId, + label: 'Bring signed consent form', + studentIds: [firstStudentId, secondStudentId], + ); + + final item = (await repository.watchItems(listId).first).single; + expect(item.studentId, firstStudentId); + expect(listItemStudentIds(item), [firstStudentId, secondStudentId]); + }); + + test('group lists reject linked students from other groups', () async { + final firstGroupId = await groupRepository.createGroup( + name: '8A', + gradeScale: defaultGradeScaleEntries, + ); + final secondGroupId = await groupRepository.createGroup( + name: '8B', + gradeScale: defaultGradeScaleEntries, + ); + final localStudentId = await studentRepository.addStudent( + groupId: firstGroupId, + firstName: 'Grace', + lastName: 'Hopper', + ); + final foreignStudentId = await studentRepository.addStudent( + groupId: secondGroupId, + firstName: 'Katherine', + lastName: 'Johnson', + ); + final listId = await repository.createList( + groupId: firstGroupId, + name: 'Quiz', + ); + + await expectLater( + () => repository.addItem( + listId: listId, + label: 'Prepare quiz', + studentIds: [localStudentId, foreignStudentId], + ), + throwsArgumentError, + ); + }); + + test('group lists can create one item per student automatically', () async { + final groupId = await groupRepository.createGroup( + name: '8C', + gradeScale: defaultGradeScaleEntries, + ); + final firstStudentId = await studentRepository.addStudent( + groupId: groupId, + firstName: 'Max', + lastName: 'Mustermann', + ); + final secondStudentId = await studentRepository.addStudent( + groupId: groupId, + firstName: 'Erika', + lastName: 'Musterfrau', + ); + + final listId = await repository.createListWithOptions( + groupId: groupId, + name: 'Presentations', + populateFromGroupStudents: true, + ); + + final items = await repository.watchItems(listId).first; + expect(items, hasLength(2)); + expect( + items.map(listItemStudentIds).toList(), + containsAll([ + [firstStudentId], + [secondStudentId], + ]), + ); + }); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0c50753..011734d 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,11 @@ #include "generated_plugin_registrant.h" #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + LocalAuthPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("LocalAuthPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index d0b33f8..de15aee 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows + local_auth_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST From 947aafe5700d99d07cc3b44de263c9dddcda22b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 08:28:27 +0000 Subject: [PATCH 2/2] fix: address review issues in list screens and student selection sheet - Fix nullable groupId map lookup in lists_screen.dart using local variable - Filter initial student IDs to valid ones in list_item_editor.dart - Filter initial selected student IDs to valid ones in student_selection_sheet.dart - Add keyboard inset padding with AnimatedPadding in student_selection_sheet.dart Agent-Logs-Url: https://github.com/openpatch/classi/sessions/21be2dc8-b61a-400e-92ad-435465384819 Co-authored-by: mikebarkmin <2592379+mikebarkmin@users.noreply.github.com> --- lib/features/lists/list_item_editor.dart | 8 +++++++- lib/features/lists/lists_screen.dart | 3 ++- lib/shared/widgets/student_selection_sheet.dart | 13 ++++++++++--- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/lib/features/lists/list_item_editor.dart b/lib/features/lists/list_item_editor.dart index 89255ed..80d1b96 100644 --- a/lib/features/lists/list_item_editor.dart +++ b/lib/features/lists/list_item_editor.dart @@ -67,7 +67,13 @@ class _ListItemEditorSheetState extends State<_ListItemEditorSheet> { void initState() { super.initState(); _labelController = TextEditingController(text: widget.initialLabel); - _studentIds = {...?widget.initialStudentIds}; + final validStudentIds = { + for (final student in widget.students) student.id, + }; + _studentIds = { + for (final studentId in widget.initialStudentIds ?? const []) + if (validStudentIds.contains(studentId)) studentId, + }; } @override diff --git a/lib/features/lists/lists_screen.dart b/lib/features/lists/lists_screen.dart index c439967..f84cb4c 100644 --- a/lib/features/lists/lists_screen.dart +++ b/lib/features/lists/lists_screen.dart @@ -281,7 +281,8 @@ class _ListsList extends StatelessWidget { separatorBuilder: (context, index) => const SizedBox(height: 12), itemBuilder: (context, index) { final list = lists[index]; - final group = groups[list.groupId]; + final groupId = list.groupId; + final group = groupId != null ? groups[groupId] : null; final tile = Card( child: ListTile( onTap: () => context.push(listPathBuilder(list)), diff --git a/lib/shared/widgets/student_selection_sheet.dart b/lib/shared/widgets/student_selection_sheet.dart index 5dc6c21..55317ba 100644 --- a/lib/shared/widgets/student_selection_sheet.dart +++ b/lib/shared/widgets/student_selection_sheet.dart @@ -57,7 +57,10 @@ class _StudentSelectionSheetState extends State<_StudentSelectionSheet> { void initState() { super.initState(); _searchController = TextEditingController(); - _selectedStudentIds = {...widget.selectedStudentIds}; + final validStudentIds = + widget.students.map((student) => student.id).toSet(); + _selectedStudentIds = + widget.selectedStudentIds.intersection(validStudentIds); } @override @@ -70,10 +73,14 @@ class _StudentSelectionSheetState extends State<_StudentSelectionSheet> { Widget build(BuildContext context) { final sections = _sections; + final keyboardInset = MediaQuery.viewInsetsOf(context).bottom; + return SizedBox( height: MediaQuery.sizeOf(context).height * 0.8, - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 24), + child: AnimatedPadding( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: EdgeInsets.fromLTRB(24, 8, 24, 24 + keyboardInset), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [