diff --git a/lib/main.dart b/lib/main.dart index 261a66e..2495961 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -200,6 +200,12 @@ class _MyAppState extends State { path: '/receipt', builder: (context, state) => const ReceiptScreen(), ), + GoRoute( + path: '/receipt/:transactionId', + builder: (context, state) => ReceiptScreen( + transactionId: state.pathParameters['transactionId'], + ), + ), GoRoute( path: '/integration', builder: (context, state) => const IntegrationScreen(), diff --git a/lib/screens/history/history_screen.dart b/lib/screens/history/history_screen.dart index 956e214..792d194 100644 --- a/lib/screens/history/history_screen.dart +++ b/lib/screens/history/history_screen.dart @@ -2,11 +2,12 @@ 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/receipt/receipt_controller.dart'; import 'package:qpay/screens/transaction_controller.dart'; -import 'package:skeletonizer/skeletonizer.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:qpay/screens/history/history_controller.dart'; -import 'package:timeago/timeago.dart' as timeago; +import 'package:qpay/widgets/transaction_item_widget.dart'; +import 'package:qpay/screens/transactions/transaction_model.dart' as txn; class HistoryScreen extends StatefulWidget { const HistoryScreen({super.key}); @@ -54,6 +55,13 @@ class _HistoryScreenState extends State }); } + /// Handles the repeat action for a transaction. + Future _repeatTransaction(Map item) async { + final receiptController = ReceiptController(context); + final transaction = txn.TransactionModel.fromJson(item); + await receiptController.repeatTransaction(transaction); + } + @override void dispose() { controller.dispose(); @@ -198,7 +206,7 @@ class _HistoryScreenState extends State borderSide: BorderSide( color: Theme.of( context, - ).colorScheme.primary.withOpacity(0.2), + ).colorScheme.primary.withValues(alpha: 0.2), ), ), suffixIcon: _searchController.text.isNotEmpty @@ -247,72 +255,15 @@ class _HistoryScreenState extends State return SlideTransition( position: animation, - child: Skeletonizer( + child: TransactionItemWidget( + transaction: e, enabled: controller.model.isLoading, - child: InkWell( - customBorder: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - onTap: () { - transactionController.updateReceiptData(e); - context.push("/receipt"); - }, - 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/${e['paymentProcessorImage']}"), - ), - ), - title: Row( - children: [ - Text( - e['billName'], - style: TextStyle(fontWeight: FontWeight.bold), - ), - SizedBox(width: 10), - Icon( - e['integrationStatus'] == 'SUCCESS' - ? Icons.check_circle - : e['integrationStatus'] == 'PENDING' - ? Icons.pending - : Icons.cancel, - size: 16, - color: e['integrationStatus'] == 'SUCCESS' - ? Colors.green - : e['integrationStatus'] == 'PENDING' - ? Colors.orange - : Colors.red, - ), - ], - ), - subtitle: Text( - e['createdAt'] != null - ? timeago.format(DateTime.parse(e['createdAt'])) - : '', - style: TextStyle(fontSize: 12), - ), - trailing: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - e['amount'].toStringAsFixed(2), - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), - ), - Text( - e['totalAmount'].toStringAsFixed(2), - style: TextStyle(fontSize: 12), - ), - ], - ), - ), - ), + onRepeat: () async { + await _repeatTransaction(e); + if (context.mounted) { + context.push('/make-payment'); + } + }, ), ); } diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart index aa132b3..6bff606 100644 --- a/lib/screens/home/home_screen.dart +++ b/lib/screens/home/home_screen.dart @@ -2,11 +2,12 @@ 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:timeago/timeago.dart' as timeago; import 'package:qpay/screens/home/home_controller.dart'; +import 'package:qpay/screens/receipt/receipt_controller.dart'; import 'package:qpay/screens/transaction_controller.dart'; +import 'package:qpay/screens/transactions/transaction_model.dart' as txn; +import 'package:qpay/widgets/transaction_item_widget.dart'; import 'package:skeletonizer/skeletonizer.dart'; -import 'dart:async'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; @@ -17,28 +18,68 @@ class HomeScreen extends StatefulWidget { State createState() => _HomeScreenState(); } -class _HomeScreenState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; +class _HomeScreenState extends State with TickerProviderStateMixin { + late HomeController homeController; late TransactionController transactionController; late SharedPreferences prefs; - late HomeController homeController; late final router = GoRouter.of(context); - late String initials; bool _isLoggedIn = false; - String _selectedFilter = 'All'; + String initials = ''; + + late AnimationController _providersAnimController; + late Animation _providersSlideAnimation; + late Animation _providersFadeAnimation; + + late AnimationController _transactionsAnimController; + late Animation _transactionsSlideAnimation; + late Animation _transactionsFadeAnimation; @override void initState() { super.initState(); - _controller = AnimationController( - duration: const Duration(milliseconds: 3000), + _providersAnimController = AnimationController( vsync: this, - )..forward(); + duration: const Duration(milliseconds: 600), + ); + _providersSlideAnimation = + Tween(begin: const Offset(0, 0.06), end: Offset.zero).animate( + CurvedAnimation( + parent: _providersAnimController, + curve: Curves.easeOutCubic, + ), + ); + _providersFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _providersAnimController, curve: Curves.easeOut), + ); + + _transactionsAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + ); + _transactionsSlideAnimation = + Tween(begin: const Offset(0, 0.06), end: Offset.zero).animate( + CurvedAnimation( + parent: _transactionsAnimController, + curve: Curves.easeOutCubic, + ), + ); + _transactionsFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _transactionsAnimController, + curve: Curves.easeOut, + ), + ); setupDataAndTransitions(); + + Future.delayed(const Duration(milliseconds: 120), () { + if (mounted) _providersAnimController.forward(); + }); + Future.delayed(const Duration(milliseconds: 280), () { + if (mounted) _transactionsAnimController.forward(); + }); } Future setupDataAndTransitions() async { @@ -49,7 +90,6 @@ class _HomeScreenState extends State listen: false, ); - // Reset the transaction controller model transactionController.model = TransactionModel(); homeController = HomeController(context); @@ -73,22 +113,6 @@ class _HomeScreenState extends State await homeController.getTransactions(prefs.getString("userId")!); } - getBoxDecoration(String? clientId) { - return BoxDecoration( - border: Border.all(color: Colors.black87.withAlpha(20)), - borderRadius: BorderRadius.circular(12), - gradient: LinearGradient( - colors: [ - Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), - Theme.of(context).colorScheme.tertiary.withValues(alpha: 0.05), - ], - stops: const [0.3, 0.7], - begin: Alignment.topRight, - end: Alignment.bottomLeft, - ), - ); - } - void _onRouteChanged() async { final location = router.routerDelegate.currentConfiguration.uri.toString(); if (location.startsWith('/home')) { @@ -102,9 +126,20 @@ class _HomeScreenState extends State } } + /// Handles the repeat action for a transaction. + Future _repeatTransaction(Map item) async { + final receiptController = ReceiptController(context); + final transaction = txn.TransactionModel.fromJson(item); + await receiptController.repeatTransaction(transaction); + if (mounted) { + context.push('/make-payment'); + } + } + @override void dispose() { - _controller.dispose(); + _providersAnimController.dispose(); + _transactionsAnimController.dispose(); homeController.dispose(); router.routerDelegate.removeListener(_onRouteChanged); super.dispose(); @@ -112,466 +147,280 @@ class _HomeScreenState extends State @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + return LayoutBuilder( builder: (context, constraints) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 30), - child: Center( - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: (constraints.maxWidth > ResponsivePolicy.lg) - ? ResponsivePolicy.lg.toDouble() - : constraints.maxWidth, // Set the maximum width here - ), - child: Scaffold( - appBar: AppBar( - title: (constraints.maxWidth < ResponsivePolicy.md) - ? Image( - image: AssetImage('assets/velocity.png'), - width: 150, - ) : SizedBox(), - actions: [ - _isLoggedIn - ? Container( - padding: const EdgeInsets.only(left: 10.0), - child: IconButton( - icon: Container( - width: 30, - height: 30, - decoration: BoxDecoration( - border: Border.all( - color: Colors.black87.withAlpha(20), - ), - shape: BoxShape.circle, - gradient: LinearGradient( - colors: [ - Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.1), - Theme.of(context).colorScheme.tertiary - .withValues(alpha: 0.05), - ], - stops: const [0.3, 0.7], - begin: Alignment.topRight, - end: Alignment.bottomLeft, - ), + return Scaffold( + backgroundColor: isDark + ? const Color(0xFF0D0D0D) + : const Color(0xFFF8F9FA), + body: ListenableBuilder( + listenable: homeController, + builder: (context, child) { + return CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverAppBar( + pinned: false, + floating: true, + stretch: true, + surfaceTintColor: Colors.transparent, + backgroundColor: isDark + ? const Color(0xFF0D0D0D) + : const Color(0xFFF8F9FA), + title: constraints.maxWidth < ResponsivePolicy.md + ? Image( + image: const AssetImage('assets/velocity.png'), + width: 130, + ) + : null, + actions: [ + if (_isLoggedIn) ...[ + Container( + width: 30, + height: 30, + margin: const EdgeInsets.only(right: 4), + decoration: BoxDecoration( + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.12) + : Colors.black87.withAlpha(20), + ), + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + theme.colorScheme.primary.withValues( + alpha: 0.15, ), - child: Center( - child: Text( - initials, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, + theme.colorScheme.tertiary.withValues( + alpha: 0.05, + ), + ], + stops: const [0.3, 0.7], + begin: Alignment.topRight, + end: Alignment.bottomLeft, + ), + ), + child: Center( + child: Text( + initials, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: isDark ? Colors.white : Colors.black87, + ), + ), + ), + ), + IconButton( + icon: Icon( + Icons.logout_rounded, + color: isDark ? Colors.white54 : Colors.black54, + size: 20, + ), + onPressed: _showLogoutDialog, + ), + ], + ], + ), + SliverToBoxAdapter( + child: Center( + child: SizedBox( + width: constraints.maxWidth > 600 + ? 600 + : constraints.maxWidth, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + child: LayoutBuilder( + builder: (context, innerConstraints) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FadeTransition( + opacity: _providersFadeAnimation, + child: SlideTransition( + position: _providersSlideAnimation, + child: _buildProvidersSection( + theme, + isDark, + innerConstraints, + ), ), ), - ), - ), - onPressed: () {}, - ), - ) - : SizedBox(), - if (_isLoggedIn) - IconButton( - icon: const Icon(Icons.turn_slight_right_sharp), - onPressed: () { - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return AlertDialog( - 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(); // Dismiss the dialog - }, - ), - TextButton( - child: const Text('Logout'), - onPressed: () { - Navigator.of( - dialogContext, - ).pop(); // Dismiss the dialog - prefs.clear(); - setState(() { - _isLoggedIn = false; - }); - // It's generally not recommended to call initState directly. - // Consider moving the logic from initState that needs to be rerun - // into a separate method and call that method here. - // For example, if you have a method like _reloadData(), call it here. - // _reloadData(); - setupDataAndTransitions(); // Or specific parts of it if not all is needed - }, + const SizedBox(height: 24), + FadeTransition( + opacity: _transactionsFadeAnimation, + child: SlideTransition( + position: _transactionsSlideAnimation, + child: _buildTransactionsSection( + theme, + isDark, + innerConstraints, + ), + ), ), ], ); }, - ); - }, - ), - ], - ), - body: Padding( - padding: const EdgeInsets.only(top: 20.0), - child: SafeArea( - child: RefreshIndicator( - onRefresh: () async { - await homeController.getTransactions( - prefs.getString("userId")!, - ); - }, - child: ListenableBuilder( - listenable: homeController, - builder: (context, child) { - if (constraints.maxWidth > ResponsivePolicy.md) { - return GridView.count( - crossAxisCount: 2, - crossAxisSpacing: 50, - mainAxisSpacing: 50, - shrinkWrap: true, - - children: [_buildProviders(), _buildTransactions()], - ); - } else { - return SingleChildScrollView( - child: Column( - children: [ - _buildProviders(), - SizedBox(height: 10), - _buildTransactions(), - ], - ), - ); - } - }, + ), + ), ), ), ), - ), - ), - ), + ], + ); + }, ), ); }, ); } - Widget _buildLogin() { - return Column( - children: [ - Container( - decoration: BoxDecoration( - border: Border.all(color: Colors.black87.withAlpha(20)), - borderRadius: BorderRadius.circular(12), - ), - padding: EdgeInsets.symmetric(vertical: 10, horizontal: 20), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Text( - 'Register or Login for more', - style: TextStyle(fontSize: 16), - ), - InkWell( - onTap: () { - _showFeaturesPopup(context); - }, - borderRadius: BorderRadius.circular(5), - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 5, vertical: 2), - child: Text( - 'features', - style: TextStyle( - fontSize: 16, - decoration: TextDecoration.underline, - decorationColor: Theme.of( - context, - ).colorScheme.primary, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ), - ], - ), - InkWell( - onTap: () { - context.push('/onboarding/landing'); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - ), - child: Text('Get Started', style: TextStyle(fontSize: 12)), - ), - ), - ], - ), - ), - SizedBox(height: 10), - ], - ); - } + // ────────────────────────────────────────────────────────────── + // Providers Section + // ────────────────────────────────────────────────────────────── + Widget _buildProvidersSection( + ThemeData theme, + bool isDark, + BoxConstraints constraints, + ) { + final containerWidth = constraints.maxWidth.isFinite + ? constraints.maxWidth + : 340.0; - Widget _buildAccounts() { - return Column( - children: [ - Row( - children: [ - ...homeController.model.accounts.map((e) => _buildAccountItem(e)), - ], - ), - SizedBox(height: 10), - ], - ); - } - - Widget _buildAccountItem(Account e) { - int index = homeController.model.accounts.indexOf(e); - Animation animation = - Tween(begin: Offset(0, -0.20), end: Offset.zero).animate( - CurvedAnimation( - parent: _controller, - curve: Interval(0.3 * index.toDouble(), 1.0, curve: Curves.easeIn), - ), - ); - return Row( - children: [ - Skeletonizer( - enabled: homeController.model.isLoading, - child: SlideTransition( - position: animation, - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () { - // context.push("/wallet"); - }, - child: Container( - width: 170, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), - decoration: BoxDecoration( - border: Border.all(color: Colors.black87.withAlpha(20)), - borderRadius: BorderRadius.circular(12), - gradient: LinearGradient( - colors: [ - Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.1), - Theme.of( - context, - ).colorScheme.tertiary.withValues(alpha: 0.05), - ], - stops: const [0.3, 0.7], - begin: Alignment.topRight, - end: Alignment.bottomLeft, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Image.asset('assets/${e.image}', width: 20), - SizedBox(width: 10), - Text( - e.currency, - style: TextStyle( - fontSize: 22, - color: Colors.black, - fontWeight: FontWeight.w100, - ), - ), - SizedBox(width: 5), - Expanded( - child: Text( - e.balance, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 22, - color: Colors.black, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - SizedBox(height: 5), - InkWell( - onTap: () { - // context.push("/withdraw"); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - 'Add Money', - style: TextStyle(fontSize: 12), - ), - ), - ), - ], - ), - ), - ), - ), - ), - SizedBox(width: 10), - ], - ); - } - - Widget _buildTransactions() { return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (!_isLoggedIn) _buildLoginPrompt(theme, isDark), + const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, children: [ - const Text( - 'Recent Activity', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), + _buildSectionLabel(theme, isDark, Icons.grid_view_rounded, "Pay"), ], ), - const SizedBox(height: 5), - if (homeController.model.transactions.isEmpty) - Column( + const SizedBox(height: 12), + Skeletonizer( + enabled: homeController.model.isLoading, + child: Wrap( + spacing: 12, + runSpacing: 12, children: [ - SizedBox(height: 30), - Center( - child: Text( - 'No transactions yet. Make a payment to see your recent activity.', - style: TextStyle(fontSize: 16), - textAlign: TextAlign.center, - ), + ...homeController.model.filterableProviders.map( + (provider) => + _buildProviderCard(provider, theme, isDark, containerWidth), ), ], ), - if (homeController.model.transactions.isNotEmpty) - Column( - children: [ - ListView( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - children: [ - ...homeController.model.transactions.map( - (e) => _buildTransactionItem(e, context), - ), - ], - ), - SizedBox(height: 10), - if (homeController.model.transactions.length > 2) - OutlinedButton( - child: Text('View All'), - onPressed: () { - context.go('/history'); - }, - ), - ], - ), + ), ], ); } - Widget _buildTransactionItem(Map e, BuildContext context) { - int index = homeController.model.transactions.indexOf(e); - Animation animation = - Tween(begin: Offset(0, -0.25), end: Offset.zero).animate( - CurvedAnimation( - parent: _controller, - curve: Interval(0.175 * index, 1.0, curve: Curves.easeIn), - ), - ); - return SlideTransition( - position: animation, - child: Skeletonizer( - enabled: homeController.model.isLoading, + // ────────────────────────────────────────────────────────────── + // Provider Card — flex-based 2-column grid + // ────────────────────────────────────────────────────────────── + Widget _buildProviderCard( + BillProvider provider, + ThemeData theme, + bool isDark, + double availableWidth, + ) { + final cardWidth = (availableWidth / 2) - 6; + + return SizedBox( + width: cardWidth, + child: Material( + color: Colors.transparent, child: InkWell( - customBorder: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + borderRadius: BorderRadius.circular(16), onTap: () { - transactionController.updateReceiptData(e); - context.push("/receipt"); + transactionController.clearFormData(); + homeController.updateSelectedProvider(provider); + transactionController.updateSelectedProvider(provider); + context.push("/recipients"); }, - 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: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + theme.colorScheme.primary.withValues(alpha: 0.08), + theme.colorScheme.tertiary.withValues(alpha: 0.03), + theme.colorScheme.primary.withValues(alpha: 0.05), + ], + stops: const [0.0, 0.5, 1.0], ), - child: Image( - image: AssetImage("assets/${e['paymentProcessorImage']}"), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.1), + width: 1, ), - ), - title: Row( - children: [ - Text( - e['billName'], - style: TextStyle(fontWeight: FontWeight.bold), - ), - SizedBox(width: 10), - Icon( - e['integrationStatus'] == 'SUCCESS' - ? Icons.check_circle - : e['integrationStatus'] == 'PENDING' - ? Icons.pending - : Icons.cancel, - size: 16, - color: e['integrationStatus'] == 'SUCCESS' - ? Colors.green - : e['integrationStatus'] == 'PENDING' - ? Colors.orange - : Colors.red, + boxShadow: [ + BoxShadow( + color: theme.colorScheme.primary.withValues(alpha: 0.06), + blurRadius: 10, + offset: const Offset(0, 3), ), ], ), - subtitle: Text( - e['createdAt'] != null - ? timeago.format(DateTime.parse(e['createdAt'])) - : '', - style: TextStyle(fontSize: 12), - ), - trailing: Column( - mainAxisAlignment: MainAxisAlignment.center, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - e['amount'].toStringAsFixed(2), - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 40, + height: 40, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues( + alpha: 0.12, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Image( + image: AssetImage("assets/${provider.image}"), + errorBuilder: (_, __, ___) => Icon( + Icons.payment_rounded, + size: 20, + color: theme.colorScheme.primary, + ), + ), + ), + Icon( + Icons.chevron_right_rounded, + size: 20, + color: theme.colorScheme.primary.withValues(alpha: 0.5), + ), + ], ), + const SizedBox(height: 12), Text( - e['totalAmount'].toStringAsFixed(2), - style: TextStyle(fontSize: 12), + provider.name, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white : Colors.black87, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + provider.description, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w400, + color: isDark ? Colors.white54 : Colors.grey.shade500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), ], ), @@ -581,144 +430,260 @@ class _HomeScreenState extends State ); } - Widget _buildProviders() { + // ────────────────────────────────────────────────────────────── + // Transactions Section + // ────────────────────────────────────────────────────────────── + Widget _buildTransactionsSection( + ThemeData theme, + bool isDark, + BoxConstraints constraints, + ) { return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (!_isLoggedIn) _buildLogin(), - SizedBox(height: 10), - // if (_isLoggedIn) _buildAccounts(homeController), - // _buildFilterChips(homeController), - ...homeController.model.filterableProviders.map( - (e) => _buildProviderItem(e, context), + _buildSectionLabel( + theme, + isDark, + Icons.history_rounded, + "Recent Activity", ), - ], - ); - } - - Column _buildProviderItem(BillProvider e, BuildContext context) { - int index = homeController.model.filterableProviders.indexOf(e); - Animation animation = - Tween(begin: Offset(0, -0.20), end: Offset.zero).animate( - CurvedAnimation( - parent: _controller, - curve: Interval(0.30 * index.toDouble(), 1.0, curve: Curves.easeIn), - ), - ); - return Column( - children: [ - Skeletonizer( - enabled: homeController.model.isLoading, - child: SlideTransition( - position: animation, - child: Container( - decoration: getBoxDecoration(e.clientId), - child: InkWell( - customBorder: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - onTap: () { - transactionController - .clearFormData(); // make sure we're starting fresh - homeController.updateSelectedProvider(e); - transactionController.updateSelectedProvider(e); - context.push("/recipients"); - }, - 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/${e.image}")), - ), - title: Text( - e.name, - style: TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Text( - e.description, - style: TextStyle( - fontWeight: FontWeight.normal, - fontSize: 10, - ), - ), - trailing: Icon( - Icons.chevron_right, - color: Theme.of( - context, - ).colorScheme.secondary.withValues(alpha: 0.7), - ), - ), - ), + const SizedBox(height: 12), + if (homeController.model.transactions.isEmpty) + _buildEmptyTransactions(isDark), + if (homeController.model.transactions.isNotEmpty) ...[ + ...homeController.model.transactions.map( + (e) => TransactionItemWidget( + transaction: e, + enabled: homeController.model.isLoading, + onRepeat: () => _repeatTransaction(e), ), ), - ), - SizedBox(height: 10), + const SizedBox(height: 8), + if (homeController.model.transactions.length > 2) + Center( + child: OutlinedButton.icon( + icon: const Icon(Icons.arrow_forward_rounded, size: 16), + label: const Text('View All'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + side: BorderSide( + color: theme.colorScheme.primary.withValues(alpha: 0.3), + ), + ), + onPressed: () { + context.go('/history'); + }, + ), + ), + ], ], ); } - Widget _buildFilterChips(HomeController controller) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( + // ────────────────────────────────────────────────────────────── + // Empty Transactions Placeholder + // ────────────────────────────────────────────────────────────── + Widget _buildEmptyTransactions(bool isDark) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 20), + 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, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - _buildFilterChip('All', controller), - ...controller.model.categories.map((category) { - return _buildFilterChip(category.name, controller, category); - }), + Icon( + Icons.receipt_long_outlined, + size: 48, + color: isDark ? Colors.white24 : Colors.grey.shade300, + ), + const SizedBox(height: 12), + Text( + 'No transactions yet', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white54 : Colors.grey.shade500, + ), + ), + const SizedBox(height: 4), + Text( + 'Make a payment to see your recent activity.', + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.white38 : Colors.grey.shade400, + ), + ), ], ), ); } - Widget _buildFilterChip( + // ────────────────────────────────────────────────────────────── + // Section Label + // ────────────────────────────────────────────────────────────── + Widget _buildSectionLabel( + ThemeData theme, + bool isDark, + IconData icon, String label, - HomeController controller, [ - Category? category, - ]) { - final isSelected = _selectedFilter == label; - return Skeletonizer( - enabled: controller.model.isLoading, - child: Padding( - padding: const EdgeInsets.only(right: 8), - child: FilterChip( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.2), - ), + ) { + return Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), ), - selected: isSelected, - label: Text(label), - onSelected: (selected) { - setState(() { - _selectedFilter = label; - }); - if (category != null) { - controller.updateSelectedCategory(category); - } else { - controller.updateSelectedCategory(null); - } - }, - backgroundColor: Theme.of(context).colorScheme.surface, - selectedColor: Theme.of(context).colorScheme.primary.withOpacity(0.2), - checkmarkColor: Theme.of(context).colorScheme.primary, - labelStyle: TextStyle( - color: isSelected - ? Theme.of(context).colorScheme.secondary.withValues(alpha: 0.7) - : Theme.of(context).colorScheme.onSurface, + child: Icon(icon, size: 16, color: theme.colorScheme.primary), + ), + const SizedBox(width: 10), + Text( + label, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white : Colors.black87, + letterSpacing: 0.3, ), ), + ], + ); + } + + // ────────────────────────────────────────────────────────────── + // Login Prompt + // ────────────────────────────────────────────────────────────── + Widget _buildLoginPrompt(ThemeData theme, bool isDark) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + theme.colorScheme.primary.withValues(alpha: 0.12), + theme.colorScheme.primary.withValues(alpha: 0.04), + ], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.15), + width: 1, + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Register or Login for more', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 2), + InkWell( + onTap: () => _showFeaturesPopup(context), + borderRadius: BorderRadius.circular(4), + child: Text( + 'features', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + decoration: TextDecoration.underline, + decorationColor: theme.colorScheme.primary, + color: theme.colorScheme.primary, + ), + ), + ), + ], + ), + ), + ElevatedButton( + onPressed: () { + context.push('/onboarding/landing'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + child: const Text( + 'Get Started', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ), + ], ), ); } + // ────────────────────────────────────────────────────────────── + // Logout Dialog + // ────────────────────────────────────────────────────────────── + 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(); + setState(() { + _isLoggedIn = false; + }); + setupDataAndTransitions(); + }, + ), + ], + ); + }, + ); + } + + // ────────────────────────────────────────────────────────────── + // Features Popup + // ────────────────────────────────────────────────────────────── void _showFeaturesPopup(BuildContext context) { showDialog( context: context, @@ -730,7 +695,7 @@ class _HomeScreenState extends State elevation: 0, backgroundColor: Colors.transparent, child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: 600), + constraints: const BoxConstraints(maxWidth: 600), child: Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( @@ -739,7 +704,7 @@ class _HomeScreenState extends State borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.1), + color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10), ), @@ -748,7 +713,6 @@ class _HomeScreenState extends State child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Header with icon and title Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( @@ -757,7 +721,7 @@ class _HomeScreenState extends State ).colorScheme.primary.withValues(alpha: 0.1), shape: BoxShape.circle, ), - child: Image( + child: const Image( image: AssetImage("assets/favicon.png"), width: 45, ), @@ -779,13 +743,11 @@ class _HomeScreenState extends State fontSize: 14, color: Theme.of( context, - ).colorScheme.onSurface.withOpacity(0.7), + ).colorScheme.onSurface.withValues(alpha: 0.7), ), textAlign: TextAlign.center, ), const SizedBox(height: 24), - - // Features list _buildFeatureItem( Icons.account_balance_wallet, 'Dual Currency Wallets', @@ -809,10 +771,7 @@ class _HomeScreenState extends State 'Smart Payments', 'Schedule payments and split bills with friends', ), - const SizedBox(height: 24), - - // Action buttons Row( children: [ Expanded( @@ -829,7 +788,7 @@ class _HomeScreenState extends State style: TextStyle( color: Theme.of( context, - ).colorScheme.onSurface.withOpacity(0.6), + ).colorScheme.onSurface.withValues(alpha: 0.6), fontSize: 16, ), ), @@ -852,7 +811,7 @@ class _HomeScreenState extends State borderRadius: BorderRadius.circular(12), ), ), - child: Text( + child: const Text( 'Get Started', style: TextStyle( fontSize: 16, @@ -908,7 +867,7 @@ class _HomeScreenState extends State fontSize: 14, color: Theme.of( context, - ).colorScheme.onSurface.withOpacity(0.7), + ).colorScheme.onSurface.withValues(alpha: 0.7), height: 1.4, ), ), diff --git a/lib/screens/integration/integration_controller.dart b/lib/screens/integration/integration_controller.dart index 76dbe2d..242e703 100644 --- a/lib/screens/integration/integration_controller.dart +++ b/lib/screens/integration/integration_controller.dart @@ -47,8 +47,10 @@ class IntegrationController extends ChangeNotifier { dynamic response = workflowResponse['body']; + String? transactionId; if (response['status'] == 'SUCCESS') { model.status = response['status']; + transactionId = response['id']; transactionController.updateReceiptData(response); await gatewayController.poll( @@ -61,7 +63,13 @@ class IntegrationController extends ChangeNotifier { if (!_disposed) notifyListeners(); WidgetsBinding.instance.addPostFrameCallback((_) { - if (!_disposed && context.mounted) context.go('/receipt'); + if (!_disposed && context.mounted) { + if (transactionId != null) { + context.go('/receipt/$transactionId'); + } else { + context.go('/receipt'); + } + } }); return ApiResponse.success(Map.from(response)); diff --git a/lib/screens/integration/integration_screen.dart b/lib/screens/integration/integration_screen.dart index 2dda1cf..9047b42 100644 --- a/lib/screens/integration/integration_screen.dart +++ b/lib/screens/integration/integration_screen.dart @@ -49,8 +49,13 @@ class _IntegrationScreenState extends State { listenable: controller, builder: (context, child) { if (controller.model.status == 'SUCCESS') { + final transactionId = controller.model.transaction?['id']; WidgetsBinding.instance.addPostFrameCallback((_) { - context.go('/receipt'); + if (transactionId != null) { + context.go('/receipt/$transactionId'); + } else { + context.go('/receipt'); + } }); } diff --git a/lib/screens/receipt/receipt_controller.dart b/lib/screens/receipt/receipt_controller.dart index 5e945c5..f422e68 100644 --- a/lib/screens/receipt/receipt_controller.dart +++ b/lib/screens/receipt/receipt_controller.dart @@ -7,9 +7,10 @@ import 'package:qpay/models/api_response.dart'; import 'package:qpay/screens/pay/pay_controller.dart' as pc; import 'package:qpay/screens/transaction_controller.dart' as tc; import 'package:qpay/screens/home/home_controller.dart' as hc; +import 'package:qpay/screens/transactions/transaction_model.dart'; class ReceiptModel { - Map? receiptData; + TransactionModel? transaction; bool isLoading = false; String? errorMessage; String status = ''; @@ -35,7 +36,7 @@ class ReceiptController extends ChangeNotifier { notifyListeners(); } - Future>> doIntegration(String id) async { + Future> doIntegration(String id) async { try { model.isLoading = true; model.status = ''; @@ -51,9 +52,13 @@ class ReceiptController extends ChangeNotifier { if (response['status'] == 'SUCCESS') { model.status = response['status']; - transactionController.updateReceiptData(response); + transactionController.updateReceiptData( + Map.from(response), + ); notifyListeners(); - return ApiResponse.success(Map.from(response)); + return ApiResponse.success( + TransactionModel.fromJson(Map.from(response)), + ); } else { model.status = response['status']; model.errorMessage = response['errorMessage']; @@ -70,10 +75,10 @@ class ReceiptController extends ChangeNotifier { } } - Future repeatTransaction(BuildContext context) async { + Future repeatTransaction(TransactionModel transaction) async { setLoading(true); // fetch provider & update tran controller - String providerId = model.receiptData?['billClientId'] ?? ''; + String providerId = transaction.billClientId ?? ''; Map provider = await http.get( '/public/providers/client/$providerId', ); @@ -84,16 +89,16 @@ class ReceiptController extends ChangeNotifier { // update tran controller formdata with credit account, email, name, phone & providerLabel transactionController.model.formData = transactionController.model.formData .copyWith( - creditAccount: model.receiptData?['creditAccount'] ?? '', - creditPhone: model.receiptData?['creditPhone'] ?? '', - creditName: model.receiptData?['creditName'] ?? '', - creditEmail: model.receiptData?['creditEmail'] ?? '', + creditAccount: transaction.creditAccount ?? '', + creditPhone: transaction.creditPhone ?? '', + creditName: transaction.creditName ?? '', + creditEmail: transaction.creditEmail ?? '', ); // fetch product and update tran controller formdata String providerUid = transactionController.model.selectedProvider?.uid ?? ''; - String productUid = model.receiptData?['productUid'] ?? ''; + String productUid = transaction.productUid ?? ''; if (productUid.isNotEmpty) { Map product = await http.get( @@ -105,27 +110,26 @@ class ReceiptController extends ChangeNotifier { } // update credit amount, notification phone number, payment processor - transactionController - .model - .formData = transactionController.model.formData.copyWith( - amount: model.receiptData?['amount'].toStringAsFixed(2) ?? '', - debitPhone: model.receiptData?['debitPhone'] ?? '', - paymentProcessorLabel: model.receiptData?['paymentProcessorLabel'] ?? '', - paymentProcessorName: model.receiptData?['paymentProcessorName'] ?? '', - id: null, - ); + transactionController.model.formData = transactionController.model.formData + .copyWith( + amount: transaction.amount.toStringAsFixed(2), + debitPhone: transaction.debitPhone, + paymentProcessorLabel: transaction.paymentProcessorLabel, + paymentProcessorName: transaction.paymentProcessorName, + id: null, + ); setLoading(false); - context.push('/make-payment'); } - Future>> getReceiptData(String id) async { + Future> getReceiptData(String id) async { setLoading(true); try { Map response = await http.get('/public/transaction/$id'); - model.receiptData = response; + final transaction = TransactionModel.fromJson(response); + model.transaction = transaction; setLoading(false); - return ApiResponse.success(response); + return ApiResponse.success(transaction); } catch (e) { setLoading(false); return ApiResponse.failure( @@ -134,8 +138,8 @@ class ReceiptController extends ChangeNotifier { } } - void updateReceiptData(Map data) { - model.receiptData = data; + void updateReceiptTransaction(TransactionModel data) { + model.transaction = data; notifyListeners(); } } diff --git a/lib/screens/receipt/receipt_screen.dart b/lib/screens/receipt/receipt_screen.dart index 40f93fa..bcc44b9 100644 --- a/lib/screens/receipt/receipt_screen.dart +++ b/lib/screens/receipt/receipt_screen.dart @@ -3,15 +3,20 @@ import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/screens/gateway/gateway_controller.dart'; -import 'package:qpay/screens/transaction_controller.dart'; +import 'package:qpay/screens/transaction_controller.dart' + show TransactionController; import 'package:go_router/go_router.dart'; import 'package:qpay/screens/receipt/receipt_controller.dart'; +import 'package:qpay/screens/transactions/transaction_model.dart'; import 'package:share_plus/share_plus.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:widgets_to_image/widgets_to_image.dart'; +import 'package:decimal/decimal.dart'; class ReceiptScreen extends StatefulWidget { - const ReceiptScreen({super.key}); + final String? transactionId; + + const ReceiptScreen({super.key, this.transactionId}); @override State createState() => _ReceiptScreenState(); @@ -36,6 +41,7 @@ class _ReceiptScreenState extends State late Animation _contentFadeAnimation; int _selectedTabIndex = 0; + TransactionModel? _transaction; late ReceiptController receiptController; final _formKey = GlobalKey(); @@ -52,9 +58,7 @@ class _ReceiptScreenState extends State receiptController = ReceiptController(context); gatewayController = GatewayController(context); - receiptController.getReceiptData( - transactionController.model.receiptData?["id"], - ); + _fetchTransaction(); // Header animation _headerAnimController = AnimationController( @@ -114,6 +118,22 @@ class _ReceiptScreenState extends State }); } + Future _fetchTransaction() async { + final id = + widget.transactionId ?? + transactionController.model.receiptData?["id"] ?? + receiptController.model.transaction?.id; + + if (id == null) return; + + final response = await receiptController.getReceiptData(id); + if (response.isSuccess && response.data != null) { + setState(() { + _transaction = response.data; + }); + } + } + Future _captureImage() async { final image = await controller.capture(); if (image != null) { @@ -129,25 +149,21 @@ class _ReceiptScreenState extends State _retry() async { receiptController.setLoading(true); - await receiptController.doIntegration( - receiptController.model.receiptData?["id"], - ); + final id = _transaction?.id ?? ''; + await receiptController.doIntegration(id); if (receiptController.model.status == "SUCCESS") { - receiptController.getReceiptData( - receiptController.model.receiptData?["id"], - ); + await _fetchTransaction(); } receiptController.setLoading(false); } _check() async { receiptController.setLoading(true); - await gatewayController.poll(receiptController.model.receiptData?["id"]); + final id = _transaction?.id ?? ''; + await gatewayController.poll(id); if (gatewayController.model.status == "SUCCESS") { - receiptController.getReceiptData( - transactionController.model.receiptData?["id"], - ); - if (transactionController.model.receiptData?["pollingStatus"]) { + await _fetchTransaction(); + if (_transaction?.pollingStatus == "SUCCESS") { receiptController.setLoading(false); return; } @@ -193,22 +209,10 @@ class _ReceiptScreenState extends State ? const Color(0xFF0D0D0D) : const Color(0xFFF8F9FA), leading: context.canPop() - ? Padding( - padding: const EdgeInsets.only(left: 8), - child: Container( - margin: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: theme.colorScheme.primary.withValues( - alpha: 0.08, - ), - borderRadius: BorderRadius.circular(14), - ), - child: IconButton( - onPressed: () => context.pop(), - icon: const Icon(Icons.arrow_back_rounded), - color: theme.colorScheme.primary, - ), - ), + ? IconButton( + onPressed: () => context.pop(), + icon: const Icon(Icons.arrow_back_rounded), + color: theme.colorScheme.primary, ) : const SizedBox.shrink(), title: Text( @@ -359,13 +363,11 @@ class _ReceiptScreenState extends State // Receipt Header Card // ────────────────────────────────────────────────────────────── Widget _buildReceiptHeader(ThemeData theme, bool isDark) { - final status = - transactionController.model.receiptData?["integrationStatus"]; - final amount = receiptController.model.receiptData?["amount"]; - final currency = - receiptController.model.receiptData?["debitCurrency"] ?? ""; - final billName = receiptController.model.receiptData?["billName"] ?? ""; - final createdAt = receiptController.model.receiptData?["createdAt"]; + final status = _transaction?.integrationStatus; + final amount = _transaction?.amount; + final currency = _transaction?.debitCurrency ?? ""; + final billName = _transaction?.billName ?? ""; + final createdAt = _transaction?.createdAt; final primaryColor = theme.colorScheme.primary; @@ -458,7 +460,7 @@ class _ReceiptScreenState extends State // Date if (createdAt != null) Text( - DateFormat.yMMMd().add_jm().format(DateTime.parse(createdAt)), + DateFormat.yMMMd().add_jm().format(createdAt), style: TextStyle( fontSize: 13, fontWeight: FontWeight.w400, @@ -502,9 +504,8 @@ class _ReceiptScreenState extends State // Action Buttons Row // ────────────────────────────────────────────────────────────── Widget _buildActionButtons(ThemeData theme) { - final integrationStatus = - receiptController.model.receiptData?["integrationStatus"]; - final paymentStatus = receiptController.model.receiptData?["paymentStatus"]; + final integrationStatus = _transaction?.integrationStatus; + final paymentStatus = _transaction?.paymentStatus; return Wrap( spacing: 12, @@ -516,7 +517,14 @@ class _ReceiptScreenState extends State label: "Repeat", color: theme.colorScheme.primary, isLoading: receiptController.model.isLoading, - onPressed: () => receiptController.repeatTransaction(context), + onPressed: () async { + await receiptController.repeatTransaction( + receiptController.model.transaction!, + ); + if (mounted) { + context.push('/make-payment'); + } + }, ), _modernActionButton( icon: Icons.ios_share_rounded, @@ -534,8 +542,7 @@ class _ReceiptScreenState extends State onPressed: _retry, ), ], - if (receiptController.model.receiptData?["pollingStatus"] != - "SUCCESS") ...[ + if (_transaction?.pollingStatus != "SUCCESS") ...[ _modernActionButton( icon: Icons.search_rounded, label: "Check", @@ -705,8 +712,8 @@ class _ReceiptScreenState extends State // Provider Details Tab // ────────────────────────────────────────────────────────────── Widget _buildProviderDetailsTab(ThemeData theme, bool isDark) { - final receiptData = receiptController.model.receiptData; - final additionalData = receiptData?["additionalData"]; + final receiptData = receiptController.model.transaction; + final additionalData = receiptData?.additionalData; final items = <_DetailItem>[]; @@ -715,14 +722,13 @@ class _ReceiptScreenState extends State _DetailItem( icon: Icons.circle_outlined, label: "Status", - value: - transactionController.model.receiptData?["integrationStatus"] ?? "", + value: _transaction?.integrationStatus ?? "", ), ); // Error message - final errorMsg = transactionController.model.receiptData?["errorMessage"]; - if (errorMsg != null && errorMsg != "") { + final errorMsg = _transaction?.errorMessage; + if (errorMsg != null && errorMsg.isNotEmpty) { items.add( _DetailItem( icon: Icons.warning_amber_rounded, @@ -751,37 +757,37 @@ class _ReceiptScreenState extends State _DetailItem( icon: Icons.tag_rounded, label: "Debit Reference", - value: transactionController.model.receiptData?["debitRef"] ?? "", + value: _transaction?.debitRef ?? "", ), ); items.add( _DetailItem( icon: Icons.phone_iphone_rounded, label: "From", - value: receiptData?["debitPhone"] ?? "", + value: receiptData?.debitPhone ?? "", ), ); items.add( _DetailItem( icon: Icons.account_balance_wallet_rounded, label: "To", - value: receiptData?["creditAccount"] ?? "", + value: receiptData?.creditAccount ?? "", ), ); items.add( _DetailItem( icon: Icons.memory_rounded, label: "Processor", - value: receiptData?["paymentProcessorName"] ?? "", + value: receiptData?.paymentProcessorName ?? "", ), ); - if (receiptData?["billProductName"] != null) { + if (receiptData?.billProductName != null) { items.add( _DetailItem( icon: Icons.category_rounded, label: "Bill Product", - value: receiptData?["billProductName"] ?? "", + value: receiptData?.billProductName ?? "", ), ); } @@ -822,12 +828,12 @@ class _ReceiptScreenState extends State // Transaction Details Tab // ────────────────────────────────────────────────────────────── Widget _buildTransactionDetailsTab(ThemeData theme, bool isDark) { - final receiptData = transactionController.model.receiptData; - final amount = receiptData?["amount"] ?? 0; - final charge = receiptData?["charge"] ?? 0; - final gatewayCharge = receiptData?["gatewayCharge"] ?? 0; - final tax = receiptData?["tax"] ?? 0; - final totalAmount = receiptData?["totalAmount"] ?? 0; + final t = _transaction; + final amount = t?.amount ?? Decimal.zero; + final charge = t?.charge ?? Decimal.zero; + final gatewayCharge = t?.gatewayCharge ?? Decimal.zero; + final tax = t?.tax ?? Decimal.zero; + final totalAmount = t?.totalAmount ?? Decimal.zero; final items = <_DetailItem>[ _DetailItem( @@ -842,7 +848,7 @@ class _ReceiptScreenState extends State ), ]; - if (charge != 0) { + if (charge != Decimal.zero) { items.add( _DetailItem( icon: Icons.percent_rounded, @@ -851,7 +857,7 @@ class _ReceiptScreenState extends State ), ); } - if (gatewayCharge != 0) { + if (gatewayCharge != Decimal.zero) { items.add( _DetailItem( icon: Icons.account_balance_rounded, @@ -860,7 +866,7 @@ class _ReceiptScreenState extends State ), ); } - if (tax != 0) { + if (tax != Decimal.zero) { items.add( _DetailItem( icon: Icons.receipt_rounded, diff --git a/lib/screens/transactions/transaction_model.dart b/lib/screens/transactions/transaction_model.dart new file mode 100644 index 0000000..eaea708 --- /dev/null +++ b/lib/screens/transactions/transaction_model.dart @@ -0,0 +1,153 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:decimal/decimal.dart'; + +part 'transaction_model.g.dart'; + +/// JsonConverter for [Decimal] values — reads from [num] (int or double) +/// in JSON and writes back as [num]. +class DecimalJsonConverter implements JsonConverter { + const DecimalJsonConverter(); + + @override + Decimal fromJson(num json) => Decimal.parse(json.toString()); + + @override + num toJson(Decimal object) => object.toDouble(); +} + +/// JsonConverter for [DateTime] values — reads from ISO‑8601 [String] +/// and writes back as ISO‑8601 [String]. +class DateTimeJsonConverter implements JsonConverter { + const DateTimeJsonConverter(); + + @override + DateTime fromJson(String json) => DateTime.parse(json); + + @override + String toJson(DateTime object) => object.toIso8601String(); +} + +/// JsonConverter for a list of key-value entries (additionalData). +class AdditionalDataJsonConverter + implements JsonConverter>?, List?> { + const AdditionalDataJsonConverter(); + + @override + List>? fromJson(List? json) { + return json + ?.map((e) => Map.from(e as Map)) + .toList(); + } + + @override + List? toJson(List>? object) { + return object?.map((e) => e as dynamic).toList(); + } +} + +@DecimalJsonConverter() +@DateTimeJsonConverter() +@AdditionalDataJsonConverter() +@JsonSerializable(explicitToJson: true) +class TransactionModel { + final String? id; + final DateTime? createdAt; + final String userId; + final String trace; + final Decimal amount; + final Decimal charge; + final Decimal gatewayCharge; + final Decimal tax; + final Decimal totalAmount; + final String reference; + final String paymentProcessorLabel; + final String integrationProcessorLabel; + final String confirmationProcessorLabel; + final String providerLabel; + final String debitPhone; + final String debitCurrency; + final String debitRef; + final String? creditPhone; + final String? creditAccount; + final String? creditRef; + final String? billClientId; + final String billName; + final String paymentProcessorName; + final String? providerImage; + final String? paymentProcessorImage; + final String confirmationStatus; + final String paymentStatus; + final String integrationStatus; + final String pollingStatus; + final String? orderId; + final String? orderTrace; + final String? orderTransactionTrace; + final String? erpSalesRef; + final String? erpSalesPaymentRef; + final int workflowId; + final String? type; + final String? authType; + final String? responseCode; + final String? status; + final String? errorMessage; + final String? billProductName; + final String? creditName; + final String? creditEmail; + final String? productUid; + final String? targetUrl; + final List>? additionalData; + + const TransactionModel({ + this.id, + this.createdAt, + required this.userId, + required this.trace, + required this.amount, + required this.charge, + required this.gatewayCharge, + required this.tax, + required this.totalAmount, + required this.reference, + required this.paymentProcessorLabel, + required this.integrationProcessorLabel, + required this.confirmationProcessorLabel, + required this.providerLabel, + required this.debitPhone, + required this.debitCurrency, + required this.debitRef, + this.creditPhone, + this.creditAccount, + this.creditRef, + this.billClientId, + required this.billName, + required this.paymentProcessorName, + this.providerImage, + this.paymentProcessorImage, + required this.confirmationStatus, + required this.paymentStatus, + required this.integrationStatus, + required this.pollingStatus, + this.orderId, + this.orderTrace, + this.orderTransactionTrace, + this.erpSalesRef, + this.erpSalesPaymentRef, + required this.workflowId, + this.type, + this.authType, + this.responseCode, + this.status, + this.errorMessage, + this.billProductName, + this.creditName, + this.creditEmail, + this.productUid, + this.targetUrl, + this.additionalData, + }); + + factory TransactionModel.fromJson(Map json) => + _$TransactionModelFromJson(json); + + Map toJson() => _$TransactionModelToJson(this); +} \ No newline at end of file diff --git a/lib/screens/transactions/transaction_model.g.dart b/lib/screens/transactions/transaction_model.g.dart new file mode 100644 index 0000000..c8cce0e --- /dev/null +++ b/lib/screens/transactions/transaction_model.g.dart @@ -0,0 +1,132 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'transaction_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TransactionModel _$TransactionModelFromJson(Map json) => + TransactionModel( + id: json['id'] as String?, + createdAt: _$JsonConverterFromJson( + json['createdAt'], + const DateTimeJsonConverter().fromJson, + ), + userId: json['userId'] as String, + trace: json['trace'] as String, + amount: const DecimalJsonConverter().fromJson(json['amount'] as num), + charge: const DecimalJsonConverter().fromJson(json['charge'] as num), + gatewayCharge: const DecimalJsonConverter().fromJson( + json['gatewayCharge'] as num, + ), + tax: const DecimalJsonConverter().fromJson(json['tax'] as num), + totalAmount: const DecimalJsonConverter().fromJson( + json['totalAmount'] as num, + ), + reference: json['reference'] as String, + paymentProcessorLabel: json['paymentProcessorLabel'] as String, + integrationProcessorLabel: json['integrationProcessorLabel'] as String, + confirmationProcessorLabel: json['confirmationProcessorLabel'] as String, + providerLabel: json['providerLabel'] as String, + debitPhone: json['debitPhone'] as String, + debitCurrency: json['debitCurrency'] as String, + debitRef: json['debitRef'] as String, + creditPhone: json['creditPhone'] as String?, + creditAccount: json['creditAccount'] as String?, + creditRef: json['creditRef'] as String?, + billClientId: json['billClientId'] as String?, + billName: json['billName'] as String, + paymentProcessorName: json['paymentProcessorName'] as String, + providerImage: json['providerImage'] as String?, + paymentProcessorImage: json['paymentProcessorImage'] as String?, + confirmationStatus: json['confirmationStatus'] as String, + paymentStatus: json['paymentStatus'] as String, + integrationStatus: json['integrationStatus'] as String, + pollingStatus: json['pollingStatus'] as String, + orderId: json['orderId'] as String?, + orderTrace: json['orderTrace'] as String?, + orderTransactionTrace: json['orderTransactionTrace'] as String?, + erpSalesRef: json['erpSalesRef'] as String?, + erpSalesPaymentRef: json['erpSalesPaymentRef'] as String?, + workflowId: (json['workflowId'] as num).toInt(), + type: json['type'] as String?, + authType: json['authType'] as String?, + responseCode: json['responseCode'] as String?, + status: json['status'] as String?, + errorMessage: json['errorMessage'] as String?, + billProductName: json['billProductName'] as String?, + creditName: json['creditName'] as String?, + creditEmail: json['creditEmail'] as String?, + productUid: json['productUid'] as String?, + targetUrl: json['targetUrl'] as String?, + additionalData: const AdditionalDataJsonConverter().fromJson( + json['additionalData'] as List?, + ), + ); + +Map _$TransactionModelToJson( + TransactionModel instance, +) => { + 'id': instance.id, + 'createdAt': _$JsonConverterToJson( + instance.createdAt, + const DateTimeJsonConverter().toJson, + ), + 'userId': instance.userId, + 'trace': instance.trace, + 'amount': const DecimalJsonConverter().toJson(instance.amount), + 'charge': const DecimalJsonConverter().toJson(instance.charge), + 'gatewayCharge': const DecimalJsonConverter().toJson(instance.gatewayCharge), + 'tax': const DecimalJsonConverter().toJson(instance.tax), + 'totalAmount': const DecimalJsonConverter().toJson(instance.totalAmount), + 'reference': instance.reference, + 'paymentProcessorLabel': instance.paymentProcessorLabel, + 'integrationProcessorLabel': instance.integrationProcessorLabel, + 'confirmationProcessorLabel': instance.confirmationProcessorLabel, + 'providerLabel': instance.providerLabel, + 'debitPhone': instance.debitPhone, + 'debitCurrency': instance.debitCurrency, + 'debitRef': instance.debitRef, + 'creditPhone': instance.creditPhone, + 'creditAccount': instance.creditAccount, + 'creditRef': instance.creditRef, + 'billClientId': instance.billClientId, + 'billName': instance.billName, + 'paymentProcessorName': instance.paymentProcessorName, + 'providerImage': instance.providerImage, + 'paymentProcessorImage': instance.paymentProcessorImage, + 'confirmationStatus': instance.confirmationStatus, + 'paymentStatus': instance.paymentStatus, + 'integrationStatus': instance.integrationStatus, + 'pollingStatus': instance.pollingStatus, + 'orderId': instance.orderId, + 'orderTrace': instance.orderTrace, + 'orderTransactionTrace': instance.orderTransactionTrace, + 'erpSalesRef': instance.erpSalesRef, + 'erpSalesPaymentRef': instance.erpSalesPaymentRef, + 'workflowId': instance.workflowId, + 'type': instance.type, + 'authType': instance.authType, + 'responseCode': instance.responseCode, + 'status': instance.status, + 'errorMessage': instance.errorMessage, + 'billProductName': instance.billProductName, + 'creditName': instance.creditName, + 'creditEmail': instance.creditEmail, + 'productUid': instance.productUid, + 'targetUrl': instance.targetUrl, + 'additionalData': const AdditionalDataJsonConverter().toJson( + instance.additionalData, + ), +}; + +Value? _$JsonConverterFromJson( + Object? json, + Value? Function(Json json) fromJson, +) => json == null ? null : fromJson(json as Json); + +Json? _$JsonConverterToJson( + Value? value, + Json? Function(Value value) toJson, +) => value == null ? null : toJson(value); diff --git a/lib/widgets/transaction_item_widget.dart b/lib/widgets/transaction_item_widget.dart new file mode 100644 index 0000000..fb4c4bf --- /dev/null +++ b/lib/widgets/transaction_item_widget.dart @@ -0,0 +1,249 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:timeago/timeago.dart' as timeago; + +/// A reusable transaction list item widget. +/// Used by both [HomeScreen] and [HistoryScreen]. +class TransactionItemWidget extends StatefulWidget { + final Map transaction; + final bool enabled; + final Future Function()? onRepeat; + + const TransactionItemWidget({ + super.key, + required this.transaction, + this.enabled = false, + this.onRepeat, + }); + + @override + State createState() => _TransactionItemWidgetState(); +} + +class _TransactionItemWidgetState extends State { + bool _isRepeating = false; + + Future _handleRepeat() async { + if (_isRepeating || widget.onRepeat == null) return; + setState(() => _isRepeating = true); + try { + await widget.onRepeat!(); + } finally { + if (mounted) setState(() => _isRepeating = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + final status = widget.transaction['integrationStatus'] as String?; + final isSuccess = status == 'SUCCESS'; + final isPending = status == 'PENDING'; + + final statusIcon = isSuccess + ? Icons.check_circle_rounded + : isPending + ? Icons.pending_rounded + : Icons.cancel_rounded; + final statusColor = isSuccess + ? const Color(0xFF10B981) + : isPending + ? const Color(0xFFF59E0B) + : const Color(0xFFEF4444); + + return Skeletonizer( + enabled: widget.enabled, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () { + context.push("/receipt/${widget.transaction['id']}"); + }, + child: Container( + padding: + const EdgeInsets.symmetric(vertical: 12, horizontal: 14), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1A1A2E) : Colors.white, + borderRadius: BorderRadius.circular(16), + 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.2) + : Colors.black.withValues(alpha: 0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Main row: provider image, bill name & date, amount + Row( + children: [ + // Provider image + Container( + width: 44, + height: 44, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues( + alpha: 0.1, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Image( + image: AssetImage( + "assets/${widget.transaction['paymentProcessorImage'] ?? ''}", + ), + errorBuilder: (_, __, ___) => Icon( + Icons.receipt_long_rounded, + size: 20, + color: theme.colorScheme.primary, + ), + ), + ), + const SizedBox(width: 14), + + // Bill name & date + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + widget.transaction['billName'] ?? '', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: isDark + ? Colors.white + : Colors.black87, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Icon( + statusIcon, + size: 16, + color: statusColor, + ), + ], + ), + const SizedBox(height: 4), + Text( + widget.transaction['createdAt'] != null + ? timeago.format( + DateTime.parse( + widget.transaction['createdAt'], + ), + ) + : '', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, + color: isDark + ? Colors.white54 + : Colors.grey.shade500, + ), + ), + ], + ), + ), + + // Amount + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + (widget.transaction['amount'] as num?) + ?.toStringAsFixed(2) ?? + '', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white : Colors.black87, + ), + ), + Text( + (widget.transaction['totalAmount'] as num?) + ?.toStringAsFixed(2) ?? + '', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: theme.colorScheme.primary.withValues( + alpha: 0.7, + ), + ), + ), + ], + ), + ], + ), + + // Quick actions row (only shown when onRepeat is provided) + if (widget.onRepeat != null) ...[ + const SizedBox(height: 10), + Divider( + height: 1, + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.grey.shade200, + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: _handleRepeat, + icon: _isRepeating + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ) + : const Icon(Icons.repeat_rounded, size: 16), + label: Text( + _isRepeating ? 'Repeating...' : 'Repeat', + style: const TextStyle(fontSize: 12), + ), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 849b909..ed318d7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -177,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + decimal: + dependency: "direct main" + description: + name: decimal + sha256: fc706a5618b81e5b367b01dd62621def37abc096f2b46a9bd9068b64c1fa36d0 + url: "https://pub.dev" + source: hosted + version: "3.2.4" dio: dependency: "direct main" description: @@ -680,6 +688,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + rational: + dependency: transitive + description: + name: rational + sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336 + url: "https://pub.dev" + source: hosted + version: "2.2.3" share_plus: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 9171ace..6f8762e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,6 +61,7 @@ dependencies: google_sign_in_web: ^1.1.0 flutter_web_plugins: sdk: flutter + decimal: ^3.2.4 dev_dependencies: flutter_test: