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

@@ -0,0 +1,4 @@
name, account, phone,billLabel, billProductName,amount
John Doe,0773591219,0773591219,ECONET_BUNDLES,Daily Data Bundle (20MB),
Jane Smith,0773591219,0773591219,ECONET,,0.1
James Top,07088597534,0773591219,ZESA,,5
1 name account phone billLabel billProductName amount
2 John Doe 0773591219 0773591219 ECONET_BUNDLES Daily Data Bundle (20MB)
3 Jane Smith 0773591219 0773591219 ECONET 0.1
4 James Top 07088597534 0773591219 ZESA 5

View File

@@ -145,4 +145,4 @@ class Http {
logger.i(response.data.toString());
return response.data;
}
}
}

View File

@@ -9,6 +9,7 @@ import 'package:qpay/screens/onboarding/onboarding_controller.dart';
import 'package:qpay/providers/splash_provider.dart';
import 'package:qpay/screens/workspaces/workspace_provider.dart';
import 'package:qpay/providers/uptime_provider.dart';
import 'package:qpay/screens/accounts/account_provider.dart';
import 'package:qpay/theme/app_theme.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
@@ -36,14 +37,13 @@ void main() async {
ChangeNotifierProvider<OnboardingController>(
create: (_) => OnboardingController(),
),
ChangeNotifierProvider<SplashProvider>(
create: (_) => SplashProvider(),
),
ChangeNotifierProvider<SplashProvider>(create: (_) => SplashProvider()),
ChangeNotifierProvider<WorkspaceProvider>(
create: (_) => WorkspaceProvider(),
),
ChangeNotifierProvider<UptimeProvider>(
create: (_) => UptimeProvider(),
ChangeNotifierProvider<UptimeProvider>(create: (_) => UptimeProvider()),
ChangeNotifierProvider<AccountProvider>(
create: (_) => AccountProvider(),
),
],
child: const MyApp(),
@@ -69,4 +69,4 @@ class _MyAppState extends State<MyApp> {
routerConfig: buildRouter(),
);
}
}
}

View File

