add traction import export
All checks were successful
Release / meta (push) Successful in 3s
Release / linux-build (push) Successful in 1m1s
Release / web-build (push) Successful in 1m22s
Release / android-build (push) Successful in 6m49s
Release / release-master (push) Successful in 5s
Release / release-dev (push) Successful in 8s

This commit is contained in:
2026-01-23 13:21:08 +00:00
parent 56cc7c0902
commit 9896b6f1f8
11 changed files with 1348 additions and 2 deletions

View File

@@ -1,13 +1,17 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/traction/traction_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/utils/download_helper.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

View File

@@ -5,6 +5,7 @@ enum _TractionMoreAction {
classLeaderboard,
adminPending,
adminPendingChanges,
adminImport,
}
class TractionPage extends StatefulWidget {
@@ -74,6 +75,7 @@ class _TractionPageState extends State<TractionPage> {
String? _lastQuerySignature;
String? _transferFromLabel;
bool _isSearching = false;
bool _classExporting = false;
@override
void initState() {
@@ -649,8 +651,11 @@ class _TractionPageState extends State<TractionPage> {
return content;
}
String get _currentClassLabel =>
(_selectedClass ?? _classController.text).trim();
bool get _hasClassQuery {
return (_selectedClass ?? _classController.text).trim().isNotEmpty;
return _currentClassLabel.isNotEmpty;
}
Widget _buildHeaderActions(BuildContext context, bool isMobile) {
@@ -668,6 +673,23 @@ class _TractionPageState extends State<TractionPage> {
);
final hasClassActions = _hasClassQuery;
final classLabel = _currentClassLabel;
final exportClassButton = !hasClassActions
? null
: FilledButton.tonalIcon(
onPressed: _classExporting ? null : _exportSelectedClass,
icon: _classExporting
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download),
label: Text(
_classExporting ? 'Exporting...' : 'Export $classLabel',
),
);
final newTractionButton = FilledButton.icon(
onPressed: () async {
@@ -730,6 +752,9 @@ class _TractionPageState extends State<TractionPage> {
);
}
break;
case _TractionMoreAction.adminImport:
await _showTractionImportSheet();
break;
}
},
itemBuilder: (context) {
@@ -793,6 +818,12 @@ class _TractionPageState extends State<TractionPage> {
child: Text('Pending changes'),
),
);
items.add(
const PopupMenuItem(
value: _TractionMoreAction.adminImport,
child: Text('Import traction'),
),
);
}
return items;
},
@@ -816,12 +847,14 @@ class _TractionPageState extends State<TractionPage> {
final desktopActions = [
refreshButton,
if (exportClassButton != null) exportClassButton,
newTractionButton,
if (moreButton != null) moreButton,
];
final mobileActions = [
if (moreButton != null) moreButton,
if (exportClassButton != null) exportClassButton,
newTractionButton,
refreshButton,
];
@@ -849,6 +882,331 @@ class _TractionPageState extends State<TractionPage> {
);
}
Future<void> _exportSelectedClass() async {
if (_classExporting) return;
final classLabel = _currentClassLabel;
if (classLabel.isEmpty) return;
setState(() => _classExporting = true);
final messenger = ScaffoldMessenger.of(context);
try {
final data = context.read<DataService>();
final encodedClass = Uri.encodeComponent(classLabel);
final response = await data.api.getBytes(
'/loco/class/export/$encodedClass',
headers: const {'accept': '*/*'},
);
final safeClassName =
classLabel.replaceAll(RegExp(r'[^a-zA-Z0-9-_]+'), '_');
final fallbackName = 'traction-${safeClassName.isEmpty ? 'class' : safeClassName}.xlsx';
final filename = response.filename ?? fallbackName;
final saveResult = await saveBytes(
Uint8List.fromList(response.bytes),
filename,
mimeType: response.contentType,
);
if (!mounted) return;
if (saveResult.canceled) {
messenger.showSnackBar(
const SnackBar(content: Text('Export canceled.')),
);
} else {
final path = saveResult.path;
messenger.showSnackBar(
SnackBar(
content: Text(
path == null || path.isEmpty
? 'Export started.'
: 'Export saved to $path',
),
),
);
}
} on ApiException catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Export failed: ${e.message}')),
);
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Export failed: $e')),
);
} finally {
if (mounted) setState(() => _classExporting = false);
}
}
Future<void> _showTractionImportSheet() async {
final isElevated = context.read<AuthService>().isElevated;
if (!isElevated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Import is available to admins only.')),
);
return;
}
XFile? selectedFile;
bool uploading = false;
Map<String, dynamic>? importResult;
String? statusMessage;
String? errorMessage;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (sheetContext) {
final theme = Theme.of(sheetContext);
return StatefulBuilder(
builder: (context, setModalState) {
Future<void> pickFile() async {
final file = await openFile(
acceptedTypeGroups: const [
XTypeGroup(
label: 'Spreadsheets',
extensions: ['xlsx', 'xls', 'ods', 'odf'],
),
],
);
if (file == null) return;
setModalState(() {
selectedFile = file;
importResult = null;
statusMessage = null;
errorMessage = null;
});
}
Future<void> uploadFile() async {
final file = selectedFile;
if (file == null || uploading) return;
setModalState(() {
uploading = true;
importResult = null;
statusMessage = null;
errorMessage = null;
});
try {
final data = context.read<DataService>();
final bytes = await file.readAsBytes();
final response = await data.api.postMultipartFile(
'/loco/class/import',
bytes: bytes,
filename: file.name,
headers: const {'accept': 'application/json'},
);
if (!mounted) return;
final parsed = response is Map
? Map<String, dynamic>.from(response)
: null;
if (parsed != null) {
final imported = _importCount(parsed['imported']);
final updated = _importCount(parsed['updated']);
final errors = _importErrors(parsed);
final errorNote =
errors.isNotEmpty ? ' (${errors.length} error(s))' : '';
statusMessage =
'Import complete. Imported $imported, updated $updated$errorNote.';
importResult = parsed;
} else {
statusMessage = 'Import complete.';
}
await data.fetchClassList();
await _refreshTraction(preservePosition: true);
} on ApiException catch (e) {
if (!mounted) return;
errorMessage = e.message;
} catch (e) {
if (!mounted) return;
errorMessage = e.toString();
} finally {
if (!mounted) return;
setModalState(() => uploading = false);
}
}
return SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Import traction data',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Upload a spreadsheet to import traction records.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Text(
selectedFile?.name ?? 'No file selected',
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: uploading ? null : pickFile,
icon: const Icon(Icons.upload_file),
label: const Text('Choose file'),
),
],
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed:
selectedFile == null || uploading ? null : uploadFile,
icon: uploading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.file_upload),
label: Text(
uploading ? 'Importing...' : 'Upload and import',
),
),
if (statusMessage != null) ...[
const SizedBox(height: 12),
Text(
statusMessage!,
style: theme.textTheme.bodyMedium,
),
],
if (errorMessage != null) ...[
const SizedBox(height: 12),
Text(
errorMessage!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
),
],
if (importResult != null)
_buildImportSummary(context, importResult!),
],
),
),
),
);
},
);
},
);
}
int _importCount(dynamic value) {
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '') ?? 0;
}
List<dynamic> _importErrors(Map<String, dynamic> result) {
final errors = result['errors'];
if (errors == null) return const [];
if (errors is List) return errors;
return [errors];
}
String _stringifyImportError(dynamic err) {
if (err == null) return '';
if (err is String) return err;
try {
return jsonEncode(err);
} catch (_) {
return err.toString();
}
}
Widget _buildImportSummary(
BuildContext context,
Map<String, dynamic> result,
) {
final imported = _importCount(result['imported']);
final updated = _importCount(result['updated']);
final errors = _importErrors(result);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Text(
'Import summary',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 8,
children: [
_buildImportChip(context, 'Imported', imported.toString()),
_buildImportChip(context, 'Updated', updated.toString()),
_buildImportChip(context, 'Errors', errors.length.toString()),
],
),
if (errors.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Errors',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 6),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: errors
.map(
(err) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(_stringifyImportError(err)),
),
)
.toList(),
),
],
],
);
}
Widget _buildImportChip(
BuildContext context,
String label,
String value,
) {
final scheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: scheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w700),
),
],
),
);
}
Future<void> _toggleClassStatsPanel() async {
if (!_hasClassQuery) return;
final targetState = !_showClassStatsPanel;