usability fixes

This commit is contained in:
2026-06-24 18:01:34 +02:00
parent 9e55ec1097
commit a6b4a04bd5
26 changed files with 1099 additions and 216 deletions

View File

@@ -105,9 +105,11 @@ class BatchController extends ChangeNotifier {
}
}
Future<ApiResponse<List<PaymentProcessor>>> loadProcessors() async {
Future<ApiResponse<List<PaymentProcessor>>> loadProcessors(currency) async {
try {
final raw = await http.get('/public/payment-processors');
final raw = await http.get(
'/public/payment-processors?currency=$currency',
);
final response = _unwrap(raw);
final List<dynamic> content = response is List
? response

View File

@@ -62,6 +62,7 @@ class GroupBatchItem {
final String? billProductName;
final String? providerLabel;
final String? providerImage;
final String? creditAddress;
const GroupBatchItem({
this.id,
@@ -78,6 +79,7 @@ class GroupBatchItem {
this.billProductName,
this.providerLabel,
this.providerImage,
this.creditAddress,
});
factory GroupBatchItem.fromJson(Map<String, dynamic> json) =>

View File

@@ -62,7 +62,7 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
}
await Future.wait([
providerController.loadProviders(_currency),
batchController.loadProcessors(),
batchController.loadProcessors(_currency),
]);
if (mounted) setState(() => _loadingDropdowns = false);
}

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:qpay/screens/accounts/account_provider.dart';
import 'package:qpay/screens/confirm/confirm_controller.dart';
import 'package:qpay/screens/groups/models/batch_item_update_model.dart';
import 'package:qpay/screens/groups/models/group_batch_model.dart';
@@ -16,10 +18,7 @@ import 'package:url_launcher/url_launcher.dart';
class BatchDetailScreen extends StatefulWidget {
final String batchId;
const BatchDetailScreen({
super.key,
required this.batchId,
});
const BatchDetailScreen({super.key, required this.batchId});
@override
State<BatchDetailScreen> createState() => _BatchDetailScreenState();
@@ -59,7 +58,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
void initState() {
super.initState();
controller = BatchController(context);
controller.loadProcessors();
_entryController = AnimationController(
duration: const Duration(milliseconds: 220),
@@ -96,6 +94,8 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
setState(() {
_initialLoading = false;
});
controller.loadProcessors(_batch!.currency);
} else {
setState(() {
_initialLoading = false;
@@ -337,55 +337,119 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
width: isSelected ? 2 : 1,
),
),
child: Row(
child: Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
'assets/${processor.image}',
width: 36,
height: 36,
errorBuilder: (_, __, ___) => Icon(
Icons.payment_rounded,
size: 28,
color: theme.colorScheme.primary,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
processor.name,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark
? Colors.white
: Colors.black87,
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(
8,
),
child: Image.asset(
'assets/${processor.image}',
width: 36,
height: 36,
errorBuilder: (_, __, ___) => Icon(
Icons.payment_rounded,
size: 28,
color: theme.colorScheme.primary,
),
),
if (processor.label.isNotEmpty)
Text(
processor.label,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
processor.name,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark
? Colors.white
: Colors.black87,
),
),
),
],
),
if (processor.label.isNotEmpty)
Text(
processor.label,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
),
),
],
),
),
Icon(
isSelected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: isSelected
? theme.colorScheme.primary
: Colors.grey.shade400,
),
],
),
Icon(
isSelected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: isSelected
? theme.colorScheme.primary
: Colors.grey.shade400,
// Row 2.5: City Wallet balance (if available for batch currency)
Builder(
builder: (context) {
final accountProvider = context
.watch<AccountProvider>();
final batchCurrency =
_batch!.currency ?? 'USD';
final cityWallet = accountProvider
.balanceForCurrency(batchCurrency);
final hasCityWallet =
cityWallet != null;
if (!hasCityWallet)
return const SizedBox.shrink();
if (processor.label != 'WALLET')
return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(
top: 8,
),
child: Row(
children: [
Icon(
Icons
.account_balance_wallet_outlined,
size: 14,
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.7),
),
const SizedBox(width: 6),
Text(
'City Wallet Balance',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.7),
),
),
const Spacer(),
Text(
'\$${cityWallet!.balance.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: Theme.of(
context,
).colorScheme.primary,
),
),
],
),
);
},
),
],
),
@@ -2458,6 +2522,7 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
const SizedBox(height: 4),
if (item.billName != null || item.billProductName != null)
_buildBillInfoRow(item, isDark),
if (item.creditAddress != null) Text(item.creditAddress ?? ''),
],
),
),

View File

@@ -1,11 +1,17 @@
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});
@@ -38,6 +44,40 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
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,
@@ -95,9 +135,7 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
);
if (groupId.isNotEmpty && batch.id != null) {
context.pushReplacement(
'/groups/$groupId/batches/${batch.id}',
);
context.pushReplacement('/groups/batches/${batch.id}');
}
} else {
// Partial failure — show errors
@@ -122,9 +160,7 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(24),
),
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
child: Column(
@@ -144,8 +180,11 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
),
Row(
children: [
Icon(Icons.warning_amber_rounded,
color: Colors.orange.shade700, size: 24),
Icon(
Icons.warning_amber_rounded,
color: Colors.orange.shade700,
size: 24,
),
const SizedBox(width: 10),
Text(
'Upload completed with errors',
@@ -161,18 +200,14 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
Text(
'${response.errorCount} of ${response.totalRows} rows failed. '
'${response.successCount} rows succeeded.',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
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,
maxHeight: MediaQuery.of(sheetContext).size.height * 0.4,
),
child: ListView.separated(
shrinkWrap: true,
@@ -241,10 +276,7 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Create from File'),
centerTitle: true,
),
appBar: AppBar(title: const Text('Create from File'), centerTitle: true),
body: ListenableBuilder(
listenable: controller,
builder: (context, _) {
@@ -272,10 +304,9 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
borderRadius: BorderRadius.circular(12),
),
),
validator: (v) =>
(v == null || v.trim().isEmpty)
? 'Group name is required'
: null,
validator: (v) => (v == null || v.trim().isEmpty)
? 'Group name is required'
: null,
),
const SizedBox(height: 16),
TextFormField(
@@ -344,10 +375,9 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
),
borderRadius: BorderRadius.circular(12),
color: _fileBytes != null
? Theme.of(context)
.colorScheme
.primary
.withAlpha(12)
? Theme.of(
context,
).colorScheme.primary.withAlpha(12)
: Colors.grey.shade50,
),
child: Column(
@@ -363,14 +393,15 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
),
const SizedBox(height: 8),
Text(
_selectedFileName ?? 'Tap to select CSV file',
_selectedFileName ??
'Tap to select CSV file',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _fileBytes != null
? Theme.of(context)
.colorScheme
.primary
? Theme.of(
context,
).colorScheme.primary
: Colors.grey.shade600,
),
textAlign: TextAlign.center,
@@ -389,14 +420,32 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
),
),
),
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),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -427,4 +476,4 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
),
);
}
}
}