@@ -33,6 +33,7 @@ import 'package:qpay/screens/workspaces/workspace_screen.dart';
import 'package:qpay/screens/users/screens/user_list_screen.dart';
import 'package:qpay/screens/more/more_screen.dart';
import 'package:qpay/screens/uptime/uptime_status_screen.dart';
import 'package:qpay/screens/contact/contact_screen.dart';
/// Tracks whether the splash animation is complete so that
/// GoRouter's redirect can show the splash first, then release
@@ -232,6 +233,10 @@ GoRouter buildRouter() {
path: '/more',
builder: (context, state) => const MoreScreen(),
),
GoRoute(
path: '/contact',
builder: (context, state) => const ContactScreen(),
),
GoRoute(
path: '/uptime',
builder: (context, state) => const UptimeStatusScreen(),

View File

@@ -17,6 +17,9 @@ class AccountProvider extends ChangeNotifier {
String? _accountError;
String? _statementError;
/// Cached balances keyed by currency code (e.g. 'USD', 'ZWG').
final Map<String, AccountModel?> _balances = {};
AccountModel? get account => _account;
StatementModel? get statement => _statement;
bool get isLoadingAccount => _isLoadingAccount;
@@ -25,6 +28,20 @@ class AccountProvider extends ChangeNotifier {
String? get statementError => _statementError;
bool _isDisposed = false;
/// Returns a cached account balance for [currency], or null if not yet
/// fetched.
AccountModel? balanceForCurrency(String currency) {
return _balances[currency.toUpperCase()];
}
/// Returns the account with name 'City Wallet' for [currency], or null.
AccountModel? cityWalletBalance() {
return _balances.values.firstWhere(
(a) => a?.name == 'City Wallet',
orElse: () => null,
);
}
@override
void notifyListeners() {
if (!_isDisposed) {
@@ -52,6 +69,7 @@ class AccountProvider extends ChangeNotifier {
);
_account = AccountModel.fromJson(response);
_balances[currency.toUpperCase()] = _account;
_isLoadingAccount = false;
notifyListeners();
return ApiResponse.success(_account!);
@@ -71,6 +89,17 @@ class AccountProvider extends ChangeNotifier {
}
}
/// Pre-load multiple currency balances at once. Results are stored in the
/// [_balances] map so any widget can access them later.
Future<void> preloadBalances(
String? workspaceId,
List<String> currencies,
) async {
for (final currency in currencies) {
await fetchAccount(workspaceId, currency);
}
}
Future<ApiResponse<StatementModel>> fetchStatement({
required String workspaceId,
required String currency,
@@ -117,4 +146,4 @@ class AccountProvider extends ChangeNotifier {
_statementError = null;
notifyListeners();
}
}
}

View File

@@ -1,6 +1,7 @@
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/accounts/models/account_model.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -20,8 +21,6 @@ class AccountBalanceWidget extends StatefulWidget {
}
class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
late AccountProvider _accountProvider;
bool _isLoggedIn = false;
String? _workspaceId;
@@ -36,20 +35,12 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
@override
void initState() {
super.initState();
_accountProvider = AccountProvider();
_loadAuthStateAndBalances();
}
@override
void dispose() {
_accountProvider.dispose();
super.dispose();
}
Future<void> _loadAuthStateAndBalances() async {
final prefs = await SharedPreferences.getInstance();
// Determine login state from persisted token
if (prefs.getString("token") != null) {
setState(() {
_isLoggedIn = true;
@@ -62,18 +53,37 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
return;
}
if (!_isLoggedIn) {
return;
}
if (!mounted) return;
final accountProvider = context.read<AccountProvider>();
setState(() {
_isLoadingUsd = true;
_isLoadingZwG = true;
});
// no need to proceed if not logged in
if (!_isLoggedIn) {
// Try the cached balances first
final cachedUsd = accountProvider.balanceForCurrency('USD');
final cachedZwg = accountProvider.balanceForCurrency('ZWG');
if (cachedUsd != null && cachedZwg != null) {
if (mounted) {
setState(() {
_usdAccount = cachedUsd;
_zwgAccount = cachedZwg;
_isLoadingUsd = false;
_isLoadingZwG = false;
});
}
return;
}
// Fetch USD balance
final usdResult = await _accountProvider.fetchAccount(_workspaceId, 'USD');
final usdResult = await accountProvider.fetchAccount(_workspaceId, 'USD');
if (mounted) {
setState(() {
_isLoadingUsd = false;
@@ -87,7 +97,7 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
}
// Fetch ZWG balance
final zwgResult = await _accountProvider.fetchAccount(_workspaceId, 'ZWG');
final zwgResult = await accountProvider.fetchAccount(_workspaceId, 'ZWG');
if (mounted) {
setState(() {
_isLoadingZwG = false;
@@ -117,12 +127,16 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
if (_workspaceId == null || _workspaceId!.isEmpty) return;
if (!mounted) return;
final accountProvider = context.read<AccountProvider>();
setState(() {
_isLoadingUsd = true;
_isLoadingZwG = true;
});
final usdResult = await _accountProvider.fetchAccount(_workspaceId, 'USD');
final usdResult = await accountProvider.fetchAccount(_workspaceId, 'USD');
if (mounted) {
setState(() {
_isLoadingUsd = false;
@@ -135,7 +149,7 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
});
}
final zwgResult = await _accountProvider.fetchAccount(_workspaceId, 'ZWG');
final zwgResult = await accountProvider.fetchAccount(_workspaceId, 'ZWG');
if (mounted) {
setState(() {
_isLoadingZwG = false;
@@ -509,4 +523,4 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
final formatter = NumberFormat('#,##0.00', 'en_US');
return '\$${formatter.format(balance)}';
}
}
}

View File

@@ -1,7 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/accounts/account_provider.dart';
import 'package:qpay/screens/confirm/confirm_controller.dart';
import 'package:qpay/widgets/app_snack_bar.dart';
import 'package:skeletonizer/skeletonizer.dart';
@@ -600,42 +602,112 @@ class _ConfirmScreenState extends State<ConfirmScreen>
onTap: () async {
_handlePayment();
},
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Image(
image: AssetImage(
"assets/${controller.transactionController.model.selectedPaymentProcessor.image}",
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Image(
image: AssetImage(
"assets/${controller.transactionController.model.selectedPaymentProcessor.image}",
),
),
),
title: Text(
controller
.transactionController
.model
.selectedPaymentProcessor
.name,
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
controller
.transactionController
.model
.selectedPaymentProcessor
.description,
style: TextStyle(
fontWeight: FontWeight.normal,
fontSize: 10,
),
),
trailing: Icon(
Icons.chevron_right,
color: Theme.of(
context,
).colorScheme.secondary.withValues(alpha: 0.7),
),
),
),
),
title: Text(
controller
.transactionController
.model
.selectedPaymentProcessor
.name,
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
controller
.transactionController
.model
.selectedPaymentProcessor
.description,
style: TextStyle(fontWeight: FontWeight.normal, fontSize: 10),
),
trailing: Icon(
Icons.chevron_right,
color: Theme.of(
context,
).colorScheme.secondary.withValues(alpha: 0.7),
Builder(
builder: (context) {
final accountProvider = context.watch<AccountProvider>();
final currency = controller
.transactionController
.model
.confirmationData["debitCurrency"]
?.toString();
final cityWallet = currency != null
? accountProvider.balanceForCurrency(currency)
: null;
final hasCityWallet = cityWallet != null;
if (!hasCityWallet) return const SizedBox.shrink();
if (controller
.transactionController
.model
.selectedPaymentProcessor
.label !=
'WALLET') {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 4, bottom: 4),
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: 11,
fontWeight: FontWeight.w500,
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.7),
),
),
const Spacer(),
Text(
'\$${cityWallet!.balance.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
);
},
),
],
),
),
),

View File

@@ -0,0 +1,296 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:qpay/models/responsive_policy.dart';
class ContactScreen extends StatelessWidget {
const ContactScreen({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark
? const Color(0xFF0D0D0D)
: const Color(0xFFF8F9FA),
appBar: AppBar(
leading: context.canPop()
? IconButton(
onPressed: () => context.pop(),
icon: const Icon(Icons.arrow_back_rounded),
color: theme.colorScheme.primary,
)
: const SizedBox.shrink(),
title: Text(
'Contact Us',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 20,
color: isDark ? Colors.white : Colors.black87,
),
),
centerTitle: true,
surfaceTintColor: Colors.transparent,
backgroundColor: isDark
? const Color(0xFF0D0D0D)
: const Color(0xFFF8F9FA),
),
body: Center(
child: LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toDouble()
: constraints.maxWidth;
return SizedBox(
width: maxWidth,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// Header card
_buildHeaderCard(theme, isDark),
const SizedBox(height: 16),
// Social links card
_buildSocialLinksCard(theme, isDark),
],
),
);
},
),
),
);
}
Widget _buildHeaderCard(ThemeData theme, bool isDark) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade200,
width: 1,
),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.3)
: Colors.black.withValues(alpha: 0.04),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
theme.colorScheme.primary.withValues(alpha: 0.2),
theme.colorScheme.primary.withValues(alpha: 0.05),
],
),
shape: BoxShape.circle,
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.15),
width: 1.5,
),
),
child: Icon(
Icons.support_agent_rounded,
size: 36,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 20),
Text(
'We\'d love to hear from you',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 8),
Text(
'Reach out to us through any of the channels below.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: isDark ? Colors.white60 : Colors.grey.shade600,
height: 1.5,
),
),
],
),
);
}
Widget _buildSocialLinksCard(ThemeData theme, bool isDark) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade200,
width: 1,
),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.3)
: Colors.black.withValues(alpha: 0.04),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Text(
'Connect With Us',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white54 : Colors.grey.shade500,
letterSpacing: 1,
),
),
),
const SizedBox(height: 4),
// Instagram
_buildSocialTile(
theme: theme,
isDark: isDark,
icon: Icons.camera_alt_rounded,
iconBackgroundColor: const Color(0xFFE1306C),
title: 'Instagram',
subtitle: 'Follow us for updates',
onTap: () {
// Instagram link will be added later
},
),
const SizedBox(height: 8),
// WhatsApp
_buildSocialTile(
theme: theme,
isDark: isDark,
icon: Icons.chat_rounded,
iconBackgroundColor: const Color(0xFF25D366),
title: 'WhatsApp',
subtitle: 'Chat with us directly',
onTap: () {
// WhatsApp link will be added later
},
),
],
),
);
}
Widget _buildSocialTile({
required ThemeData theme,
required bool isDark,
required IconData icon,
required Color iconBackgroundColor,
required String title,
required String subtitle,
required VoidCallback onTap,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.03)
: Colors.grey.shade50,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.04)
: Colors.grey.shade100,
width: 1,
),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: iconBackgroundColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Center(
child: Icon(icon, color: iconBackgroundColor, size: 24),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w400,
color: isDark ? Colors.white54 : Colors.grey.shade600,
),
),
],
),
),
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: iconBackgroundColor.withValues(alpha: 0.1),
),
child: const Icon(
Icons.arrow_forward_rounded,
size: 16,
color: Colors.grey,
),
),
],
),
),
),
);
}
}

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> {
),
);
}
}
}

