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()); logger.i(response.data.toString());
return response.data; 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/providers/splash_provider.dart';
import 'package:qpay/screens/workspaces/workspace_provider.dart'; import 'package:qpay/screens/workspaces/workspace_provider.dart';
import 'package:qpay/providers/uptime_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:qpay/theme/app_theme.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
@@ -36,14 +37,13 @@ void main() async {
ChangeNotifierProvider<OnboardingController>( ChangeNotifierProvider<OnboardingController>(
create: (_) => OnboardingController(), create: (_) => OnboardingController(),
), ),
ChangeNotifierProvider<SplashProvider>( ChangeNotifierProvider<SplashProvider>(create: (_) => SplashProvider()),
create: (_) => SplashProvider(),
),
ChangeNotifierProvider<WorkspaceProvider>( ChangeNotifierProvider<WorkspaceProvider>(
create: (_) => WorkspaceProvider(), create: (_) => WorkspaceProvider(),
), ),
ChangeNotifierProvider<UptimeProvider>( ChangeNotifierProvider<UptimeProvider>(create: (_) => UptimeProvider()),
create: (_) => UptimeProvider(), ChangeNotifierProvider<AccountProvider>(
create: (_) => AccountProvider(),
), ),
], ],
child: const MyApp(), child: const MyApp(),
@@ -69,4 +69,4 @@ class _MyAppState extends State<MyApp> {
routerConfig: buildRouter(), 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/users/screens/user_list_screen.dart';
import 'package:qpay/screens/more/more_screen.dart'; import 'package:qpay/screens/more/more_screen.dart';
import 'package:qpay/screens/uptime/uptime_status_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 /// Tracks whether the splash animation is complete so that
/// GoRouter's redirect can show the splash first, then release /// GoRouter's redirect can show the splash first, then release
@@ -232,6 +233,10 @@ GoRouter buildRouter() {
path: '/more', path: '/more',
builder: (context, state) => const MoreScreen(), builder: (context, state) => const MoreScreen(),
), ),
GoRoute(
path: '/contact',
builder: (context, state) => const ContactScreen(),
),
GoRoute( GoRoute(
path: '/uptime', path: '/uptime',
builder: (context, state) => const UptimeStatusScreen(), builder: (context, state) => const UptimeStatusScreen(),

View File

@@ -17,6 +17,9 @@ class AccountProvider extends ChangeNotifier {
String? _accountError; String? _accountError;
String? _statementError; String? _statementError;
/// Cached balances keyed by currency code (e.g. 'USD', 'ZWG').
final Map<String, AccountModel?> _balances = {};
AccountModel? get account => _account; AccountModel? get account => _account;
StatementModel? get statement => _statement; StatementModel? get statement => _statement;
bool get isLoadingAccount => _isLoadingAccount; bool get isLoadingAccount => _isLoadingAccount;
@@ -25,6 +28,20 @@ class AccountProvider extends ChangeNotifier {
String? get statementError => _statementError; String? get statementError => _statementError;
bool _isDisposed = false; 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 @override
void notifyListeners() { void notifyListeners() {
if (!_isDisposed) { if (!_isDisposed) {
@@ -52,6 +69,7 @@ class AccountProvider extends ChangeNotifier {
); );
_account = AccountModel.fromJson(response); _account = AccountModel.fromJson(response);
_balances[currency.toUpperCase()] = _account;
_isLoadingAccount = false; _isLoadingAccount = false;
notifyListeners(); notifyListeners();
return ApiResponse.success(_account!); 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({ Future<ApiResponse<StatementModel>> fetchStatement({
required String workspaceId, required String workspaceId,
required String currency, required String currency,
@@ -117,4 +146,4 @@ class AccountProvider extends ChangeNotifier {
_statementError = null; _statementError = null;
notifyListeners(); notifyListeners();
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.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/confirm/confirm_controller.dart';
import 'package:qpay/screens/groups/models/batch_item_update_model.dart'; import 'package:qpay/screens/groups/models/batch_item_update_model.dart';
import 'package:qpay/screens/groups/models/group_batch_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 { class BatchDetailScreen extends StatefulWidget {
final String batchId; final String batchId;
const BatchDetailScreen({ const BatchDetailScreen({super.key, required this.batchId});
super.key,
required this.batchId,
});
@override @override
State<BatchDetailScreen> createState() => _BatchDetailScreenState(); State<BatchDetailScreen> createState() => _BatchDetailScreenState();
@@ -59,7 +58,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
void initState() { void initState() {
super.initState(); super.initState();
controller = BatchController(context); controller = BatchController(context);
controller.loadProcessors();
_entryController = AnimationController( _entryController = AnimationController(
duration: const Duration(milliseconds: 220), duration: const Duration(milliseconds: 220),
@@ -96,6 +94,8 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
setState(() { setState(() {
_initialLoading = false; _initialLoading = false;
}); });
controller.loadProcessors(_batch!.currency);
} else { } else {
setState(() { setState(() {
_initialLoading = false; _initialLoading = false;
@@ -337,55 +337,119 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
width: isSelected ? 2 : 1, width: isSelected ? 2 : 1,
), ),
), ),
child: Row( child: Column(
children: [ children: [
ClipRRect( Row(
borderRadius: BorderRadius.circular(8), children: [
child: Image.asset( ClipRRect(
'assets/${processor.image}', borderRadius: BorderRadius.circular(
width: 36, 8,
height: 36, ),
errorBuilder: (_, __, ___) => Icon( child: Image.asset(
Icons.payment_rounded, 'assets/${processor.image}',
size: 28, width: 36,
color: theme.colorScheme.primary, height: 36,
), errorBuilder: (_, __, ___) => Icon(
), Icons.payment_rounded,
), size: 28,
const SizedBox(width: 12), color: theme.colorScheme.primary,
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( const SizedBox(width: 12),
processor.label, Expanded(
style: TextStyle( child: Column(
fontSize: 12, crossAxisAlignment:
color: Colors.grey.shade500, 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( // Row 2.5: City Wallet balance (if available for batch currency)
isSelected Builder(
? Icons.radio_button_checked builder: (context) {
: Icons.radio_button_unchecked, final accountProvider = context
color: isSelected .watch<AccountProvider>();
? theme.colorScheme.primary final batchCurrency =
: Colors.grey.shade400, _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), const SizedBox(height: 4),
if (item.billName != null || item.billProductName != null) if (item.billName != null || item.billProductName != null)
_buildBillInfoRow(item, isDark), _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/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:go_router/go_router.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/controllers/group_controller.dart';
import 'package:qpay/screens/groups/models/create_group_from_file_response.dart'; import 'package:qpay/screens/groups/models/create_group_from_file_response.dart';
import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/widgets/app_snack_bar.dart'; import 'package:qpay/widgets/app_snack_bar.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
class CreateGroupFromFileScreen extends StatefulWidget { class CreateGroupFromFileScreen extends StatefulWidget {
const CreateGroupFromFileScreen({super.key}); const CreateGroupFromFileScreen({super.key});
@@ -38,6 +44,40 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
super.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 { Future<void> _pickFile() async {
final result = await FilePicker.pickFiles( final result = await FilePicker.pickFiles(
type: FileType.custom, type: FileType.custom,
@@ -95,9 +135,7 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
); );
if (groupId.isNotEmpty && batch.id != null) { if (groupId.isNotEmpty && batch.id != null) {
context.pushReplacement( context.pushReplacement('/groups/batches/${batch.id}');
'/groups/$groupId/batches/${batch.id}',
);
} }
} else { } else {
// Partial failure — show errors // Partial failure — show errors
@@ -122,9 +160,7 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A2E) : Colors.white, color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
borderRadius: const BorderRadius.vertical( borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
top: Radius.circular(24),
),
), ),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
child: Column( child: Column(
@@ -144,8 +180,11 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
), ),
Row( Row(
children: [ children: [
Icon(Icons.warning_amber_rounded, Icon(
color: Colors.orange.shade700, size: 24), Icons.warning_amber_rounded,
color: Colors.orange.shade700,
size: 24,
),
const SizedBox(width: 10), const SizedBox(width: 10),
Text( Text(
'Upload completed with errors', 'Upload completed with errors',
@@ -161,18 +200,14 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
Text( Text(
'${response.errorCount} of ${response.totalRows} rows failed. ' '${response.errorCount} of ${response.totalRows} rows failed. '
'${response.successCount} rows succeeded.', '${response.successCount} rows succeeded.',
style: TextStyle( style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
fontSize: 13,
color: Colors.grey.shade600,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (response.errors.isNotEmpty) ...[ if (response.errors.isNotEmpty) ...[
Flexible( Flexible(
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
maxHeight: maxHeight: MediaQuery.of(sheetContext).size.height * 0.4,
MediaQuery.of(sheetContext).size.height * 0.4,
), ),
child: ListView.separated( child: ListView.separated(
shrinkWrap: true, shrinkWrap: true,
@@ -241,10 +276,7 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('Create from File'), centerTitle: true),
title: const Text('Create from File'),
centerTitle: true,
),
body: ListenableBuilder( body: ListenableBuilder(
listenable: controller, listenable: controller,
builder: (context, _) { builder: (context, _) {
@@ -272,10 +304,9 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
validator: (v) => validator: (v) => (v == null || v.trim().isEmpty)
(v == null || v.trim().isEmpty) ? 'Group name is required'
? 'Group name is required' : null,
: null,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
@@ -344,10 +375,9 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
), ),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
color: _fileBytes != null color: _fileBytes != null
? Theme.of(context) ? Theme.of(
.colorScheme context,
.primary ).colorScheme.primary.withAlpha(12)
.withAlpha(12)
: Colors.grey.shade50, : Colors.grey.shade50,
), ),
child: Column( child: Column(
@@ -363,14 +393,15 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
_selectedFileName ?? 'Tap to select CSV file', _selectedFileName ??
'Tap to select CSV file',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: _fileBytes != null color: _fileBytes != null
? Theme.of(context) ? Theme.of(
.colorScheme context,
.primary ).colorScheme.primary
: Colors.grey.shade600, : Colors.grey.shade600,
), ),
textAlign: TextAlign.center, 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), const SizedBox(height: 32),
ElevatedButton( ElevatedButton(
onPressed: controller.model.isLoading onPressed: controller.model.isLoading
? null ? null
: _submit, : _submit,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: padding: const EdgeInsets.symmetric(vertical: 16),
const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), 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: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/http/http.dart';
import 'package:qpay/models/api_response.dart'; import 'package:qpay/models/api_response.dart';
import 'package:qpay/models/pageable_model.dart'; import 'package:qpay/models/pageable_model.dart';
@@ -183,6 +186,58 @@ class HistoryController extends ChangeNotifier {
_safeNotify(); _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() { void clearFilters() {
model.statusFilter = null; model.statusFilter = null;
model.typeFilter = 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:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:qpay/auth_state.dart';
import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/receipt/receipt_controller.dart'; import 'package:qpay/screens/receipt/receipt_controller.dart';
import 'package:qpay/screens/transaction_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 @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
@@ -396,6 +534,15 @@ class _HistoryScreenState extends State<HistoryScreen>
}, },
), ),
const SizedBox(width: 8), 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( _buildIconButton(
icon: Icons.add, icon: Icons.add,
tooltip: 'New payment', 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:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../auth_state.dart';
import '../../models/responsive_policy.dart'; import '../../models/responsive_policy.dart';
class MoreScreen extends StatelessWidget { class MoreScreen extends StatefulWidget {
const MoreScreen({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -22,6 +72,12 @@ class MoreScreen extends StatelessWidget {
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
_NavOptionTile(
icon: Icons.swap_horiz,
title: 'Change Workspace',
subtitle: 'Switch to a different workspace',
onTap: () => context.go('/workspace'),
),
_NavOptionTile( _NavOptionTile(
icon: Icons.people_outline, icon: Icons.people_outline,
title: 'Users', title: 'Users',
@@ -34,6 +90,18 @@ class MoreScreen extends StatelessWidget {
subtitle: 'View system uptime and service status', subtitle: 'View system uptime and service status',
onTap: () => context.push('/uptime'), 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, onTap: onTap,
); );
} }
} }

View File

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

View File

@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$PaymentProcessor implements DiagnosticableTreeMixin { 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 /// Create a copy of PaymentProcessor
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -29,21 +29,21 @@ $PaymentProcessorCopyWith<PaymentProcessor> get copyWith => _$PaymentProcessorCo
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties properties
..add(DiagnosticsProperty('type', 'PaymentProcessor')) ..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 @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @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 @override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { 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; factory $PaymentProcessorCopyWith(PaymentProcessor value, $Res Function(PaymentProcessor) _then) = _$PaymentProcessorCopyWithImpl;
@useResult @useResult
$Res call({ $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 /// Create a copy of PaymentProcessor
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_self.copyWith(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable 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 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,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,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,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,authType: null == authType ? _self.authType : authType // ignore: cast_nullable_to_non_nullable
as String, 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) { switch (_that) {
case _PaymentProcessor() when $default != null: 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(); 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) { switch (_that) {
case _PaymentProcessor(): 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'); 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) { switch (_that) {
case _PaymentProcessor() when $default != null: 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; return null;
} }
@@ -221,7 +222,7 @@ return $default(_that.name,_that.description,_that.image,_that.accountFieldName,
@JsonSerializable() @JsonSerializable()
class _PaymentProcessor with DiagnosticableTreeMixin implements PaymentProcessor { 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); factory _PaymentProcessor.fromJson(Map<String, dynamic> json) => _$PaymentProcessorFromJson(json);
@override final String name; @override final String name;
@@ -230,6 +231,7 @@ class _PaymentProcessor with DiagnosticableTreeMixin implements PaymentProcessor
@override final String accountFieldName; @override final String accountFieldName;
@override final String accountFieldLabel; @override final String accountFieldLabel;
@override final String label; @override final String label;
@override final String currency;
@override final String authType; @override final String authType;
/// Create a copy of PaymentProcessor /// Create a copy of PaymentProcessor
@@ -246,21 +248,21 @@ Map<String, dynamic> toJson() {
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties properties
..add(DiagnosticsProperty('type', 'PaymentProcessor')) ..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 @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @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 @override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { 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; factory _$PaymentProcessorCopyWith(_PaymentProcessor value, $Res Function(_PaymentProcessor) _then) = __$PaymentProcessorCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $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 /// Create a copy of PaymentProcessor
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_PaymentProcessor(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable 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 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,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,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,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,authType: null == authType ? _self.authType : authType // ignore: cast_nullable_to_non_nullable
as String, as String,
)); ));

View File

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

View File

@@ -144,13 +144,17 @@ class ProviderController extends ChangeNotifier {
// ── Payment Processors ───────────────────────────────────── // ── Payment Processors ─────────────────────────────────────
Future<ApiResponse<List<PaymentProcessor>>> loadProcessors() async { Future<ApiResponse<List<PaymentProcessor>>> loadProcessors(
String currency,
) async {
model.isLoadingProcessors = true; model.isLoadingProcessors = true;
model.errorMessage = null; model.errorMessage = null;
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
try { try {
final raw = await http.get('/public/payment-processors'); final raw = await http.get(
'/public/payment-processors?currency=$currency',
);
final response = _unwrap(raw); final response = _unwrap(raw);
final List<dynamic> list = response is List final List<dynamic> list = response is List
? response ? 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( child: Column(
children: [ 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 // Status badge
if (status != null) ...[ if (status != null) ...[
_buildStatusBadge(status, theme), _buildStatusBadge(status, theme),

View File

@@ -216,7 +216,8 @@ class _TransactionItemWidgetState extends State<TransactionItemWidget> {
), ),
// Quick actions row (only shown when onRepeat is provided) // 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), const SizedBox(height: 10),
Divider( Divider(
height: 1, height: 1,

View File

@@ -12,6 +12,12 @@ http {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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 compression
gzip on; gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml; 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 source: hosted
version: "1.1.0" version: "1.1.0"
path_provider: path_provider:
dependency: transitive dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.5" version: "2.1.6"
path_provider_android: path_provider_android:
dependency: transitive dependency: transitive
description: description:

View File

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