Files
velocity-pay-flutter/lib/screens/groups/screens/create_group_from_file_screen.dart
2026-06-24 18:01:34 +02:00

480 lines
19 KiB
Dart

import 'dart:io' show File, Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:file_picker/file_picker.dart';
import 'package:go_router/go_router.dart';
import 'package:path_provider/path_provider.dart';
import 'package:qpay/screens/groups/controllers/group_controller.dart';
import 'package:qpay/screens/groups/models/create_group_from_file_response.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/widgets/app_snack_bar.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
class CreateGroupFromFileScreen extends StatefulWidget {
const CreateGroupFromFileScreen({super.key});
@override
State<CreateGroupFromFileScreen> createState() =>
_CreateGroupFromFileScreenState();
}
class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
late GroupController controller;
final _formKey = GlobalKey<FormState>();
final _groupNameController = TextEditingController();
final _descriptionController = TextEditingController();
String _currency = 'USD';
List<int>? _fileBytes;
String? _selectedFileName;
@override
void initState() {
super.initState();
controller = GroupController(context);
}
@override
void dispose() {
_groupNameController.dispose();
_descriptionController.dispose();
controller.dispose();
super.dispose();
}
Future<void> _downloadTemplate() async {
try {
final bytes = await rootBundle.load('assets/group-batch-example.csv');
final data = bytes.buffer.asUint8List();
if (kIsWeb) {
// Web: use url_launcher with a data URI
final uri = Uri.dataFromString(
String.fromCharCodes(data),
mimeType: 'text/csv',
encoding: null,
);
await launchUrl(uri, mode: LaunchMode.platformDefault);
} else {
// Mobile/desktop: write to temp file and open with url_launcher
final dir = Platform.isAndroid
? await getTemporaryDirectory()
: await getApplicationDocumentsDirectory();
final file = File('${dir.path}/group-batch-example.csv');
await file.writeAsBytes(data);
final fileUri = Uri.file(file.path);
if (await canLaunchUrl(fileUri)) {
await launchUrl(fileUri, mode: LaunchMode.externalApplication);
} else {
throw Exception('Cannot open file');
}
}
} catch (e) {
if (mounted) {
AppSnackBar.showError(context, 'Failed to download template: $e');
}
}
}
Future<void> _pickFile() async {
final result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: ['csv'],
withData: true,
);
if (result != null && result.files.isNotEmpty) {
final file = result.files.first;
if (file.bytes != null) {
setState(() {
_fileBytes = file.bytes;
_selectedFileName = file.name;
});
}
}
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
if (_fileBytes == null || _selectedFileName == null) {
AppSnackBar.show(context, 'Please select a CSV file to upload.');
return;
}
final prefs = await SharedPreferences.getInstance();
final workspaceId = prefs.getString('workspaceId') ?? '';
final userId = prefs.getString('userId') ?? '';
final result = await controller.createGroupFromFile(
groupName: _groupNameController.text.trim(),
workspaceId: workspaceId,
userId: userId,
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
currency: _currency,
fileBytes: _fileBytes!,
fileName: _selectedFileName!,
);
if (!mounted) return;
if (result.isSuccess) {
final response = result.data!;
if (response.isSuccess && response.batch != null) {
final batch = response.batch!;
final groupId = batch.recipientGroupId ?? '';
AppSnackBar.showSuccess(
context,
'Group & batch created successfully. '
'${response.successCount} of ${response.totalRows} rows processed.',
);
if (groupId.isNotEmpty && batch.id != null) {
context.pushReplacement('/groups/batches/${batch.id}');
}
} else {
// Partial failure — show errors
_showErrorsBottomSheet(response);
}
} else {
AppSnackBar.showError(
context,
result.error ?? 'Failed to create group from file.',
);
}
}
void _showErrorsBottomSheet(CreateGroupFromFileResponse response) {
final isDark = Theme.of(context).brightness == Brightness.dark;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
Row(
children: [
Icon(
Icons.warning_amber_rounded,
color: Colors.orange.shade700,
size: 24,
),
const SizedBox(width: 10),
Text(
'Upload completed with errors',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
),
],
),
const SizedBox(height: 8),
Text(
'${response.errorCount} of ${response.totalRows} rows failed. '
'${response.successCount} rows succeeded.',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
const SizedBox(height: 16),
if (response.errors.isNotEmpty) ...[
Flexible(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(sheetContext).size.height * 0.4,
),
child: ListView.separated(
shrinkWrap: true,
itemCount: response.errors.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, index) {
final error = response.errors[index];
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
radius: 14,
backgroundColor: Colors.red.shade50,
child: Text(
'${error.lineNumber}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.red.shade700,
),
),
),
title: Text(
error.name.isNotEmpty
? '${error.name} (${error.account})'
: error.account,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
subtitle: Text(
error.message,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
);
},
),
),
),
const SizedBox(height: 16),
],
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.of(sheetContext).pop(),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: const Text('Dismiss'),
),
),
],
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Create from File'), centerTitle: true),
body: ListenableBuilder(
listenable: controller,
builder: (context, _) {
return Center(
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toDouble()
: constraints.maxWidth;
return SizedBox(
width: width,
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _groupNameController,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText: 'Group Name',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (v) => (v == null || v.trim().isEmpty)
? 'Group name is required'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText: 'Description (optional)',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: _currency,
decoration: InputDecoration(
labelText: 'Currency',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
items: const [
DropdownMenuItem(
value: 'USD',
child: Text('USD'),
),
DropdownMenuItem(
value: 'ZWG',
child: Text('ZWG'),
),
],
onChanged: (v) {
if (v != null) setState(() => _currency = v);
},
),
const SizedBox(height: 24),
Text(
'Recipients File',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey.shade800,
),
),
const SizedBox(height: 4),
Text(
'Upload a CSV file with columns: name, account, amount, billProductName',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 12),
InkWell(
onTap: _pickFile,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
border: Border.all(
color: _fileBytes != null
? Theme.of(context).colorScheme.primary
: Colors.grey.shade300,
width: 1.5,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(12),
color: _fileBytes != null
? Theme.of(
context,
).colorScheme.primary.withAlpha(12)
: Colors.grey.shade50,
),
child: Column(
children: [
Icon(
_fileBytes != null
? Icons.insert_drive_file
: Icons.cloud_upload_outlined,
size: 40,
color: _fileBytes != null
? Theme.of(context).colorScheme.primary
: Colors.grey.shade400,
),
const SizedBox(height: 8),
Text(
_selectedFileName ??
'Tap to select CSV file',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _fileBytes != null
? Theme.of(
context,
).colorScheme.primary
: Colors.grey.shade600,
),
textAlign: TextAlign.center,
),
if (_fileBytes == null) ...[
const SizedBox(height: 4),
Text(
'Accepted format: .csv',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
),
),
],
],
),
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: _downloadTemplate,
icon: const Icon(
Icons.download_rounded,
size: 18,
),
label: const Text('Download Template'),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
visualDensity: VisualDensity.compact,
),
),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: controller.model.isLoading
? null
: _submit,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: controller.model.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Upload & Create',
style: TextStyle(fontSize: 16),
),
),
],
),
),
),
);
},
),
);
},
),
);
}
}