View File

@@ -1,4 +1,7 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:qpay/config/app_config.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/models/pageable_model.dart';
@@ -183,6 +186,58 @@ class HistoryController extends ChangeNotifier {
_safeNotify();
}
/// Builds the fully-qualified download URL for the transactions report.
Uri _buildReportUrl({
required String workspaceId,
required DateTime startDate,
required DateTime endDate,
}) {
final df = DateFormat("yyyy-MM-dd'T'HH:mm:ss");
final start = Uri.encodeComponent(
df.format(
DateTime(startDate.year, startDate.month, startDate.day, 0, 0, 0),
),
);
final end = Uri.encodeComponent(
df.format(
DateTime(endDate.year, endDate.month, endDate.day, 23, 59, 59),
),
);
return Uri.parse(
'${AppConfig.baseUrl}/public/reports/transactions/download'
'?startDate=$start'
'&endDate=$end'
'&workspaceId=$workspaceId',
);
}
/// Opens the transactions report download URL in the device browser.
/// The browser handles the actual file download natively — no file I/O
/// or plugin channels required, works on Android, iOS, and web.
Future<void> downloadReport({
required String workspaceId,
required DateTime startDate,
required DateTime endDate,
}) async {
model.isActing = true;
_safeNotify();
try {
final uri = _buildReportUrl(
workspaceId: workspaceId,
startDate: startDate,
endDate: endDate,
);
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (e) {
logger.e('Failed to open report URL: $e');
model.errorMessage = 'Failed to download report';
} finally {
model.isActing = false;
_safeNotify();
}
}
void clearFilters() {
model.statusFilter = null;
model.typeFilter = null;
@@ -219,4 +274,4 @@ class HistoryController extends ChangeNotifier {
},
];
}
}
}

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/auth_state.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/receipt/receipt_controller.dart';
import 'package:qpay/screens/transaction_controller.dart';
@@ -251,6 +253,142 @@ class _HistoryScreenState extends State<HistoryScreen>
);
}
/// Shows the report download bottom sheet with start/end date pickers.
void _showReportSheet() {
DateTime selectedStart = DateTime.now().subtract(const Duration(days: 7));
DateTime selectedEnd = DateTime.now();
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) {
return StatefulBuilder(
builder: (context, setSheetState) {
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Download Report',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(height: 20),
_buildDateField(
label: 'Start Date',
date: selectedStart,
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: selectedStart,
firstDate: DateTime(2020),
lastDate: selectedEnd,
);
if (picked != null) {
setSheetState(() => selectedStart = picked);
}
},
),
const SizedBox(height: 16),
_buildDateField(
label: 'End Date',
date: selectedEnd,
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: selectedEnd,
firstDate: selectedStart,
lastDate: DateTime.now(),
);
if (picked != null) {
setSheetState(() => selectedEnd = picked);
}
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: controller.model.isActing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.download),
label: Text(
controller.model.isActing
? 'Downloading…'
: 'Download Report',
),
onPressed: controller.model.isActing
? null
: () {
Navigator.of(ctx).pop();
final workspaceId =
prefs.getString("workspaceId") ?? '';
controller.downloadReport(
workspaceId: workspaceId,
startDate: selectedStart,
endDate: selectedEnd,
);
},
),
),
const SizedBox(height: 8),
],
),
);
},
);
},
);
}
/// Builds a tappable date field for the report sheet.
Widget _buildDateField({
required String label,
required DateTime date,
required VoidCallback onTap,
}) {
final df = DateFormat('dd MMM yyyy');
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: Text(
'$label: ${df.format(date)}',
style: const TextStyle(fontSize: 15),
),
),
Icon(Icons.calendar_today, size: 20, color: Colors.grey.shade600),
],
),
),
);
}
@override
void dispose() {
_searchController.dispose();
@@ -396,6 +534,15 @@ class _HistoryScreenState extends State<HistoryScreen>
},
),
const SizedBox(width: 8),
// Report download — only visible to logged-in users.
if (authState.isLoggedIn)
_buildIconButton(
icon: Icons.description_outlined,
tooltip: 'Download report',
isActive: false,
onPressed: _showReportSheet,
),
const SizedBox(width: 8),
_buildIconButton(
icon: Icons.add,
tooltip: 'New payment',
@@ -679,4 +826,4 @@ class _HistoryScreenState extends State<HistoryScreen>
),
);
}
}
}

View File

@@ -1,11 +1,61 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../auth_state.dart';
import '../../models/responsive_policy.dart';
class MoreScreen extends StatelessWidget {
class MoreScreen extends StatefulWidget {
const MoreScreen({super.key});
@override
State<MoreScreen> createState() => _MoreScreenState();
}
class _MoreScreenState extends State<MoreScreen> {
late SharedPreferences _prefs;
@override
void initState() {
super.initState();
_initPrefs();
}
Future<void> _initPrefs() async {
_prefs = await SharedPreferences.getInstance();
}
void _showLogoutDialog() {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: const Text('Confirm Logout'),
content: const Text('Are you sure you want to log out?'),
actions: <Widget>[
TextButton(
child: const Text('Cancel'),
onPressed: () {
Navigator.of(dialogContext).pop();
},
),
TextButton(
child: const Text('Logout'),
onPressed: () {
Navigator.of(dialogContext).pop();
_prefs.clear();
authState.refresh();
},
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -22,6 +72,12 @@ class MoreScreen extends StatelessWidget {
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_NavOptionTile(
icon: Icons.swap_horiz,
title: 'Change Workspace',
subtitle: 'Switch to a different workspace',
onTap: () => context.go('/workspace'),
),
_NavOptionTile(
icon: Icons.people_outline,
title: 'Users',
@@ -34,6 +90,18 @@ class MoreScreen extends StatelessWidget {
subtitle: 'View system uptime and service status',
onTap: () => context.push('/uptime'),
),
_NavOptionTile(
icon: Icons.support_agent_rounded,
title: 'Contact Us',
subtitle: 'Get in touch with our team',
onTap: () => context.push('/contact'),
),
_NavOptionTile(
icon: Icons.logout,
title: 'Logout',
subtitle: 'Sign out of your account',
onTap: () => _showLogoutDialog(),
),
],
),
);
@@ -77,4 +145,4 @@ class _NavOptionTile extends StatelessWidget {
onTap: onTap,
);
}
}
}

View File

@@ -35,6 +35,7 @@ abstract class PaymentProcessor with _$PaymentProcessor {
required String accountFieldName,
required String accountFieldLabel,
required String label,
required String currency,
required String authType,
}) = _PaymentProcessor;
@@ -176,15 +177,22 @@ class PayController extends ChangeNotifier {
}
}
Future<ApiResponse<List<PaymentProcessor>>> getPaymentProcessors() async {
Future<ApiResponse<List<PaymentProcessor>>> getPaymentProcessors(
String currency,
) async {
try {
model.isLoading = true;
model.paymentProcessors = getFakePaymentProcessors();
notifyListeners();
List<dynamic> response = await http.get('/public/payment-processors');
model.paymentProcessors = response
.map((e) => PaymentProcessor.fromJson(e))
final response = await http.get(
'/public/payment-processors?currency=$currency',
);
final List<dynamic> content = response is List
? response
: (response['content'] ?? []);
model.paymentProcessors = content
.map((e) => PaymentProcessor.fromJson(e as Map<String, dynamic>))
.toList();
model.isLoading = false;
@@ -238,6 +246,7 @@ class PayController extends ChangeNotifier {
accountFieldLabel: "EcoCash Phone Number",
label: "ECOCASH",
authType: "REMOTE",
currency: "USD",
),
];
}

View File

@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$PaymentProcessor implements DiagnosticableTreeMixin {
String get name; String get description; String get image; String get accountFieldName; String get accountFieldLabel; String get label; String get authType;
String get name; String get description; String get image; String get accountFieldName; String get accountFieldLabel; String get label; String get currency; String get authType;
/// Create a copy of PaymentProcessor
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -29,21 +29,21 @@ $PaymentProcessorCopyWith<PaymentProcessor> get copyWith => _$PaymentProcessorCo
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'PaymentProcessor'))
..add(DiagnosticsProperty('name', name))..add(DiagnosticsProperty('description', description))..add(DiagnosticsProperty('image', image))..add(DiagnosticsProperty('accountFieldName', accountFieldName))..add(DiagnosticsProperty('accountFieldLabel', accountFieldLabel))..add(DiagnosticsProperty('label', label))..add(DiagnosticsProperty('authType', authType));
..add(DiagnosticsProperty('name', name))..add(DiagnosticsProperty('description', description))..add(DiagnosticsProperty('image', image))..add(DiagnosticsProperty('accountFieldName', accountFieldName))..add(DiagnosticsProperty('accountFieldLabel', accountFieldLabel))..add(DiagnosticsProperty('label', label))..add(DiagnosticsProperty('currency', currency))..add(DiagnosticsProperty('authType', authType));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is PaymentProcessor&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.image, image) || other.image == image)&&(identical(other.accountFieldName, accountFieldName) || other.accountFieldName == accountFieldName)&&(identical(other.accountFieldLabel, accountFieldLabel) || other.accountFieldLabel == accountFieldLabel)&&(identical(other.label, label) || other.label == label)&&(identical(other.authType, authType) || other.authType == authType));
return identical(this, other) || (other.runtimeType == runtimeType&&other is PaymentProcessor&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.image, image) || other.image == image)&&(identical(other.accountFieldName, accountFieldName) || other.accountFieldName == accountFieldName)&&(identical(other.accountFieldLabel, accountFieldLabel) || other.accountFieldLabel == accountFieldLabel)&&(identical(other.label, label) || other.label == label)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.authType, authType) || other.authType == authType));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,name,description,image,accountFieldName,accountFieldLabel,label,authType);
int get hashCode => Object.hash(runtimeType,name,description,image,accountFieldName,accountFieldLabel,label,currency,authType);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'PaymentProcessor(name: $name, description: $description, image: $image, accountFieldName: $accountFieldName, accountFieldLabel: $accountFieldLabel, label: $label, authType: $authType)';
return 'PaymentProcessor(name: $name, description: $description, image: $image, accountFieldName: $accountFieldName, accountFieldLabel: $accountFieldLabel, label: $label, currency: $currency, authType: $authType)';
}
@@ -54,7 +54,7 @@ abstract mixin class $PaymentProcessorCopyWith<$Res> {
factory $PaymentProcessorCopyWith(PaymentProcessor value, $Res Function(PaymentProcessor) _then) = _$PaymentProcessorCopyWithImpl;
@useResult
$Res call({
String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String authType
String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String currency, String authType
});
@@ -71,7 +71,7 @@ class _$PaymentProcessorCopyWithImpl<$Res>
/// Create a copy of PaymentProcessor
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? description = null,Object? image = null,Object? accountFieldName = null,Object? accountFieldLabel = null,Object? label = null,Object? authType = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? description = null,Object? image = null,Object? accountFieldName = null,Object? accountFieldLabel = null,Object? label = null,Object? currency = null,Object? authType = null,}) {
return _then(_self.copyWith(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
@@ -79,6 +79,7 @@ as String,image: null == image ? _self.image : image // ignore: cast_nullable_to
as String,accountFieldName: null == accountFieldName ? _self.accountFieldName : accountFieldName // ignore: cast_nullable_to_non_nullable
as String,accountFieldLabel: null == accountFieldLabel ? _self.accountFieldLabel : accountFieldLabel // ignore: cast_nullable_to_non_nullable
as String,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
as String,currency: null == currency ? _self.currency : currency // ignore: cast_nullable_to_non_nullable
as String,authType: null == authType ? _self.authType : authType // ignore: cast_nullable_to_non_nullable
as String,
));
@@ -165,10 +166,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String authType)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String currency, String authType)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _PaymentProcessor() when $default != null:
return $default(_that.name,_that.description,_that.image,_that.accountFieldName,_that.accountFieldLabel,_that.label,_that.authType);case _:
return $default(_that.name,_that.description,_that.image,_that.accountFieldName,_that.accountFieldLabel,_that.label,_that.currency,_that.authType);case _:
return orElse();
}
@@ -186,10 +187,10 @@ return $default(_that.name,_that.description,_that.image,_that.accountFieldName,
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String authType) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String currency, String authType) $default,) {final _that = this;
switch (_that) {
case _PaymentProcessor():
return $default(_that.name,_that.description,_that.image,_that.accountFieldName,_that.accountFieldLabel,_that.label,_that.authType);case _:
return $default(_that.name,_that.description,_that.image,_that.accountFieldName,_that.accountFieldLabel,_that.label,_that.currency,_that.authType);case _:
throw StateError('Unexpected subclass');
}
@@ -206,10 +207,10 @@ return $default(_that.name,_that.description,_that.image,_that.accountFieldName,
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String authType)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String currency, String authType)? $default,) {final _that = this;
switch (_that) {
case _PaymentProcessor() when $default != null:
return $default(_that.name,_that.description,_that.image,_that.accountFieldName,_that.accountFieldLabel,_that.label,_that.authType);case _:
return $default(_that.name,_that.description,_that.image,_that.accountFieldName,_that.accountFieldLabel,_that.label,_that.currency,_that.authType);case _:
return null;
}
@@ -221,7 +222,7 @@ return $default(_that.name,_that.description,_that.image,_that.accountFieldName,
@JsonSerializable()
class _PaymentProcessor with DiagnosticableTreeMixin implements PaymentProcessor {
const _PaymentProcessor({required this.name, required this.description, required this.image, required this.accountFieldName, required this.accountFieldLabel, required this.label, required this.authType});
const _PaymentProcessor({required this.name, required this.description, required this.image, required this.accountFieldName, required this.accountFieldLabel, required this.label, required this.currency, required this.authType});
factory _PaymentProcessor.fromJson(Map<String, dynamic> json) => _$PaymentProcessorFromJson(json);
@override final String name;
@@ -230,6 +231,7 @@ class _PaymentProcessor with DiagnosticableTreeMixin implements PaymentProcessor
@override final String accountFieldName;
@override final String accountFieldLabel;
@override final String label;
@override final String currency;
@override final String authType;
/// Create a copy of PaymentProcessor
@@ -246,21 +248,21 @@ Map<String, dynamic> toJson() {
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'PaymentProcessor'))
..add(DiagnosticsProperty('name', name))..add(DiagnosticsProperty('description', description))..add(DiagnosticsProperty('image', image))..add(DiagnosticsProperty('accountFieldName', accountFieldName))..add(DiagnosticsProperty('accountFieldLabel', accountFieldLabel))..add(DiagnosticsProperty('label', label))..add(DiagnosticsProperty('authType', authType));
..add(DiagnosticsProperty('name', name))..add(DiagnosticsProperty('description', description))..add(DiagnosticsProperty('image', image))..add(DiagnosticsProperty('accountFieldName', accountFieldName))..add(DiagnosticsProperty('accountFieldLabel', accountFieldLabel))..add(DiagnosticsProperty('label', label))..add(DiagnosticsProperty('currency', currency))..add(DiagnosticsProperty('authType', authType));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PaymentProcessor&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.image, image) || other.image == image)&&(identical(other.accountFieldName, accountFieldName) || other.accountFieldName == accountFieldName)&&(identical(other.accountFieldLabel, accountFieldLabel) || other.accountFieldLabel == accountFieldLabel)&&(identical(other.label, label) || other.label == label)&&(identical(other.authType, authType) || other.authType == authType));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PaymentProcessor&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.image, image) || other.image == image)&&(identical(other.accountFieldName, accountFieldName) || other.accountFieldName == accountFieldName)&&(identical(other.accountFieldLabel, accountFieldLabel) || other.accountFieldLabel == accountFieldLabel)&&(identical(other.label, label) || other.label == label)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.authType, authType) || other.authType == authType));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,name,description,image,accountFieldName,accountFieldLabel,label,authType);
int get hashCode => Object.hash(runtimeType,name,description,image,accountFieldName,accountFieldLabel,label,currency,authType);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'PaymentProcessor(name: $name, description: $description, image: $image, accountFieldName: $accountFieldName, accountFieldLabel: $accountFieldLabel, label: $label, authType: $authType)';
return 'PaymentProcessor(name: $name, description: $description, image: $image, accountFieldName: $accountFieldName, accountFieldLabel: $accountFieldLabel, label: $label, currency: $currency, authType: $authType)';
}
@@ -271,7 +273,7 @@ abstract mixin class _$PaymentProcessorCopyWith<$Res> implements $PaymentProcess
factory _$PaymentProcessorCopyWith(_PaymentProcessor value, $Res Function(_PaymentProcessor) _then) = __$PaymentProcessorCopyWithImpl;
@override @useResult
$Res call({
String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String authType
String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String currency, String authType
});
@@ -288,7 +290,7 @@ class __$PaymentProcessorCopyWithImpl<$Res>
/// Create a copy of PaymentProcessor
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? description = null,Object? image = null,Object? accountFieldName = null,Object? accountFieldLabel = null,Object? label = null,Object? authType = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? description = null,Object? image = null,Object? accountFieldName = null,Object? accountFieldLabel = null,Object? label = null,Object? currency = null,Object? authType = null,}) {
return _then(_PaymentProcessor(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
@@ -296,6 +298,7 @@ as String,image: null == image ? _self.image : image // ignore: cast_nullable_to
as String,accountFieldName: null == accountFieldName ? _self.accountFieldName : accountFieldName // ignore: cast_nullable_to_non_nullable
as String,accountFieldLabel: null == accountFieldLabel ? _self.accountFieldLabel : accountFieldLabel // ignore: cast_nullable_to_non_nullable
as String,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
as String,currency: null == currency ? _self.currency : currency // ignore: cast_nullable_to_non_nullable
as String,authType: null == authType ? _self.authType : authType // ignore: cast_nullable_to_non_nullable
as String,
));

View File

@@ -14,6 +14,7 @@ _PaymentProcessor _$PaymentProcessorFromJson(Map<String, dynamic> json) =>
accountFieldName: json['accountFieldName'] as String,
accountFieldLabel: json['accountFieldLabel'] as String,
label: json['label'] as String,
currency: json['currency'] as String,
authType: json['authType'] as String,
);
@@ -25,6 +26,7 @@ Map<String, dynamic> _$PaymentProcessorToJson(_PaymentProcessor instance) =>
'accountFieldName': instance.accountFieldName,
'accountFieldLabel': instance.accountFieldLabel,
'label': instance.label,
'currency': instance.currency,
'authType': instance.authType,
};

