diff --git a/.metadata b/.metadata index e8f7bf9..5722f85 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "ea121f8859e4b13e47a8f845e4586164519588bc" + revision: "9f455d2486bcb28cad87b062475f42edc959f636" channel: "stable" project_type: app @@ -13,26 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: ea121f8859e4b13e47a8f845e4586164519588bc - base_revision: ea121f8859e4b13e47a8f845e4586164519588bc - - platform: android - create_revision: ea121f8859e4b13e47a8f845e4586164519588bc - base_revision: ea121f8859e4b13e47a8f845e4586164519588bc - - platform: ios - create_revision: ea121f8859e4b13e47a8f845e4586164519588bc - base_revision: ea121f8859e4b13e47a8f845e4586164519588bc - - platform: linux - create_revision: ea121f8859e4b13e47a8f845e4586164519588bc - base_revision: ea121f8859e4b13e47a8f845e4586164519588bc - - platform: macos - create_revision: ea121f8859e4b13e47a8f845e4586164519588bc - base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 - platform: web - create_revision: ea121f8859e4b13e47a8f845e4586164519588bc - base_revision: ea121f8859e4b13e47a8f845e4586164519588bc - - platform: windows - create_revision: ea121f8859e4b13e47a8f845e4586164519588bc - base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 # User provided section diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c17ed31 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,71 @@ +# syntax=docker/dockerfile:1.6 + +############################################### +# Build stage: compile Flutter web artifacts. # +############################################### +FROM ubuntu:22.04 AS build + +ENV DEBIAN_FRONTEND=noninteractive \ + FLUTTER_CHANNEL=stable \ + FLUTTER_VERSION=3.24.3 \ + FLUTTER_HOME=/opt/flutter + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + unzip \ + xz-utils \ + zip \ + libglu1-mesa && \ + rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL "https://storage.googleapis.com/flutter_infra_release/releases/${FLUTTER_CHANNEL}/linux/flutter_linux_${FLUTTER_VERSION}-${FLUTTER_CHANNEL}.tar.xz" \ + | tar -xJ -C /opt && \ + ln -s ${FLUTTER_HOME} /usr/local/flutter + +ENV PATH="${FLUTTER_HOME}/bin:${FLUTTER_HOME}/bin/cache/dart-sdk/bin:${PATH}" + +RUN flutter config --enable-web && \ + flutter doctor -v + +WORKDIR /app + +COPY pubspec.yaml pubspec.lock ./ +RUN flutter pub get + +COPY . . +RUN flutter build web --release + +########################################## +# Runtime stage: serve assets via nginx. # +########################################## +FROM ubuntu:22.04 AS runner + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && \ + apt-get install -y --no-install-recommends nginx && \ + rm -rf /var/lib/apt/lists/* + +# Remove default site definition to avoid duplicate configs. +RUN rm -f /etc/nginx/sites-enabled/default + +# Provide a minimal nginx site config for the Flutter web assets. +RUN printf 'server {\n\ + listen 80 default_server;\n\ + listen [::]:80 default_server;\n\ + root /var/www/html;\n\ + index index.html;\n\ + location / {\n\ + try_files $uri $uri/ /index.html;\n\ + }\n\ +}\n' > /etc/nginx/conf.d/flutter.conf + +COPY --from=build /app/build/web /var/www/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5a558c1..50096c7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,7 +3,7 @@ diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 8e478ad..b4cbe58 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - Peak + Velocity \ No newline at end of file diff --git a/assets/.env b/assets/.env index 6f89a20..7645daa 100644 --- a/assets/.env +++ b/assets/.env @@ -1,10 +1,11 @@ BASE_URL=https://peakapi.qantra.co.zw/api ; BASE_URL=http://192.168.100.26:6950/api -; BASE_URL=http://10.69.5.204:6950/api +; BASE_URL=http://173.212.247.232:6950/api ; BASE_URL=http://192.168.1.164:6950/api ; BASE_URL=http://192.168.120.160:6950/api ; BASE_URL=http://10.10.2.92:6950/api ; BASE_URL=http://172.20.5.105:6950/api ; BASE_URL=http://10.10.3.92:6950/api CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com -SIMULATE_PAYMENT_SUCCESS=false \ No newline at end of file +SIMULATE_PAYMENT_SUCCESS=false +; SIMULATE_PAYMENT_SUCCESS=true diff --git a/assets/favicon.png b/assets/favicon.png new file mode 100644 index 0000000..721aa6e Binary files /dev/null and b/assets/favicon.png differ diff --git a/assets/velocity-animation.gif b/assets/velocity-animation.gif new file mode 100644 index 0000000..ca82573 Binary files /dev/null and b/assets/velocity-animation.gif differ diff --git a/assets/velocity.png b/assets/velocity.png new file mode 100644 index 0000000..8a3a7ca Binary files /dev/null and b/assets/velocity.png differ diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..b042345 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,5 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: + - provider: true + - shared_preferences: true \ No newline at end of file diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index c73d391..717c9ba 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Peak + Velocity CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -13,7 +13,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - Peak + Velocity CFBundlePackageType APPL CFBundleShortVersionString diff --git a/lib/interop.dart b/lib/interop.dart new file mode 100644 index 0000000..7dd7770 --- /dev/null +++ b/lib/interop.dart @@ -0,0 +1,7 @@ +import 'dart:js_interop'; + +@JS('configure') +external void configure(String sessionID); + +@JS('showEmbeddedPage') +external void showEmbeddedPage(); diff --git a/lib/main.dart b/lib/main.dart index d37a350..e089fb2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.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_screen.dart'; import 'package:qpay/screens/integration/integration_screen.dart'; @@ -13,9 +14,11 @@ import 'package:qpay/screens/onboarding/phone/phone_screen.dart'; 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/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'; @@ -66,7 +69,7 @@ class _MyAppState extends State { Widget build(BuildContext context) { if (_showSplash) { return MaterialApp( - title: 'Peak', + title: 'Velocity', theme: AppTheme.lightTheme, themeMode: ThemeMode.light, home: SplashScreen(onSplashComplete: _onSplashComplete), @@ -75,7 +78,7 @@ class _MyAppState extends State { } return MaterialApp.router( - title: 'Peak', + title: 'Velocity', theme: AppTheme.lightTheme, themeMode: ThemeMode.light, // Force light mode regardless of OS setting debugShowCheckedModeBanner: false, @@ -112,6 +115,14 @@ class _MyAppState extends State { path: '/gateway', builder: (context, state) => const GatewayScreen(), ), + GoRoute( + path: '/gateway-web', + builder: (context, state) => const GatewayWebScreen(), + ), + GoRoute( + path: '/poll', + builder: (context, state) => const PollScreen(), + ), GoRoute( path: '/receipt', builder: (context, state) => const ReceiptScreen(), @@ -167,65 +178,3 @@ class _MyAppState extends State { ); } } - -class ScaffoldWithNavBar extends StatelessWidget { - const ScaffoldWithNavBar({super.key, required this.child}); - - final Widget child; - - @override - Widget build(BuildContext context) { - return Scaffold( - body: child, - bottomNavigationBar: NavigationBar( - onDestinationSelected: (index) { - switch (index) { - case 0: - context.go('/home'); - break; - // case 1: - // context.go('/recipients'); - // break; - case 1: - context.go('/history'); - break; - // case 2: - // context.go('/profile'); - // break; - } - }, - selectedIndex: _calculateSelectedIndex(context), - destinations: const [ - NavigationDestination( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home), - label: 'Home', - ), - // NavigationDestination( - // icon: Icon(Icons.contacts_outlined), - // selectedIcon: Icon(Icons.contacts), - // label: 'Recipients', - // ), - NavigationDestination( - icon: Icon(Icons.history_outlined), - selectedIcon: Icon(Icons.history), - label: 'History', - ), - // NavigationDestination( - // icon: Icon(Icons.person_outline), - // selectedIcon: Icon(Icons.person), - // label: 'Profile', - // ), - ], - ), - ); - } - - int _calculateSelectedIndex(BuildContext context) { - final String location = GoRouterState.of(context).uri.path; - // if (location.startsWith('/recipients')) return 1; - if (location.startsWith('/history')) return 1; - // if (location.startsWith('/profile')) return 2; - return 0; - } -} diff --git a/lib/models/responsive_policy.dart b/lib/models/responsive_policy.dart new file mode 100644 index 0000000..5ea284d --- /dev/null +++ b/lib/models/responsive_policy.dart @@ -0,0 +1,5 @@ +class ResponsivePolicy { + static const int sm = 400; + static const int md = 600; + static const int lg = 950; +} \ No newline at end of file diff --git a/lib/navbar.dart b/lib/navbar.dart new file mode 100644 index 0000000..2f13235 --- /dev/null +++ b/lib/navbar.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'models/responsive_policy.dart'; + +class ScaffoldWithNavBar extends StatefulWidget { + const ScaffoldWithNavBar({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _ScaffoldWithNavBarState(); +} + +class _ScaffoldWithNavBarState extends State { + bool _isLoggedIn = false; + late String initials; + + @override + void initState() { + super.initState(); + init(); + } + + Future init() async { + var prefs = await SharedPreferences.getInstance(); + + if (prefs.getString("token") != null) { + setState(() { + _isLoggedIn = true; + initials = prefs.getString("initials")!; + }); + } + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth > ResponsivePolicy.md) { + return Scaffold( + body: SafeArea( + child: Row( + children: [ + NavigationRail( + extended: true, + leading: Padding( + padding: const EdgeInsets.only(top: 20.0, bottom: 10), + child: Image( + image: AssetImage('assets/velocity.png'), + width: 150 + ), + ), + trailing: _buildLogin(context), + labelType: NavigationRailLabelType.none, + indicatorColor: Theme.of( + context, + ).colorScheme.primary.withAlpha(30), + onDestinationSelected: (index) { + _handleTap(index, context); + }, + selectedIndex: _calculateSelectedIndex(context), + destinations: _buildNavigationRailDestinations(), + ), + VerticalDivider( + thickness: 1, + width: 1, + color: Theme.of(context).colorScheme.primary.withAlpha(30), + ), + Expanded(child: widget.child), + ], + ), + ), + ); + } + return Scaffold( + body: widget.child, + bottomNavigationBar: NavigationBar( + indicatorColor: Theme.of(context).colorScheme.primary.withAlpha(30), + onDestinationSelected: (index) { + _handleTap(index, context); + }, + selectedIndex: _calculateSelectedIndex(context), + destinations: _buildNavigationDestinations(), + ), + ); + }, + ); + } + + List _buildNavigationRailDestinations() { + List destinations = []; + for (var destination in _buildDestinationData()) { + destinations.add( + NavigationRailDestination( + icon: destination["icon"] as Widget, + selectedIcon: destination["selectedIcon"] as Widget, + label: Text(destination["label"], style: TextStyle(fontSize: 16)), + ), + ); + } + return destinations; + } + + List _buildNavigationDestinations() { + List destinations = []; + for (var destination in _buildDestinationData()) { + destinations.add( + NavigationDestination( + icon: destination["icon"] as Widget, + selectedIcon: destination["selectedIcon"] as Widget, + label: destination["label"] as String, + ), + ); + } + return destinations; + } + + List> _buildDestinationData() { + return [ + { + "icon": Icon(Icons.home_outlined), + "selectedIcon": Icon(Icons.home), + "label": 'Home', + }, + { + "icon": Icon(Icons.history_outlined), + "selectedIcon": Icon(Icons.history), + "label": 'History', + }, + ]; + } + + Widget _buildLogin(BuildContext context) { + if(_isLoggedIn) { + return SizedBox(); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Column( + children: [ + Container( + width: 220, + 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.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 10, + children: [ + Text( + 'Register or Login for more features', + style: TextStyle(fontSize: 16), + ), + InkWell( + onTap: () { + context.push('/onboarding/landing'); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Text('Get Started', style: TextStyle(fontSize: 14)), + ), + ), + ], + ), + ), + SizedBox(height: 10), + ], + ), + ); + } + + void _handleTap(int index, BuildContext context) { + switch (index) { + case 0: + context.go('/home'); + break; + // case 1: + // context.go('/recipients'); + // break; + case 1: + context.go('/history'); + break; + // case 2: + // context.go('/profile'); + // break; + } + } + + int _calculateSelectedIndex(BuildContext context) { + final String location = GoRouterState.of(context).uri.path; + // if (location.startsWith('/recipients')) return 1; + if (location.startsWith('/history')) return 1; + // if (location.startsWith('/profile')) return 2; + return 0; + } +} diff --git a/lib/screens/confirm/confirm_controller.dart b/lib/screens/confirm/confirm_controller.dart index d5db3af..c579f7d 100644 --- a/lib/screens/confirm/confirm_controller.dart +++ b/lib/screens/confirm/confirm_controller.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:go_router/go_router.dart'; @@ -81,10 +82,13 @@ class ConfirmController extends ChangeNotifier { transactionController.updateReceiptData(response); - if (transactionController.model.selectedPaymentProcessor.authType == - "WEB") { + if (transactionController.model.selectedPaymentProcessor.authType == "WEB") { if (context.mounted) { - context.push('/gateway'); + if(kIsWeb){ + context.push('/gateway-web'); + }else { + context.push('/gateway'); + } } } else { await pollTransaction(transactionController.model.receiptData?['id']); diff --git a/lib/screens/confirm/confirm_screen.dart b/lib/screens/confirm/confirm_screen.dart index d38469f..125bf5a 100644 --- a/lib/screens/confirm/confirm_screen.dart +++ b/lib/screens/confirm/confirm_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/screens/confirm/confirm_controller.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -88,255 +89,266 @@ class _ConfirmScreenState extends State position: _slideAnimation, child: Form( key: _formKey, - 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, + child: Center( + child: LayoutBuilder( + 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), ), - ), - padding: EdgeInsets.only( - bottom: 4.0, - ), // Adjust spacing - child: Text( - "PROVIDER DETAILS", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - ...controller - .transactionController - .model - .confirmationData["additionalData"] - .map((data) { - return Column( - children: [ - const SizedBox(height: 10), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, + + 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 + .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, + ), + ), + ), + 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), + ), + ], + ), + const SizedBox(height: 10), + if (controller + .transactionController + .model + .confirmationData["charge"] != + 0) + Column( children: [ - Text( - data["name"] ?? "", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - Text( - data["value"] ?? "", - style: TextStyle(fontSize: 16), + 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), ], ), - ], - ); - }) - .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, - ), - ), - ), - 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), - ), - ], - ), - 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 + if (controller .transactionController .model - .confirmationData["charge"] - .toString(), - style: TextStyle(fontSize: 16), + .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), + ], ), - ], - ), - 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 + if (controller .transactionController .model - .confirmationData["gatewayCharge"] - .toString(), - style: TextStyle(fontSize: 16), + .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), + ], ), - ], - ), - 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, + 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), + ), + ], + ), + const SizedBox(height: 5), + Text( + "NB: Your payment provider may charge additional fees.", + style: TextStyle( + fontSize: 11, + color: Colors.red, ), - 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), - ), - ], - ), - const SizedBox(height: 5), - Text( - "NB: Your payment provider may charge additional fees.", - style: TextStyle( - fontSize: 11, - color: Colors.red, ), - ), - ], - ), - ), - const SizedBox(height: 20), - _buildPaymentProcessorButton(), - const SizedBox(height: 20), - Center( - child: OutlinedButton( - child: Text('Cancel'), - onPressed: () { - context.go('/'); - }, - ), - ), - ], + const SizedBox(height: 20), + _buildPaymentProcessorButton(), + const SizedBox(height: 20), + Center( + child: OutlinedButton( + child: Text('Cancel'), + onPressed: () { + context.go('/'); + }, + ), + ), + ], + ), + ); + } + ), ), ), ), diff --git a/lib/screens/gateway/gateway_controller.dart b/lib/screens/gateway/gateway_controller.dart index 666aff6..0298a85 100644 --- a/lib/screens/gateway/gateway_controller.dart +++ b/lib/screens/gateway/gateway_controller.dart @@ -76,15 +76,22 @@ class GatewayController extends ChangeNotifier { } 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 - await Future.delayed(const Duration(seconds: 5)); + 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); + + if(max > 30) { + model.isCancelled = true; + } } } diff --git a/lib/screens/gateway/gateway_screen.dart b/lib/screens/gateway/gateway_screen.dart index bfc8e78..5fda0f2 100644 --- a/lib/screens/gateway/gateway_screen.dart +++ b/lib/screens/gateway/gateway_screen.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:go_router/go_router.dart'; @@ -17,6 +15,7 @@ class _GatewayScreenState extends State { late WebViewController _webViewController; late GatewayController controller; late String url; + String? sessionID; bool integrationStarted = false; @@ -24,63 +23,71 @@ class _GatewayScreenState extends State { void initState() { super.initState(); controller = GatewayController(context); + // https://na.gateway.mastercard.com/checkout/pay/SESSION0002220827459I8059951J88?checkoutVersion=1.0.0 + url = controller.transactionController.model.receiptData?['targetUrl']; - _initWebView(url); + + _initWebView(); + } + + void _initWebView() async { + _webViewController = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate( + NavigationDelegate( + onProgress: (int progress) { + // Update loading bar. + }, + onPageStarted: (String url) { + controller.startLoading(); + }, + onPageFinished: (String url) { + controller.finishLoading(); + }, + onHttpError: (HttpResponseError error) {}, + onWebResourceError: (WebResourceError error) {}, + onUrlChange: (UrlChange url) async { + print("URL Changed to ${url.url!}"); + + _navigateWhenDone(url.url!); + }, + ), + ) + ..loadRequest(Uri.parse(url)); bool simulate = bool.parse(dotenv.env['SIMULATE_PAYMENT_SUCCESS']!); - if(simulate) { - sleep(Duration(seconds: 10)); + if (simulate) { + await Future.delayed(Duration(seconds: 10)); String targetUrl = url.replaceAll("/pay/", "/receipt/"); _webViewController.loadRequest(Uri.parse(targetUrl)); } } - void _initWebView(String url) { - _webViewController = - WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setNavigationDelegate( - NavigationDelegate( - onProgress: (int progress) { - // Update loading bar. - }, - onPageStarted: (String url) { - controller.startLoading(); - }, - onPageFinished: (String url) { - controller.finishLoading(); - }, - onHttpError: (HttpResponseError error) {}, - onWebResourceError: (WebResourceError error) {}, - onUrlChange: (UrlChange url) async { - print("URL Changed to ${url.url!}"); + void _navigateWhenDone(String url) { + if (url.contains("/checkout/receipt/")) { + if (integrationStarted) { + return; + } - if(url.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), + ), + ); - 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; + }); - setState(() { - integrationStarted = true; - }); - - WidgetsBinding.instance.addPostFrameCallback((_) { - context.go('/integration'); - }); - } - } - ), - ) - ..loadRequest(Uri.parse(url)); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.go('/integration'); + }); + } } @override @@ -94,15 +101,14 @@ class _GatewayScreenState extends State { return Scaffold( appBar: AppBar( title: const Text('Complete your payment'), - 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(), ), body: ListenableBuilder( listenable: controller, @@ -112,9 +118,12 @@ class _GatewayScreenState extends State { context.go('/integration'); }); } + return controller.model.isLoading ? const Center(child: CircularProgressIndicator(strokeWidth: 5)) - : WebViewWidget(controller: _webViewController); + : SizedBox.expand( + child: WebViewWidget(controller: _webViewController), + ); }, ), ); diff --git a/lib/screens/gateway/gateway_web_screen.dart b/lib/screens/gateway/gateway_web_screen.dart new file mode 100644 index 0000000..37cf157 --- /dev/null +++ b/lib/screens/gateway/gateway_web_screen.dart @@ -0,0 +1,259 @@ +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 { + const GatewayWebScreen({super.key}); + + @override + State createState() => _GatewayWebScreenState(); +} + +class _GatewayWebScreenState extends State { + late WebViewController _webViewController; + late GatewayController controller; + late String url; + String? sessionID; + + bool integrationStarted = false; + + @override + void initState() { + super.initState(); + controller = GatewayController(context); + + 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); + + // Disconnect the observer once the action is done + (observer as web.ResizeObserver).disconnect(); + }.toJS, + ); + + 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 { + 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; + }); + + WidgetsBinding.instance.addPostFrameCallback((_) { + context.go('/integration'); + }); + } + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Complete your payment'), + leading: context.canPop() + ? IconButton( + onPressed: () { + context.pop(); + }, + icon: const Icon(Icons.arrow_back), + ) + : SizedBox.shrink(), + ), + body: ListenableBuilder( + listenable: controller, + builder: (context, child) { + if (controller.model.status == 'SUCCESS') { + WidgetsBinding.instance.addPostFrameCallback((_) { + context.go('/integration'); + }); + } + + return LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: Center( + child: SizedBox( + width: ResponsivePolicy.md.toDouble(), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + 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), + ), + ), + ), + ], + ), + ), + SizedBox(height: 10,), + SizedBox( + width: double.infinity, + height: constraints.maxHeight + 200, + child: HtmlElementView.fromTagName( + tagName: 'div', + onElementCreated: (element) { + final divElement = + element as web.HTMLDivElement; + divElement.id = "embed-target"; + setupAttachmentObserver(element); + }, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/screens/history/history_controller.dart b/lib/screens/history/history_controller.dart index 97b13a2..5a663fb 100644 --- a/lib/screens/history/history_controller.dart +++ b/lib/screens/history/history_controller.dart @@ -92,7 +92,7 @@ class HistoryController extends ChangeNotifier { "debitPhone": "", "debitAccount": "", "debitCurrency": "USD", - "debitRef": "peak", + "debitRef": "Velocity", "creditPhone": "", "creditAccount": "07088597534", "billClientId": "powertel_zesa", diff --git a/lib/screens/history/history_screen.dart b/lib/screens/history/history_screen.dart index 3e1e980..956e214 100644 --- a/lib/screens/history/history_screen.dart +++ b/lib/screens/history/history_screen.dart @@ -1,6 +1,7 @@ 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:shared_preferences/shared_preferences.dart'; @@ -23,7 +24,6 @@ class _HistoryScreenState extends State final TextEditingController _searchController = TextEditingController(); final Map _queryParams = {}; - final List> _alignListAnimations = []; final int _pageSize = 8; @override @@ -38,21 +38,9 @@ class _HistoryScreenState extends State ); _controller = AnimationController( - duration: const Duration(milliseconds: 600), + duration: const Duration(milliseconds: 3000), vsync: this, )..forward(); - - List tranListIndices = [0, 1]; - for (int i = 0; i < tranListIndices.length; i++) { - _alignListAnimations.add( - Tween(begin: Offset(0, -0.25), end: Offset.zero).animate( - CurvedAnimation( - parent: _controller, - curve: Interval(0.075 * i, 1.0, curve: Curves.easeOut), - ), - ), - ); - } } void setupData() async { @@ -90,56 +78,78 @@ class _HistoryScreenState extends State builder: (context, child) { return Container( padding: const EdgeInsets.all(16), - child: Column( - children: [ - _buildSearchField(controller), - if (controller.model.transactions.isEmpty) - Column( - children: [ - SizedBox(height: 30), - Text( - 'No transactions yet. Make a payment to see your recent activity.', - style: TextStyle(fontSize: 16), - textAlign: TextAlign.center, - ), - ], - ), - if (controller.model.transactions.isNotEmpty) - Column( - children: [ - ListView( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - children: [ - ...controller.model.transactions.map( - (e) => _buildTransactionItem(e, context), + child: Center( + child: LayoutBuilder( + builder: (context, constraints) { + return SizedBox( + width: double.parse( + constraints.maxWidth > ResponsivePolicy.md + ? ResponsivePolicy.md.toString() + : constraints.maxWidth.toString(), + ), + child: Column( + children: [ + _buildSearchField(controller), + SizedBox(height: 10), + if (controller.model.transactions.isEmpty) + Column( + children: [ + SizedBox(height: 30), + Text( + 'No transactions yet. Make a payment to see your recent activity.', + style: TextStyle(fontSize: 16), + textAlign: TextAlign.center, + ), + ], ), - ], - ), - SizedBox(height: 10), - if (controller.model.pageableModel != null && - controller.model.pageableModel!.number < - controller.model.pageableModel!.totalPages) - OutlinedButton( - child: Text('Load more'), - onPressed: () { - controller.getTransactions({ - 'userId': prefs.getString("userId")!, - 'page': - (controller - .model - .pageableModel! - .number + - 1) - .toString(), - 'size': _pageSize.toString(), - 'sort': 'createdAt,desc', - }); - }, - ), - ], - ), - ], + if (controller.model.transactions.isNotEmpty) + Column( + children: [ + ListView( + shrinkWrap: true, + physics: + const NeverScrollableScrollPhysics(), + children: [ + ...controller.model.transactions.map( + (e) => + _buildTransactionItem(e, context), + ), + ], + ), + SizedBox(height: 10), + if (controller.model.pageableModel != + null && + controller.model.pageableModel!.number < + controller + .model + .pageableModel! + .totalPages) + OutlinedButton( + child: Text('Load more'), + onPressed: () { + controller.getTransactions({ + 'userId': prefs.getString( + "userId", + )!, + 'page': + (controller + .model + .pageableModel! + .number + + 1) + .toString(), + 'size': _pageSize.toString(), + 'sort': 'createdAt,desc', + }); + }, + ), + ], + ), + ], + ), + ); + }, + ), ), ); }, @@ -191,20 +201,19 @@ class _HistoryScreenState extends State ).colorScheme.primary.withOpacity(0.2), ), ), - suffixIcon: - _searchController.text.isNotEmpty - ? IconButton( - onPressed: () { - _searchController.clear(); - _queryParams.clear(); - controller.getTransactions({ - 'userId': prefs.getString("userId")!, - }); - }, - icon: Icon(Icons.clear), - color: Theme.of(context).colorScheme.primary, - ) - : null, + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + onPressed: () { + _searchController.clear(); + _queryParams.clear(); + controller.getTransactions({ + 'userId': prefs.getString("userId")!, + }); + }, + icon: Icon(Icons.clear), + color: Theme.of(context).colorScheme.primary, + ) + : null, ), onChanged: (value) { // controller.updateCreditAccount(value); @@ -227,8 +236,17 @@ class _HistoryScreenState extends State } Widget _buildTransactionItem(Map e, BuildContext context) { + int index = controller.model.transactions.indexOf(e); + Animation animation = + Tween(begin: Offset(0, -0.25), end: Offset.zero).animate( + CurvedAnimation( + parent: _controller, + curve: Interval(0.175 * index, 1.0, curve: Curves.easeIn), + ), + ); + return SlideTransition( - position: _alignListAnimations[0], + position: animation, child: Skeletonizer( enabled: controller.model.isLoading, child: InkWell( @@ -260,18 +278,17 @@ class _HistoryScreenState extends State ), SizedBox(width: 10), Icon( - e['status'] == 'SUCCESS' + e['integrationStatus'] == 'SUCCESS' ? Icons.check_circle - : e['status'] == 'PENDING' + : e['integrationStatus'] == 'PENDING' ? Icons.pending : Icons.cancel, size: 16, - color: - e['status'] == 'SUCCESS' - ? Colors.green - : e['status'] == 'PENDING' - ? Colors.orange - : Colors.red, + color: e['integrationStatus'] == 'SUCCESS' + ? Colors.green + : e['integrationStatus'] == 'PENDING' + ? Colors.orange + : Colors.red, ), ], ), diff --git a/lib/screens/home/home_controller.dart b/lib/screens/home/home_controller.dart index 4e92413..96d3159 100644 --- a/lib/screens/home/home_controller.dart +++ b/lib/screens/home/home_controller.dart @@ -104,6 +104,9 @@ class HomeController extends ChangeNotifier { Future getProviders() async { try { + model.filterableProviders = getFakeProviders(); + model.transactions = getFakeTransactions(); + model.isLoading = true; notifyListeners(); @@ -138,6 +141,7 @@ class HomeController extends ChangeNotifier { Future getTransactions(String userId) async { model.isLoading = true; + // model.transactions = getFakeTransactions(); notifyListeners(); try { @@ -147,8 +151,9 @@ class HomeController extends ChangeNotifier { PageableModel pageableModel = PageableModel.fromJson(response); - model.transactions = - pageableModel.content.map((e) => e as Map).toList(); + model.transactions = pageableModel.content + .map((e) => e as Map) + .toList(); } catch (e) { logger.e(e); _showErrorSnackBar( @@ -165,8 +170,6 @@ class HomeController extends ChangeNotifier { Future getCategories() async { // activate skeletonizer model.categories = getFakeCategoryData(); - model.transactions = getFakeTransactions(); - // model.filterableProviders = getFakeProviders(); model.isLoading = true; notifyListeners(); @@ -200,10 +203,9 @@ class HomeController extends ChangeNotifier { model.selectedCategory = category; - model.filterableProviders = - model.providers - .where((element) => element.category == category.label) - .toList(); + model.filterableProviders = model.providers + .where((element) => element.category == category.label) + .toList(); notifyListeners(); } @@ -224,7 +226,7 @@ class HomeController extends ChangeNotifier { "debitPhone": "", "debitAccount": "", "debitCurrency": "USD", - "debitRef": "peak", + "debitRef": "Velocity", "creditPhone": "", "creditAccount": "07088597534", "billClientId": "powertel_zesa", @@ -242,21 +244,21 @@ class HomeController extends ChangeNotifier { List getFakeProviders() { return [ BillProvider( - clientId: '1', - name: 'ZESA', - description: 'Pay your electricity bills easily', + clientId: 'econet_airtime', + name: 'Econet Airtime', + description: 'Econet Airtime', requiresAccount: false, - requiresAmount: false, + requiresAmount: true, requiresAmountFromMerchant: false, - requiresPhone: false, - requiresReversal: false, + requiresPhone: true, + requiresReversal: true, additionalDataString: '', - processorType: '', - uid: '', + processorType: 'AIRTIME', + uid: '78f1f497-c9eb-401c-b22c-884756e68e40', image: 'econet.png', - label: '', - category: '', - accountFieldName: '', + label: 'ECONET', + category: 'ECONET', + accountFieldName: 'Phone Number', ), ]; } diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart index db4642b..9f28f66 100644 --- a/lib/screens/home/home_screen.dart +++ b/lib/screens/home/home_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import 'package:qpay/models/responsive_policy.dart'; import 'package:timeago/timeago.dart' as timeago; import 'package:qpay/screens/home/home_controller.dart'; import 'package:qpay/screens/transaction_controller.dart'; @@ -19,7 +20,6 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; - late Animation _alignAnimation; late TransactionController transactionController; late SharedPreferences prefs; late HomeController homeController; @@ -29,35 +29,15 @@ class _HomeScreenState extends State bool _isLoggedIn = false; String _selectedFilter = 'All'; - final List> _animations = []; - final List> _alignListAnimations = []; - @override void initState() { super.initState(); _controller = AnimationController( - duration: const Duration(milliseconds: 600), + duration: const Duration(milliseconds: 3000), vsync: this, )..forward(); - List tranListIndices = [0, 1]; - for (int i = 0; i < tranListIndices.length; i++) { - _alignListAnimations.add( - Tween(begin: Offset(0, -0.25), end: Offset.zero).animate( - CurvedAnimation( - parent: _controller, - curve: Interval(0.075 * i, 1.0, curve: Curves.easeOut), - ), - ), - ); - } - - _alignAnimation = Tween( - begin: Alignment.centerLeft, - end: Alignment.center, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); - setupDataAndTransitions(); } @@ -77,21 +57,6 @@ class _HomeScreenState extends State await homeController.getCategories(); await homeController.getProviders(); - List buttonIndices = List.generate( - homeController.model.providers.length, - (index) => index, - ); - for (int i = 0; i < buttonIndices.length; i++) { - _animations.add( - Tween(begin: Offset(-0.15, 0), end: Offset.zero).animate( - CurvedAnimation( - parent: _controller, - curve: Interval(0.055 * i, 1.0, curve: Curves.easeOut), - ), - ), - ); - } - prefs = await SharedPreferences.getInstance(); if (prefs.getString("token") != null) { @@ -101,7 +66,7 @@ class _HomeScreenState extends State }); } - if(prefs.getString("userId") == null) { + if (prefs.getString("userId") == null) { prefs.setString("userId", Uuid().v4()); } @@ -147,347 +112,227 @@ class _HomeScreenState extends State @override Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: RefreshIndicator( - onRefresh: () async { - await homeController.getTransactions(prefs.getString("userId")!); - }, - child: CustomScrollView( - slivers: [ - SliverAppBar( - stretch: true, - stretchTriggerOffset: 100.0, - expandedHeight: 50.0, - surfaceTintColor: Colors.transparent, - floating: true, - title: Image(image: AssetImage('assets/peak.png'), width: 85), - leading: _isLoggedIn ? Container( - padding: const EdgeInsets.only(left: 10.0), - child: IconButton( - icon: Container( - width: 52, - height: 52, - decoration: BoxDecoration( - border: Border.all(color: Colors.black87.withAlpha(20)), - shape: BoxShape.circle, - gradient: LinearGradient( - colors: [ - Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.1), - Theme.of( - context, - ).colorScheme.tertiary.withValues(alpha: 0.05), - ], - stops: const [0.3, 0.7], - begin: Alignment.topRight, - end: Alignment.bottomLeft, - ), + return LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 30), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: (constraints.maxWidth > ResponsivePolicy.lg) + ? ResponsivePolicy.lg.toDouble() + : constraints.maxWidth, // Set the maximum width here + ), + child: Scaffold( + appBar: AppBar( + title: (constraints.maxWidth < ResponsivePolicy.md) + ? Image( + image: AssetImage('assets/velocity.png'), + width: 150, + ) : SizedBox(), + actions: [ + _isLoggedIn + ? Container( + padding: const EdgeInsets.only(left: 10.0), + child: IconButton( + icon: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + border: Border.all( + color: Colors.black87.withAlpha(20), + ), + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.1), + Theme.of(context).colorScheme.tertiary + .withValues(alpha: 0.05), + ], + stops: const [0.3, 0.7], + begin: Alignment.topRight, + end: Alignment.bottomLeft, + ), + ), + child: Center( + child: Text( + initials, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + onPressed: () {}, + ), + ) + : SizedBox(), + if (_isLoggedIn) + IconButton( + icon: const Icon(Icons.turn_slight_right_sharp), + onPressed: () { + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: const Text('Confirm Logout'), + content: const Text( + 'Are you sure you want to log out?', + ), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.of( + dialogContext, + ).pop(); // Dismiss the dialog + }, + ), + TextButton( + child: const Text('Logout'), + onPressed: () { + Navigator.of( + dialogContext, + ).pop(); // Dismiss the dialog + prefs.remove("token"); + setState(() { + _isLoggedIn = false; + }); + // It's generally not recommended to call initState directly. + // Consider moving the logic from initState that needs to be rerun + // into a separate method and call that method here. + // For example, if you have a method like _reloadData(), call it here. + // _reloadData(); + setupDataAndTransitions(); // Or specific parts of it if not all is needed + }, + ), + ], + ); + }, + ); + }, ), - child: Center( - child: Text( - initials, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - ), + ], + ), + body: Padding( + padding: const EdgeInsets.only(top: 20.0), + child: SafeArea( + child: RefreshIndicator( + onRefresh: () async { + await homeController.getTransactions( + prefs.getString("userId")!, + ); + }, + child: ListenableBuilder( + listenable: homeController, + builder: (context, child) { + if (constraints.maxWidth > ResponsivePolicy.md) { + return GridView.count( + crossAxisCount: 2, + crossAxisSpacing: 50, + mainAxisSpacing: 50, + shrinkWrap: true, + + children: [_buildProviders(), _buildTransactions()], + ); + } else { + return SingleChildScrollView( + child: Column( + children: [ + _buildProviders(), + SizedBox(height: 10), + _buildTransactions(), + ], + ), + ); + } + }, + ), + ), + ), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildLogin() { + return Column( + children: [ + Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.black87.withAlpha(20)), + borderRadius: BorderRadius.circular(12), + ), + padding: EdgeInsets.symmetric(vertical: 10, horizontal: 20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text( + 'Register or Login for more', + style: TextStyle(fontSize: 16), + ), + InkWell( + onTap: () { + _showFeaturesPopup(context); + }, + borderRadius: BorderRadius.circular(5), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 5, vertical: 2), + child: Text( + 'features', + style: TextStyle( + fontSize: 16, + decoration: TextDecoration.underline, + decorationColor: Theme.of( + context, + ).colorScheme.primary, + color: Theme.of(context).colorScheme.primary, ), ), ), - onPressed: () {}, - ), - ) : SizedBox(), - actions: [ - if(_isLoggedIn) - IconButton( - icon: const Icon(Icons.turn_slight_right_sharp), - onPressed: () { - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return AlertDialog( - title: const Text('Confirm Logout'), - content: const Text('Are you sure you want to log out?'), - actions: [ - TextButton( - child: const Text('Cancel'), - onPressed: () { - Navigator.of(dialogContext).pop(); // Dismiss the dialog - }, - ), - TextButton( - child: const Text('Logout'), - onPressed: () { - Navigator.of(dialogContext).pop(); // Dismiss the dialog - prefs.remove("token"); - setState(() { - _isLoggedIn = false; - }); - // It's generally not recommended to call initState directly. - // Consider moving the logic from initState that needs to be rerun - // into a separate method and call that method here. - // For example, if you have a method like _reloadData(), call it here. - // _reloadData(); - setupDataAndTransitions(); // Or specific parts of it if not all is needed - }, - ), - ], - ); - }, - ); - }, ), ], ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: ListenableBuilder( - listenable: homeController, - builder: (context, child) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!_isLoggedIn) _buildLogin(homeController), - // if (_isLoggedIn) _buildAccounts(homeController), - _buildFilterChips(homeController), - SizedBox(height: 10), - Column( - children: [ - ...homeController.model.filterableProviders.map( - (e) => _buildProviderItem(e, context), - ), - ], - ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Text( - 'Recent Activity', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 5), - if (homeController.model.transactions.isEmpty) - Column( - children: [ - SizedBox(height: 30), - Center( - child: Text( - 'No transactions yet. Make a payment to see your recent activity.', - style: TextStyle(fontSize: 16), - textAlign: TextAlign.center, - ), - ), - ], - ), - if (homeController.model.transactions.isNotEmpty) - Column( - children: [ - ListView( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - children: [ - ...homeController.model.transactions.map( - (e) => _buildTransactionItem(e, context), - ), - ], - ), - SizedBox(height: 10), - if (homeController.model.transactions.length > - 2) - OutlinedButton( - child: Text('View All'), - onPressed: () { - context.go('/history'); - }, - ), - ], - ), - ], - ); - }, + InkWell( + onTap: () { + context.push('/onboarding/landing'); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), ), + child: Text('Get Started', style: TextStyle(fontSize: 12)), ), ), ], ), ), - ), - ); - } - - Widget _buildLogin(HomeController controller) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Text( - 'Register or Login for more', - style: TextStyle(fontSize: 16), - ), - InkWell( - onTap: () { - _showFeaturesPopup(context); - }, - borderRadius: BorderRadius.circular(5), - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 5, vertical: 2), - child: Text( - 'features', - style: TextStyle( - fontSize: 16, - decoration: TextDecoration.underline, - decorationColor: Theme.of(context).colorScheme.primary, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ), - ], - ), - InkWell( - onTap: () { - context.push('/onboarding/landing'); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - ), - child: Text('Get Started', style: TextStyle(fontSize: 12)), - ), - ), - ], - ), SizedBox(height: 10), - Divider( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2), - ), ], ); } - Widget _buildAccounts(HomeController controller) { + Widget _buildAccounts() { return Column( children: [ Row( children: [ - ...controller.model.accounts.map( - (e) => Row( - children: [ - Skeletonizer( - enabled: controller.model.isLoading, - child: SlideTransition( - position: _alignListAnimations[0], - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () { - // context.push("/wallet"); - }, - child: Container( - width: 170, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), - decoration: BoxDecoration( - border: Border.all( - color: Colors.black87.withAlpha(20), - ), - borderRadius: BorderRadius.circular(12), - gradient: LinearGradient( - colors: [ - Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.1), - Theme.of( - context, - ).colorScheme.tertiary.withValues(alpha: 0.05), - ], - stops: const [0.3, 0.7], - begin: Alignment.topRight, - end: Alignment.bottomLeft, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Image.asset('assets/${e.image}', width: 20), - SizedBox(width: 10), - Text( - e.currency, - style: TextStyle( - fontSize: 22, - color: Colors.black, - fontWeight: FontWeight.w100, - ), - ), - SizedBox(width: 5), - Expanded( - child: Text( - e.balance, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 22, - color: Colors.black, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - SizedBox(height: 5), - InkWell( - onTap: () { - // context.push("/withdraw"); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary - .withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - 'Add Money', - style: TextStyle(fontSize: 12), - ), - ), - ), - ], - ), - ), - ), - ), - ), - SizedBox(width: 10), - ], - ), - ), + ...homeController.model.accounts.map((e) => _buildAccountItem(e)), ], ), SizedBox(height: 10), @@ -495,9 +340,177 @@ class _HomeScreenState extends State ); } + Widget _buildAccountItem(Account e) { + int index = homeController.model.accounts.indexOf(e); + Animation animation = + Tween(begin: Offset(0, -0.20), end: Offset.zero).animate( + CurvedAnimation( + parent: _controller, + curve: Interval(0.40 * index.toDouble(), 1.0, curve: Curves.easeIn), + ), + ); + return Row( + children: [ + Skeletonizer( + enabled: homeController.model.isLoading, + child: SlideTransition( + position: animation, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + // context.push("/wallet"); + }, + child: Container( + width: 170, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.black87.withAlpha(20)), + borderRadius: BorderRadius.circular(12), + gradient: LinearGradient( + colors: [ + Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.1), + Theme.of( + context, + ).colorScheme.tertiary.withValues(alpha: 0.05), + ], + stops: const [0.3, 0.7], + begin: Alignment.topRight, + end: Alignment.bottomLeft, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Image.asset('assets/${e.image}', width: 20), + SizedBox(width: 10), + Text( + e.currency, + style: TextStyle( + fontSize: 22, + color: Colors.black, + fontWeight: FontWeight.w100, + ), + ), + SizedBox(width: 5), + Expanded( + child: Text( + e.balance, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 22, + color: Colors.black, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + SizedBox(height: 5), + InkWell( + onTap: () { + // context.push("/withdraw"); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'Add Money', + style: TextStyle(fontSize: 12), + ), + ), + ), + ], + ), + ), + ), + ), + ), + SizedBox(width: 10), + ], + ); + } + + Widget _buildTransactions() { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text( + 'Recent Activity', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 5), + if (homeController.model.transactions.isEmpty) + Column( + children: [ + SizedBox(height: 30), + Center( + child: Text( + 'No transactions yet. Make a payment to see your recent activity.', + style: TextStyle(fontSize: 16), + textAlign: TextAlign.center, + ), + ), + ], + ), + if (homeController.model.transactions.isNotEmpty) + Column( + children: [ + ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + ...homeController.model.transactions.map( + (e) => _buildTransactionItem(e, context), + ), + ], + ), + SizedBox(height: 10), + if (homeController.model.transactions.length > 2) + OutlinedButton( + child: Text('View All'), + onPressed: () { + context.go('/history'); + }, + ), + ], + ), + ], + ); + } + Widget _buildTransactionItem(Map e, BuildContext context) { + int index = homeController.model.transactions.indexOf(e); + Animation animation = + Tween(begin: Offset(0, -0.25), end: Offset.zero).animate( + CurvedAnimation( + parent: _controller, + curve: Interval(0.175 * index, 1.0, curve: Curves.easeIn), + ), + ); return SlideTransition( - position: _alignListAnimations[0], + position: animation, child: Skeletonizer( enabled: homeController.model.isLoading, child: InkWell( @@ -535,12 +548,11 @@ class _HomeScreenState extends State ? Icons.pending : Icons.cancel, size: 16, - color: - e['integrationStatus'] == 'SUCCESS' - ? Colors.green - : e['integrationStatus'] == 'PENDING' - ? Colors.orange - : Colors.red, + color: e['integrationStatus'] == 'SUCCESS' + ? Colors.green + : e['integrationStatus'] == 'PENDING' + ? Colors.orange + : Colors.red, ), ], ), @@ -569,13 +581,35 @@ class _HomeScreenState extends State ); } + Widget _buildProviders() { + return Column( + children: [ + if (!_isLoggedIn) _buildLogin(), + SizedBox(height: 10), + // if (_isLoggedIn) _buildAccounts(homeController), + // _buildFilterChips(homeController), + ...homeController.model.filterableProviders.map( + (e) => _buildProviderItem(e, context), + ), + ], + ); + } + Column _buildProviderItem(BillProvider e, BuildContext context) { + int index = homeController.model.filterableProviders.indexOf(e); + Animation animation = + Tween(begin: Offset(0, -0.20), end: Offset.zero).animate( + CurvedAnimation( + parent: _controller, + curve: Interval(0.40 * index.toDouble(), 1.0, curve: Curves.easeIn), + ), + ); return Column( children: [ Skeletonizer( enabled: homeController.model.isLoading, child: SlideTransition( - position: _animations[homeController.model.providers.indexOf(e)], + position: animation, child: Container( decoration: getBoxDecoration(e.clientId), child: InkWell( @@ -676,12 +710,9 @@ class _HomeScreenState extends State selectedColor: Theme.of(context).colorScheme.primary.withOpacity(0.2), checkmarkColor: Theme.of(context).colorScheme.primary, labelStyle: TextStyle( - color: - isSelected - ? Theme.of( - context, - ).colorScheme.secondary.withValues(alpha: 0.7) - : Theme.of(context).colorScheme.onSurface, + color: isSelected + ? Theme.of(context).colorScheme.secondary.withValues(alpha: 0.7) + : Theme.of(context).colorScheme.onSurface, ), ), ), @@ -698,135 +729,142 @@ class _HomeScreenState extends State ), elevation: 0, backgroundColor: Colors.transparent, - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - shape: BoxShape.rectangle, - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Header with icon and title - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.1), - shape: BoxShape.circle, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 600), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + shape: BoxShape.rectangle, + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, 10), ), - child: Image(image: AssetImage("assets/peak.png"), width: 45), - ), - const SizedBox(height: 16), - Text( - 'Unlock Additional Features', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header with icon and title + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Image( + image: AssetImage("assets/favicon.png"), + width: 45, + ), ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - 'Register or login to access these great features', - style: TextStyle( - fontSize: 14, - color: Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.7), + const SizedBox(height: 16), + Text( + 'Unlock Additional Features', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), + const SizedBox(height: 8), + Text( + 'Register or login to access these great features', + style: TextStyle( + fontSize: 14, + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.7), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), - // Features list - _buildFeatureItem( - Icons.account_balance_wallet, - 'Dual Currency Wallets', - 'USD and ZWG wallets for prepayments that can be utilized later', - ), - const SizedBox(height: 16), - _buildFeatureItem( - Icons.history, - 'Transaction History', - 'All transactions are saved for reference from any device', - ), - const SizedBox(height: 16), - _buildFeatureItem( - Icons.people, - 'Recipient Management', - 'Save and manage recipient details for future use', - ), - const SizedBox(height: 16), - _buildFeatureItem( - Icons.schedule, - 'Smart Payments', - 'Schedule payments and split bills with friends', - ), + // Features list + _buildFeatureItem( + Icons.account_balance_wallet, + 'Dual Currency Wallets', + 'USD and ZWG wallets for prepayments that can be utilized later', + ), + const SizedBox(height: 16), + _buildFeatureItem( + Icons.history, + 'Transaction History', + 'All transactions are saved for reference from any device', + ), + const SizedBox(height: 16), + _buildFeatureItem( + Icons.people, + 'Recipient Management', + 'Save and manage recipient details for future use', + ), + const SizedBox(height: 16), + _buildFeatureItem( + Icons.schedule, + 'Smart Payments', + 'Schedule payments and split bills with friends', + ), - const SizedBox(height: 24), + const SizedBox(height: 24), - // Action buttons - Row( - children: [ - Expanded( - child: TextButton( - onPressed: () => Navigator.of(context).pop(), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + // Action buttons + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + 'Maybe Later', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), + fontSize: 16, + ), ), ), - child: Text( - 'Maybe Later', - style: TextStyle( - color: Theme.of( + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + context.push('/onboarding/landing'); + }, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + backgroundColor: Theme.of( context, - ).colorScheme.onSurface.withOpacity(0.6), - fontSize: 16, + ).colorScheme.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + 'Get Started', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), ), ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); - context.push('/onboarding/landing'); - }, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - backgroundColor: - Theme.of(context).colorScheme.primary, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Text( - 'Get Started', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), ); diff --git a/lib/screens/integration/integration_controller.dart b/lib/screens/integration/integration_controller.dart index beea518..def9934 100644 --- a/lib/screens/integration/integration_controller.dart +++ b/lib/screens/integration/integration_controller.dart @@ -63,12 +63,14 @@ class IntegrationController extends ChangeNotifier { model.status = response['status']; transactionController.updateReceiptData(response); - gatewayController.poll(transactionController.model.confirmationData['id']); - } else { - model.status = response['status']; - model.errorMessage = response['errorMessage']; - _showErrorSnackBar(response['errorMessage']); + await gatewayController.poll(transactionController.model.confirmationData['id']); } + + // regardless of poll result we proceed to receipt + WidgetsBinding.instance.addPostFrameCallback((_) { + context.go('/receipt'); + }); + model.isLoading = false; notifyListeners(); diff --git a/lib/screens/integration/integration_screen.dart b/lib/screens/integration/integration_screen.dart index 9e0a8b2..f59bc1f 100644 --- a/lib/screens/integration/integration_screen.dart +++ b/lib/screens/integration/integration_screen.dart @@ -1,6 +1,7 @@ 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'; @@ -68,49 +69,55 @@ class _IntegrationScreenState extends State { return Container( padding: EdgeInsets.all(50), child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox(height: 75), + 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, - ), - Image.asset('assets/peak-animation.gif', width: 150), - SizedBox( - width: 50, - child: LinearProgressIndicator(), - ), - 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(); - }, + 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(); + }, + ), + ), + ], + ), + ], + ), + ], + ), + ); + } ), ), ); diff --git a/lib/screens/onboarding/complete_screen.dart b/lib/screens/onboarding/complete_screen.dart index 2fe67c4..2daff6e 100644 --- a/lib/screens/onboarding/complete_screen.dart +++ b/lib/screens/onboarding/complete_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/screens/onboarding/login/login_controller.dart'; class CompleteScreen extends StatefulWidget { @@ -16,56 +17,61 @@ class _CompleteScreenState extends State { body: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(25), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox(height: 75), - Text( - 'Registration Complete', - style: TextStyle( - fontSize: 30, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - SizedBox(height: 5), - Text( - 'Thank you for registering with Peak payments', - style: TextStyle(fontSize: 18), - textAlign: TextAlign.center, - ), - Image.asset('assets/landing.png', width: 300), - Text( - 'Tap Login to get started', - style: TextStyle(fontSize: 18), - ), - SizedBox(height: 10), - ], - ), - ), - ), - bottomNavigationBar: SizedBox( - height: 130, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 25), - child: Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: () { - context.go('/onboarding/login'); - }, - child: Text('Go to Login', style: TextStyle(fontSize: 16)), - ), + 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: [ + SizedBox(height: 75), + Text( + 'Registration Complete', + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + SizedBox(height: 5), + Text( + 'Thank you for registering with Velocity payments', + style: TextStyle(fontSize: 18), + textAlign: TextAlign.center, + ), + Image.asset('assets/landing.png', width: 300), + Text( + 'Tap Login to get started', + style: TextStyle(fontSize: 18), + ), + SizedBox(height: 30), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 25), + child: Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () { + context.go('/onboarding/login'); + }, + child: Text('Go to Login', style: TextStyle(fontSize: 16)), + ), + ), + ], + ), + ), + SizedBox(height: 5), + ], ), - ], - ), + ); + } ), - SizedBox(height: 5), - ], + ), ), ), ); diff --git a/lib/screens/onboarding/landing/landing_screen.dart b/lib/screens/onboarding/landing/landing_screen.dart index eaf6586..fe7c4b6 100644 --- a/lib/screens/onboarding/landing/landing_screen.dart +++ b/lib/screens/onboarding/landing/landing_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/screens/onboarding/login/login_controller.dart'; class LandingScreen extends StatefulWidget { @@ -16,73 +17,82 @@ class _LandingScreenState extends State { body: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(25), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox(height: 75), - Text( - 'Welcome to Peak', - style: TextStyle( - fontSize: 30, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - SizedBox(height: 5), - Text( - 'The fastest way to pay for local goods and services', - style: TextStyle(fontSize: 18), - textAlign: TextAlign.center, - ), - Image.asset('assets/landing.png', width: 300), - Image.asset('assets/peak.png', width: 100), - SizedBox(height: 5), - Text( - 'Register or login to get started', - style: TextStyle(fontSize: 16), - ), - SizedBox(height: 10), - ], - ), - ), - ), - bottomNavigationBar: SizedBox( - height: 130, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 25), - child: Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: () { - context.go('/onboarding/register'); - }, - child: Text('Register', style: TextStyle(fontSize: 16)), - ), + 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: [ + SizedBox(height: 75), + Text( + 'Welcome to Velocity', + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + SizedBox(height: 5), + Text( + 'The fastest way to pay for local goods and services', + style: TextStyle(fontSize: 18), + textAlign: TextAlign.center, + ), + Image.asset('assets/landing.png', width: 300), + Image.asset('assets/velocity.png', width: 150), + SizedBox(height: 5), + Text( + 'Register or login to get started', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 50), + Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 25), + child: Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () { + context.go('/onboarding/register'); + }, + child: Text('Register', style: TextStyle(fontSize: 16)), + ), + ), + SizedBox(width: 16), + Expanded( + child: OutlinedButton( + onPressed: () { + context.go('/onboarding/login'); + }, + child: Text('Login', style: TextStyle(fontSize: 16)), + ), + ), + ], + ), + ), + SizedBox(height: 10), + TextButton( + onPressed: () { + context.go('/'); + }, + child: Text('Continue as guest?', style: TextStyle(fontSize: 14)), + ), + ], + ) + ], ), - SizedBox(width: 16), - Expanded( - child: TextButton( - onPressed: () { - context.go('/onboarding/login'); - }, - child: Text('Login', style: TextStyle(fontSize: 16)), - ), - ), - ], - ), - ), - SizedBox(height: 5), - TextButton( - onPressed: () { - context.go('/'); + ); }, - child: Text('Continue as guest?', style: TextStyle(fontSize: 14)), ), - ], + ), ), ), ); diff --git a/lib/screens/onboarding/login/login_screen.dart b/lib/screens/onboarding/login/login_screen.dart index ff87c5f..8d64b3a 100644 --- a/lib/screens/onboarding/login/login_screen.dart +++ b/lib/screens/onboarding/login/login_screen.dart @@ -1,12 +1,16 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:go_router/go_router.dart'; import 'package:google_sign_in/google_sign_in.dart'; +import 'package:google_sign_in_web/web_only.dart' as web; +import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/screens/onboarding/login/login_controller.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:skeletonizer/skeletonizer.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -17,6 +21,8 @@ class LoginScreen extends StatefulWidget { class _LoginScreenState extends State { late SharedPreferences prefs; + late GoogleSignIn googleSignIn; + final _formKey = GlobalKey(); bool _loading = false; @@ -30,22 +36,23 @@ class _LoginScreenState extends State { void initState() { super.initState(); _loginController = LoginController(context); + + googleSignIn = GoogleSignIn.instance; + if(kIsWeb){ + googleSignIn.initialize(); + googleSignIn.authenticationEvents + .listen(_handleAuthenticationEvent) + .onError(_handleAuthenticationError); + + }else { + unawaited(googleSignIn.initialize(serverClientId: dotenv.env['CLIENTID'])); + } + } Future signInWithGoogle() async { - final GoogleSignIn googleSignIn = GoogleSignIn.instance; - - unawaited(googleSignIn.initialize(serverClientId: dotenv.env['CLIENTID'])); - if (GoogleSignIn.instance.supportsAuthenticate()){ - unawaited(GoogleSignIn.instance.authenticate().then((value) async { - print(value); - - saveUser(value); - await _loginController.login(value.email, value.id); - context.go('/'); - googleSignIn.authenticationEvents .listen(_handleAuthenticationEvent) .onError(_handleAuthenticationError); @@ -85,6 +92,12 @@ class _LoginScreenState extends State { ?.authorizationClient .authorizationForScopes(scopes); // #enddocregion CheckAuthorization + + print(user); + saveUser(user as GoogleSignInAccount); + + await _loginController.login(user.email, user.id); + context.go('/'); } Future _handleAuthenticationError(Object e) async { @@ -105,100 +118,117 @@ class _LoginScreenState extends State { padding: const EdgeInsets.all(25), child: Form( key: _formKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox(height: 75), - Text( - 'Login', - style: TextStyle( - fontSize: 30, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - SizedBox(height: 5), - Text( - 'Welcome back to Peak', - style: TextStyle(fontSize: 18), - textAlign: TextAlign.center, - ), - SizedBox(height: 40), - Image.asset('assets/password.png', width: 300), - Text( - 'Tap on your preferred social media platform to get started', - style: TextStyle(fontSize: 18), - textAlign: TextAlign.center, - ), - // Username Field - SizedBox(height: 20), - // Forgot Password Link - // Divider with "or" text - Row( - children: [ - Expanded(child: Divider(color: Colors.grey.shade300)), - Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'choose from the options below to continue', - style: TextStyle( - color: Colors.grey.shade600, - fontSize: 14, - ), - textAlign: TextAlign.center, - ), - ), - Expanded(child: Divider(color: Colors.grey.shade300)), - ], - ), - SizedBox(height: 30), - // Social Login Buttons - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () async { - await signInWithGoogle(); - - }, - icon: Image.asset( - 'assets/google.png', - width: 20, - height: 20, - ), - label: Text('Google', style: TextStyle(fontSize: 16)), - style: OutlinedButton.styleFrom( - padding: EdgeInsets.symmetric(vertical: 16), - side: BorderSide(color: Colors.grey.shade300), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + 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: [ + SizedBox(height: 75), + Text( + 'Login', + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, ), ), - ), + SizedBox(height: 5), + Text( + 'Welcome back to Velocity', + style: TextStyle(fontSize: 18), + textAlign: TextAlign.center, + ), + SizedBox(height: 30), + Image.asset('assets/password.png', width: 300), + Text( + 'Tap on your preferred social media platform to get started', + style: TextStyle(fontSize: 18), + textAlign: TextAlign.center, + ), + // Username Field + SizedBox(height: 20), + // Forgot Password Link + // Divider with "or" text + Row( + children: [ + Expanded(child: Divider(color: Colors.grey.shade300)), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'choose from the options below to continue', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + ), + Expanded(child: Divider(color: Colors.grey.shade300)), + ], + ), + SizedBox(height: 30), + // Social Login Buttons + if(GoogleSignIn.instance.supportsAuthenticate()) + _buildGoogleButton() + else ...[ + if (kIsWeb) + web.renderButton() + ], + SizedBox(height: 40), + TextButton( + onPressed: () { + context.go('/'); + }, + child: Text('Continue as guest?', style: TextStyle(fontSize: 14)), + ), + ], ), - ], - ), - SizedBox(height: 40), - ], + ); + } + ), ), ), ), ), - bottomNavigationBar: SizedBox( - height: 130, - child: Column( - children: [ - SizedBox(height: 5), - TextButton( + ); + } + + Widget _buildGoogleButton(){ + return Skeletonizer( + enabled: _loading, + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( onPressed: () { - context.go('/'); + signInWithGoogle(); }, - child: Text('Continue as guest?', style: TextStyle(fontSize: 14)), + icon: Image.asset( + 'assets/google.png', + width: 20, + height: 20, + ), + label: Text('Google', style: TextStyle(fontSize: 16)), + style: OutlinedButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: 16), + side: BorderSide(color: Colors.grey.shade300), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), ), - ], - ), + ), + SizedBox(width: 16), + ], ), ); } + } diff --git a/lib/screens/onboarding/phone/phone_screen.dart b/lib/screens/onboarding/phone/phone_screen.dart index 1835815..1a0c253 100644 --- a/lib/screens/onboarding/phone/phone_screen.dart +++ b/lib/screens/onboarding/phone/phone_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:go_router/go_router.dart'; +import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/screens/onboarding/phone/phone_controller.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -82,89 +83,94 @@ class _PhoneScreenState extends State { padding: const EdgeInsets.all(25.0), child: Form( key: _formKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox(height: 75), - Text( - 'Verify Your Device', - style: TextStyle( - fontSize: 30, - fontWeight: FontWeight.bold, - color: Theme - .of(context) - .colorScheme - .primary, - ), - textAlign: TextAlign.center, - ), - SizedBox(height: 10), - Text( - 'We will send you a verification code to this number', - style: TextStyle(fontSize: 18), - textAlign: TextAlign.center, - ), - Image.asset('assets/phone.png', width: 300), - SizedBox(height: 20), - _buildPhoneField(), - SizedBox(height: 30), - ], - ), - ), - ), - ), - bottomNavigationBar: SizedBox( - height: 130, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 25), - child: Skeletonizer( - enabled: phoneController.model.isLoading, - child: Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: () async { - if (_formKey.currentState!.validate()) { - String fullPhoneNumber = - selectedCountryCode + _phoneController.text; - - phoneController.updatePhone(fullPhoneNumber); - await phoneController.register(); - if(phoneController.model.status != 'failed') { - context.go('/onboarding/verify'); - } - } - }, - child: Text('Send Code', style: TextStyle(fontSize: 16)), - ), - ), - SizedBox(width: 16), - Expanded( - child: TextButton( - onPressed: () { - context.go('/onboarding/login'); - }, - child: Text( - 'Go to Login', - style: TextStyle(fontSize: 16), + 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: [ + SizedBox(height: 75), + Text( + 'Verify Your Device', + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + color: Theme + .of(context) + .colorScheme + .primary, + ), + textAlign: TextAlign.center, ), - ), + SizedBox(height: 10), + Text( + 'We will send you a verification code to this number', + style: TextStyle(fontSize: 18), + textAlign: TextAlign.center, + ), + Image.asset('assets/phone.png', width: 300), + SizedBox(height: 20), + _buildPhoneField(), + SizedBox(height: 30), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 25), + child: Skeletonizer( + enabled: phoneController.model.isLoading, + child: Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () async { + if (_formKey.currentState!.validate()) { + String fullPhoneNumber = + selectedCountryCode + _phoneController.text; + + phoneController.updatePhone(fullPhoneNumber); + await phoneController.register(); + if(phoneController.model.status != 'failed') { + context.go('/onboarding/verify'); + } + } + }, + child: Text('Send Code', style: TextStyle(fontSize: 16)), + ), + ), + SizedBox(width: 16), + Expanded( + child: OutlinedButton( + onPressed: () { + context.go('/onboarding/login'); + }, + child: Text( + 'Go to Login', + style: TextStyle(fontSize: 16), + ), + ), + ), + ], + ), + ), + ), + SizedBox(height: 10), + TextButton( + onPressed: () { + context.go('/'); + }, + child: Text('Continue as guest?', style: TextStyle(fontSize: 14)), + ), + ], ), - ], - ), + ); + } ), ), - SizedBox(height: 5), - TextButton( - onPressed: () { - context.go('/'); - }, - child: Text('Continue as guest?', style: TextStyle(fontSize: 14)), - ), - ], + ), ), ), ); diff --git a/lib/screens/onboarding/register/register_screen.dart b/lib/screens/onboarding/register/register_screen.dart index b9e5b2b..7bc6f54 100644 --- a/lib/screens/onboarding/register/register_screen.dart +++ b/lib/screens/onboarding/register/register_screen.dart @@ -1,10 +1,13 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:go_router/go_router.dart'; import 'package:google_sign_in/google_sign_in.dart'; +import 'package:google_sign_in_web/web_only.dart' as web; import 'package:provider/provider.dart'; +import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/screens/onboarding/onboarding_controller.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -17,6 +20,7 @@ class RegisterScreen extends StatefulWidget { class _RegisterScreenState extends State { late OnboardingController onboardingController; + late GoogleSignIn googleSignIn; bool _loading = false; @@ -32,17 +36,22 @@ class _RegisterScreenState extends State { listen: false, ); // onboardingController.resetState(); + + googleSignIn = GoogleSignIn.instance; + if(kIsWeb){ + googleSignIn.initialize(); + googleSignIn.authenticationEvents + .listen(_handleAuthenticationEvent) + .onError(_handleAuthenticationError); + + }else { + unawaited(googleSignIn.initialize(serverClientId: dotenv.env['CLIENTID'])); + } } void signInWithGoogle() { - final GoogleSignIn googleSignIn = GoogleSignIn.instance; - - unawaited(googleSignIn.initialize(serverClientId: dotenv.env['CLIENTID'])); - if (GoogleSignIn.instance.supportsAuthenticate()){ unawaited(GoogleSignIn.instance.authenticate().then((value) { - print(value); - onboardingController.updateGoogleUser(value); googleSignIn.authenticationEvents .listen(_handleAuthenticationEvent) .onError(_handleAuthenticationError); @@ -51,9 +60,7 @@ class _RegisterScreenState extends State { } } - Future _handleAuthenticationEvent( - GoogleSignInAuthenticationEvent event, - ) async { + Future _handleAuthenticationEvent(GoogleSignInAuthenticationEvent event) async { // #docregion CheckAuthorization final GoogleSignInAccount? user = // ... // #enddocregion CheckAuthorization @@ -68,6 +75,9 @@ class _RegisterScreenState extends State { ?.authorizationClient .authorizationForScopes(scopes); // #enddocregion CheckAuthorization + print(user); + onboardingController.updateGoogleUser(user as GoogleSignInAccount); + } Future _handleAuthenticationError(Object e) async { @@ -83,170 +93,184 @@ class _RegisterScreenState extends State { body: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(25), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox(height: 75), - Text( - 'Registration', - style: TextStyle( - fontSize: 30, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - SizedBox(height: 5), - Text( - "Let's start by getting some of your details online to get you started", - style: TextStyle(fontSize: 18), - textAlign: TextAlign.center, - ), - Image.asset('assets/social.png', width: 300), - SizedBox(height: 20), - - // Social Login Buttons - if(onboardingController.model.googleUser == null) - Column( - children: [ - Row( + 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: [ - Expanded(child: Divider(color: Colors.grey.shade300)), - Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'Choose from the options below to continue', - style: TextStyle( - color: Colors.grey.shade600, - fontSize: 14, - ), - textAlign: TextAlign.center, + SizedBox(height: 75), + Text( + 'Registration', + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, ), ), - Expanded(child: Divider(color: Colors.grey.shade300)), - ], - ), - SizedBox(height: 20), - Skeletonizer( - enabled: _loading, - child: Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () { - signInWithGoogle(); - }, - icon: Image.asset( - 'assets/google.png', - width: 20, - height: 20, - ), - label: Text('Google', style: TextStyle(fontSize: 16)), - style: OutlinedButton.styleFrom( - padding: EdgeInsets.symmetric(vertical: 16), - side: BorderSide(color: Colors.grey.shade300), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + SizedBox(height: 5), + Text( + "Let's start by getting some of your online details to get you started", + style: TextStyle(fontSize: 18), + textAlign: TextAlign.center, + ), + Image.asset('assets/social.png', width: 300), + SizedBox(height: 20), + + // Social Login Buttons + if(onboardingController.model.googleUser == null) + Column( + children: [ + Row( + children: [ + Expanded(child: Divider(color: Colors.grey.shade300)), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Choose from the options below to continue', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + ), + Expanded(child: Divider(color: Colors.grey.shade300)), + ], + ), + SizedBox(height: 20), + if(GoogleSignIn.instance.supportsAuthenticate()) + _buildGoogleButton() + else ...[ + if (kIsWeb) + web.renderButton() + ], + ], + ), + if(onboardingController.model.googleUser != null) + Column( + children: [ + SizedBox( + width: 100, + child: Divider( + color: Colors.grey.shade300, + thickness: 1, ), ), - ), + SizedBox(height: 20,), + Text( + "Welcome, ${onboardingController.model.googleUser?.displayName}", + style: TextStyle(fontSize: 25), + textAlign: TextAlign.center, + ), + SizedBox(height: 10,), + Text("Tap Proceed to continue", style: TextStyle(fontSize: 18),) + ], + ), + SizedBox(height: 50), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 25), + child: Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () { + if (onboardingController.model.googleUser == null) { + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: const Text('Sign-In Required'), + content: const Text( + "Please sign in with one of the social media platforms to proceed."), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () { + Navigator.of(dialogContext).pop(); // Close the dialog + }, + ), + ], + ); + }, + ); + } else { + context.go('/onboarding/phone'); + } + }, + child: Text('Proceed', style: TextStyle(fontSize: 16)), + ), + ), + SizedBox(width: 16), + Expanded( + child: OutlinedButton( + onPressed: () { + context.go('/onboarding/login'); + }, + child: Text( + 'Go to Login', + style: TextStyle(fontSize: 16), + ), + ), + ), + ], ), - SizedBox(width: 16), - ], - ), - ), - ], - ), - if(onboardingController.model.googleUser != null) - Column( - children: [ - SizedBox( - width: 100, - child: Divider( - color: Colors.grey.shade300, - thickness: 1, ), - ), - SizedBox(height: 20,), - Text( - "Welcome, ${onboardingController.model.googleUser?.displayName}", - style: TextStyle(fontSize: 25), - textAlign: TextAlign.center, - ), - SizedBox(height: 10,), - Text("Tap Proceed to continue", style: TextStyle(fontSize: 18),) - ], - ), - SizedBox(height: 40), + SizedBox(height: 10), + TextButton( + onPressed: () { + context.go('/'); + }, + child: Text('Continue as guest?', style: TextStyle(fontSize: 14)), + ), - ], + ], + ), + ); + } + ), ), ), ), - bottomNavigationBar: SizedBox( - height: 130, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 25), - child: Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: () { - if (onboardingController.model.googleUser == null) { - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return AlertDialog( - title: const Text('Sign-In Required'), - content: const Text( - "Please sign in with one of the social media platforms to proceed."), - actions: [ - TextButton( - child: const Text('OK'), - onPressed: () { - Navigator.of(dialogContext).pop(); // Close the dialog - }, - ), - ], - ); - }, - ); - } else { - context.go('/onboarding/phone'); - } - }, - child: Text('Proceed', style: TextStyle(fontSize: 16)), - ), - ), - SizedBox(width: 16), - Expanded( - child: TextButton( - onPressed: () { - context.go('/onboarding/login'); - }, - child: Text( - 'Go to Login', - style: TextStyle(fontSize: 16), - ), - ), - ), - ], - ), - ), - SizedBox(height: 5), - TextButton( - onPressed: () { - context.go('/'); - }, - child: Text('Continue as guest?', style: TextStyle(fontSize: 14)), - ), - ], - ), - ), ); } ); } + + Widget _buildGoogleButton(){ + return Skeletonizer( + enabled: _loading, + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + signInWithGoogle(); + }, + icon: Image.asset( + 'assets/google.png', + width: 20, + height: 20, + ), + label: Text('Google', style: TextStyle(fontSize: 16)), + style: OutlinedButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: 16), + side: BorderSide(color: Colors.grey.shade300), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + SizedBox(width: 16), + ], + ), + ); + } } diff --git a/lib/screens/onboarding/verify/verify_screen.dart b/lib/screens/onboarding/verify/verify_screen.dart index a86e61d..8dc37bc 100644 --- a/lib/screens/onboarding/verify/verify_screen.dart +++ b/lib/screens/onboarding/verify/verify_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/screens/onboarding/verify/verify_controller.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -59,169 +60,162 @@ class _VerifyScreenState extends State { body: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(25), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox(height: 75), - Text( - 'Verify Your Account', - style: TextStyle( - fontSize: 30, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - SizedBox(height: 5), - Text( - 'Enter the 6-digit code sent to your phone/email', - style: TextStyle(fontSize: 18), - textAlign: TextAlign.center, - ), - Image.asset('assets/verify.png', width: 300), - // Verification Code Input - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: List.generate( - 6, - (index) => SizedBox( - width: 50, - child: TextFormField( - controller: _codeControllers[index], - focusNode: _focusNodes[index], - textAlign: TextAlign.center, - keyboardType: TextInputType.number, - maxLength: 1, - decoration: InputDecoration( - counterText: '', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.grey.shade300), + 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: [ + SizedBox(height: 75), + Text( + 'Verify Your Account', + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary, - width: 2, + ), + SizedBox(height: 5), + Text( + 'Enter the 6-digit code sent to your phone/email', + style: TextStyle(fontSize: 18), + textAlign: TextAlign.center, + ), + Image.asset('assets/verify.png', width: 300), + // Verification Code Input + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate( + 6, + (index) => SizedBox( + width: 50, + child: TextFormField( + controller: _codeControllers[index], + focusNode: _focusNodes[index], + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + maxLength: 1, + decoration: InputDecoration( + counterText: '', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + ), + contentPadding: EdgeInsets.symmetric(vertical: 16), + ), + onChanged: (value) => _onCodeChanged(value, index), + ), ), ), - contentPadding: EdgeInsets.symmetric(vertical: 16), ), - onChanged: (value) => _onCodeChanged(value, index), - ), - ), - ), - ), - SizedBox(height: 5), - if(errorMessage != null) - Text(errorMessage ?? '', style: TextStyle(color: Colors.red)), - SizedBox(height: 30), - // Resend Code Section - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "Didn't receive the code? ", - style: TextStyle(color: Colors.grey.shade600, fontSize: 16), - ), - TextButton( - onPressed: () { - verifyController.resendOtp(); - }, - child: Text( - 'Resend Code', - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontSize: 16, - fontWeight: FontWeight.w600, + SizedBox(height: 5), + if(errorMessage != null) + Text(errorMessage ?? '', style: TextStyle(color: Colors.red)), + SizedBox(height: 30), + // Resend Code Section + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Didn't receive the code? ", + style: TextStyle(color: Colors.grey.shade600, fontSize: 16), + ), + TextButton( + onPressed: () { + verifyController.resendOtp(); + }, + child: Text( + 'Resend Code', + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ), - ), - ), - ], - ), - // SizedBox(height: 20), - // // Timer for resend (optional) - // Text( - // 'Resend available in 2:30', - // style: TextStyle(color: Colors.grey.shade500, fontSize: 14), - // ), - SizedBox(height: 40), - ], - ), - ), - ), - bottomNavigationBar: SizedBox( - height: 130, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 25), - child: Skeletonizer( - enabled: verifyController.model.isLoading, - child: Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: () async { - showErrorMessage(null); - String code = ''; - for (var controller in _codeControllers) { - code += controller.text; - } + // SizedBox(height: 20), + // // Timer for resend (optional) + // Text( + // 'Resend available in 2:30', + // style: TextStyle(color: Colors.grey.shade500, fontSize: 14), + // ), + SizedBox(height: 40), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 25), + child: Skeletonizer( + enabled: verifyController.model.isLoading, + child: Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () async { + showErrorMessage(null); + String code = ''; + for (var controller in _codeControllers) { + code += controller.text; + } - if(code.length != 6) { - showErrorMessage('Invalid code'); - return; - } - verifyController.updateCode(code); - await verifyController.verifyOtp(); - if(verifyController.model.status == 'done') { - context.go('/onboarding/complete'); - } - }, - style: ElevatedButton.styleFrom( - padding: EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + if(code.length != 6) { + showErrorMessage('Invalid code'); + return; + } + verifyController.updateCode(code); + await verifyController.verifyOtp(); + if(verifyController.model.status == 'done') { + context.go('/onboarding/complete'); + } + }, + child: Text( + 'Verify Code', + style: TextStyle(fontSize: 16), + ), + ), + ), + SizedBox(width: 16), + Expanded( + child: OutlinedButton( + onPressed: () { + context.go('/onboarding/login'); + }, + child: Text( + 'Back to Login', + style: TextStyle(fontSize: 16), + ), + ), + ), + ], ), ), - child: Text( - 'Verify Code', - style: TextStyle(fontSize: 16), - ), ), - ), - SizedBox(width: 16), - Expanded( - child: OutlinedButton( + SizedBox(height: 10), + TextButton( onPressed: () { - context.go('/onboarding/login'); + context.go('/'); }, - style: OutlinedButton.styleFrom( - padding: EdgeInsets.symmetric(vertical: 16), - side: BorderSide(color: Colors.grey.shade300), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Text( - 'Back to Login', - style: TextStyle(fontSize: 16), - ), + child: Text('Continue as guest?', style: TextStyle(fontSize: 14)), ), - ), - ], - ), - ), + + ], + ), + ); + } ), - SizedBox(height: 20), - TextButton( - onPressed: () { - context.go('/'); - }, - child: Text('Continue as guest?', style: TextStyle(fontSize: 14)), - ), - ], + ), ), ), ); diff --git a/lib/screens/pay/pay_controller.dart b/lib/screens/pay/pay_controller.dart index 8df6e12..ccee845 100644 --- a/lib/screens/pay/pay_controller.dart +++ b/lib/screens/pay/pay_controller.dart @@ -95,7 +95,7 @@ class PayController extends ChangeNotifier { transactionController.model.formData.copyWith( type: "CONFIRM", billClientId: model.selectedProvider?.clientId ?? '', - debitRef: 'peak', + debitRef: 'Velocity', debitCurrency: 'USD', creditAccount: transactionController.model.formData.creditAccount, creditPhone: transactionController.model.formData.creditPhone, diff --git a/lib/screens/pay/pay_screen.dart b/lib/screens/pay/pay_screen.dart index 0eb1caa..dbc720e 100644 --- a/lib/screens/pay/pay_screen.dart +++ b/lib/screens/pay/pay_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/screens/pay/pay_controller.dart'; import 'package:qpay/screens/transaction_controller.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -178,105 +179,127 @@ class _PayScreenState extends State with TickerProviderStateMixin { position: _slideAnimation, child: Form( key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "TRANSACTIONS DETAILS", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 20), - 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), - ), - ], - ), - const SizedBox(height: 10), - Divider(color: Theme.of(context).colorScheme.primary), - InkWell( - customBorder: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - onTap: () { - setState(() { - showAdditionalRecipientDetails = - !showAdditionalRecipientDetails; - }); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - onPressed: () {}, - icon: Icon( - showAdditionalRecipientDetails - ? Icons.arrow_drop_up - : Icons.arrow_drop_down, + child: Center( + child: LayoutBuilder( + 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( + 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), + ), + ], + ), + 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), + ), + ], + ), + ], + ), ), - color: Theme.of(context).colorScheme.primary, - ), - Text( - showAdditionalRecipientDetails - ? "Hide additional recipient details" - : "Show additional recipient details", - style: TextStyle(fontSize: 16), - ), - ], - ), - ), - if (showAdditionalRecipientDetails) - _buildAdditionalRecipientDetails(), - const SizedBox(height: 7), - if (controller.model.products.isNotEmpty) - _buildBillProductSelector(controller), - const SizedBox(height: 7), - _buildPhoneField(), - const SizedBox(height: 7), - _buildAmountField(), - const SizedBox(height: 20), - // _buildPayButton(controller), - ...controller.model.paymentProcessors.map( - (e) => _buildPaymentProcessorItem(e, context), - ), - ], + SizedBox(height: 20), + InkWell( + customBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + onTap: () { + setState(() { + showAdditionalRecipientDetails = + !showAdditionalRecipientDetails; + }); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + onPressed: () {}, + icon: Icon( + showAdditionalRecipientDetails + ? Icons.arrow_drop_up + : Icons.arrow_drop_down, + ), + color: Theme.of(context).colorScheme.primary, + ), + Text( + showAdditionalRecipientDetails + ? "Hide additional recipient details" + : "Show additional recipient details", + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + if (showAdditionalRecipientDetails) + _buildAdditionalRecipientDetails(), + const SizedBox(height: 7), + if (controller.model.products.isNotEmpty) + _buildBillProductSelector(controller), + const SizedBox(height: 7), + _buildPhoneField(), + const SizedBox(height: 7), + _buildAmountField(), + const SizedBox(height: 20), + // _buildPayButton(controller), + ...controller.model.paymentProcessors.map( + (e) => _buildPaymentProcessorItem(e, context), + ), + ], + ), + ); + } + ), ), ), ), diff --git a/lib/screens/poll/poll_screen.dart b/lib/screens/poll/poll_screen.dart new file mode 100644 index 0000000..9774aad --- /dev/null +++ b/lib/screens/poll/poll_screen.dart @@ -0,0 +1,126 @@ +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'; + +class PollScreen extends StatefulWidget { + const PollScreen({super.key}); + + @override + State createState() => _PollScreenState(); +} + +class _PollScreenState extends State { + late GatewayController controller; + late TransactionController transactionController; + + bool integrating = false; + + @override + void initState() { + super.initState(); + controller = GatewayController(context); + transactionController = Provider.of( + context, + listen: false, + ); + + controller.pollTransaction(transactionController.model.confirmationData['id']); + } + + 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(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Completing your transaction'), + ), + body: ListenableBuilder( + listenable: controller, + builder: (context, child) { + if (controller.model.status == 'SUCCESS') { + WidgetsBinding.instance.addPostFrameCallback((_) { + context.go('/integration'); + }); + } + + 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.pollTransaction(transactionController.model.confirmationData['id']); + }, + ), + ), + ], + ), + ], + ), + ], + ), + ); + } + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/screens/receipt/receipt_screen.dart b/lib/screens/receipt/receipt_screen.dart index 6e55997..6b303a5 100644 --- a/lib/screens/receipt/receipt_screen.dart +++ b/lib/screens/receipt/receipt_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; +import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/screens/gateway/gateway_controller.dart'; import 'package:qpay/screens/transaction_controller.dart'; import 'package:go_router/go_router.dart'; @@ -59,15 +60,13 @@ class _ReceiptScreenState 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(); } @@ -87,9 +86,13 @@ class _ReceiptScreenState extends State _retry() async { receiptController.setLoading(true); - await receiptController.doIntegration(receiptController.model.receiptData?["id"]); - if(receiptController.model.status == "SUCCESS"){ - receiptController.getReceiptData(transactionController.model.receiptData?["id"]); + await receiptController.doIntegration( + receiptController.model.receiptData?["id"], + ); + if (receiptController.model.status == "SUCCESS") { + receiptController.getReceiptData( + receiptController.model.receiptData?["id"], + ); } receiptController.setLoading(false); } @@ -97,9 +100,11 @@ class _ReceiptScreenState extends State _check() async { receiptController.setLoading(true); await gatewayController.poll(receiptController.model.receiptData?["id"]); - if(gatewayController.model.status == "SUCCESS"){ - receiptController.getReceiptData(transactionController.model.receiptData?["id"]); - if(transactionController.model.receiptData?["pollingStatus"]) { + if (gatewayController.model.status == "SUCCESS") { + receiptController.getReceiptData( + transactionController.model.receiptData?["id"], + ); + if (transactionController.model.receiptData?["pollingStatus"]) { receiptController.setLoading(false); return; // no need to proceed if we've already polled successfully } @@ -131,15 +136,14 @@ class _ReceiptScreenState extends State expandedHeight: 50.0, surfaceTintColor: Colors.transparent, floating: true, - 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: Text( "Payment Result", style: TextStyle(color: Colors.black87), @@ -158,180 +162,266 @@ class _ReceiptScreenState extends State position: _slideAnimation, child: Form( key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - 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: LayoutBuilder( + builder: (context, constraints) { + return Center( + child: SizedBox( + width: + constraints.maxWidth > + ResponsivePolicy.md + ? ResponsivePolicy.md.toDouble() + : constraints.maxWidth, 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, + 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), + ), + ], ), - 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( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.center, 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, + 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, ), - ), - child: + SizedBox(width: 5), + Text( receiptController .model - .isLoading - ? Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: - 2.5, - valueColor: AlwaysStoppedAnimation< - Color - >( + .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, - ), - ), + .primary + .withOpacity( + 0.3, + ), + width: 1, ), - ) - : IconButton( - onPressed: () { + ), + child: receiptController - .repeatTransaction( - context, - ); + .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.repeat, + Icons.share, color: Theme.of( context, @@ -341,449 +431,603 @@ class _ReceiptScreenState extends State 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, - ), - ), - ], - ), - ], - ), - ], - ), - ), - ), - 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), - 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", + 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, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ), + 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, + ), + ), ), - if (receiptController - .model - .receiptData?["additionalData"] != - null) - ...receiptController - .model - .receiptData?["additionalData"] - .map((data) { - return Skeletonizer( - enabled: receiptController + 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: Column( + 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, + ), + 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: @@ -791,8 +1035,7 @@ class _ReceiptScreenState extends State .spaceBetween, children: [ Text( - data["name"] ?? - "", + "Our Charge", style: TextStyle( fontSize: 16, @@ -802,13 +1045,17 @@ class _ReceiptScreenState extends State ), ), Text( - data["value"] ?? - "", - style: - TextStyle( - fontSize: - 16, - ), + transactionController + .model + .receiptData?["charge"] + .toStringAsFixed( + 2, + ) ?? + "0.00", + style: TextStyle( + fontSize: + 16, + ), ), ], ), @@ -817,198 +1064,132 @@ class _ReceiptScreenState extends State ), ], ), - ); - }) - .toList(), - ], + 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, + ), + ], + ), + ), + ], + ), ), - ), - 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), - ], - ), - ), - ], - ), + ], + ), + ], ), - ], - ), - ], + ), + ); + }, ), ), ), diff --git a/lib/screens/recipient/recipients_screen.dart b/lib/screens/recipient/recipients_screen.dart index c06167d..9db87a2 100644 --- a/lib/screens/recipient/recipients_screen.dart +++ b/lib/screens/recipient/recipients_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/screens/transaction_controller.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:qpay/screens/recipient/recipients_controller.dart'; @@ -83,81 +84,107 @@ class _RecipientsScreenState extends State return SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "TRANSACTIONS DETAILS", - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text("Provider", style: TextStyle(fontSize: 16)), - Text( - controller.model.selectedProvider?.name ?? "", - style: TextStyle(fontSize: 16), - ), - ], - ), - const SizedBox(height: 20), - Divider(color: Theme.of(context).colorScheme.primary), - const SizedBox(height: 20), - _buildAccountField(controller), - const SizedBox(height: 20), - 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, + child: Center( + child: LayoutBuilder( + 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( + 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), ), - ), - SizedBox(width: 5), - Icon(Icons.arrow_forward_ios, size: 12), - ], + 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), + ), + ], + ), + ], + ), ), - ), - ) - : SizedBox(), - const SizedBox(height: 20), - Text( - "Recent Recipients", - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 10), - _buildRecipientItems(controller), - ], + const SizedBox(height: 20), + _buildAccountField(controller), + const SizedBox(height: 20), + 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, + ), + ), + 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), + ), + const SizedBox(height: 10), + _buildRecipientItems(controller), + ], + ), + ); + } + ), ), ), ); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 9005b53..2a85495 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import google_sign_in_ios import path_provider_foundation import share_plus import shared_preferences_foundation +import url_launcher_macos import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -18,5 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index bcecaf4..a32c08e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -153,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -377,7 +385,7 @@ packages: source: hosted version: "3.1.0" google_sign_in_web: - dependency: transitive + dependency: "direct main" description: name: google_sign_in_web sha256: "2fc1f941e6443b2d6984f4056a727a3eaeab15d8ee99ba7125d79029be75a1da" @@ -392,6 +400,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + html: + dependency: "direct main" + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: transitive description: @@ -861,6 +877,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + url: "https://pub.dev" + source: hosted + version: "6.3.6" url_launcher_linux: dependency: transitive description: @@ -869,6 +909,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -950,7 +998,7 @@ packages: source: hosted version: "1.1.4" web: - dependency: transitive + dependency: "direct main" description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" diff --git a/pubspec.yaml b/pubspec.yaml index 003afcf..264b554 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: qpay -description: "Peak payments solutions" +description: "Velocity payments solutions" # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev @@ -55,6 +55,10 @@ dependencies: gif_view: ^0.4.0 google_sign_in: ^7.1.1 firebase_core: ^4.1.0 + url_launcher: ^6.3.2 + html: ^0.15.6 + web: ^1.1.1 + google_sign_in_web: ^1.1.0 dev_dependencies: flutter_test: diff --git a/web/favicon.png b/web/favicon.png index 8aaa46a..721aa6e 100644 Binary files a/web/favicon.png and b/web/favicon.png differ diff --git a/web/index.html b/web/index.html index d1bc15b..61823fe 100644 --- a/web/index.html +++ b/web/index.html @@ -20,6 +20,7 @@ + @@ -30,8 +31,27 @@ - qpay + Velocity + + + + + +