diff --git a/.vscode/launch.json b/.vscode/launch.json index 2cf2908..2bd70d1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,8 @@ "type": "dart", "deviceId": "chrome", "args": [ - "--dart-define=APP_ENV=test" + "--dart-define=APP_ENV=test", + "--web-port=9005" ] }, diff --git a/assets/.env.test b/assets/.env.test index 34c4714..3dfacdc 100644 --- a/assets/.env.test +++ b/assets/.env.test @@ -1,6 +1,7 @@ # BASE_URL=http://192.168.100.85:6950/api +# BASE_URL=https://peakapi.qantra.co.zw/api BASE_URL=http://localhost:6950/api CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com -SIMULATE_PAYMENT_SUCCESS=true +SIMULATE_PAYMENT_SUCCESS=false GATEWAY_SCRIPT_URL=https://test-gateway.mastercard.com/static/checkout/checkout.min.js diff --git a/lib/main.dart b/lib/main.dart index 1d5bfb6..261a66e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'package:provider/provider.dart'; import 'package:go_router/go_router.dart'; import 'package:qpay/navbar.dart'; import 'package:qpay/screens/confirm/confirm_screen.dart'; import 'package:qpay/screens/gateway/gateway_redirect.dart'; import 'package:qpay/screens/gateway/gateway_screen.dart'; +import 'package:qpay/screens/gateway/gateway_web_screen.dart'; +import 'package:qpay/screens/home/home_screen.dart'; +import 'package:qpay/screens/history/history_screen.dart'; import 'package:qpay/screens/integration/integration_screen.dart'; import 'package:qpay/screens/onboarding/complete_screen.dart'; import 'package:qpay/screens/onboarding/landing/landing_screen.dart'; @@ -16,15 +20,12 @@ import 'package:qpay/screens/onboarding/register/register_screen.dart'; import 'package:qpay/screens/onboarding/verify/verify_screen.dart'; import 'package:qpay/screens/pay/pay_screen.dart'; import 'package:qpay/screens/poll/poll_screen.dart'; +import 'package:qpay/screens/splash_screen.dart'; import 'package:qpay/screens/receipt/receipt_screen.dart'; import 'package:qpay/screens/recipient/recipients_screen.dart'; import 'package:qpay/screens/transaction_controller.dart'; -import 'screens/gateway/gateway_web_screen.dart'; -import 'screens/home/home_screen.dart'; -import 'screens/history/history_screen.dart'; -import 'screens/profile_screen.dart'; -import 'screens/splash_screen.dart'; -import 'theme/app_theme.dart'; +import 'package:qpay/screens/profile_screen.dart'; +import 'package:qpay/theme/app_theme.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:firebase_core/firebase_core.dart'; import 'firebase_options.dart'; @@ -43,8 +44,39 @@ String resolveEnvFile(String env) { } } +/// Tracks whether the splash animation is complete so that +/// GoRouter's redirect can show the splash first, then release +/// navigation to the originally intended URL. +class SplashState extends ChangeNotifier { + bool _complete = false; + /// The route the user originally intended to visit before being + /// redirected to /splash. Set by the redirect callback on first + /// invocation. + String? _intendedRoute; + bool get complete => _complete; + String? get intendedRoute => _intendedRoute; + + /// Called by the redirect to store the intended destination and + /// return the splash route. + String? guard(String attemptedRoute) { + if (_complete) return null; + // Avoid saving the splash route itself. + if (attemptedRoute == '/splash') return null; + _intendedRoute = attemptedRoute; + return '/splash'; + } + + void finish() { + _complete = true; + notifyListeners(); + } +} + +final SplashState splashState = SplashState(); + void main() async { WidgetsFlutterBinding.ensureInitialized(); + usePathUrlStrategy(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await dotenv.load(fileName: resolveEnvFile(appEnv)); @@ -71,37 +103,54 @@ class MyApp extends StatefulWidget { } class _MyAppState extends State { - bool _showSplash = true; - - void _onSplashComplete() { - setState(() { - _showSplash = false; - }); - } - @override Widget build(BuildContext context) { - if (_showSplash) { - return MaterialApp( - title: 'Velocity', - theme: AppTheme.lightTheme, - themeMode: ThemeMode.light, - home: SplashScreen(onSplashComplete: _onSplashComplete), - debugShowCheckedModeBanner: false, - ); - } - return MaterialApp.router( title: 'Velocity', theme: AppTheme.lightTheme, - themeMode: ThemeMode.light, // Force light mode regardless of OS setting + themeMode: ThemeMode.light, debugShowCheckedModeBanner: false, routerConfig: GoRouter( initialLocation: '/', + refreshListenable: splashState, + errorBuilder: (context, state) => Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Page not found', style: TextStyle(fontSize: 20)), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => context.go('/'), + child: const Text('Go Home'), + ), + ], + ), + ), + ), + redirect: (context, state) { + // Before splash is complete: redirect everything to /splash, + // but save the original intended destination. + final guardResult = splashState.guard(state.uri.path); + if (guardResult != null) return guardResult; + + // Once splash is complete: if we're still on /splash, navigate + // to the intended destination (or home as fallback). + if (splashState.complete && state.uri.path == '/splash') { + return splashState.intendedRoute ?? '/'; + } + + return null; + }, routes: [ + // Splash route sits outside the ShellRoute so it renders + // without the bottom nav bar or side rail. + GoRoute( + path: '/splash', + builder: (context, state) => const SplashScreen(), + ), ShellRoute( builder: (context, state, child) { - // If the current location is an onboarding route, don't show the navbar final location = GoRouterState.of(context).uri.toString(); if (location.startsWith('/onboarding/')) { return child; @@ -137,6 +186,12 @@ class _MyAppState extends State { path: '/gateway-redirect', builder: (context, state) => const GatewayRedirectScreen(), ), + GoRoute( + path: '/poll/:id', + builder: (context, state) => PollScreen( + transactionId: state.pathParameters['id'], + ), + ), GoRoute( path: '/poll', builder: (context, state) => const PollScreen(), @@ -195,4 +250,4 @@ class _MyAppState extends State { ), ); } -} +} \ No newline at end of file diff --git a/lib/models/api_response.dart b/lib/models/api_response.dart new file mode 100644 index 0000000..664bead --- /dev/null +++ b/lib/models/api_response.dart @@ -0,0 +1,21 @@ +class ApiResponse { + final T? data; + final String? error; + final int? statusCode; + + const ApiResponse({ + this.data, + this.error, + this.statusCode, + }); + + bool get isSuccess => error == null && data != null; + + factory ApiResponse.success(T data, {int? statusCode}) { + return ApiResponse(data: data, statusCode: statusCode); + } + + factory ApiResponse.failure(String error, {int? statusCode}) { + return ApiResponse(error: error, statusCode: statusCode); + } +} \ No newline at end of file diff --git a/lib/screens/confirm/confirm_controller.dart b/lib/screens/confirm/confirm_controller.dart index 514289d..867d13e 100644 --- a/lib/screens/confirm/confirm_controller.dart +++ b/lib/screens/confirm/confirm_controller.dart @@ -1,8 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import 'package:qpay/models/api_response.dart'; import 'package:qpay/screens/transaction_controller.dart'; import '../../http/http.dart'; @@ -53,7 +53,7 @@ class ConfirmController extends ChangeNotifier { }); } - Future?> doTransaction() async { + Future>> doTransaction() async { try { model.isLoading = true; notifyListeners(); @@ -77,33 +77,30 @@ class ConfirmController extends ChangeNotifier { dynamic response = workflowResponse['body']; + model.isLoading = false; + notifyListeners(); + if (response['status'] != 'FAILED') { model.status = response['status']; - transactionController.updateReceiptData(response); - - model.isLoading = false; - notifyListeners(); - return response; + return ApiResponse.success(Map.from(response)); } else { model.status = response['status']; model.errorMessage = response['errorMessage']; - _showErrorSnackBar(response['errorMessage']); + return ApiResponse.failure(response['errorMessage'] ?? "Transaction failed"); } } catch (e) { logger.e(e); model.errorMessage = e.toString(); - _showErrorSnackBar( + model.isLoading = false; + notifyListeners(); + return ApiResponse.failure( "Problem completing the transaction, please try again.", ); } - - model.isLoading = false; - notifyListeners(); - return null; } - Future pollTransaction(String uid) async { + Future>> pollTransaction(String uid) async { while (!model.isCancelled) { // Check again after delay in case it was cancelled during the delay if (model.isCancelled) break; @@ -117,7 +114,7 @@ class ConfirmController extends ChangeNotifier { transactionController.updateReceiptData(response); model.isCancelled = true; notifyListeners(); - break; + return ApiResponse.success(Map.from(response)); } } catch (e) { logger.e(e); @@ -130,6 +127,7 @@ class ConfirmController extends ChangeNotifier { // Wait 5 seconds before the next poll await Future.delayed(const Duration(seconds: 5)); } + return ApiResponse.failure("Polling cancelled"); } @override diff --git a/lib/screens/confirm/confirm_screen.dart b/lib/screens/confirm/confirm_screen.dart index 9081701..2f3050c 100644 --- a/lib/screens/confirm/confirm_screen.dart +++ b/lib/screens/confirm/confirm_screen.dart @@ -40,15 +40,13 @@ class _ConfirmScreenState extends State ), ); - _slideAnimation = Tween( - begin: const Offset(0, 0.1), - end: Offset.zero, - ).animate( - CurvedAnimation( - parent: _controller, - curve: const Interval(0.0, 0.5, curve: Curves.easeOut), - ), - ); + _slideAnimation = + Tween(begin: const Offset(0, 0.1), end: Offset.zero).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.0, 0.5, curve: Curves.easeOut), + ), + ); _controller.forward(); } @@ -65,44 +63,59 @@ class _ConfirmScreenState extends State if (controller.model.isLoading) return; final response = await controller.doTransaction(); - if (response!['status'] == 'FAILED') { + if (!response.isSuccess) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + controller.model.errorMessage ?? + response.error ?? + "Transaction failed", + ), + behavior: SnackBarBehavior.floating, + width: 600, + backgroundColor: Colors.deepOrange, + ), + ); + } return; } if (!mounted) return; - if (controller.transactionController.model.selectedPaymentProcessor.authType == "WEB") { - if(kIsWeb){ - // the one we use here depends on when mpgs embedded bug will be fixed + if (controller + .transactionController + .model + .selectedPaymentProcessor + .authType == + "WEB") { + if (kIsWeb) { context.push('/gateway-web'); - // context.push('/gateway-redirect'); - }else { + } else { context.push('/gateway'); } } else { if (!mounted) return; context.push('/poll'); } - }catch (e,s) { + } catch (e, s) { print(e); print(s); } } - @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - leading: - context.canPop() - ? IconButton( - onPressed: () { - context.pop(); - }, - icon: const Icon(Icons.arrow_back), - ) - : SizedBox.shrink(), + leading: context.canPop() + ? IconButton( + onPressed: () { + context.pop(); + }, + icon: const Icon(Icons.arrow_back), + ) + : SizedBox.shrink(), title: const Text( 'Confirm Payment', style: TextStyle(color: Colors.black87), @@ -122,246 +135,124 @@ class _ConfirmScreenState extends State key: _formKey, child: Center( child: LayoutBuilder( - builder: (context, constraints) { - return SizedBox( - width: double.parse(constraints.maxWidth > ResponsivePolicy.md ? - ResponsivePolicy.md.toString() : - constraints.maxWidth.toString()), + builder: (context, constraints) { + return SizedBox( + width: double.parse( + constraints.maxWidth > ResponsivePolicy.md + ? ResponsivePolicy.md.toString() + : constraints.maxWidth.toString(), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: EdgeInsets.all(15), - decoration: BoxDecoration( - border: Border.all( - color: Theme.of( - context, - ).colorScheme.primary.withOpacity(0.3), - ), - borderRadius: BorderRadius.circular(10), - ), - - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - width: 1.0, - color: - Theme.of(context).colorScheme.primary, - ), - ), - ), - padding: EdgeInsets.only( - bottom: 4.0, - ), // Adjust spacing - child: Text( - "PROVIDER DETAILS", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - ...controller + // Provider Details Card + _buildInfoCard( + icon: Icons.business_outlined, + title: "PROVIDER DETAILS", + children: [ + ...controller + .transactionController + .model + .confirmationData["additionalData"] + .map((data) { + return _buildDetailRow( + icon: Icons.info_outline, + label: data["name"] ?? "", + value: data["value"] ?? "", + ); + }) + .toList(), + ], + ), + const SizedBox(height: 20), + // Transaction Details Card + _buildInfoCard( + icon: Icons.receipt_long_outlined, + title: "TRANSACTION DETAILS", + children: [ + _buildDetailRow( + icon: Icons.monetization_on_outlined, + label: "Amount", + value: controller .transactionController .model - .confirmationData["additionalData"] - .map((data) { - return Column( - children: [ - const SizedBox(height: 10), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - data["name"] ?? "", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - Text( - data["value"] ?? "", - style: TextStyle(fontSize: 16), - ), - ], - ), - ], - ); - }) - .toList(), - const SizedBox(height: 30), - Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - width: 1.0, - color: - Theme.of(context).colorScheme.primary, - ), - ), - ), - padding: EdgeInsets.only( - bottom: 4.0, - ), // Adjust spacing - child: Text( - "TRANSACTIONS DETAILS", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), + .confirmationData["amount"] + .toString(), + ), + if (controller + .transactionController + .model + .confirmationData["charge"] != + 0) + _buildDetailRow( + icon: Icons.percent_outlined, + label: "Our Charge", + value: controller + .transactionController + .model + .confirmationData["charge"] + .toString(), ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - "Amount", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - Text( - controller - .transactionController - .model - .confirmationData["amount"] - .toString(), - style: TextStyle(fontSize: 16), - ), - ], + if (controller + .transactionController + .model + .confirmationData["gatewayCharge"] != + 0) + _buildDetailRow( + icon: Icons.account_balance_outlined, + label: "Gateway Charge", + value: controller + .transactionController + .model + .confirmationData["gatewayCharge"] + .toString(), ), - const SizedBox(height: 10), - if (controller - .transactionController - .model - .confirmationData["charge"] != - 0) - Column( - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - "Our Charge", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - Text( - controller - .transactionController - .model - .confirmationData["charge"] - .toString(), - style: TextStyle(fontSize: 16), - ), - ], - ), - const SizedBox(height: 10), - ], - ), - if (controller - .transactionController - .model - .confirmationData["gatewayCharge"] != - 0) - Column( - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - "Gateway Charge", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - Text( - controller - .transactionController - .model - .confirmationData["gatewayCharge"] - .toString(), - style: TextStyle(fontSize: 16), - ), - ], - ), - const SizedBox(height: 10), - ], - ), - if (controller - .transactionController - .model - .confirmationData["tax"] != - 0) - Column( - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - "Tax", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - Text( - controller - .transactionController - .model - .confirmationData["tax"] - .toString(), - style: TextStyle(fontSize: 16), - ), - ], - ), - const SizedBox(height: 10), - ], - ), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - "Total Amount", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - Text( - controller - .transactionController - .model - .confirmationData["totalAmount"] - .toString(), - style: TextStyle(fontSize: 16), - ), - ], + if (controller + .transactionController + .model + .confirmationData["tax"] != + 0) + _buildDetailRow( + icon: Icons.receipt_outlined, + label: "Tax", + value: controller + .transactionController + .model + .confirmationData["tax"] + .toString(), ), - const SizedBox(height: 5), - Text( - "NB: Your payment provider may charge additional fees.", - style: TextStyle( - fontSize: 11, - color: Colors.red, - ), + const Divider( + height: 1, + color: Colors.grey, + ), + const SizedBox(height: 12), + _buildDetailRow( + icon: Icons.summarize_outlined, + label: "Total Amount", + value: controller + .transactionController + .model + .confirmationData["totalAmount"] + .toString(), + valueStyle: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Theme.of( + context, + ).colorScheme.primary, ), - ], + ), + ], + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + "NB: Your payment provider may charge additional fees.", + style: TextStyle( + fontSize: 11, + color: Colors.red.shade600, + ), ), ), const SizedBox(height: 20), @@ -378,7 +269,7 @@ class _ConfirmScreenState extends State ], ), ); - } + }, ), ), ), @@ -391,6 +282,91 @@ class _ConfirmScreenState extends State ); } + Widget _buildInfoCard({ + required IconData icon, + required String title, + required List children, + }) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200, width: 1), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + icon, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.primary, + letterSpacing: 1.0, + ), + ), + ], + ), + const SizedBox(height: 16), + Divider(height: 1, color: Colors.grey.shade200), + const SizedBox(height: 16), + ...children, + ], + ), + ), + ); + } + + Widget _buildDetailRow({ + required IconData icon, + required String label, + required String value, + TextStyle? valueStyle, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Icon(icon, size: 16, color: Colors.grey.shade500), + const SizedBox(width: 10), + Text( + label, + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), + const Spacer(), + Text( + value, + style: + valueStyle ?? + TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ], + ), + ); + } + Widget _buildPaymentProcessorButton() { return Skeletonizer( enabled: controller.model.isLoading, diff --git a/lib/screens/gateway/gateway_controller.dart b/lib/screens/gateway/gateway_controller.dart index e7c51f7..417a8b2 100644 --- a/lib/screens/gateway/gateway_controller.dart +++ b/lib/screens/gateway/gateway_controller.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; -import 'dart:async'; // Add Timer import +import 'dart:async'; import 'package:qpay/http/http.dart'; +import 'package:qpay/models/api_response.dart'; import 'package:qpay/screens/transaction_controller.dart'; import 'package:provider/provider.dart'; @@ -50,10 +51,11 @@ class GatewayController extends ChangeNotifier { notifyListeners(); } - Future poll(String uid) async { + Future>> poll(String uid) async { startLoading(); try { - dynamic workflowResponse = await http.get('/public/transaction/poll/$uid'); + dynamic workflowResponse = + await http.get('/public/transaction/poll/$uid'); logger.i(workflowResponse.toString()); @@ -63,41 +65,65 @@ class GatewayController extends ChangeNotifier { transactionController.model.paymentStatus = response['paymentStatus']; finishLoading(); - if(model.status == 'SUCCESS') { + if (model.status == 'SUCCESS') { transactionController.updateReceiptData(response); } notifyListeners(); + return ApiResponse.success(Map.from(response)); } catch (e) { finishLoading(); + model.status = 'FAILED'; + model.errorMessage = "Network error. Please try again or contact support"; logger.e(e); - _showErrorSnackBar( + notifyListeners(); + return ApiResponse.failure( "Network error. Please try again or contact support", ); } } - Future pollTransaction(String uid) async { - // only poll for 5 minutes - int max = 30; - while (!model.isCancelled) { - max++; - // Check cancellation flag instead of true - // Wait 5 seconds before the next poll + Future>> pollTransaction(String uid) async { + // poll up to 30 times (~5 minutes at 10-second intervals) + int maxAttempts = 30; + int attempt = 0; + + while (!model.isCancelled && attempt < maxAttempts) { + attempt++; + + // Wait 10 seconds before each poll request await Future.delayed(const Duration(seconds: 10)); // Check again after delay in case it was cancelled during the delay if (model.isCancelled) break; - await poll(uid); + ApiResponse> result = await poll(uid); - if(max > 30) { - model.isCancelled = true; + // Only stop on success or failure + if (result.isSuccess) { + String status = model.status; + // If status is SUCCESS, we're done + if (status == 'SUCCESS') { + return result; + } + // Otherwise (e.g. PENDING), continue polling + } else { + // Network error — show failure UI and stop + return result; } } + + // Timed out after max attempts + model.isCancelled = true; + model.status = 'FAILED'; + model.errorMessage = 'We failed to check your transaction status'; + finishLoading(); + notifyListeners(); + return ApiResponse.failure("Polling timed out"); } // Alternative method using Timer instead of while loop - void pollTransactionWithTimer(String uid) { + Future>> pollTransactionWithTimer( + String uid) async { _pollTimer = Timer.periodic(const Duration(seconds: 5), (timer) async { if (model.isCancelled) { timer.cancel(); @@ -121,6 +147,10 @@ class GatewayController extends ChangeNotifier { ); } }); + // This returns immediately; the caller should use the timer callback approach. + // Consider refactoring this to use poll(String uid) directly for a consistent API. + return ApiResponse.failure( + "Polling via timer started; use a different flow for the result"); } @override @@ -129,4 +159,4 @@ class GatewayController extends ChangeNotifier { _pollTimer?.cancel(); // Cancel timer if it exists super.dispose(); } -} +} \ No newline at end of file diff --git a/lib/screens/gateway/gateway_redirect.dart b/lib/screens/gateway/gateway_redirect.dart index 6cbe061..43e1f89 100644 --- a/lib/screens/gateway/gateway_redirect.dart +++ b/lib/screens/gateway/gateway_redirect.dart @@ -1,15 +1,8 @@ -import 'dart:js_interop'; - import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:go_router/go_router.dart'; -import 'package:qpay/interop.dart'; import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/screens/gateway/gateway_controller.dart'; -import 'package:skeletonizer/skeletonizer.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:webview_flutter/webview_flutter.dart'; -import 'package:web/web.dart' as web; class GatewayRedirectScreen extends StatefulWidget { const GatewayRedirectScreen({super.key}); diff --git a/lib/screens/gateway/gateway_web_screen.dart b/lib/screens/gateway/gateway_web_screen.dart index 4a1c668..da68cb6 100644 --- a/lib/screens/gateway/gateway_web_screen.dart +++ b/lib/screens/gateway/gateway_web_screen.dart @@ -1,12 +1,8 @@ -import 'dart:js_interop'; - import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:go_router/go_router.dart'; -import 'package:qpay/interop.dart'; import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/screens/gateway/gateway_controller.dart'; -import 'package:webview_flutter/webview_flutter.dart'; import 'package:web/web.dart' as web; class GatewayWebScreen extends StatefulWidget { @@ -17,12 +13,11 @@ class GatewayWebScreen extends StatefulWidget { } class _GatewayWebScreenState extends State { - late WebViewController _webViewController; late GatewayController controller; late String url; - String? sessionID; bool integrationStarted = false; + bool redirecting = false; @override void initState() { @@ -32,115 +27,40 @@ class _GatewayWebScreenState extends State { url = controller.transactionController.model.receiptData?['targetUrl']; } - // A function to set up the ResizeObserver - void setupAttachmentObserver(web.Element element) { - final observer = web.ResizeObserver( - (JSArray entries, JSAny observer) { - // The element has been laid out (and thus attached to the DOM) - onElementAttached(element); + void _redirectToGateway() { + if (redirecting) return; - // Disconnect the observer once the action is done - (observer as web.ResizeObserver).disconnect(); - }.toJS, + setState(() { + redirecting = true; + }); + + bool simulate = bool.parse( + dotenv.env['SIMULATE_PAYMENT_SUCCESS'] ?? 'false', ); - - observer.observe(element); - } - - // Called after `element` is attached to the DOM. - void onElementAttached(web.Element element) { - // Your code to execute after the element is in the DOM goes here. - print('Element with ID ${element.id} is attached to the DOM.'); - // You can now safely query the DOM or call JavaScript functions that rely - // on the element being present. - - Future.delayed(const Duration(milliseconds: 50), () { - initIframeView(); - }); - } - - void initIframeView() async { - await ensureCheckoutScriptLoaded(); - - final uri = Uri.parse(url); - // 2. The pathSegments property returns a list of the parts of the path. - // For "checkout/pay/SESSION...", the segments are ["checkout", "pay", "SESSION..."]. - final pathSegments = uri.pathSegments; - // 3. Check if there are any segments and return the last one, - // which is the session ID in this URL structure. - if (pathSegments.isNotEmpty) { - sessionID = pathSegments.last; - - configure(sessionID!); - } - showEmbeddedPage(); - - Future.delayed(const Duration(seconds: 10), () async { - web.HTMLIFrameElement element = fetchIframeFromDom( - "hc-comms-layer-iframe", - )!; - print(element); - element.onload = (JSAny event) { - // This is called every time a new page loads in the iframe - print(event); - - try { - final newLocation = element.contentWindow?.location.href; - print('Iframe navigated to: $newLocation'); - // Perform actions based on the new URL - - _navigateWhenDone(newLocation!); - } catch (e) { - // Handle potential (though unlikely for same-origin) security errors - print('Could not access iframe location: $e'); - } - }.toJS; - - bool simulate = bool.parse(dotenv.env['SIMULATE_PAYMENT_SUCCESS']!); - if (simulate) { - await Future.delayed(Duration(milliseconds: 100)); - _navigateWhenDone("/checkout/receipt/"); - } - }); - } - - web.HTMLIFrameElement? fetchIframeFromDom(String id) { - final element = web.window.document.querySelector('#$id'); - if (element is web.HTMLIFrameElement) { - print('Found iframe in DOM: ${element.id}'); - print(element.toString()); - return element; - } - - print('Could not find iframe with id: $id in the DOM.'); - return null; - } - - void _navigateWhenDone(String url) { - if (url.contains("/checkout/receipt/")) { - if (integrationStarted) { - return; - } - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Payment was successful, please wait while we process your order.", - ), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.black87, - duration: const Duration(seconds: 10), - ), - ); - - setState(() { - integrationStarted = true; - }); - + if (simulate) { + // In simulation mode, just go to polling WidgetsBinding.instance.addPostFrameCallback((_) { context.go('/poll'); }); + return; } + + // Save the transaction ID to localStorage so index.html can redirect + // the user back to /poll/:id when the gateway returns to this app. + final transactionId = + controller.transactionController.model.confirmationData['id']; + if (transactionId != null) { + web.window.localStorage.setItem( + 'pendingTransactionId', + transactionId.toString(), + ); + } + + // Navigate the current browser tab to the payment gateway URL. + // After payment, the gateway will redirect back to this app's host. + // index.html then reads the saved transaction ID and navigates to + // /poll/:id so the app can immediately start polling for the result. + web.window.location.href = url; } @override @@ -161,91 +81,98 @@ class _GatewayWebScreenState extends State { }, icon: const Icon(Icons.arrow_back), ) - : SizedBox.shrink(), + : const SizedBox.shrink(), ), body: ListenableBuilder( listenable: controller, builder: (context, child) { - return LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - child: Center( - child: SizedBox( - width: ResponsivePolicy.md.toDouble(), + return Center( + child: LayoutBuilder( + builder: (context, constraints) { + return SizedBox( + width: constraints.maxWidth > ResponsivePolicy.md + ? ResponsivePolicy.md.toDouble() + : constraints.maxWidth.toDouble(), + child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(20.0), child: Column( + mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, 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: [ - Text( - 'Once done on this page, check transaction status', - style: TextStyle(fontSize: 16), - ), - InkWell( - onTap: () { - context.push('/poll'); - }, - 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( - 'Check status', - style: TextStyle(fontSize: 12), - ), - ), - ), - ], - ), + const SizedBox(height: 40), + + // Info icon + Icon( + Icons.lock_outline, + size: 64, + color: Theme.of(context).colorScheme.primary, ), - SizedBox(height: 10), + const SizedBox(height: 24), + + Text( + 'You are about to leave this app to complete your payment on a secure payment gateway.', + style: const TextStyle(fontSize: 18), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + + Text( + 'After completing payment, you will be automatically redirected back to this app to see your transaction status.', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 40), + + Image.asset( + 'assets/velocity-animation.gif', + width: 150, + ), + + const SizedBox(height: 40), + SizedBox( width: double.infinity, - height: constraints.maxHeight + 300, - child: HtmlElementView.fromTagName( - tagName: 'div', - onElementCreated: (element) { - final divElement = - element as web.HTMLDivElement; - divElement.id = "embed-target"; - setupAttachmentObserver(element); - }, + child: ElevatedButton( + onPressed: redirecting + ? null + : _redirectToGateway, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 16, + ), + ), + child: Text( + redirecting + ? 'Redirecting...' + : 'Proceed to Payment Gateway', + style: const TextStyle(fontSize: 16), + ), ), ), + + const SizedBox(height: 16), + + TextButton( + onPressed: () { + context.go('/poll'); + }, + child: const Text('Check transaction status'), + ), ], ), ), ), - ), - ); - }, + ); + }, + ), ); }, ), ); } -} +} \ No newline at end of file diff --git a/lib/screens/history/history_controller.dart b/lib/screens/history/history_controller.dart index 5a663fb..3fbe2fb 100644 --- a/lib/screens/history/history_controller.dart +++ b/lib/screens/history/history_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:qpay/http/http.dart'; +import 'package:qpay/models/api_response.dart'; import 'package:qpay/models/pageable_model.dart'; class HistoryModel { @@ -17,17 +18,7 @@ class HistoryController extends ChangeNotifier { HistoryController(this.context); - void _showErrorSnackBar(String? message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message.toString()), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.deepOrange, - ), - ); - } - - Future getTransactions(Map params) async { + Future>>> getTransactions(Map params) async { model.isLoading = true; if (params['page'] == '0') { model.transactions = getFakeTransactions(); @@ -51,16 +42,19 @@ class HistoryController extends ChangeNotifier { model.pageableModel!.content .map((e) => e as Map) .toList(); - } catch (e) { - logger.e(e); - _showErrorSnackBar( - "Problem fetching transactions, are you connected to the internet?", - ); - model.errorMessage = e.toString(); - model.transactions = []; - } finally { + model.isLoading = false; notifyListeners(); + return ApiResponse.success(List>.from(model.transactions)); + } catch (e) { + logger.e(e); + model.errorMessage = e.toString(); + model.transactions = []; + model.isLoading = false; + notifyListeners(); + return ApiResponse.failure( + "Problem fetching transactions, are you connected to the internet?", + ); } } diff --git a/lib/screens/home/home_controller.dart b/lib/screens/home/home_controller.dart index f2c3908..5df95b4 100644 --- a/lib/screens/home/home_controller.dart +++ b/lib/screens/home/home_controller.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:qpay/http/http.dart'; +import 'package:qpay/models/api_response.dart'; import 'package:qpay/models/pageable_model.dart'; part 'home_controller.freezed.dart'; @@ -93,24 +94,13 @@ class HomeController extends ChangeNotifier { super.dispose(); } - void _showErrorSnackBar(String? message) { - WidgetsBinding.instance.addPostFrameCallback((_) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message.toString()), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.deepOrange, - ), - ); - }); - } - - Future getAccounts() async { + Future>> getAccounts() async { model.accounts = getFakeAccounts(); if (!_disposed) notifyListeners(); + return ApiResponse.success(model.accounts); } - Future getProviders() async { + Future>> getProviders() async { try { model.filterableProviders = getFakeProviders(); model.transactions = getFakeTransactions(); @@ -125,19 +115,22 @@ class HomeController extends ChangeNotifier { if (model.providers.isNotEmpty) { updateSelectedProvider(model.providers[0]); } + + model.isLoading = false; + if (!_disposed) notifyListeners(); + return ApiResponse.success(model.providers); } catch (e, s) { logger.e(e); logger.e(s); - _showErrorSnackBar( - "Problem fetching providers, are you connected to the internet?", - ); model.errorMessage = e.toString(); model.providers = []; model.filterableProviders = []; + model.isLoading = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure( + "Problem fetching providers, are you connected to the internet?", + ); } - - model.isLoading = false; - if (!_disposed) notifyListeners(); } void updateSelectedProvider(BillProvider provider) { @@ -148,9 +141,8 @@ class HomeController extends ChangeNotifier { if (!_disposed) notifyListeners(); } - Future getTransactions(String userId) async { + Future>>> getTransactions(String userId) async { model.isLoading = true; - // model.transactions = getFakeTransactions(); if (!_disposed) notifyListeners(); try { @@ -163,20 +155,23 @@ class HomeController extends ChangeNotifier { model.transactions = pageableModel.content .map((e) => e as Map) .toList(); - } catch (e) { - logger.e(e); - _showErrorSnackBar( - "Problem fetching transactions, are you connected to the internet?", - ); - model.errorMessage = e.toString(); - model.transactions = []; - } finally { + model.isLoading = false; if (!_disposed) notifyListeners(); + return ApiResponse.success(model.transactions); + } catch (e) { + logger.e(e); + model.errorMessage = e.toString(); + model.transactions = []; + model.isLoading = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure( + "Problem fetching transactions, are you connected to the internet?", + ); } } - Future getCategories() async { + Future>> getCategories() async { // activate skeletonizer model.categories = getFakeCategoryData(); model.isLoading = true; @@ -190,20 +185,23 @@ class HomeController extends ChangeNotifier { if (model.categories.isNotEmpty) { updateSelectedCategory(model.categories[0]); } - } on Exception catch (e) { - logger.e(e); - _showErrorSnackBar( - "Problem fetching categories, are you connected to the internet?", - ); - model.errorMessage = e.toString(); - model.categories = []; - } finally { + model.isLoading = false; if (!_disposed) notifyListeners(); + return ApiResponse.success(model.categories); + } on Exception catch (e) { + logger.e(e); + model.errorMessage = e.toString(); + model.categories = []; + model.isLoading = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure( + "Problem fetching categories, are you connected to the internet?", + ); } } - Future updateSelectedCategory(Category? category) async { + void updateSelectedCategory(Category? category) { if (category == null) { model.filterableProviders = model.providers; if (!_disposed) notifyListeners(); diff --git a/lib/screens/integration/integration_controller.dart b/lib/screens/integration/integration_controller.dart index 1fa0457..76dbe2d 100644 --- a/lib/screens/integration/integration_controller.dart +++ b/lib/screens/integration/integration_controller.dart @@ -1,8 +1,8 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:qpay/http/http.dart'; +import 'package:qpay/models/api_response.dart'; import 'package:qpay/screens/gateway/gateway_controller.dart'; import 'package:qpay/screens/transaction_controller.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -34,19 +34,7 @@ class IntegrationController extends ChangeNotifier { gatewayController = GatewayController(context); } - void _showErrorSnackBar(String? message) { - WidgetsBinding.instance.addPostFrameCallback((_) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message.toString()), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.deepOrange, - ), - ); - }); - } - - Future doIntegration() async { + Future>> doIntegration() async { try { model.isLoading = true; notifyListeners(); @@ -75,10 +63,13 @@ class IntegrationController extends ChangeNotifier { WidgetsBinding.instance.addPostFrameCallback((_) { if (!_disposed && context.mounted) context.go('/receipt'); }); + + return ApiResponse.success(Map.from(response)); } catch (e) { model.isLoading = false; model.errorMessage = e.toString(); if (!_disposed) notifyListeners(); + return ApiResponse.failure(e.toString()); } } diff --git a/lib/screens/integration/integration_screen.dart b/lib/screens/integration/integration_screen.dart index f59bc1f..2dda1cf 100644 --- a/lib/screens/integration/integration_screen.dart +++ b/lib/screens/integration/integration_screen.dart @@ -1,9 +1,8 @@ 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/transaction_controller.dart'; -import 'package:skeletonizer/skeletonizer.dart'; +import 'package:qpay/widgets/step_loading_widget.dart'; import 'integration_controller.dart'; @@ -18,8 +17,6 @@ class _IntegrationScreenState extends State { late IntegrationController controller; late TransactionController transactionController; - bool integrating = false; - @override void initState() { super.initState(); @@ -32,19 +29,6 @@ class _IntegrationScreenState extends State { controller.doIntegration(); } - void _showErrorSnackBar(String? message) { - WidgetsBinding.instance.addPostFrameCallback((_) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message.toString()), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.deepOrange, - ), - ); - }); - } - - @override void dispose() { controller.dispose(); @@ -56,6 +40,10 @@ class _IntegrationScreenState extends State { return Scaffold( appBar: AppBar( title: const Text('Completing your transaction'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/'), + ), ), body: ListenableBuilder( listenable: controller, @@ -66,60 +54,18 @@ class _IntegrationScreenState extends State { }); } - return Container( - padding: EdgeInsets.all(50), - child: Center( - child: LayoutBuilder( - builder: (context, constraints) { - return SizedBox( - width: double.parse(constraints.maxWidth > ResponsivePolicy.md ? - ResponsivePolicy.md.toString() : - constraints.maxWidth.toString()), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox(height: 75), - - Column( - children: [ - Text( - 'We\'ve successfully collected your funds. We are now ' - 'updating your billing account. This won\'t take long', - style: TextStyle(fontSize: 20), - textAlign: TextAlign.center, - ), - SizedBox(height: 50,), - Image.asset('assets/velocity-animation.gif', width: 150), - SizedBox(height: 50), - if(controller.model.status == 'FAILED') - Column( - children: [ - Text( - 'We failed to update your billing account', - style: TextStyle(fontSize: 20), - textAlign: TextAlign.center, - ), - SizedBox(height: 20,), - Skeletonizer( - enabled: controller.model.isLoading, - child: OutlinedButton( - child: Text('Retry'), - onPressed: () { - controller.doIntegration(); - }, - ), - ), - ], - ), - ], - ), - ], - ), - ); - } - ), - ), + return StepLoadingWidget( + currentStep: 2, + title: 'Updating Account', + subtitle: + 'We\'ve successfully collected your funds. We are now ' + 'updating your billing account. This won\'t take long.', + status: controller.model.status, + isLoading: controller.model.isLoading, + errorMessage: 'We failed to update your billing account', + onRetry: () { + controller.doIntegration(); + }, ); }, ), diff --git a/lib/screens/onboarding/login/login_controller.dart b/lib/screens/onboarding/login/login_controller.dart index 1b649fa..a9c8670 100644 --- a/lib/screens/onboarding/login/login_controller.dart +++ b/lib/screens/onboarding/login/login_controller.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:qpay/http/http.dart'; +import 'package:qpay/models/api_response.dart'; import 'package:shared_preferences/shared_preferences.dart'; class LoginScreenModel { @@ -20,19 +21,7 @@ class LoginController extends ChangeNotifier { LoginController(this.context); - void _showErrorSnackBar(String? message) { - WidgetsBinding.instance.addPostFrameCallback((_) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message.toString()), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.deepOrange, - ), - ); - }); - } - - Future login(String username, String password) async { + Future> login(String username, String password) async { try { model.isLoading = true; notifyListeners(); @@ -45,6 +34,9 @@ class LoginController extends ChangeNotifier { }); logger.i(response.toString()); + model.isLoading = false; + notifyListeners(); + if (response.containsKey('token')) { model.status = 'SUCCESS'; @@ -57,26 +49,25 @@ class LoginController extends ChangeNotifier { prefs.setString('userId', response['uuid']); prefs.setString('initials', response['firstName'].substring(0, 1) + response['lastName'].substring(0, 1)); - return model; + return ApiResponse.success(model); } else { model.errorMessage = response['errorMessage'] ?? "Problem with the transaction. Please try again or contact support"; - _showErrorSnackBar(model.errorMessage); + return ApiResponse.failure(model.errorMessage!); } } on DioException catch (e, s) { logger.e(s); - _showErrorSnackBar(e.response?.data['errors'][0] ?? "Unknown error"); + model.isLoading = false; + notifyListeners(); + return ApiResponse.failure(e.response?.data['errors'][0] ?? "Unknown error"); } catch (e, s) { logger.e(s); - _showErrorSnackBar( + model.isLoading = false; + notifyListeners(); + return ApiResponse.failure( "Problem processing that transaction. Please try again or contact support", ); } - - model.isLoading = false; - notifyListeners(); - - return model; } } diff --git a/lib/screens/onboarding/phone/phone_controller.dart b/lib/screens/onboarding/phone/phone_controller.dart index bbc1cb4..3548bf6 100644 --- a/lib/screens/onboarding/phone/phone_controller.dart +++ b/lib/screens/onboarding/phone/phone_controller.dart @@ -1,9 +1,8 @@ - -import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:qpay/abstracts/AbstractController.dart'; import 'package:qpay/http/http.dart'; +import 'package:qpay/models/api_response.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../onboarding_controller.dart'; @@ -37,7 +36,7 @@ class PhoneController extends AbstractController { } - Future register() async { + Future>> register() async { prefs = await SharedPreferences.getInstance(); Map user = { @@ -62,19 +61,26 @@ class PhoneController extends AbstractController { if(response['state'] == 'failed') { model.errorMessage = response['message']; showErrorSnackBar(model.errorMessage); + model.isLoading = false; + notifyListeners(); + return ApiResponse.failure(response['message'] ?? "Registration failed"); } Map body = response['body']; body['workflowId'] = response['workflowId']; onboardingController.updateModel(body); + + model.isLoading = false; + notifyListeners(); + return ApiResponse.success(Map.from(body)); } catch (e) { logger.e(e); showErrorSnackBar( "Problem during registration, please try again or contact support?", ); + model.isLoading = false; + notifyListeners(); + return ApiResponse.failure("Problem during registration, please try again or contact support?"); } - - model.isLoading = false; - notifyListeners(); } } \ No newline at end of file diff --git a/lib/screens/onboarding/phone/phone_screen.dart b/lib/screens/onboarding/phone/phone_screen.dart index 1a0c253..de82100 100644 --- a/lib/screens/onboarding/phone/phone_screen.dart +++ b/lib/screens/onboarding/phone/phone_screen.dart @@ -85,12 +85,13 @@ class _PhoneScreenState extends State { key: _formKey, child: Center( child: LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth > ResponsivePolicy.md - ? ResponsivePolicy.md.toDouble() - : constraints.maxWidth; - return SizedBox( - width: maxWidth, + builder: (context, constraints) { + final maxWidth = + constraints.maxWidth > ResponsivePolicy.md + ? ResponsivePolicy.md.toDouble() + : constraints.maxWidth; + return SizedBox( + width: maxWidth, child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, @@ -101,10 +102,7 @@ class _PhoneScreenState extends State { style: TextStyle( fontSize: 30, fontWeight: FontWeight.bold, - color: Theme - .of(context) - .colorScheme - .primary, + color: Theme.of(context).colorScheme.primary, ), textAlign: TextAlign.center, ), @@ -119,7 +117,9 @@ class _PhoneScreenState extends State { _buildPhoneField(), SizedBox(height: 30), Padding( - padding: const EdgeInsets.symmetric(horizontal: 25), + padding: const EdgeInsets.symmetric( + horizontal: 25, + ), child: Skeletonizer( enabled: phoneController.model.isLoading, child: Row( @@ -127,18 +127,25 @@ class _PhoneScreenState extends State { Expanded( child: ElevatedButton( onPressed: () async { - if (_formKey.currentState!.validate()) { + if (_formKey.currentState! + .validate()) { String fullPhoneNumber = - selectedCountryCode + _phoneController.text; + _phoneController.text; - phoneController.updatePhone(fullPhoneNumber); + phoneController.updatePhone( + fullPhoneNumber, + ); await phoneController.register(); - if(phoneController.model.status != 'failed') { + if (phoneController.model.status != + 'failed') { context.go('/onboarding/verify'); } } }, - child: Text('Send Code', style: TextStyle(fontSize: 16)), + child: Text( + 'Send Code', + style: TextStyle(fontSize: 16), + ), ), ), SizedBox(width: 16), @@ -162,19 +169,22 @@ class _PhoneScreenState extends State { onPressed: () { context.go('/'); }, - child: Text('Continue as guest?', style: TextStyle(fontSize: 14)), + child: Text( + 'Continue as guest?', + style: TextStyle(fontSize: 14), + ), ), ], ), ); - } + }, ), ), ), ), ), ); - } + }, ); } @@ -201,8 +211,7 @@ class _PhoneScreenState extends State { isExpanded: true, icon: const Icon(Icons.arrow_drop_down), padding: const EdgeInsets.symmetric(horizontal: 8), - items: - countryCodes.map((country) { + items: countryCodes.map((country) { return DropdownMenuItem( value: country['code'], child: Row( @@ -272,5 +281,4 @@ class _PhoneScreenState extends State { ], ); } - } diff --git a/lib/screens/onboarding/verify/verify_controller.dart b/lib/screens/onboarding/verify/verify_controller.dart index def688c..c79ed12 100644 --- a/lib/screens/onboarding/verify/verify_controller.dart +++ b/lib/screens/onboarding/verify/verify_controller.dart @@ -2,6 +2,7 @@ import 'package:flutter/cupertino.dart'; import 'package:provider/provider.dart'; import 'package:qpay/abstracts/AbstractController.dart'; import 'package:qpay/http/http.dart'; +import 'package:qpay/models/api_response.dart'; import 'package:qpay/screens/onboarding/onboarding_controller.dart'; class VerifyModel { @@ -30,7 +31,7 @@ class VerifyController extends AbstractController { notifyListeners(); } - Future verifyOtp () async { + Future>> verifyOtp () async { Map user = { "username": onboardingController.model.googleUser?.email, "otpCode": model.code, @@ -47,22 +48,25 @@ class VerifyController extends AbstractController { user ); model.status = response['state']; + model.isLoading = false; + notifyListeners(); + if(response['state'] == 'failed') { model.errorMessage = response['message']; - showErrorSnackBar(model.errorMessage); + return ApiResponse.failure(response['message'] ?? "Verification failed"); } + return ApiResponse.success(Map.from(response)); } catch (e) { logger.e(e); - showErrorSnackBar( + model.isLoading = false; + notifyListeners(); + return ApiResponse.failure( "Problem during registration, please try again or contact support?", ); } - - model.isLoading = false; - notifyListeners(); } - Future resendOtp () async { + Future>> resendOtp () async { Map user = { "username": onboardingController.model.googleUser?.email, "phone": onboardingController.model.phone, @@ -78,18 +82,21 @@ class VerifyController extends AbstractController { user ); model.status = response['state']; + model.isLoading = false; + notifyListeners(); + if(response['state'] == 'failed') { model.errorMessage = response['message']; - showErrorSnackBar(model.errorMessage); + return ApiResponse.failure(response['message'] ?? "Resend failed"); } + return ApiResponse.success(Map.from(response)); } catch (e) { logger.e(e); - showErrorSnackBar( + model.isLoading = false; + notifyListeners(); + return ApiResponse.failure( "Problem during registration, please try again or contact support?", ); } - - model.isLoading = false; - notifyListeners(); } } \ No newline at end of file diff --git a/lib/screens/pay/pay_controller.dart b/lib/screens/pay/pay_controller.dart index 2930f00..e02f242 100644 --- a/lib/screens/pay/pay_controller.dart +++ b/lib/screens/pay/pay_controller.dart @@ -4,6 +4,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:qpay/http/http.dart'; +import 'package:qpay/models/api_response.dart'; import 'package:qpay/screens/home/home_controller.dart' as hc; import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/transaction_controller.dart' as tc; @@ -69,21 +70,9 @@ class PayController extends ChangeNotifier { model.selectedProduct = transactionController.model.selectedProduct; } - void _showErrorSnackBar(String? message) { - WidgetsBinding.instance.addPostFrameCallback((_) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message.toString()), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.deepOrange, - ), - ); - }); - } - // formData can come from a repeat transaction // otherwise build it from elements of the normal flow - Future doTransaction([tc.FormData? formData]) async { + Future>> doTransaction([tc.FormData? formData]) async { try { model.isLoading = true; notifyListeners(); @@ -120,52 +109,59 @@ class PayController extends ChangeNotifier { dynamic response = workflowResponse['body']; + model.isLoading = false; + notifyListeners(); + if (response['status'] == 'SUCCESS') { model.status = response['status']; - transactionController.updateConfirmationData(response); + return ApiResponse.success(Map.from(response)); } else { model.status = response['status']; model.errorMessage = response['errorMessage'] ?? "Problem with the transaction. Please try again or contact support"; - _showErrorSnackBar(model.errorMessage); + return ApiResponse.failure(model.errorMessage!); } } on DioException catch (e, s) { logger.e(s); - _showErrorSnackBar("Network error. Please try again or contact support"); + model.isLoading = false; + notifyListeners(); + return ApiResponse.failure("Network error. Please try again or contact support"); } catch (e, s) { logger.e(s); - _showErrorSnackBar( + model.isLoading = false; + notifyListeners(); + return ApiResponse.failure( "Problem processing that transaction. Please try again or contact support", ); } - - model.isLoading = false; - notifyListeners(); } - Future getProducts(String providerId) async { - // model.products = getFakeProducts(); + Future>> getProducts(String providerId) async { try { model.isLoading = true; + notifyListeners(); List response = await http.get( '/public/providers/$providerId/products', ); model.products = response.map((e) => BillProduct.fromJson(e)).toList(); + + model.isLoading = false; + notifyListeners(); + return ApiResponse.success(model.products); } catch (e) { logger.e(e); - _showErrorSnackBar( + model.isLoading = false; + notifyListeners(); + return ApiResponse.failure( "Problem fetching products, are you connected to the internet?", ); } - - model.isLoading = false; - notifyListeners(); } - Future getPaymentProcessors() async { + Future>> getPaymentProcessors() async { try { model.isLoading = true; model.paymentProcessors = getFakePaymentProcessors(); @@ -175,10 +171,14 @@ class PayController extends ChangeNotifier { model.paymentProcessors = response.map((e) => PaymentProcessor.fromJson(e)).toList(); + model.isLoading = false; notifyListeners(); + return ApiResponse.success(model.paymentProcessors); } catch (e) { logger.e(e); - _showErrorSnackBar( + model.isLoading = false; + notifyListeners(); + return ApiResponse.failure( "Problem fetching payment processors, are you connected to the internet?", ); } diff --git a/lib/screens/pay/pay_screen.dart b/lib/screens/pay/pay_screen.dart index d9ee141..f80863c 100644 --- a/lib/screens/pay/pay_screen.dart +++ b/lib/screens/pay/pay_screen.dart @@ -71,14 +71,7 @@ class _PayScreenState extends State with TickerProviderStateMixin { listen: false, ); - _nameController.text = - transactionController.model.formData.creditName ?? ''; - _emailController.text = - transactionController.model.formData.creditEmail ?? ''; - _amountController.text = transactionController.model.formData.amount; - - // Initialize phone number and country code - // updatePhoneNumber(transactionController.model.formData.creditPhone ?? ''); + _setupData(); _controller = AnimationController( vsync: this, @@ -113,7 +106,6 @@ class _PayScreenState extends State with TickerProviderStateMixin { } _controller.forward(); - _setupData(); } _setupData() async { @@ -121,27 +113,67 @@ class _PayScreenState extends State with TickerProviderStateMixin { if (prefs.containsKey('phone')) { updatePhoneNumber(prefs.getString('phone')!); } + + _nameController.text = + transactionController.model.formData.creditName ?? ''; + _emailController.text = + transactionController.model.formData.creditEmail ?? ''; + _amountController.text = transactionController.model.formData.amount; + _phoneController.text = + transactionController.model.formData.debitPhone ?? ''; } - Future updatePhoneNumber(String phoneNumber) async { - String existingPhone = phoneNumber; - if (existingPhone.isNotEmpty) { - // Try to extract country code from existing phone number - for (var country in countryCodes) { - if (existingPhone.startsWith(country['code']!)) { - setState(() { - selectedCountryCode = country['code']!; - }); - _phoneController.text = existingPhone.substring( - country['code']!.length, - ); - break; - } + Future updatePhoneNumber(String newNumber) async { + for (var country in countryCodes) { + if (newNumber.startsWith(country['code']!)) { + setState(() { + selectedCountryCode = country['code']!; + }); + _phoneController.text = newNumber.substring(country['code']!.length); + break; } - // If no country code found, use default and set the full number - if (_phoneController.text.isEmpty) { - _phoneController.text = existingPhone; + } + } + + Future _submitPayment(PaymentProcessor e, BuildContext context) async { + controller.updateAmount(_amountController.text); + if (!controller.model.selectedProvider!.requiresAmount) { + controller.updateAmount( + controller.model.selectedProduct!.defaultAmount.toString(), + ); + } + + String fullPhoneNumber = _phoneController.text; + controller.updatePhone(fullPhoneNumber); + + if (showAdditionalRecipientDetails) { + controller.updateAdditionalRecipientDetails( + _nameController.text, + _emailController.text, + ); + } + controller.updateSelectedPaymentProcessor(e); + transactionController.updateSelectedPaymentProcessor(e); + + final apiResponse = await controller.doTransaction(); + + if (!apiResponse.isSuccess) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + controller.model.errorMessage ?? + apiResponse.error ?? + "Transaction failed", + ), + behavior: SnackBarBehavior.floating, + width: 600, + backgroundColor: Colors.deepOrange, + ), + ); } + } else if (controller.model.status == "SUCCESS" && context.mounted) { + context.push('/confirm'); } } @@ -197,77 +229,32 @@ class _PayScreenState extends State with TickerProviderStateMixin { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - decoration: BoxDecoration( - border: Border.all( - color: Colors.black87.withAlpha(20), + _buildTransactionDetailsCard( + children: [ + _buildDetailRow( + icon: Icons.business_outlined, + label: "Provider", + value: + controller + .model + .selectedProvider + ?.name ?? + "", ), - borderRadius: BorderRadius.circular(12), - ), - padding: EdgeInsets.symmetric( - vertical: 10, - horizontal: 20, - ), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - "TRANSACTIONS DETAILS", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - "Provider", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - Text( - controller - .model - .selectedProvider - ?.name ?? - "", - style: TextStyle(fontSize: 16), - ), - ], - ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - controller - .model - .selectedProvider - ?.accountFieldName ?? - "", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - Text( - transactionController - .model - .formData - .creditAccount, - style: TextStyle(fontSize: 16), - ), - ], - ), - ], - ), + _buildDetailRow( + icon: Icons.account_balance_outlined, + label: + controller + .model + .selectedProvider + ?.accountFieldName ?? + "", + value: transactionController + .model + .formData + .creditAccount, + ), + ], ), SizedBox(height: 20), InkWell( @@ -313,7 +300,6 @@ class _PayScreenState extends State with TickerProviderStateMixin { const SizedBox(height: 7), _buildAmountField(), const SizedBox(height: 20), - // _buildPayButton(controller), ...controller.model.paymentProcessors.map( (e) => _buildPaymentProcessorItem(e, context), ), @@ -343,9 +329,18 @@ class _PayScreenState extends State with TickerProviderStateMixin { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text( - 'Debit Phone Number', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + Row( + children: [ + const Text( + 'Debit Phone Number', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + SizedBox(width: 5), + const Text( + '(to be charged or notified)', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ], ), if (!kIsWeb) TextButton( @@ -372,7 +367,9 @@ class _PayScreenState extends State with TickerProviderStateMixin { width: 100, decoration: BoxDecoration( border: Border.all( - color: Theme.of(context).colorScheme.primary.withOpacity(0.2), + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.2), ), borderRadius: BorderRadius.circular(12), ), @@ -427,7 +424,7 @@ class _PayScreenState extends State with TickerProviderStateMixin { borderSide: BorderSide( color: Theme.of( context, - ).colorScheme.primary.withOpacity(0.2), + ).colorScheme.primary.withValues(alpha: 0.2), ), ), ), @@ -435,7 +432,6 @@ class _PayScreenState extends State with TickerProviderStateMixin { if (value == null || value.isEmpty) { return 'Please enter a phone number'; } - // Remove any non-digit characters for validation String digitsOnly = value.replaceAll(RegExp(r'[^\d]'), ''); if (digitsOnly.length < 7) { return 'Phone number must be at least 7 digits'; @@ -479,7 +475,9 @@ class _PayScreenState extends State with TickerProviderStateMixin { enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary.withOpacity(0.2), + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.2), ), ), ), @@ -526,7 +524,7 @@ class _PayScreenState extends State with TickerProviderStateMixin { borderSide: BorderSide( color: Theme.of( context, - ).colorScheme.primary.withOpacity(0.2), + ).colorScheme.primary.withValues(alpha: 0.2), ), ), ), @@ -558,7 +556,7 @@ class _PayScreenState extends State with TickerProviderStateMixin { borderSide: BorderSide( color: Theme.of( context, - ).colorScheme.primary.withOpacity(0.2), + ).colorScheme.primary.withValues(alpha: 0.2), ), ), ), @@ -586,7 +584,7 @@ class _PayScreenState extends State with TickerProviderStateMixin { enabled: controller.model.isLoading, child: DropdownButtonHideUnderline( child: DropdownButtonFormField( - value: controller.model.selectedProduct?.uid, + initialValue: controller.model.selectedProduct?.uid, isExpanded: true, icon: const Icon(Icons.arrow_drop_down), decoration: InputDecoration( @@ -633,6 +631,84 @@ class _PayScreenState extends State with TickerProviderStateMixin { ); } + Widget _buildTransactionDetailsCard({required List children}) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200, width: 1), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.receipt_long_outlined, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + "TRANSACTION DETAILS", + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.primary, + letterSpacing: 1.0, + ), + ), + ], + ), + const SizedBox(height: 16), + Divider(height: 1, color: Colors.grey.shade200), + const SizedBox(height: 16), + ...children, + ], + ), + ), + ); + } + + Widget _buildDetailRow({ + required IconData icon, + required String label, + required String value, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Icon(icon, size: 16, color: Colors.grey.shade500), + const SizedBox(width: 10), + Text( + label, + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), + const Spacer(), + Text( + value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ], + ), + ); + } + Widget _buildPaymentProcessorItem(PaymentProcessor e, BuildContext context) { return Column( children: [ @@ -667,34 +743,7 @@ class _PayScreenState extends State with TickerProviderStateMixin { if (_formKey.currentState!.validate()) { if (controller.model.isLoading) return; - controller.updateAmount(_amountController.text); - if (!controller.model.selectedProvider!.requiresAmount) { - controller.updateAmount( - controller.model.selectedProduct!.defaultAmount - .toString(), - ); - } - - // Update phone number with country code - String fullPhoneNumber = - selectedCountryCode + _phoneController.text; - controller.updatePhone(fullPhoneNumber); - - if (showAdditionalRecipientDetails) { - controller.updateAdditionalRecipientDetails( - _nameController.text, - _emailController.text, - ); - } - controller.updateSelectedPaymentProcessor(e); - transactionController.updateSelectedPaymentProcessor(e); - - await controller.doTransaction(); - - if (controller.model.status == "SUCCESS" && - context.mounted) { - context.push('/confirm'); - } + await _submitPayment(e, context); } }, child: ListTile( diff --git a/lib/screens/poll/poll_screen.dart b/lib/screens/poll/poll_screen.dart index 8f22f89..982ab61 100644 --- a/lib/screens/poll/poll_screen.dart +++ b/lib/screens/poll/poll_screen.dart @@ -1,13 +1,16 @@ +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/gateway/gateway_controller.dart'; import 'package:qpay/screens/transaction_controller.dart'; -import 'package:skeletonizer/skeletonizer.dart'; +import 'package:qpay/widgets/step_loading_widget.dart'; +import 'package:web/web.dart' as web; class PollScreen extends StatefulWidget { - const PollScreen({super.key}); + final String? transactionId; + + const PollScreen({super.key, this.transactionId}); @override State createState() => _PollScreenState(); @@ -17,8 +20,6 @@ class _PollScreenState extends State { late GatewayController controller; late TransactionController transactionController; - bool integrating = false; - @override void initState() { super.initState(); @@ -28,21 +29,35 @@ class _PollScreenState extends State { listen: false, ); - controller.pollTransaction(transactionController.model.confirmationData['id']); + _startPolling(); } - void _showErrorSnackBar(String? message) { - WidgetsBinding.instance.addPostFrameCallback((_) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message.toString()), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.deepOrange, - ), - ); - }); - } + void _startPolling() { + // Priority 1: transaction ID passed via route param (/poll/:id) + String? transactionId = widget.transactionId; + // Priority 2: localStorage (for when gateway redirect causes page reload + // and the route param path is not available due to hub redirect override) + if (transactionId == null && kIsWeb) { + transactionId = web.window.localStorage.getItem('pendingTransactionId'); + web.window.localStorage.removeItem('pendingTransactionId'); + } + + // Priority 3: in-memory confirmation data (normal SPA navigation) + if (transactionId == null) { + try { + transactionId = + transactionController.model.confirmationData['id'] as String?; + } catch (_) { + // confirmationData is late and may not be initialized + // after a page reload -- ignore + } + } + + if (transactionId != null && transactionId.isNotEmpty) { + controller.pollTransaction(transactionId); + } + } @override void dispose() { @@ -55,6 +70,10 @@ class _PollScreenState extends State { return Scaffold( appBar: AppBar( title: const Text('Completing your transaction'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/'), + ), ), body: ListenableBuilder( listenable: controller, @@ -65,60 +84,19 @@ class _PollScreenState extends State { }); } - return Container( - padding: EdgeInsets.all(50), - child: Center( - child: LayoutBuilder( - builder: (context, constraints) { - return SizedBox( - width: double.parse(constraints.maxWidth > ResponsivePolicy.md ? - ResponsivePolicy.md.toString() : - constraints.maxWidth.toString()), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox(height: 75), - - Column( - children: [ - Text( - 'We\'re checking with our gateway if your transaction was successful. This won\'t take long', - style: TextStyle(fontSize: 20), - textAlign: TextAlign.center, - ), - SizedBox(height: 50,), - Image.asset('assets/velocity-animation.gif', width: 150), - SizedBox(height: 50), - if(controller.model.status == 'FAILED') - Column( - children: [ - Text( - 'We failed to check your transaction status', - style: TextStyle(fontSize: 20), - textAlign: TextAlign.center, - ), - SizedBox(height: 20,), - Skeletonizer( - enabled: controller.model.isLoading, - child: OutlinedButton( - child: Text('Retry'), - onPressed: () { - controller.model.isCancelled = false; - controller.pollTransaction(transactionController.model.confirmationData['id']); - }, - ), - ), - ], - ), - ], - ), - ], - ), - ); - } - ), - ), + return StepLoadingWidget( + currentStep: 1, + title: 'Verifying Payment', + subtitle: + 'We\'re checking with our gateway if your transaction was ' + 'successful. This won\'t take long.', + status: controller.model.status, + isLoading: controller.model.isLoading, + errorMessage: 'We failed to check your transaction status', + onRetry: () { + controller.model.isCancelled = false; + _startPolling(); + }, ); }, ), diff --git a/lib/screens/receipt/receipt_controller.dart b/lib/screens/receipt/receipt_controller.dart index 8d7b8c0..5e945c5 100644 --- a/lib/screens/receipt/receipt_controller.dart +++ b/lib/screens/receipt/receipt_controller.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:provider/provider.dart'; import 'package:qpay/http/http.dart'; +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; @@ -29,50 +30,43 @@ class ReceiptController extends ChangeNotifier { ); } - void _showErrorSnackBar(String? message) { - WidgetsBinding.instance.addPostFrameCallback((_) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message.toString()), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.deepOrange, - ), - ); - }); - } - void setLoading(bool value) { model.isLoading = value; notifyListeners(); } - Future doIntegration(String id) async { + Future>> doIntegration(String id) async { try { model.isLoading = true; model.status = ''; notifyListeners(); dynamic workflowResponse = await http.get( - '/public/transaction/integration/$id' + '/public/transaction/integration/$id', ); logger.i(workflowResponse.toString()); dynamic response = workflowResponse['body']; + model.isLoading = false; if (response['status'] == 'SUCCESS') { model.status = response['status']; transactionController.updateReceiptData(response); + notifyListeners(); + return ApiResponse.success(Map.from(response)); } else { model.status = response['status']; model.errorMessage = response['errorMessage']; - _showErrorSnackBar(response['errorMessage']); + notifyListeners(); + return ApiResponse.failure( + response['errorMessage'] ?? "Integration failed", + ); } - notifyListeners(); - } catch (e) { model.isLoading = false; model.errorMessage = e.toString(); notifyListeners(); + return ApiResponse.failure(e.toString()); } } @@ -115,27 +109,28 @@ class ReceiptController extends ChangeNotifier { .model .formData = transactionController.model.formData.copyWith( amount: model.receiptData?['amount'].toStringAsFixed(2) ?? '', - creditPhone: model.receiptData?['creditPhone'] ?? '', + debitPhone: model.receiptData?['debitPhone'] ?? '', paymentProcessorLabel: model.receiptData?['paymentProcessorLabel'] ?? '', paymentProcessorName: model.receiptData?['paymentProcessorName'] ?? '', - id: null + 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; + setLoading(false); + return ApiResponse.success(response); } catch (e) { - _showErrorSnackBar( + setLoading(false); + return ApiResponse.failure( "Problem fetching transaction data, are you connected to the internet?", ); - } finally { - setLoading(false); } } diff --git a/lib/screens/receipt/receipt_screen.dart b/lib/screens/receipt/receipt_screen.dart index 7468c89..40f93fa 100644 --- a/lib/screens/receipt/receipt_screen.dart +++ b/lib/screens/receipt/receipt_screen.dart @@ -8,7 +8,6 @@ import 'package:go_router/go_router.dart'; import 'package:qpay/screens/receipt/receipt_controller.dart'; import 'package:share_plus/share_plus.dart'; import 'package:skeletonizer/skeletonizer.dart'; -import 'package:timeago/timeago.dart' as timeago; import 'package:widgets_to_image/widgets_to_image.dart'; class ReceiptScreen extends StatefulWidget { @@ -22,10 +21,21 @@ class _ReceiptScreenState extends State with TickerProviderStateMixin { late TransactionController transactionController; late GatewayController gatewayController; - late Animation _fadeAnimation; - late Animation _slideAnimation; - late AnimationController _controller; - late final TabController _tabController; + + // Enhanced animation controllers + late AnimationController _headerAnimController; + late Animation _headerSlideAnimation; + late Animation _headerFadeAnimation; + + late AnimationController _cardAnimController; + late Animation _cardSlideAnimation; + late Animation _cardFadeAnimation; + + late AnimationController _contentAnimController; + late Animation _contentSlideAnimation; + late Animation _contentFadeAnimation; + + int _selectedTabIndex = 0; late ReceiptController receiptController; final _formKey = GlobalKey(); @@ -46,29 +56,62 @@ class _ReceiptScreenState extends State transactionController.model.receiptData?["id"], ); - _tabController = TabController(length: 2, vsync: this); + // Header animation + _headerAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _headerSlideAnimation = + Tween(begin: const Offset(0, -0.08), end: Offset.zero).animate( + CurvedAnimation( + parent: _headerAnimController, + curve: Curves.easeOutCubic, + ), + ); + _headerFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _headerAnimController, curve: Curves.easeOut), + ); - _controller = AnimationController( + // Card animation + _cardAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + ); + _cardSlideAnimation = + Tween(begin: const Offset(0, 0.06), end: Offset.zero).animate( + CurvedAnimation( + parent: _cardAnimController, + curve: Curves.easeOutCubic, + ), + ); + _cardFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _cardAnimController, curve: Curves.easeOut), + ); + + // Content animation + _contentAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); - - _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation( - parent: _controller, - curve: const Interval(0.0, 0.5, curve: Curves.easeOut), - ), - ); - - _slideAnimation = - Tween(begin: const Offset(0, 0.1), end: Offset.zero).animate( + _contentSlideAnimation = + Tween(begin: const Offset(0, 0.08), end: Offset.zero).animate( CurvedAnimation( - parent: _controller, - curve: const Interval(0.0, 0.5, curve: Curves.easeOut), + parent: _contentAnimController, + curve: Curves.easeOutCubic, ), ); + _contentFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _contentAnimController, curve: Curves.easeOut), + ); - _controller.forward(); + // Start staggered animations + _headerAnimController.forward(); + Future.delayed(const Duration(milliseconds: 150), () { + if (mounted) _cardAnimController.forward(); + }); + Future.delayed(const Duration(milliseconds: 350), () { + if (mounted) _contentAnimController.forward(); + }); } Future _captureImage() async { @@ -106,7 +149,7 @@ class _ReceiptScreenState extends State ); if (transactionController.model.receiptData?["pollingStatus"]) { receiptController.setLoading(false); - return; // no need to proceed if we've already polled successfully + return; } await _retry(); } @@ -115,1122 +158,134 @@ class _ReceiptScreenState extends State @override void dispose() { - // TODO: implement dispose - super.dispose(); - _controller.dispose(); - _tabController.dispose(); + _headerAnimController.dispose(); + _cardAnimController.dispose(); + _contentAnimController.dispose(); receiptController.dispose(); + super.dispose(); } @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), body: ListenableBuilder( listenable: receiptController, builder: (context, child) { return CustomScrollView( + physics: const BouncingScrollPhysics(), slivers: [ + // Modern app bar SliverAppBar( - stretch: true, - stretchTriggerOffset: 100.0, - expandedHeight: 50.0, - surfaceTintColor: Colors.transparent, + pinned: false, floating: true, + stretch: true, + stretchTriggerOffset: 80.0, + expandedHeight: 60.0, + collapsedHeight: 60.0, + surfaceTintColor: Colors.transparent, + backgroundColor: isDark + ? const Color(0xFF0D0D0D) + : const Color(0xFFF8F9FA), leading: context.canPop() - ? IconButton( - onPressed: () { - context.pop(); - }, - icon: const Icon(Icons.arrow_back), + ? 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, + ), + ), ) - : SizedBox.shrink(), + : const SizedBox.shrink(), title: Text( - "Payment Result", - style: TextStyle(color: Colors.black87), + "Receipt", + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 20, + color: isDark ? Colors.white : Colors.black87, + ), ), + centerTitle: true, ), + + // Main content SliverToBoxAdapter( child: WidgetsToImage( controller: controller, child: Container( - color: Colors.white, + color: isDark + ? const Color(0xFF0D0D0D) + : const Color(0xFFF8F9FA), child: Padding( - padding: const EdgeInsets.all(20.0), - child: FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition( - position: _slideAnimation, - child: Form( - key: _formKey, - child: LayoutBuilder( - builder: (context, constraints) { - return Center( - child: SizedBox( - width: - constraints.maxWidth > - ResponsivePolicy.md - ? ResponsivePolicy.md.toDouble() - : constraints.maxWidth, - child: Column( - children: [ - Skeletonizer( - enabled: - receiptController.model.isLoading, - child: Container( - padding: EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Theme.of(context) - .colorScheme - .primary - .withOpacity(0.1), - Theme.of(context) - .colorScheme - .tertiary - .withOpacity(0.05), - Theme.of(context) - .colorScheme - .tertiary - .withOpacity(0.15), - ], - stops: [0.0, 0.5, 1.0], - ), - border: Border.all( - color: Theme.of(context) - .colorScheme - .primary - .withOpacity(0.3), - ), - borderRadius: - BorderRadius.circular(10), - boxShadow: [ - BoxShadow( - color: Theme.of(context) - .colorScheme - .primary - .withOpacity(0.1), - blurRadius: 10, - spreadRadius: 1, - offset: Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.center, - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.center, - crossAxisAlignment: - CrossAxisAlignment.center, - children: [ - Icon( - transactionController - .model - .receiptData?["integrationStatus"] == - 'SUCCESS' - ? Icons.check_circle - : transactionController - .model - .receiptData?["integrationStatus"] == - 'PENDING' - ? Icons.pending - : Icons.cancel, - size: 18, - color: - transactionController - .model - .receiptData?["integrationStatus"] == - 'SUCCESS' - ? Colors.green - : transactionController - .model - .receiptData?["integrationStatus"] == - 'PENDING' - ? Colors.orange - : Colors.red, - ), - SizedBox(width: 5), - Text( - receiptController - .model - .receiptData?["debitCurrency"] ?? - "", - style: TextStyle( - fontSize: 26, - fontWeight: - FontWeight.bold, - ), - ), - SizedBox(width: 5), - Text( - receiptController - .model - .receiptData?["amount"] - .toStringAsFixed( - 2, - ) ?? - "", - style: TextStyle( - fontSize: 26, - ), - ), - ], - ), - Column( - crossAxisAlignment: - CrossAxisAlignment.center, - children: [ - SizedBox(height: 5), - Text( - receiptController - .model - .receiptData?["billName"] ?? - "", - style: TextStyle( - fontSize: 16, - ), - ), - Text( - receiptController - .model - .receiptData?["createdAt"] != - null - ? DateFormat.yMMMd() - .add_jm() - .format( - DateTime.parse( - receiptController - .model - .receiptData?["createdAt"], - ), - ) - : "", - style: TextStyle( - fontSize: 15, - ), - ), - ], - ), - SizedBox(height: 10), - Row( - mainAxisAlignment: - MainAxisAlignment - .spaceEvenly, - children: [ - Column( - children: [ - Container( - width: 50, - height: 50, - decoration: BoxDecoration( - shape: - BoxShape.circle, - color: Colors.white, - border: Border.all( - color: - Theme.of( - context, - ) - .colorScheme - .primary - .withOpacity( - 0.3, - ), - width: 1, - ), - ), - child: - receiptController - .model - .isLoading - ? Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: - 2.5, - valueColor: - AlwaysStoppedAnimation< - Color - >( - Theme.of( - context, - ).colorScheme.primary, - ), - ), - ), - ) - : IconButton( - onPressed: () { - receiptController - .repeatTransaction( - context, - ); - }, - icon: Icon( - Icons - .repeat, - color: Theme.of( - context, - ).colorScheme.primary, - size: 24, - ), - ), - ), - SizedBox(height: 8), - Text( - "Repeat", - style: TextStyle( - fontSize: 14, - fontWeight: - FontWeight.w500, - ), - ), - ], - ), - Column( - children: [ - Container( - width: 50, - height: 50, - decoration: BoxDecoration( - shape: - BoxShape.circle, - color: Colors.white, - border: Border.all( - color: - Theme.of( - context, - ) - .colorScheme - .primary - .withOpacity( - 0.3, - ), - width: 1, - ), - ), - child: IconButton( - onPressed: () { - _captureImage(); - }, - icon: Icon( - Icons.share, - color: - Theme.of( - context, - ) - .colorScheme - .primary, - size: 24, - ), - ), - ), - SizedBox(height: 8), - Text( - "Share", - style: TextStyle( - fontSize: 14, - fontWeight: - FontWeight.w500, - ), - ), - ], - ), - if (receiptController - .model - .receiptData?["integrationStatus"] != - "SUCCESS" && - receiptController - .model - .receiptData?["paymentStatus"] == - "SUCCESS") - Column( - children: [ - Container( - width: 50, - height: 50, - decoration: BoxDecoration( - shape: BoxShape - .circle, - color: - Colors.white, - border: Border.all( - color: - Theme.of( - context, - ) - .colorScheme - .primary - .withOpacity( - 0.3, - ), - width: 1, - ), - ), - child: IconButton( - onPressed: () { - _retry(); - }, - icon: Icon( - Icons.redo, - color: Theme.of( - context, - ).colorScheme.primary, - size: 24, - ), - ), - ), - SizedBox(height: 8), - Text( - "Retry", - style: TextStyle( - fontSize: 14, - fontWeight: - FontWeight - .w500, - ), - ), - ], - ), - if (receiptController - .model - .receiptData?["pollingStatus"] != - "SUCCESS") - Column( - children: [ - Container( - width: 50, - height: 50, - decoration: BoxDecoration( - shape: BoxShape - .circle, - color: - Colors.white, - border: Border.all( - color: - Theme.of( - context, - ) - .colorScheme - .primary - .withOpacity( - 0.3, - ), - width: 1, - ), - ), - child: IconButton( - onPressed: () { - _check(); - }, - icon: Icon( - Icons - .price_check, - color: Theme.of( - context, - ).colorScheme.primary, - size: 24, - ), - ), - ), - SizedBox(height: 8), - Text( - "Check status", - style: TextStyle( - fontSize: 14, - fontWeight: - FontWeight - .w500, - ), - ), - ], - ), - ], - ), - ], - ), - ), + padding: const EdgeInsets.fromLTRB(16, 4, 16, 32), + child: Form( + key: _formKey, + child: LayoutBuilder( + builder: (context, constraints) { + return Center( + child: SizedBox( + width: + constraints.maxWidth > ResponsivePolicy.md + ? ResponsivePolicy.md.toDouble() + : constraints.maxWidth, + child: Column( + children: [ + // Animated header section + FadeTransition( + opacity: _headerFadeAnimation, + child: SlideTransition( + position: _headerSlideAnimation, + child: _buildReceiptHeader( + theme, + isDark, ), - SizedBox(height: 20), - Column( - children: [ - Container( - decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: - BorderRadius.circular(12), - ), - child: TabBar( - controller: _tabController, - indicator: BoxDecoration( - borderRadius: - BorderRadius.circular(8), - color: Theme.of( - context, - ).colorScheme.primary, - ), - indicatorPadding: - EdgeInsets.all(5), - indicatorSize: - TabBarIndicatorSize.tab, - labelColor: Colors.white, - unselectedLabelColor: - Colors.grey[600], - dividerColor: - Colors.transparent, - tabs: [ - Tab( - child: Text( - "Provider Details", - style: TextStyle( - fontWeight: - FontWeight.bold, - ), - ), - ), - Tab( - child: Text( - "Transaction Details", - style: TextStyle( - fontWeight: - FontWeight.bold, - ), - ), - ), - ], - ), - ), - SizedBox( - height: - MediaQuery.of( - context, - ).size.height * - 0.5, - child: TabBarView( - controller: _tabController, - children: [ - Padding( - padding: - const EdgeInsets.all( - 10.0, - ), - child: Column( - children: [ - Skeletonizer( - enabled: - receiptController - .model - .isLoading, - child: Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - Text( - "Status", - style: TextStyle( - fontSize: 16, - fontWeight: - FontWeight - .bold, - ), - ), - Text( - transactionController - .model - .receiptData?["integrationStatus"] ?? - "", - style: - TextStyle( - fontSize: - 16, - ), - ), - ], - ), - ), - if (transactionController - .model - .receiptData?["errorMessage"] != - null && - transactionController - .model - .receiptData?["errorMessage"] != - "") - Column( - children: [ - const SizedBox( - height: 10, - ), - Skeletonizer( - enabled: - receiptController - .model - .isLoading, - child: Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - Text( - "Message", - style: TextStyle( - fontSize: - 16, - fontWeight: - FontWeight.bold, - ), - ), - Expanded( - child: Text( - transactionController.model.receiptData?["errorMessage"] ?? - "", - style: TextStyle( - fontSize: - 16, - ), - textAlign: - TextAlign.end, - softWrap: - true, - ), - ), - ], - ), - ), - ], - ), - if (receiptController - .model - .receiptData?["integrationStatus"] != - "SUCCESS" && - receiptController - .model - .receiptData?["paymentStatus"] == - "SUCCESS") - Column( - children: [ - const SizedBox( - height: 10, - ), - Skeletonizer( - enabled: - receiptController - .model - .isLoading, - child: Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - Text( - "Additional action", - style: TextStyle( - fontSize: - 16, - fontWeight: - FontWeight.bold, - ), - ), - Expanded( - child: Text( - "Billing account update failed, please tap on Retry to try again", - style: TextStyle( - fontSize: - 16, - color: Theme.of( - context, - ).colorScheme.primary, - ), - textAlign: - TextAlign.end, - softWrap: - true, - ), - ), - ], - ), - ), - ], - ), - SizedBox(height: 10), - - Skeletonizer( - enabled: - receiptController - .model - .isLoading, - child: Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - Text( - "Debit Reference", - style: TextStyle( - fontSize: 16, - fontWeight: - FontWeight - .bold, - ), - ), - Text( - transactionController - .model - .receiptData?["debitRef"] ?? - "", - style: - TextStyle( - fontSize: - 16, - ), - ), - ], - ), - ), - const SizedBox( - height: 10, - ), - Skeletonizer( - enabled: - receiptController - .model - .isLoading, - child: Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - Text( - "From", - style: TextStyle( - fontSize: 16, - fontWeight: - FontWeight - .bold, - ), - ), - Text( - receiptController - .model - .receiptData?["debitPhone"] ?? - "", - style: - TextStyle( - fontSize: - 16, - ), - ), - ], - ), - ), - const SizedBox( - height: 10, - ), - Skeletonizer( - enabled: - receiptController - .model - .isLoading, - child: Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - Text( - "To", - style: TextStyle( - fontSize: 16, - fontWeight: - FontWeight - .bold, - ), - ), - Text( - receiptController - .model - .receiptData?["creditAccount"] ?? - "", - style: - TextStyle( - fontSize: - 16, - ), - ), - ], - ), - ), - const SizedBox( - height: 10, - ), - Skeletonizer( - enabled: - receiptController - .model - .isLoading, - child: Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - Text( - "Processor", - style: TextStyle( - fontSize: 16, - fontWeight: - FontWeight - .bold, - ), - ), - Text( - receiptController - .model - .receiptData?["paymentProcessorName"] ?? - "", - style: - TextStyle( - fontSize: - 16, - ), - ), - ], - ), - ), - const SizedBox( - height: 10, - ), - if(receiptController - .model - .receiptData?["billProductName"] != null) - Skeletonizer( - enabled: - receiptController - .model - .isLoading, - child: Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - Text( - "Bill Product", - style: TextStyle( - fontSize: 16, - fontWeight: - FontWeight - .bold, - ), - ), - Text( - receiptController - .model - .receiptData?["billProductName"] ?? - "", - style: - TextStyle( - fontSize: - 16, - ), - ), - ], - ), - ), - const SizedBox( - height: 10, - ), - Divider( - color: - Colors.grey[300], - thickness: 1, - ), - const SizedBox( - height: 10, - ), - if (receiptController - .model - .receiptData?["additionalData"] == - null) - Skeletonizer( - enabled: - receiptController - .model - .isLoading, - child: Column( - children: [ - SizedBox( - height: 20, - ), - Center( - child: Text( - "No details found", - ), - ), - ], - ), - ), - if (receiptController - .model - .receiptData?["additionalData"] != - null) - ...receiptController.model.receiptData?["additionalData"].map< - Widget - >((data) { - return Skeletonizer( - enabled: - receiptController - .model - .isLoading, - child: Column( - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - Text( - data["name"] ?? - "", - style: TextStyle( - fontSize: - 16, - fontWeight: - FontWeight.bold, - ), - ), - Text( - data["value"] ?? - "", - style: TextStyle( - fontSize: - 16, - ), - ), - ], - ), - const SizedBox( - height: 10, - ), - ], - ), - ); - }).toList(), - ], - ), - ), - Padding( - padding: - const EdgeInsets.all( - 10.0, - ), - child: Column( - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - Text( - "Amount", - style: TextStyle( - fontSize: 16, - fontWeight: - FontWeight - .bold, - ), - ), - Text( - transactionController - .model - .receiptData?["amount"] - .toStringAsFixed( - 2, - ) ?? - "0.00", - style: TextStyle( - fontSize: 16, - ), - ), - ], - ), - const SizedBox( - height: 10, - ), - if (transactionController - .model - .receiptData?["charge"] != - 0) - Column( - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - Text( - "Our Charge", - style: TextStyle( - fontSize: - 16, - fontWeight: - FontWeight - .bold, - ), - ), - Text( - transactionController - .model - .receiptData?["charge"] - .toStringAsFixed( - 2, - ) ?? - "0.00", - style: TextStyle( - fontSize: - 16, - ), - ), - ], - ), - const SizedBox( - height: 10, - ), - ], - ), - if (transactionController - .model - .receiptData?["gatewayCharge"] != - 0) - Column( - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - Text( - "Gateway Charge", - style: TextStyle( - fontSize: - 16, - fontWeight: - FontWeight - .bold, - ), - ), - Text( - transactionController - .model - .receiptData?["gatewayCharge"] - .toStringAsFixed( - 2, - ) ?? - "0.00", - style: TextStyle( - fontSize: - 16, - ), - ), - ], - ), - const SizedBox( - height: 10, - ), - ], - ), - if (transactionController - .model - .receiptData?["tax"] != - 0) - Column( - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - Text( - "Tax", - style: TextStyle( - fontSize: - 16, - fontWeight: - FontWeight - .bold, - ), - ), - Text( - transactionController - .model - .receiptData?["tax"] - .toStringAsFixed( - 2, - ) ?? - "0.00", - style: TextStyle( - fontSize: - 16, - ), - ), - ], - ), - const SizedBox( - height: 10, - ), - ], - ), - Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - Text( - "Total Amount", - style: TextStyle( - fontSize: 16, - fontWeight: - FontWeight - .bold, - ), - ), - Text( - transactionController - .model - .receiptData?["totalAmount"] - .toStringAsFixed( - 2, - ) ?? - "0.00", - style: TextStyle( - fontSize: 16, - ), - ), - ], - ), - const SizedBox( - height: 20, - ), - ], - ), - ), - ], - ), - ), - ], - ), - ], + ), ), - ), - ); - }, - ), - ), + const SizedBox(height: 24), + + // Animated info card section + FadeTransition( + opacity: _cardFadeAnimation, + child: SlideTransition( + position: _cardSlideAnimation, + child: _buildSegmentedTabBar( + theme, + isDark, + ), + ), + ), + const SizedBox(height: 20), + + // Animated content section + FadeTransition( + opacity: _contentFadeAnimation, + child: SlideTransition( + position: _contentSlideAnimation, + child: _buildSelectedTabContent( + theme, + isDark, + ), + ), + ), + ], + ), + ), + ); + }, ), ), ), @@ -1243,4 +298,811 @@ class _ReceiptScreenState extends State ), ); } + + // ────────────────────────────────────────────────────────────── + // Status Badge Helper + // ────────────────────────────────────────────────────────────── + Widget _buildStatusBadge(String status, ThemeData theme) { + final bool isSuccess = status == 'SUCCESS'; + final bool isPending = status == 'PENDING'; + + final Color bgColor = isSuccess + ? const Color(0xFFD1FAE5) + : isPending + ? const Color(0xFFFEF3C7) + : const Color(0xFFFEE2E2); + final Color textColor = isSuccess + ? const Color(0xFF065F46) + : isPending + ? const Color(0xFF92400E) + : const Color(0xFF991B1B); + final Color dotColor = isSuccess + ? const Color(0xFF10B981) + : isPending + ? const Color(0xFFF59E0B) + : const Color(0xFFEF4444); + final String label = isSuccess + ? 'Success' + : isPending + ? 'Pending' + : 'Failed'; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + ], + ), + ); + } + + // ────────────────────────────────────────────────────────────── + // 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 primaryColor = theme.colorScheme.primary; + + return Skeletonizer( + enabled: receiptController.model.isLoading, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + primaryColor.withValues(alpha: 0.10), + primaryColor.withValues(alpha: 0.04), + primaryColor.withValues(alpha: 0.08), + ], + stops: const [0.0, 0.5, 1.0], + ), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: primaryColor.withValues(alpha: 0.12), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: primaryColor.withValues(alpha: 0.08), + blurRadius: 20, + offset: const Offset(0, 6), + ), + ], + ), + padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 24), + child: Column( + children: [ + // Status badge + if (status != null) ...[ + _buildStatusBadge(status, theme), + const SizedBox(height: 16), + ], + + // Amount row + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + currency, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white70 : Colors.grey.shade600, + ), + ), + const SizedBox(width: 4), + Text( + amount?.toStringAsFixed(2) ?? "", + style: TextStyle( + fontSize: 42, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white : Colors.black87, + height: 1.1, + ), + ), + ], + ), + const SizedBox(height: 12), + + // Bill name (if available) + if (billName.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 6, + ), + decoration: BoxDecoration( + color: primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + billName, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: primaryColor, + ), + ), + ), + const SizedBox(height: 6), + + // Date + if (createdAt != null) + Text( + DateFormat.yMMMd().add_jm().format(DateTime.parse(createdAt)), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w400, + color: isDark ? Colors.white54 : Colors.grey.shade500, + ), + ), + const SizedBox(height: 24), + + // Divider with dots pattern + _buildDottedDivider(isDark), + const SizedBox(height: 20), + + // Action buttons row + _buildActionButtons(theme), + ], + ), + ), + ); + } + + // ────────────────────────────────────────────────────────────── + // Dotted Divider + // ────────────────────────────────────────────────────────────── + Widget _buildDottedDivider(bool isDark) { + return Row( + children: List.generate( + 60, + (index) => Expanded( + child: Container( + height: 1.5, + color: index.isEven + ? (isDark ? Colors.white24 : Colors.grey.shade300) + : Colors.transparent, + ), + ), + ), + ); + } + + // ────────────────────────────────────────────────────────────── + // Action Buttons Row + // ────────────────────────────────────────────────────────────── + Widget _buildActionButtons(ThemeData theme) { + final integrationStatus = + receiptController.model.receiptData?["integrationStatus"]; + final paymentStatus = receiptController.model.receiptData?["paymentStatus"]; + + return Wrap( + spacing: 12, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + _modernActionButton( + icon: Icons.repeat_rounded, + label: "Repeat", + color: theme.colorScheme.primary, + isLoading: receiptController.model.isLoading, + onPressed: () => receiptController.repeatTransaction(context), + ), + _modernActionButton( + icon: Icons.ios_share_rounded, + label: "Share", + color: const Color(0xFF6366F1), + isLoading: false, + onPressed: _captureImage, + ), + if (integrationStatus != "SUCCESS" && paymentStatus == "SUCCESS") ...[ + _modernActionButton( + icon: Icons.refresh_rounded, + label: "Retry", + color: const Color(0xFFF59E0B), + isLoading: false, + onPressed: _retry, + ), + ], + if (receiptController.model.receiptData?["pollingStatus"] != + "SUCCESS") ...[ + _modernActionButton( + icon: Icons.search_rounded, + label: "Check", + color: const Color(0xFF10B981), + isLoading: false, + onPressed: _check, + ), + ], + ], + ); + } + + Widget _modernActionButton({ + required IconData icon, + required String label, + required Color color, + required bool isLoading, + required VoidCallback onPressed, + }) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + color.withValues(alpha: 0.15), + color.withValues(alpha: 0.05), + ], + ), + border: Border.all(color: color.withValues(alpha: 0.2), width: 1.5), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: isLoading + ? Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ) + : Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(26), + onTap: onPressed, + child: Center(child: Icon(icon, color: color, size: 22)), + ), + ), + ), + const SizedBox(height: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.grey.shade600, + ), + ), + ], + ); + } + + // ────────────────────────────────────────────────────────────── + // Segmented Tab Bar (Modern UI) + // ────────────────────────────────────────────────────────────── + Widget _buildSegmentedTabBar(ThemeData theme, bool isDark) { + final segments = ["Provider Details", "Transaction Details"]; + + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.grey.shade100, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: List.generate(segments.length, (index) { + final isSelected = _selectedTabIndex == index; + return Expanded( + child: GestureDetector( + onTap: () => setState(() => _selectedTabIndex = index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isSelected + ? theme.colorScheme.primary + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + boxShadow: isSelected + ? [ + BoxShadow( + color: theme.colorScheme.primary.withValues( + alpha: 0.3, + ), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Center( + child: Text( + segments[index], + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isSelected ? Colors.white : Colors.grey.shade600, + ), + ), + ), + ), + ), + ); + }), + ), + ); + } + + // ────────────────────────────────────────────────────────────── + // Selected Tab Content with AnimatedSwitcher + // ────────────────────────────────────────────────────────────── + Widget _buildSelectedTabContent(ThemeData theme, bool isDark) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.04), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, + child: KeyedSubtree( + key: ValueKey(_selectedTabIndex), + child: _selectedTabIndex == 0 + ? _buildProviderDetailsTab(theme, isDark) + : _buildTransactionDetailsTab(theme, isDark), + ), + ); + } + + // ────────────────────────────────────────────────────────────── + // Provider Details Tab + // ────────────────────────────────────────────────────────────── + Widget _buildProviderDetailsTab(ThemeData theme, bool isDark) { + final receiptData = receiptController.model.receiptData; + final additionalData = receiptData?["additionalData"]; + + final items = <_DetailItem>[]; + + // Status + items.add( + _DetailItem( + icon: Icons.circle_outlined, + label: "Status", + value: + transactionController.model.receiptData?["integrationStatus"] ?? "", + ), + ); + + // Error message + final errorMsg = transactionController.model.receiptData?["errorMessage"]; + if (errorMsg != null && errorMsg != "") { + items.add( + _DetailItem( + icon: Icons.warning_amber_rounded, + label: "Message", + value: errorMsg, + isWarning: true, + ), + ); + } + + // Additional data from provider + if (additionalData != null) { + for (final data in additionalData) { + items.add( + _DetailItem( + icon: Icons.info_outline_rounded, + label: data["name"] ?? "", + value: data["value"] ?? "", + ), + ); + } + } + + // Standard fields + items.add( + _DetailItem( + icon: Icons.tag_rounded, + label: "Debit Reference", + value: transactionController.model.receiptData?["debitRef"] ?? "", + ), + ); + items.add( + _DetailItem( + icon: Icons.phone_iphone_rounded, + label: "From", + value: receiptData?["debitPhone"] ?? "", + ), + ); + items.add( + _DetailItem( + icon: Icons.account_balance_wallet_rounded, + label: "To", + value: receiptData?["creditAccount"] ?? "", + ), + ); + items.add( + _DetailItem( + icon: Icons.memory_rounded, + label: "Processor", + value: receiptData?["paymentProcessorName"] ?? "", + ), + ); + + if (receiptData?["billProductName"] != null) { + items.add( + _DetailItem( + icon: Icons.category_rounded, + label: "Bill Product", + value: receiptData?["billProductName"] ?? "", + ), + ); + } + + if (additionalData == null && items.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.inbox_outlined, size: 48, color: Colors.grey.shade400), + const SizedBox(height: 12), + Text( + "No provider details found", + style: TextStyle( + fontSize: 15, + color: Colors.grey.shade500, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + return _buildInfoCard( + theme: theme, + isDark: isDark, + icon: Icons.business_rounded, + title: "Provider Details", + items: items, + ); + } + + // ────────────────────────────────────────────────────────────── + // 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 items = <_DetailItem>[ + _DetailItem( + icon: Icons.monetization_on_rounded, + label: "Amount", + value: amount.toStringAsFixed(2), + trailing: const Icon( + Icons.trending_up_rounded, + size: 16, + color: Colors.green, + ), + ), + ]; + + if (charge != 0) { + items.add( + _DetailItem( + icon: Icons.percent_rounded, + label: "Our Charge", + value: charge.toStringAsFixed(2), + ), + ); + } + if (gatewayCharge != 0) { + items.add( + _DetailItem( + icon: Icons.account_balance_rounded, + label: "Gateway Charge", + value: gatewayCharge.toStringAsFixed(2), + ), + ); + } + if (tax != 0) { + items.add( + _DetailItem( + icon: Icons.receipt_rounded, + label: "Tax", + value: tax.toStringAsFixed(2), + ), + ); + } + + return _buildInfoCard( + theme: theme, + isDark: isDark, + icon: Icons.receipt_long_rounded, + title: "Transaction Details", + items: items, + totalAmount: totalAmount.toStringAsFixed(2), + ); + } + + // ────────────────────────────────────────────────────────────── + // Modern Info Card + // ────────────────────────────────────────────────────────────── + Widget _buildInfoCard({ + required ThemeData theme, + required bool isDark, + required IconData icon, + required String title, + required List<_DetailItem> items, + String? totalAmount, + }) { + return Container( + width: double.infinity, + 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), + ), + BoxShadow( + color: isDark + ? Colors.black.withValues(alpha: 0.15) + : Colors.black.withValues(alpha: 0.02), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title row + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 18, color: theme.colorScheme.primary), + ), + const SizedBox(width: 12), + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white : Colors.black87, + letterSpacing: 0.5, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Divider + Container( + height: 1, + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.grey.shade200, + ), + const SizedBox(height: 16), + + // Detail items + ...items.map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 14), + child: _buildModernDetailRow( + theme: theme, + isDark: isDark, + item: item, + ), + ), + ), + + // Total amount divider & total + if (totalAmount != null) ...[ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Container( + height: 1, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + theme.colorScheme.primary.withValues(alpha: 0.3), + Colors.transparent, + ], + ), + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.summarize_rounded, + size: 16, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 10), + Text( + "Total", + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.black87, + ), + ), + const Spacer(), + Text( + totalAmount, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: theme.colorScheme.primary, + ), + ), + ], + ), + ], + ], + ), + ), + ); + } + + // ────────────────────────────────────────────────────────────── + // Modern Detail Row + // ────────────────────────────────────────────────────────────── + Widget _buildModernDetailRow({ + required ThemeData theme, + required bool isDark, + required _DetailItem item, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + item.icon, + size: 16, + color: item.isWarning + ? const Color(0xFFF59E0B) + : isDark + ? Colors.white38 + : Colors.grey.shade500, + ), + const SizedBox(width: 10), + Expanded( + flex: 2, + child: Text( + item.label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: isDark ? Colors.white60 : Colors.grey.shade600, + ), + ), + ), + if (item.trailing != null) ...[ + item.trailing!, + const SizedBox(width: 6), + ], + Expanded( + flex: 3, + child: Text( + item.value, + style: TextStyle( + fontSize: 14, + fontWeight: item.isWarning ? FontWeight.w500 : FontWeight.w600, + color: item.isWarning + ? const Color(0xFF92400E) + : isDark + ? Colors.white + : Colors.black87, + ), + textAlign: TextAlign.end, + softWrap: true, + ), + ), + ], + ); + } +} + +// ────────────────────────────────────────────────────────────── +// Data class for detail items +// ────────────────────────────────────────────────────────────── +class _DetailItem { + final IconData icon; + final String label; + final String value; + final bool isWarning; + final Widget? trailing; + + _DetailItem({ + required this.icon, + required this.label, + required this.value, + this.isWarning = false, + this.trailing, + }); } diff --git a/lib/screens/recipient/recipients_controller.dart b/lib/screens/recipient/recipients_controller.dart index f01c512..9f29833 100644 --- a/lib/screens/recipient/recipients_controller.dart +++ b/lib/screens/recipient/recipients_controller.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:qpay/http/http.dart'; +import 'package:qpay/models/api_response.dart'; import 'package:qpay/screens/home/home_controller.dart' as hc; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:qpay/screens/transaction_controller.dart'; @@ -46,19 +47,7 @@ class RecipientsController extends ChangeNotifier { ).model.selectedProvider; } - void _showErrorSnackBar(String? message) { - WidgetsBinding.instance.addPostFrameCallback((_) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message.toString()), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.deepOrange, - ), - ); - }); - } - - Future getRecipients([Map? params]) async { + Future>> getRecipients([Map? params]) async { model.errorMessage = null; model.isLoading = true; model.recipients = getDummyRecipients(); @@ -71,18 +60,19 @@ class RecipientsController extends ChangeNotifier { ); model.recipients.clear(); model.recipients.addAll(response.map((e) => Recipient.fromJson(e))); + model.isLoading = false; + notifyListeners(); + return ApiResponse.success(List.from(model.recipients)); } catch (e) { - _showErrorSnackBar( - "Problem fetching recipients, are you connected to the internet?", - ); model.errorMessage = e.toString(); model.recipients = []; logger.e(e); + model.isLoading = false; notifyListeners(); + return ApiResponse.failure( + "Problem fetching recipients, are you connected to the internet?", + ); } - - model.isLoading = false; - notifyListeners(); } String buildQueryParameters(Map params) { diff --git a/lib/screens/recipient/recipients_screen.dart b/lib/screens/recipient/recipients_screen.dart index 6136dda..f093ebe 100644 --- a/lib/screens/recipient/recipients_screen.dart +++ b/lib/screens/recipient/recipients_screen.dart @@ -89,43 +89,18 @@ class _RecipientsScreenState extends State child: LayoutBuilder( builder: (context, constraints) { return SizedBox( - width: double.parse(constraints.maxWidth > ResponsivePolicy.md ? - ResponsivePolicy.md.toString() : - constraints.maxWidth.toString()), + width: double.parse( + constraints.maxWidth > ResponsivePolicy.md + ? ResponsivePolicy.md.toString() + : constraints.maxWidth.toString(), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - decoration: BoxDecoration( - border: Border.all(color: Colors.black87.withAlpha(20)), - borderRadius: BorderRadius.circular(12), - ), - padding: EdgeInsets.symmetric(vertical: 10, horizontal: 20), - - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "TRANSACTIONS DETAILS", - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text("Provider", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - )), - Text( - controller.model.selectedProvider?.name ?? "", - style: TextStyle(fontSize: 16), - ), - ], - ), - ], - ), + _buildTransactionDetailsCard( + label: "Provider", + value: + controller.model.selectedProvider?.name ?? "", ), const SizedBox(height: 20), _buildAccountField(controller), @@ -133,58 +108,67 @@ class _RecipientsScreenState extends State controller.model.creditAccount != null && controller.model.creditAccount!.isNotEmpty ? SlideTransition( - position: _alignListAnimations[0], - child: ElevatedButton( - onPressed: () { - String phone = ''; - if (!transactionController - .model - .selectedProvider! - .requiresAccount) { - phone = _accountController.text; - } - transactionController.model.formData = - transactionController.model.formData.copyWith( - creditAccount: _accountController.text, - creditPhone: phone, - ); - context.push("/make-payment"); - }, - style: ElevatedButton.styleFrom( - backgroundColor: - Theme.of(context).colorScheme.primary, - foregroundColor: - Theme.of(context).colorScheme.onPrimary, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - "Use as new recipient", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, + position: _alignListAnimations[0], + child: ElevatedButton( + onPressed: () { + String phone = ''; + if (!transactionController + .model + .selectedProvider! + .requiresAccount) { + phone = _accountController.text; + } + transactionController.model.formData = + transactionController.model.formData + .copyWith( + creditAccount: + _accountController.text, + creditPhone: phone, + ); + context.push("/make-payment"); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.primary, + foregroundColor: Theme.of( + context, + ).colorScheme.onPrimary, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Text( + "Use as new recipient", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), - ), - SizedBox(width: 5), - Icon(Icons.arrow_forward_ios, size: 12), - ], + SizedBox(width: 5), + Icon(Icons.arrow_forward_ios, size: 12), + ], + ), ), - ), - ) + ) : SizedBox(), const SizedBox(height: 20), Text( "Recent Recipients", - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), const SizedBox(height: 10), _buildRecipientItems(controller), ], ), ); - } + }, ), ), ), @@ -204,22 +188,24 @@ class _RecipientsScreenState extends State mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(fieldName, style: TextStyle(fontSize: 16)), - if(!kIsWeb) - TextButton( - onPressed: () async { - if (await FlutterContacts.requestPermission()) { - final contact = await FlutterContacts.openExternalPick(); - if (contact != null) { - _accountController.text = contact.phones.first.number; - controller.updateCreditAccount(contact.phones.first.number); + if (!kIsWeb) + TextButton( + onPressed: () async { + if (await FlutterContacts.requestPermission()) { + final contact = await FlutterContacts.openExternalPick(); + if (contact != null) { + _accountController.text = contact.phones.first.number; + controller.updateCreditAccount( + contact.phones.first.number, + ); + } } - } - }, - child: Text( - "Fetch from contacts", - style: TextStyle(fontSize: 12, color: Colors.red), + }, + child: Text( + "Fetch from contacts", + style: TextStyle(fontSize: 12, color: Colors.red), + ), ), - ), ], ), const SizedBox(height: 12), @@ -242,7 +228,7 @@ class _RecipientsScreenState extends State borderSide: BorderSide( color: Theme.of( context, - ).colorScheme.primary.withOpacity(0.2), + ).colorScheme.primary.withValues(alpha: 0.2), ), ), ), @@ -287,121 +273,363 @@ class _RecipientsScreenState extends State Widget _buildRecipientItems(RecipientsController controller) { if (controller.model.recipients.isEmpty && !controller.model.isLoading) { - return Center(child: Text("No recipients yet")); + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: Column( + children: [ + Icon(Icons.people_outline, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + "No recipients yet", + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade600, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...controller.model.recipients.map( - (recipient) => Skeletonizer( - enabled: controller.model.isLoading, - child: InkWell( - onTap: () { - String account = recipient.account ?? ''; - if (!transactionController - .model - .selectedProvider! - .requiresAccount) { - account = recipient.phoneNumber ?? ''; - } - transactionController - .model - .formData = transactionController.model.formData.copyWith( - creditAccount: account, - creditName: recipient.name ?? '', - creditPhone: recipient.phoneNumber ?? '', - creditEmail: recipient.email ?? '', - providerLabel: - transactionController.model.formData.providerLabel.isEmpty - ? recipient.latestProviderLabel ?? '' - : transactionController.model.formData.providerLabel, - ); - context.push("/make-payment"); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: EdgeInsets.all(5), - child: SlideTransition( - position: _alignListAnimations[0], - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: Text( - recipient.initials ?? 'PK', - style: TextStyle( - fontSize: 25, - fontWeight: FontWeight.bold, - ), - ), + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: controller.model.recipients.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final recipient = controller.model.recipients[index]; + final animIdx = index < _alignListAnimations.length + ? index + : _alignListAnimations.length - 1; + + return Skeletonizer( + enabled: controller.model.isLoading, + child: SlideTransition( + position: + _alignListAnimations[animIdx.clamp( + 0, + _alignListAnimations.length - 1, + )], + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + String account = recipient.account ?? ''; + if (!transactionController + .model + .selectedProvider! + .requiresAccount) { + account = recipient.phoneNumber ?? ''; + } + transactionController + .model + .formData = transactionController.model.formData.copyWith( + creditAccount: account, + creditName: recipient.name ?? '', + creditPhone: recipient.phoneNumber ?? '', + creditEmail: recipient.email ?? '', + providerLabel: + transactionController + .model + .formData + .providerLabel + .isEmpty + ? recipient.latestProviderLabel ?? '' + : transactionController.model.formData.providerLabel, + ); + context.push("/make-payment"); + }, + borderRadius: BorderRadius.circular(16), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200, width: 1), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), ), - SizedBox(width: 20), - Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - recipient.latestProviderLabel ?? '', - style: TextStyle(fontWeight: FontWeight.bold), - ), - SizedBox(width: 5), - Text( - recipient.account ?? '', - style: TextStyle(fontWeight: FontWeight.normal), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 16, + ), + child: Row( + children: [ + // Avatar + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.primary, + Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.7), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.25), + blurRadius: 6, + offset: const Offset(0, 2), ), ], ), - if ((recipient.phoneNumber != null && - recipient.phoneNumber!.isNotEmpty) || - (recipient.email != null && - recipient.email!.isNotEmpty)) - Row( - spacing: 5, - children: [ - if (recipient.phoneNumber != null && - recipient.phoneNumber!.isNotEmpty) - Text( - recipient.phoneNumber ?? '', - style: TextStyle( - fontWeight: FontWeight.bold, - ), - ), - if (recipient.email != null && - recipient.email!.isNotEmpty) - Text( - recipient.email ?? '', - style: TextStyle( - fontWeight: FontWeight.normal, - ), - ), - ], + alignment: Alignment.center, + child: Text( + recipient.initials ?? 'PK', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, ), - if (recipient.name != null && - recipient.name!.isNotEmpty) - Text( - recipient.name ?? '', - style: TextStyle(fontWeight: FontWeight.normal), - ), - ], - ), - ], + ), + ), + const SizedBox(width: 16), + // Details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Name + if (recipient.name != null && + recipient.name!.isNotEmpty) + Text( + recipient.name ?? '', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (recipient.name != null && + recipient.name!.isNotEmpty) + const SizedBox(height: 4), + // Account number and provider label row + Row( + children: [ + if (recipient.latestProviderLabel != null && + recipient.latestProviderLabel!.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + recipient.latestProviderLabel ?? '', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of( + context, + ).colorScheme.primary, + ), + ), + ), + if (recipient.latestProviderLabel != null && + recipient + .latestProviderLabel! + .isNotEmpty && + recipient.account != null && + recipient.account!.isNotEmpty) + const SizedBox(width: 6), + if (recipient.account != null && + recipient.account!.isNotEmpty) + Expanded( + child: Text( + recipient.account ?? '', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade700, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 4), + // Phone / Email row + if ((recipient.phoneNumber != null && + recipient.phoneNumber!.isNotEmpty) || + (recipient.email != null && + recipient.email!.isNotEmpty)) + Row( + spacing: 8, + children: [ + if (recipient.phoneNumber != null && + recipient.phoneNumber!.isNotEmpty) + _buildContactChip( + Icons.phone_outlined, + recipient.phoneNumber!, + Colors.grey.shade600, + ), + if (recipient.email != null && + recipient.email!.isNotEmpty) + _buildContactChip( + Icons.email_outlined, + recipient.email!, + Colors.grey.shade500, + ), + ], + ), + ], + ), + ), + // Chevron + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: Colors.grey.shade100, + shape: BoxShape.circle, + ), + child: Icon( + Icons.arrow_forward_ios, + size: 12, + color: Colors.grey.shade400, + ), + ), + ], + ), ), ), ), ), ), + ); + }, + ); + } + + Widget _buildContactChip(IconData icon, String text, Color color) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: color), + const SizedBox(width: 3), + Flexible( + child: Text( + text, + style: TextStyle(fontSize: 12, color: color), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), ], ); } + + Widget _buildTransactionDetailsCard({ + required String label, + String? value, + List? children, + }) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200, width: 1), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.receipt_long_outlined, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + "TRANSACTION DETAILS", + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.primary, + letterSpacing: 1.0, + ), + ), + ], + ), + const SizedBox(height: 16), + Divider(height: 1, color: Colors.grey.shade200), + const SizedBox(height: 16), + if (children != null) + ...children + else + _buildDetailRow( + icon: Icons.business_outlined, + label: label, + value: value ?? '', + ), + ], + ), + ), + ); + } + + Widget _buildDetailRow({ + required IconData icon, + required String label, + required String value, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Icon(icon, size: 16, color: Colors.grey.shade500), + const SizedBox(width: 10), + Text( + label, + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), + const Spacer(), + Text( + value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ], + ), + ); + } } diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index 36bda9a..a884130 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; import 'package:gif_view/gif_view.dart'; +import 'package:qpay/main.dart'; class SplashScreen extends StatefulWidget { - final VoidCallback onSplashComplete; - - const SplashScreen({super.key, required this.onSplashComplete}); + const SplashScreen({super.key}); @override State createState() => _SplashScreenState(); @@ -57,14 +56,15 @@ class _SplashScreenState extends State // Start fade out animation await _fadeController.reverse(); - // Call the completion callback + // Mark splash complete so GoRouter redirect releases navigation + // to the originally intended route (e.g. /poll/:id). if (mounted) { - widget.onSplashComplete(); + splashState.finish(); } } catch (e) { // If there's an error, still complete the splash screen if (mounted) { - widget.onSplashComplete(); + splashState.finish(); } } } @@ -108,4 +108,4 @@ class _SplashScreenState extends State ), ); } -} +} \ No newline at end of file diff --git a/lib/widgets/step_loading_widget.dart b/lib/widgets/step_loading_widget.dart new file mode 100644 index 0000000..6755701 --- /dev/null +++ b/lib/widgets/step_loading_widget.dart @@ -0,0 +1,407 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:qpay/models/responsive_policy.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +class StepLoadingWidget extends StatefulWidget { + final int currentStep; // 1 or 2 + final String title; + final String subtitle; + final String status; // '', 'SUCCESS', 'FAILED' + final bool isLoading; + final String? errorMessage; + final VoidCallback? onRetry; + + const StepLoadingWidget({ + super.key, + required this.currentStep, + required this.title, + required this.subtitle, + required this.status, + required this.isLoading, + this.errorMessage, + this.onRetry, + }); + + @override + State createState() => _StepLoadingWidgetState(); +} + +class _StepLoadingWidgetState extends State + with TickerProviderStateMixin { + late AnimationController _pulseController; + late Animation _pulseAnimation; + late AnimationController _spinController; + + @override + void initState() { + super.initState(); + + // Pulse animation for the outer ring + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(reverse: true); + + _pulseAnimation = Tween(begin: 0.85, end: 1.15).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + ); + + // Spin animation for the arc + _spinController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(); + } + + @override + void dispose() { + _pulseController.dispose(); + _spinController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final primaryColor = theme.colorScheme.primary; + + return Container( + padding: const EdgeInsets.all(50), + child: Center( + child: LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth > ResponsivePolicy.md + ? ResponsivePolicy.md.toDouble() + : constraints.maxWidth; + + return SizedBox( + width: maxWidth, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 60), + + // Step indicator + _StepIndicator( + currentStep: widget.currentStep, + status: widget.status, + primaryColor: primaryColor, + ), + + const SizedBox(height: 60), + + // Animated loader + if (widget.status != 'FAILED' && + widget.status != 'SUCCESS') + AnimatedBuilder( + animation: Listenable.merge( + [_pulseController, _spinController]), + builder: (context, child) { + return SizedBox( + width: 120, + height: 120, + child: CustomPaint( + painter: _LoadingPainter( + pulseValue: _pulseAnimation.value, + spinValue: _spinController.value, + color: primaryColor, + ), + ), + ); + }, + ), + + if (widget.status == 'SUCCESS') + _buildCheckIcon(primaryColor), + + if (widget.status == 'FAILED') + _buildErrorIcon(theme), + + const SizedBox(height: 40), + + // Title + Text( + widget.title, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w600, + color: theme.colorScheme.tertiary, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 16), + + // Subtitle + Text( + widget.subtitle, + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade600, + height: 1.4, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 50), + + // Error state with retry + if (widget.status == 'FAILED') + Column( + children: [ + Text( + widget.errorMessage ?? + 'Something went wrong', + style: TextStyle( + fontSize: 16, + color: theme.colorScheme.error, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Skeletonizer( + enabled: widget.isLoading, + child: OutlinedButton.icon( + icon: const Icon(Icons.refresh, size: 20), + label: const Text('Retry'), + onPressed: widget.onRetry, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 14, + ), + side: BorderSide( + color: primaryColor, + width: 1.5, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ], + ), + ); + }, + ), + ), + ); + } + + Widget _buildCheckIcon(Color primaryColor) { + return Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: primaryColor.withValues(alpha: 0.1), + ), + child: Icon( + Icons.check_circle_rounded, + size: 80, + color: primaryColor, + ), + ); + } + + Widget _buildErrorIcon(ThemeData theme) { + return Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.colorScheme.error.withValues(alpha: 0.1), + ), + child: Icon( + Icons.error_outline_rounded, + size: 80, + color: theme.colorScheme.error, + ), + ); + } +} + +class _StepIndicator extends StatelessWidget { + final int currentStep; + final String status; + final Color primaryColor; + + const _StepIndicator({ + required this.currentStep, + required this.status, + required this.primaryColor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isSuccess = status == 'SUCCESS'; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildStep( + number: '1', + label: 'Verify Payment', + isActive: currentStep == 1, + isCompleted: currentStep > 1 || (currentStep == 1 && isSuccess), + theme: theme, + ), + Container( + width: 60, + height: 2, + margin: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: currentStep >= 2 + ? primaryColor + : Colors.grey.shade300, + borderRadius: BorderRadius.circular(1), + ), + ), + _buildStep( + number: '2', + label: 'Update Account', + isActive: currentStep == 2, + isCompleted: currentStep == 2 && isSuccess, + theme: theme, + ), + ], + ), + ); + } + + Widget _buildStep({ + required String number, + required String label, + required bool isActive, + required bool isCompleted, + required ThemeData theme, + }) { + final Color circleColor; + final Widget circleChild; + + if (isCompleted) { + circleColor = theme.colorScheme.primary; + circleChild = const Icon(Icons.check, size: 16, color: Colors.white); + } else if (isActive) { + circleColor = theme.colorScheme.primary; + circleChild = Text( + number, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ); + } else { + circleColor = Colors.grey.shade300; + circleChild = Text( + number, + style: TextStyle( + color: Colors.grey.shade600, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: circleColor, + ), + child: Center(child: circleChild), + ), + const SizedBox(height: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + color: isActive + ? theme.colorScheme.tertiary + : Colors.grey.shade500, + ), + ), + ], + ); + } +} + +class _LoadingPainter extends CustomPainter { + final double pulseValue; + final double spinValue; + final Color color; + + _LoadingPainter({ + required this.pulseValue, + required this.spinValue, + required this.color, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final baseRadius = size.width / 2 - 8; + + // Draw pulsing outer ring + final pulseRadius = baseRadius * pulseValue; + final pulsePaint = Paint() + ..color = color.withValues(alpha: 0.15) + ..style = PaintingStyle.fill; + canvas.drawCircle(center, pulseRadius, pulsePaint); + + // Draw ring outline + final ringPaint = Paint() + ..color = color.withValues(alpha: 0.25) + ..style = PaintingStyle.stroke + ..strokeWidth = 3; + canvas.drawCircle(center, baseRadius, ringPaint); + + // Draw spinning arc + final arcPaint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 3.5 + ..strokeCap = StrokeCap.round; + + final arcStart = spinValue * 2 * pi; + final arcSweep = pi * 1.2; + canvas.drawArc( + Rect.fromCircle(center: center, radius: baseRadius), + arcStart, + arcSweep, + false, + arcPaint, + ); + + // Draw inner dot + final dotPaint = Paint() + ..color = color + ..style = PaintingStyle.fill; + canvas.drawCircle(center, 6, dotPaint); + } + + @override + bool shouldRepaint(_LoadingPainter oldDelegate) { + return oldDelegate.pulseValue != pulseValue || + oldDelegate.spinValue != spinValue; + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 9acfca3..849b909 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -292,7 +292,7 @@ packages: source: sdk version: "0.0.0" flutter_web_plugins: - dependency: transitive + dependency: "direct main" description: flutter source: sdk version: "0.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 264b554..9171ace 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,8 @@ dependencies: html: ^0.15.6 web: ^1.1.1 google_sign_in_web: ^1.1.0 + flutter_web_plugins: + sdk: flutter dev_dependencies: flutter_test: diff --git a/web/index.html b/web/index.html index 8d1fbc8..b8179f7 100644 --- a/web/index.html +++ b/web/index.html @@ -34,6 +34,19 @@ Velocity - + \ No newline at end of file