View File

@@ -4,6 +4,7 @@ import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/accounts/account_provider.dart';
import 'package:qpay/screens/pay/pay_controller.dart';
import 'package:qpay/screens/transaction_controller.dart';
import 'package:qpay/widgets/app_snack_bar.dart';
@@ -64,7 +65,6 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
super.initState();
controller = PayController(context);
controller.getPaymentProcessors();
controller.getProducts(controller.model.selectedProvider?.id ?? "");
transactionController = Provider.of<TransactionController>(
@@ -72,6 +72,7 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
listen: false,
);
controller.getPaymentProcessors(transactionController.model.currency);
_setupData();
_controller = AnimationController(
@@ -234,38 +235,71 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTransactionDetailsCard(
children: [
_buildDetailRow(
icon: Icons.business_outlined,
label: "Provider",
value:
controller
Builder(
builder: (context) {
// Look up City Wallet balance matching the
// transaction currency from the shared
// AccountProvider.
final accountProvider = context
.watch<AccountProvider>();
final currency =
transactionController.model.currency;
final cityWallet = accountProvider
.balanceForCurrency(currency);
final hasCityWallet =
cityWallet != null &&
cityWallet.name == 'City Wallet';
return _buildTransactionDetailsCard(
children: [
_buildDetailRow(
icon: Icons.business_outlined,
label: "Provider",
value:
controller
.model
.selectedProvider
?.name ??
"",
),
_buildDetailRow(
icon: Icons.account_balance_outlined,
label:
controller
.model
.selectedProvider
?.accountFieldName ??
"",
value: transactionController
.model
.selectedProvider
?.name ??
"",
),
_buildDetailRow(
icon: Icons.account_balance_outlined,
label:
controller
.formData
.creditAccount,
),
_buildDetailRow(
icon: Icons.monetization_on,
label: "Currency",
value: transactionController
.model
.selectedProvider
?.accountFieldName ??
"",
value: transactionController
.model
.formData
.creditAccount,
),
_buildDetailRow(
icon: Icons.monetization_on,
label: "Currency",
value:
transactionController.model.currency,
),
],
.currency,
),
if (hasCityWallet)
_buildDetailRow(
icon: Icons
.account_balance_wallet_outlined,
label: "City Wallet Balance",
value:
'\$${cityWallet!.balance.toStringAsFixed(2)}',
valueStyle: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: Theme.of(
context,
).colorScheme.primary,
),
),
],
);
},
),
SizedBox(height: 20),
InkWell(
@@ -748,6 +782,7 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
required IconData icon,
required String label,
required String value,
TextStyle? valueStyle,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
@@ -762,11 +797,13 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
const Spacer(),
Text(
value,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
style:
valueStyle ??
TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
],
),

View File

@@ -144,13 +144,17 @@ class ProviderController extends ChangeNotifier {
// ── Payment Processors ─────────────────────────────────────
Future<ApiResponse<List<PaymentProcessor>>> loadProcessors() async {
Future<ApiResponse<List<PaymentProcessor>>> loadProcessors(
String currency,
) async {
model.isLoadingProcessors = true;
model.errorMessage = null;
if (!_disposed) notifyListeners();
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> list = response is List
? response

View File

@@ -404,9 +404,21 @@ class _ReceiptScreenState extends State<ReceiptScreen>
),
],
),
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20),
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Need help? '),
TextButton(
onPressed: () => context.push('/contact'),
child: Text('Get in touch'),
),
],
),
const SizedBox(height: 8),
// Status badge
if (status != null) ...[
_buildStatusBadge(status, theme),

View File

@@ -216,7 +216,8 @@ class _TransactionItemWidgetState extends State<TransactionItemWidget> {
),
// Quick actions row (only shown when onRepeat is provided)
if (widget.onRepeat != null) ...[
if (widget.onRepeat != null &&
widget.transaction['batchId'] == null) ...[
const SizedBox(height: 10),
Divider(
height: 1,

View File

@@ -12,6 +12,12 @@ http {
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;

View File

@@ -577,13 +577,13 @@ packages:
source: hosted
version: "1.1.0"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.5"
version: "2.1.6"
path_provider_android:
dependency: transitive
description:

View File

@@ -54,6 +54,7 @@ dependencies:
gif_view: ^0.4.0
firebase_core: ^4.1.0
url_launcher: ^6.3.2
path_provider: ^2.1.6
html: ^0.15.6
web: ^1.1.1
flutter_web_plugins: