diff --git a/assets/group-batch-example.csv b/assets/group-batch-example.csv new file mode 100644 index 0000000..bffb20f --- /dev/null +++ b/assets/group-batch-example.csv @@ -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 diff --git a/lib/http/http.dart b/lib/http/http.dart index 2920bd6..6fe4484 100644 --- a/lib/http/http.dart +++ b/lib/http/http.dart @@ -145,4 +145,4 @@ class Http { logger.i(response.data.toString()); return response.data; } -} +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index c627b51..23afa56 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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( create: (_) => OnboardingController(), ), - ChangeNotifierProvider( - create: (_) => SplashProvider(), - ), + ChangeNotifierProvider(create: (_) => SplashProvider()), ChangeNotifierProvider( create: (_) => WorkspaceProvider(), ), - ChangeNotifierProvider( - create: (_) => UptimeProvider(), + ChangeNotifierProvider(create: (_) => UptimeProvider()), + ChangeNotifierProvider( + create: (_) => AccountProvider(), ), ], child: const MyApp(), @@ -69,4 +69,4 @@ class _MyAppState extends State { routerConfig: buildRouter(), ); } -} \ No newline at end of file +} diff --git a/lib/routes.dart b/lib/routes.dart index b4dccc6..972fa5a 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -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(), diff --git a/lib/screens/accounts/account_provider.dart b/lib/screens/accounts/account_provider.dart index 48af6f7..1ec6766 100644 --- a/lib/screens/accounts/account_provider.dart +++ b/lib/screens/accounts/account_provider.dart @@ -17,6 +17,9 @@ class AccountProvider extends ChangeNotifier { String? _accountError; String? _statementError; + /// Cached balances keyed by currency code (e.g. 'USD', 'ZWG'). + final Map _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 preloadBalances( + String? workspaceId, + List currencies, + ) async { + for (final currency in currencies) { + await fetchAccount(workspaceId, currency); + } + } + Future> fetchStatement({ required String workspaceId, required String currency, @@ -117,4 +146,4 @@ class AccountProvider extends ChangeNotifier { _statementError = null; notifyListeners(); } -} +} \ No newline at end of file diff --git a/lib/screens/accounts/widgets/account_balance_widget.dart b/lib/screens/accounts/widgets/account_balance_widget.dart index eb5d61a..b8a1b67 100644 --- a/lib/screens/accounts/widgets/account_balance_widget.dart +++ b/lib/screens/accounts/widgets/account_balance_widget.dart @@ -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 { - late AccountProvider _accountProvider; - bool _isLoggedIn = false; String? _workspaceId; @@ -36,20 +35,12 @@ class _AccountBalanceWidgetState extends State { @override void initState() { super.initState(); - _accountProvider = AccountProvider(); _loadAuthStateAndBalances(); } - @override - void dispose() { - _accountProvider.dispose(); - super.dispose(); - } - Future _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 { return; } + if (!_isLoggedIn) { + return; + } + + if (!mounted) return; + + final accountProvider = context.read(); + 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 { } // 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 { if (_workspaceId == null || _workspaceId!.isEmpty) return; + if (!mounted) return; + + final accountProvider = context.read(); + 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 { }); } - 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 { final formatter = NumberFormat('#,##0.00', 'en_US'); return '\$${formatter.format(balance)}'; } -} +} \ No newline at end of file diff --git a/lib/screens/confirm/confirm_screen.dart b/lib/screens/confirm/confirm_screen.dart index c2d1af1..11b1d37 100644 --- a/lib/screens/confirm/confirm_screen.dart +++ b/lib/screens/confirm/confirm_screen.dart @@ -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 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(); + 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, + ), + ), + ], + ), + ); + }, + ), + ], ), ), ), diff --git a/lib/screens/contact/contact_screen.dart b/lib/screens/contact/contact_screen.dart new file mode 100644 index 0000000..b537890 --- /dev/null +++ b/lib/screens/contact/contact_screen.dart @@ -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, + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/groups/controllers/batch_controller.dart b/lib/screens/groups/controllers/batch_controller.dart index b05fcb9..85d11de 100644 --- a/lib/screens/groups/controllers/batch_controller.dart +++ b/lib/screens/groups/controllers/batch_controller.dart @@ -105,9 +105,11 @@ class BatchController extends ChangeNotifier { } } - Future>> loadProcessors() async { + Future>> 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 content = response is List ? response diff --git a/lib/screens/groups/models/group_batch_model.dart b/lib/screens/groups/models/group_batch_model.dart index 4b26be0..a62c289 100644 --- a/lib/screens/groups/models/group_batch_model.dart +++ b/lib/screens/groups/models/group_batch_model.dart @@ -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 json) => diff --git a/lib/screens/groups/screens/batch_create_screen.dart b/lib/screens/groups/screens/batch_create_screen.dart index e1fd1a3..e5e4fad 100644 --- a/lib/screens/groups/screens/batch_create_screen.dart +++ b/lib/screens/groups/screens/batch_create_screen.dart @@ -62,7 +62,7 @@ class _BatchCreateScreenState extends State { } await Future.wait([ providerController.loadProviders(_currency), - batchController.loadProcessors(), + batchController.loadProcessors(_currency), ]); if (mounted) setState(() => _loadingDropdowns = false); } diff --git a/lib/screens/groups/screens/batch_detail_screen.dart b/lib/screens/groups/screens/batch_detail_screen.dart index fac2735..65ba30f 100644 --- a/lib/screens/groups/screens/batch_detail_screen.dart +++ b/lib/screens/groups/screens/batch_detail_screen.dart @@ -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 createState() => _BatchDetailScreenState(); @@ -59,7 +58,6 @@ class _BatchDetailScreenState extends State void initState() { super.initState(); controller = BatchController(context); - controller.loadProcessors(); _entryController = AnimationController( duration: const Duration(milliseconds: 220), @@ -96,6 +94,8 @@ class _BatchDetailScreenState extends State setState(() { _initialLoading = false; }); + + controller.loadProcessors(_batch!.currency); } else { setState(() { _initialLoading = false; @@ -337,55 +337,119 @@ class _BatchDetailScreenState extends State 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(); + 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 const SizedBox(height: 4), if (item.billName != null || item.billProductName != null) _buildBillInfoRow(item, isDark), + if (item.creditAddress != null) Text(item.creditAddress ?? ''), ], ), ), diff --git a/lib/screens/groups/screens/create_group_from_file_screen.dart b/lib/screens/groups/screens/create_group_from_file_screen.dart index 17e0f50..58e9de8 100644 --- a/lib/screens/groups/screens/create_group_from_file_screen.dart +++ b/lib/screens/groups/screens/create_group_from_file_screen.dart @@ -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 { super.dispose(); } + Future _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 _pickFile() async { final result = await FilePicker.pickFiles( type: FileType.custom, @@ -95,9 +135,7 @@ class _CreateGroupFromFileScreenState extends State { ); 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 { 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 { ), 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 { 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 { @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 { 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 { ), 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 { ), 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 { ), ), ), + 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 { ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/history/history_controller.dart b/lib/screens/history/history_controller.dart index 8d72633..14ecb62 100644 --- a/lib/screens/history/history_controller.dart +++ b/lib/screens/history/history_controller.dart @@ -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 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 { }, ]; } -} +} \ No newline at end of file diff --git a/lib/screens/history/history_screen.dart b/lib/screens/history/history_screen.dart index eebf143..bbe0fc3 100644 --- a/lib/screens/history/history_screen.dart +++ b/lib/screens/history/history_screen.dart @@ -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 ); } + /// 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 }, ), 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 ), ); } -} +} \ No newline at end of file diff --git a/lib/screens/more/more_screen.dart b/lib/screens/more/more_screen.dart index cedfc04..aad33e2 100644 --- a/lib/screens/more/more_screen.dart +++ b/lib/screens/more/more_screen.dart @@ -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 createState() => _MoreScreenState(); +} + +class _MoreScreenState extends State { + late SharedPreferences _prefs; + + @override + void initState() { + super.initState(); + _initPrefs(); + } + + Future _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: [ + 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, ); } -} +} \ No newline at end of file diff --git a/lib/screens/pay/pay_controller.dart b/lib/screens/pay/pay_controller.dart index f3723b1..30339d8 100644 --- a/lib/screens/pay/pay_controller.dart +++ b/lib/screens/pay/pay_controller.dart @@ -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>> getPaymentProcessors() async { + Future>> getPaymentProcessors( + String currency, + ) async { try { model.isLoading = true; model.paymentProcessors = getFakePaymentProcessors(); notifyListeners(); - List 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 content = response is List + ? response + : (response['content'] ?? []); + model.paymentProcessors = content + .map((e) => PaymentProcessor.fromJson(e as Map)) .toList(); model.isLoading = false; @@ -238,6 +246,7 @@ class PayController extends ChangeNotifier { accountFieldLabel: "EcoCash Phone Number", label: "ECOCASH", authType: "REMOTE", + currency: "USD", ), ]; } diff --git a/lib/screens/pay/pay_controller.freezed.dart b/lib/screens/pay/pay_controller.freezed.dart index aab1fce..ef30af0 100644 --- a/lib/screens/pay/pay_controller.freezed.dart +++ b/lib/screens/pay/pay_controller.freezed.dart @@ -15,7 +15,7 @@ T _$identity(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 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 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 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 Function( String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String authType) $default,) {final _that = this; +@optionalTypeArgs TResult when(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? Function( String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String authType)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(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 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 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, )); diff --git a/lib/screens/pay/pay_controller.g.dart b/lib/screens/pay/pay_controller.g.dart index 4995394..94cc078 100644 --- a/lib/screens/pay/pay_controller.g.dart +++ b/lib/screens/pay/pay_controller.g.dart @@ -14,6 +14,7 @@ _PaymentProcessor _$PaymentProcessorFromJson(Map 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 _$PaymentProcessorToJson(_PaymentProcessor instance) => 'accountFieldName': instance.accountFieldName, 'accountFieldLabel': instance.accountFieldLabel, 'label': instance.label, + 'currency': instance.currency, 'authType': instance.authType, }; diff --git a/lib/screens/pay/pay_screen.dart b/lib/screens/pay/pay_screen.dart index 6fe9f95..d1d2604 100644 --- a/lib/screens/pay/pay_screen.dart +++ b/lib/screens/pay/pay_screen.dart @@ -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 with TickerProviderStateMixin { super.initState(); controller = PayController(context); - controller.getPaymentProcessors(); controller.getProducts(controller.model.selectedProvider?.id ?? ""); transactionController = Provider.of( @@ -72,6 +72,7 @@ class _PayScreenState extends State with TickerProviderStateMixin { listen: false, ); + controller.getPaymentProcessors(transactionController.model.currency); _setupData(); _controller = AnimationController( @@ -234,38 +235,71 @@ class _PayScreenState extends State 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(); + 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 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 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, + ), ), ], ), diff --git a/lib/screens/provider/provider_controller.dart b/lib/screens/provider/provider_controller.dart index 19a9484..8ab2008 100644 --- a/lib/screens/provider/provider_controller.dart +++ b/lib/screens/provider/provider_controller.dart @@ -144,13 +144,17 @@ class ProviderController extends ChangeNotifier { // ── Payment Processors ───────────────────────────────────── - Future>> loadProcessors() async { + Future>> 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 list = response is List ? response diff --git a/lib/screens/receipt/receipt_screen.dart b/lib/screens/receipt/receipt_screen.dart index 4be939d..78c5a06 100644 --- a/lib/screens/receipt/receipt_screen.dart +++ b/lib/screens/receipt/receipt_screen.dart @@ -404,9 +404,21 @@ class _ReceiptScreenState extends State ), ], ), - 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), diff --git a/lib/widgets/transaction_item_widget.dart b/lib/widgets/transaction_item_widget.dart index cf6982e..50babba 100644 --- a/lib/widgets/transaction_item_widget.dart +++ b/lib/widgets/transaction_item_widget.dart @@ -216,7 +216,8 @@ class _TransactionItemWidgetState extends State { ), // 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, diff --git a/nginx.conf b/nginx.conf index d7e26e3..78e81e4 100644 --- a/nginx.conf +++ b/nginx.conf @@ -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; diff --git a/pubspec.lock b/pubspec.lock index 803631c..13b2c2d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 2bb5330..47dd18e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: