Skip to content

Commit 05913c0

Browse files
mikebarkminCopilot
andcommitted
feat(groups): let user choose save location for CSV exports
On desktop a native save-file dialog is shown (FilePicker.saveFile) with the background lock suspended to prevent spurious lock events. On mobile the share sheet handles destination selection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3e2eb4d commit 05913c0

2 files changed

Lines changed: 68 additions & 31 deletions

File tree

lib/features/groups/group_detail_screen.dart

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import '../../shared/widgets/surface_list_tile.dart';
2323
import '../../shared/widgets/student_avatar.dart';
2424
import '../../shared/widgets/swipe_action_background.dart';
2525
import '../../shared/theme/app_ui.dart';
26+
import '../groups/group_export_service.dart';
2627
import '../lists/list_repository.dart';
2728
import '../lists/list_editor.dart';
2829
import '../lessons/lesson_support.dart';
@@ -814,31 +815,49 @@ class GroupDetailScreen extends ConsumerWidget {
814815
final service = ref.read(groupExportServiceProvider);
815816
final messenger = ScaffoldMessenger.of(context);
816817
try {
817-
switch (exportType) {
818-
case _ExportType.grades:
819-
await service.exportGradesCsv(
818+
final result = switch (exportType) {
819+
_ExportType.grades => await service.exportGradesCsv(
820820
groupId: groupId,
821821
groupName: groupName,
822-
);
823-
case _ExportType.attendance:
824-
await service.exportAttendanceCsv(
822+
),
823+
_ExportType.attendance => await service.exportAttendanceCsv(
825824
groupId: groupId,
826825
groupName: groupName,
827-
);
828-
case _ExportType.homeworkMaterial:
829-
await service.exportHomeworkMaterialCsv(
826+
),
827+
_ExportType.homeworkMaterial => await service.exportHomeworkMaterialCsv(
830828
groupId: groupId,
831829
groupName: groupName,
832-
);
833-
case _ExportType.summary:
834-
await service.exportSummaryCsv(
830+
),
831+
_ExportType.summary => await service.exportSummaryCsv(
835832
groupId: groupId,
836833
groupName: groupName,
837-
);
838-
}
839-
messenger.showSnackBar(
840-
SnackBar(content: Text('export_success'.tr())),
834+
),
835+
};
836+
if (!context.mounted) return;
837+
final session = ref.read(appSessionProvider);
838+
final saved = await shareOrSaveFile(
839+
file: result.file,
840+
filename: result.filename,
841+
mimeType: 'text/csv',
842+
subject: result.filename,
843+
savePathResolver: () async {
844+
session.suspendBackgroundLock();
845+
try {
846+
return await FilePicker.saveFile(
847+
fileName: result.filename,
848+
type: FileType.custom,
849+
allowedExtensions: ['csv'],
850+
);
851+
} finally {
852+
session.resumeBackgroundLock();
853+
}
854+
},
841855
);
856+
if (saved && context.mounted) {
857+
messenger.showSnackBar(
858+
SnackBar(content: Text('export_success'.tr())),
859+
);
860+
}
842861
} catch (e, st) {
843862
developer.log(
844863
'Export failed',

lib/features/groups/group_export_service.dart

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class GroupExportService {
2323
// 1. Grades matrix: students × sessions → grade value
2424
// ---------------------------------------------------------------------------
2525

26-
Future<File> exportGradesCsv({
26+
Future<({File file, String filename})> exportGradesCsv({
2727
required int groupId,
2828
required String groupName,
2929
}) async {
@@ -116,7 +116,7 @@ class GroupExportService {
116116
buf.write('\n');
117117
}
118118

119-
return _saveAndReturn(
119+
return _buildFile(
120120
content: buf.toString(),
121121
filename: '${_sanitize(groupName)}_grades.csv',
122122
);
@@ -126,7 +126,7 @@ class GroupExportService {
126126
// 2. Attendance sheet: students × session dates
127127
// ---------------------------------------------------------------------------
128128

129-
Future<File> exportAttendanceCsv({
129+
Future<({File file, String filename})> exportAttendanceCsv({
130130
required int groupId,
131131
required String groupName,
132132
}) async {
@@ -206,7 +206,7 @@ class GroupExportService {
206206
buf.write(';$absentCount;$excusedCount;$rate\n');
207207
}
208208

209-
return _saveAndReturn(
209+
return _buildFile(
210210
content: buf.toString(),
211211
filename: '${_sanitize(groupName)}_attendance.csv',
212212
);
@@ -216,7 +216,7 @@ class GroupExportService {
216216
// 3. Homework & material compliance
217217
// ---------------------------------------------------------------------------
218218

219-
Future<File> exportHomeworkMaterialCsv({
219+
Future<({File file, String filename})> exportHomeworkMaterialCsv({
220220
required int groupId,
221221
required String groupName,
222222
}) async {
@@ -265,7 +265,7 @@ class GroupExportService {
265265
dateFormat: dateFormat,
266266
);
267267

268-
return _saveAndReturn(
268+
return _buildFile(
269269
content: buf.toString(),
270270
filename: '${_sanitize(groupName)}_homework_material.csv',
271271
);
@@ -313,7 +313,7 @@ class GroupExportService {
313313
// 4. Full student summary (flat)
314314
// ---------------------------------------------------------------------------
315315

316-
Future<File> exportSummaryCsv({
316+
Future<({File file, String filename})> exportSummaryCsv({
317317
required int groupId,
318318
required String groupName,
319319
}) async {
@@ -426,7 +426,7 @@ class GroupExportService {
426426
buf.write('\n');
427427
}
428428

429-
return _saveAndReturn(
429+
return _buildFile(
430430
content: buf.toString(),
431431
filename: '${_sanitize(groupName)}_summary.csv',
432432
);
@@ -436,16 +436,16 @@ class GroupExportService {
436436
// Helpers
437437
// ---------------------------------------------------------------------------
438438

439-
Future<File> _saveAndReturn({
439+
Future<({File file, String filename})> _buildFile({
440440
required String content,
441441
required String filename,
442442
}) async {
443-
final dir = await getApplicationDocumentsDirectory();
443+
final dir = await getTemporaryDirectory();
444444
final file = File('${dir.path}/$filename');
445445
// UTF-8 BOM for Excel compatibility.
446446
final bom = [0xEF, 0xBB, 0xBF];
447447
await file.writeAsBytes([...bom, ...utf8.encode(content)]);
448-
return file;
448+
return (file: file, filename: filename);
449449
}
450450

451451
List<DateTime> _uniqueDates(Iterable<DateTime> raw) {
@@ -500,12 +500,20 @@ class _SessionKey {
500500
String get id => '${date.toIso8601String()}|$categoryId|$label';
501501
}
502502

503-
/// Shares [file] on platforms that support a share sheet, otherwise just
504-
/// keeps the saved file.
505-
Future<void> shareOrSaveFile({
503+
/// Delivers [file] to the user.
504+
///
505+
/// On Android/iOS shows a share sheet so the user can pick the destination.
506+
/// On desktop, [savePathResolver] is called to obtain the target path
507+
/// (e.g. via a FilePicker dialog). If it returns `null` the export is
508+
/// cancelled and this function returns `false`.
509+
Future<bool> shareOrSaveFile({
506510
required File file,
511+
required String filename,
507512
required String mimeType,
508513
String? subject,
514+
/// Called on desktop to resolve where the user wants to save the file.
515+
/// Should open a save-file dialog and return the chosen path, or null.
516+
Future<String?> Function()? savePathResolver,
509517
}) async {
510518
if (Platform.isAndroid || Platform.isIOS) {
511519
await SharePlus.instance.share(
@@ -514,6 +522,16 @@ Future<void> shareOrSaveFile({
514522
subject: subject,
515523
),
516524
);
525+
return true;
517526
}
518-
// On desktop the file is already saved; nothing more to do.
527+
528+
// Desktop: ask the caller for a save path.
529+
final savePath = savePathResolver != null
530+
? await savePathResolver()
531+
: null;
532+
if (savePath == null) return false; // user cancelled or no resolver
533+
534+
final bytes = await file.readAsBytes();
535+
await File(savePath).writeAsBytes(bytes);
536+
return true;
519537
}

0 commit comments

Comments
 (0)