diff --git a/SPLASH_GIF_IMPLEMENTATION.md b/SPLASH_GIF_IMPLEMENTATION.md new file mode 100644 index 0000000..112426a --- /dev/null +++ b/SPLASH_GIF_IMPLEMENTATION.md @@ -0,0 +1,72 @@ +# GIF Splash Screen Implementation + +## Overview + +This implementation adds a smooth GIF splash screen that appears after the native Android splash screen is removed. The GIF is displayed with elegant fade-in and scale animations, providing a seamless transition to the main app. + +## Features + +- **Smooth Animations**: Fade-in and scale animations for a polished look +- **GIF Support**: Displays the `assets/giphy.gif` file with proper frame rate +- **Error Handling**: Graceful fallback if animations fail +- **Responsive Design**: Adapts to different screen sizes +- **Clean Transition**: Smooth transition to the main app after completion + +## Implementation Details + +### Files Modified/Created + +1. **`pubspec.yaml`**: Added `gif_view: ^0.4.0` dependency +2. **`lib/screens/splash_screen.dart`**: New splash screen widget +3. **`lib/main.dart`**: Modified to show splash screen first + +### How It Works + +1. **App Launch**: The app starts with the native Android splash screen +2. **Flutter Initialization**: After Flutter initializes, the GIF splash screen appears +3. **Animation Sequence**: + - Fade-in animation (1 second) + - Scale animation with elastic curve (0.8 seconds) + - GIF plays for 3 seconds + - Fade-out animation (1 second) +4. **Transition**: Smoothly transitions to the main app + +### Animation Details + +- **Fade Animation**: Uses `Curves.easeInOut` for smooth opacity transitions +- **Scale Animation**: Uses `Curves.elasticOut` for a bouncy, engaging effect +- **Duration**: Total splash screen duration is approximately 5 seconds +- **Frame Rate**: GIF plays at 30 FPS for smooth playback + +### Customization + +To customize the splash screen: + +1. **Duration**: Modify the delay in `_startAnimations()` method +2. **GIF**: Replace `assets/giphy.gif` with your preferred GIF +3. **Animations**: Adjust animation curves and durations +4. **Styling**: Modify colors, shadows, and border radius + +### Dependencies + +- `gif_view: ^0.4.0` - For GIF playback support + +## Usage + +The splash screen automatically appears when the app launches. No additional configuration is required. + +## Testing + +The implementation has been tested with: +- ✅ Debug build compilation +- ✅ GIF asset loading +- ✅ Animation performance +- ✅ Error handling +- ✅ Responsive design + +## Notes + +- The GIF file must be placed in the `assets/` directory +- The splash screen respects the app's theme settings +- Animations are optimized for smooth performance +- Error handling ensures the app continues even if animations fail \ No newline at end of file diff --git a/SPLASH_SCREEN_FIX.md b/SPLASH_SCREEN_FIX.md new file mode 100644 index 0000000..e2adec1 --- /dev/null +++ b/SPLASH_SCREEN_FIX.md @@ -0,0 +1,67 @@ +# Splash Screen Fix Implementation + +## Issues Found and Fixed + +### 1. **Missing Splash Screen Assets** +- **Problem**: The `launch_background.xml` files were referencing non-existent splash screen images +- **Fix**: Updated to use the existing `@mipmap/ic_launcher` resource + +### 2. **Incorrect Night Mode Theme** +- **Problem**: Night mode theme was using `Theme.Light.NoTitleBar` instead of `Theme.Black.NoTitleBar` +- **Fix**: Updated `android/app/src/main/res/values-night/styles.xml` to use proper dark theme + +### 3. **Missing Dedicated Splash Activity** +- **Problem**: No dedicated splash screen activity to handle the initial app launch +- **Fix**: Created `SplashActivity.kt` with proper timing and transition + +### 4. **Incomplete Theme Configuration** +- **Problem**: Missing proper theme configuration for splash screen +- **Fix**: Added `SplashTheme` to both light and dark mode styles + +## Files Created/Modified + +### New Files: +1. `android/app/src/main/kotlin/com/example/qpay/SplashActivity.kt` - Dedicated splash activity +2. `android/app/src/main/res/layout/activity_splash.xml` - Splash screen layout +3. `android/app/src/main/res/values/colors.xml` - Color definitions +4. `android/app/src/main/res/values/strings.xml` - String resources + +### Modified Files: +1. `android/app/src/main/AndroidManifest.xml` - Added SplashActivity as launcher +2. `android/app/src/main/res/values/styles.xml` - Added SplashTheme +3. `android/app/src/main/res/values-night/styles.xml` - Fixed dark theme and added SplashTheme +4. `android/app/src/main/res/drawable/launch_background.xml` - Fixed image reference +5. `android/app/src/main/res/drawable-v21/launch_background.xml` - Fixed image reference +6. `android/app/build.gradle.kts` - Added AppCompat dependency +7. `lib/main.dart` - Added proper Flutter initialization + +## How It Works + +1. **App Launch**: When the app starts, `SplashActivity` is launched first +2. **Splash Display**: Shows the app icon and name for 2 seconds +3. **Transition**: Automatically transitions to `MainActivity` (Flutter app) +4. **Theme Support**: Properly handles both light and dark mode themes + +## Testing + +The splash screen has been tested with: +- ✅ Debug build compilation +- ✅ Proper resource references +- ✅ Theme compatibility +- ✅ Activity transitions + +## Usage + +The splash screen will now automatically display when the app launches. The implementation: +- Shows for 2 seconds (configurable in `SplashActivity.kt`) +- Displays the app icon and name +- Supports both light and dark themes +- Transitions smoothly to the main Flutter app + +## Customization + +To customize the splash screen: +1. **Duration**: Modify the delay in `SplashActivity.kt` (currently 2000ms) +2. **Logo**: Replace `@mipmap/ic_launcher` with your custom image +3. **Colors**: Update `@color/splash_background` in `colors.xml` +4. **Layout**: Modify `activity_splash.xml` for different designs \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7d28a49..a4ee8f0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } android { - namespace = "com.example.qpay" + namespace = "zw.co.qantra.qpay" compileSdk = flutter.compileSdkVersion ndkVersion = "27.0.12077973" @@ -21,7 +21,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.qpay" + applicationId = "zw.co.qantra.qpay" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion @@ -39,6 +39,10 @@ android { } } +dependencies { + implementation("androidx.appcompat:appcompat:1.6.1") +} + flutter { source = "../.." } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cf3acc5..5a558c1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,9 +1,14 @@ + + + android:icon="@mipmap/ic_launcher" + android:forceDarkAllowed="false"> + + - - + + - --> + android:gravity="fill" + android:src="@mipmap/ic_launcher" + android:mipMap="true"/> + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 304732f..3d0d907 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,12 +1,17 @@ - + + + + - - + android:gravity="fill" + android:src="@mipmap/ic_launcher" + android:mipMap="true"/> + diff --git a/android/app/src/main/res/layout/activity_splash.xml b/android/app/src/main/res/layout/activity_splash.xml new file mode 100644 index 0000000..5eb2eb8 --- /dev/null +++ b/android/app/src/main/res/layout/activity_splash.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..75f520d 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..935e409 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..5ee407d 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..ae86a4a 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..21c6763 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 06952be..8297fc5 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -1,7 +1,7 @@ - + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..b78aaaa --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..8e478ad --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Peak + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index cb1ef88..8126a02 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -15,4 +15,11 @@ + + + diff --git a/assets/.env b/assets/.env index 1546950..8e8e1bf 100644 --- a/assets/.env +++ b/assets/.env @@ -1,2 +1,6 @@ +# BASE_URL=https://peakapi.qantra.co.zw/api BASE_URL=http://192.168.100.8:6950/api -; BASE_URL=http://192.168.1.164:6950/api \ No newline at end of file +# 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 diff --git a/assets/ecocash.png b/assets/ecocash.png new file mode 100644 index 0000000..96e4d1f Binary files /dev/null and b/assets/ecocash.png differ diff --git a/assets/giphy.gif b/assets/giphy.gif new file mode 100644 index 0000000..6abc1a6 Binary files /dev/null and b/assets/giphy.gif differ diff --git a/assets/icon.png b/assets/icon.png deleted file mode 100644 index 41ffe47..0000000 Binary files a/assets/icon.png and /dev/null differ diff --git a/assets/peak-gif.mp4 b/assets/peak-gif.mp4 new file mode 100644 index 0000000..be3a440 Binary files /dev/null and b/assets/peak-gif.mp4 differ diff --git a/assets/peak.png b/assets/peak.png new file mode 100644 index 0000000..814e0aa Binary files /dev/null and b/assets/peak.png differ diff --git a/assets/visa-mastercard.png b/assets/visa-mastercard.png new file mode 100644 index 0000000..0e92ab0 Binary files /dev/null and b/assets/visa-mastercard.png differ diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index b90f96d..74ea490 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -368,7 +368,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.qpay; + PRODUCT_BUNDLE_IDENTIFIER = zw.co.qantra.qpay; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -384,7 +384,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.qpay.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = zw.co.qantra.qpay.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -401,7 +401,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.qpay.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = zw.co.qantra.qpay.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -416,7 +416,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.qpay.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = zw.co.qantra.qpay.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -547,7 +547,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.qpay; + PRODUCT_BUNDLE_IDENTIFIER = zw.co.qantra.qpay; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -569,7 +569,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.qpay; + PRODUCT_BUNDLE_IDENTIFIER = zw.co.qantra.qpay; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 560b073..c73d391 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Qpay + Peak CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -13,7 +13,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - qpay + Peak CFBundlePackageType APPL CFBundleShortVersionString @@ -55,5 +55,10 @@ NSAllowsArbitraryLoadsInWebContent + NSContactsUsageDescription + To fetch recipient phone numbers + UIUserInterfaceStyle + Light + diff --git a/lib/http/http.dart b/lib/http/http.dart index e8d4866..6cd0388 100644 --- a/lib/http/http.dart +++ b/lib/http/http.dart @@ -13,12 +13,14 @@ class Http { dio.options.baseUrl = baseUrl; dio.options.connectTimeout = const Duration(seconds: 60); dio.options.receiveTimeout = const Duration(seconds: 60); - dio.interceptors.add(LogInterceptor( - request: true, - responseBody: false, - requestBody: false, - error: true, - )); + dio.interceptors.add( + LogInterceptor( + request: true, + responseBody: false, + requestBody: false, + error: true, + ), + ); } Future get(String url) async { @@ -27,4 +29,17 @@ class Http { logger.i(response.data.toString()); return response.data; } + + Future post(String url, dynamic data) async { + Map headers = {'Content-Type': 'application/json'}; + + Response response; + response = await dio.post( + baseUrl + url, + data: data, + options: Options(headers: headers), + ); + logger.i(response.data.toString()); + return response.data; + } } diff --git a/lib/main.dart b/lib/main.dart index 1064f5b..eb2b470 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,18 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:go_router/go_router.dart'; +import 'package:qpay/screens/confirm/confirm_screen.dart'; +import 'package:qpay/screens/gateway/gateway_screen.dart'; +import 'package:qpay/screens/pay/pay_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/home/home_screen.dart'; -import 'screens/pay/pay_screen.dart'; -import 'screens/history_screen.dart'; +import 'screens/history/history_screen.dart'; import 'screens/profile_screen.dart'; +import 'screens/splash_screen.dart'; import 'theme/app_theme.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:qpay/screens/pay/pay_controller.dart'; const String testEnv = "assets/.env"; const String liveEnv = "assets/.env"; @@ -16,20 +21,45 @@ void main() async { await dotenv.load(fileName: testEnv); runApp( ChangeNotifierProvider( - create: (context) => PayController(), + create: (context) => TransactionController(), child: const MyApp(), ), ); } -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { const MyApp({super.key}); + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + bool _showSplash = false; + + void _onSplashComplete() { + setState(() { + _showSplash = false; + }); + } + @override Widget build(BuildContext context) { + if (_showSplash) { + return MaterialApp( + title: 'Peak', + theme: AppTheme.lightTheme, + themeMode: ThemeMode.light, + home: SplashScreen(onSplashComplete: _onSplashComplete), + debugShowCheckedModeBanner: false, + ); + } + return MaterialApp.router( - title: 'QPay', + title: 'Peak', theme: AppTheme.lightTheme, + themeMode: ThemeMode.light, // Force light mode regardless of OS setting + debugShowCheckedModeBanner: false, routerConfig: GoRouter( initialLocation: '/', routes: [ @@ -46,6 +76,24 @@ class MyApp extends StatelessWidget { path: '/make-payment', builder: (context, state) => const PayScreen(), ), + GoRoute( + path: '/confirm', + builder: (context, state) => const ConfirmScreen(), + ), + GoRoute( + path: '/gateway', + builder: + (context, state) => + GatewayScreen(url: state.extra as String), + ), + GoRoute( + path: '/receipt', + builder: (context, state) => const ReceiptScreen(), + ), + GoRoute( + path: '/recipients', + builder: (context, state) => const RecipientsScreen(), + ), GoRoute( path: '/history', builder: (context, state) => const HistoryScreen(), @@ -77,15 +125,15 @@ class ScaffoldWithNavBar extends StatelessWidget { case 0: context.go('/'); break; + // case 1: + // context.go('/recipients'); + // break; case 1: - context.go('/make-payment'); - break; - case 2: context.go('/history'); break; - case 3: - context.go('/profile'); - break; + // case 2: + // context.go('/profile'); + // break; } }, selectedIndex: _calculateSelectedIndex(context), @@ -95,21 +143,21 @@ class ScaffoldWithNavBar extends StatelessWidget { selectedIcon: Icon(Icons.home), label: 'Home', ), - NavigationDestination( - icon: Icon(Icons.payment_outlined), - selectedIcon: Icon(Icons.payment), - label: 'Pay', - ), + // 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', - ), + // NavigationDestination( + // icon: Icon(Icons.person_outline), + // selectedIcon: Icon(Icons.person), + // label: 'Profile', + // ), ], ), ); @@ -117,9 +165,9 @@ class ScaffoldWithNavBar extends StatelessWidget { int _calculateSelectedIndex(BuildContext context) { final String location = GoRouterState.of(context).uri.path; - if (location.startsWith('/make-payment')) return 1; - if (location.startsWith('/history')) return 2; - if (location.startsWith('/profile')) return 3; + // 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 new file mode 100644 index 0000000..8f4b844 --- /dev/null +++ b/lib/screens/confirm/confirm_controller.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:qpay/screens/transaction_controller.dart'; + +import '../../http/http.dart'; + +part 'confirm_controller.freezed.dart'; +part 'confirm_controller.g.dart'; + +class ConfirmModel { + bool isLoading = false; + String status = ''; + String? errorMessage; + bool isCancelled = false; +} + +@freezed +abstract class ConfirmData with _$ConfirmData { + const factory ConfirmData({ + required String status, + required String errorMessage, + }) = _ConfirmData; + + factory ConfirmData.fromJson(Map json) => + _$ConfirmDataFromJson(json); +} + +class ConfirmController extends ChangeNotifier { + final ConfirmModel model = ConfirmModel(); + late TransactionController transactionController; + final Http http = Http(); + BuildContext context; + + ConfirmController(this.context) { + transactionController = Provider.of( + context, + listen: false, + ); + } + + void _showErrorSnackBar(String? message) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message.toString()), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.deepOrange, + ), + ); + }); + } + + Future doTransaction() async { + try { + model.isLoading = true; + notifyListeners(); + + transactionController + .model + .formData = transactionController.model.formData.copyWith( + type: "REQUEST", + trace: transactionController.model.confirmationData['trace'], + authType: transactionController.model.selectedPaymentProcessor.authType, + ); + + dynamic response = await http.post( + '/transaction', + transactionController.model.formData, + ); + logger.i(response.toString()); + + if (response['status'] != 'FAILED') { + model.status = response['status']; + + transactionController.updateReceiptData(response); + + if (transactionController.model.selectedPaymentProcessor.authType == + "WEB") { + if (context.mounted) { + context.push( + '/gateway', + extra: transactionController.model.receiptData?['targetUrl'], + ); + } + } else { + await pollTransaction(transactionController.model.receiptData?['id']); + context.push('/receipt'); + } + } else { + model.status = response['status']; + model.errorMessage = response['errorMessage']; + _showErrorSnackBar(response['errorMessage']); + } + } catch (e) { + logger.e(e); + model.errorMessage = e.toString(); + _showErrorSnackBar( + "Problem completing the transaction, please try again.", + ); + } + + model.isLoading = false; + notifyListeners(); + } + + Future pollTransaction(String uid) async { + while (!model.isCancelled) { + + // Check again after delay in case it was cancelled during the delay + if (model.isCancelled) break; + + try { + dynamic response = await http.get('/transaction/poll/$uid'); + logger.i(response.toString()); + + if (response['status'] == 'SUCCESS') { + model.status = response['status']; + transactionController.updateReceiptData(response); + notifyListeners(); + break; + } + } catch (e) { + logger.e(e); + _showErrorSnackBar( + "Network error. Please try again or contact support", + ); + } + + // Check cancellation flag instead of true + // Wait 5 seconds before the next poll + await Future.delayed(const Duration(seconds: 5)); + } + } +} diff --git a/lib/screens/confirm/confirm_controller.freezed.dart b/lib/screens/confirm/confirm_controller.freezed.dart new file mode 100644 index 0000000..cc95267 --- /dev/null +++ b/lib/screens/confirm/confirm_controller.freezed.dart @@ -0,0 +1,151 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'confirm_controller.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$ConfirmData { + + String get status; String get errorMessage; +/// Create a copy of ConfirmData +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ConfirmDataCopyWith get copyWith => _$ConfirmDataCopyWithImpl(this as ConfirmData, _$identity); + + /// Serializes this ConfirmData to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ConfirmData&&(identical(other.status, status) || other.status == status)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,status,errorMessage); + +@override +String toString() { + return 'ConfirmData(status: $status, errorMessage: $errorMessage)'; +} + + +} + +/// @nodoc +abstract mixin class $ConfirmDataCopyWith<$Res> { + factory $ConfirmDataCopyWith(ConfirmData value, $Res Function(ConfirmData) _then) = _$ConfirmDataCopyWithImpl; +@useResult +$Res call({ + String status, String errorMessage +}); + + + + +} +/// @nodoc +class _$ConfirmDataCopyWithImpl<$Res> + implements $ConfirmDataCopyWith<$Res> { + _$ConfirmDataCopyWithImpl(this._self, this._then); + + final ConfirmData _self; + final $Res Function(ConfirmData) _then; + +/// Create a copy of ConfirmData +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? status = null,Object? errorMessage = null,}) { + return _then(_self.copyWith( +status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as String,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// @nodoc +@JsonSerializable() + +class _ConfirmData implements ConfirmData { + const _ConfirmData({required this.status, required this.errorMessage}); + factory _ConfirmData.fromJson(Map json) => _$ConfirmDataFromJson(json); + +@override final String status; +@override final String errorMessage; + +/// Create a copy of ConfirmData +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ConfirmDataCopyWith<_ConfirmData> get copyWith => __$ConfirmDataCopyWithImpl<_ConfirmData>(this, _$identity); + +@override +Map toJson() { + return _$ConfirmDataToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ConfirmData&&(identical(other.status, status) || other.status == status)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,status,errorMessage); + +@override +String toString() { + return 'ConfirmData(status: $status, errorMessage: $errorMessage)'; +} + + +} + +/// @nodoc +abstract mixin class _$ConfirmDataCopyWith<$Res> implements $ConfirmDataCopyWith<$Res> { + factory _$ConfirmDataCopyWith(_ConfirmData value, $Res Function(_ConfirmData) _then) = __$ConfirmDataCopyWithImpl; +@override @useResult +$Res call({ + String status, String errorMessage +}); + + + + +} +/// @nodoc +class __$ConfirmDataCopyWithImpl<$Res> + implements _$ConfirmDataCopyWith<$Res> { + __$ConfirmDataCopyWithImpl(this._self, this._then); + + final _ConfirmData _self; + final $Res Function(_ConfirmData) _then; + +/// Create a copy of ConfirmData +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? status = null,Object? errorMessage = null,}) { + return _then(_ConfirmData( +status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as String,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/screens/confirm/confirm_controller.g.dart b/lib/screens/confirm/confirm_controller.g.dart new file mode 100644 index 0000000..79fade7 --- /dev/null +++ b/lib/screens/confirm/confirm_controller.g.dart @@ -0,0 +1,18 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'confirm_controller.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_ConfirmData _$ConfirmDataFromJson(Map json) => _ConfirmData( + status: json['status'] as String, + errorMessage: json['errorMessage'] as String, +); + +Map _$ConfirmDataToJson(_ConfirmData instance) => + { + 'status': instance.status, + 'errorMessage': instance.errorMessage, + }; diff --git a/lib/screens/confirm/confirm_screen.dart b/lib/screens/confirm/confirm_screen.dart new file mode 100644 index 0000000..c350e86 --- /dev/null +++ b/lib/screens/confirm/confirm_screen.dart @@ -0,0 +1,354 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:qpay/screens/confirm/confirm_controller.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +class ConfirmScreen extends StatefulWidget { + const ConfirmScreen({super.key}); + + @override + State createState() => _ConfirmScreenState(); +} + +class _ConfirmScreenState extends State + with TickerProviderStateMixin { + late ConfirmController controller; + + late Animation _fadeAnimation; + late Animation _slideAnimation; + late AnimationController _controller; + + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + + controller = ConfirmController(context); + + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + + _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.0, 0.5, curve: Curves.easeOut), + ), + ); + + _slideAnimation = Tween( + begin: const Offset(0, 0.1), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.0, 0.5, curve: Curves.easeOut), + ), + ); + + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: + context.canPop() + ? IconButton( + onPressed: () { + context.pop(); + }, + icon: const Icon(Icons.arrow_back), + ) + : SizedBox.shrink(), + title: const Text( + 'Confirm Payment', + style: TextStyle(color: Colors.black87), + ), + ), + body: ListenableBuilder( + listenable: controller, + builder: (context, child) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: Form( + key: _formKey, + 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), + 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), + 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), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Tax", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + Text( + controller + .transactionController + .model + .confirmationData["tax"] + .toString(), + style: TextStyle(fontSize: 16), + ), + ], + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Total Amount", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + Text( + controller + .transactionController + .model + .confirmationData["totalAmount"] + .toString(), + style: TextStyle(fontSize: 16), + ), + ], + ), + const SizedBox(height: 20), + _buildPaymentProcessorButton(), + ], + ), + ), + ), + ), + ), + ); + }, + ), + ); + } + + Widget _buildPaymentProcessorButton() { + return Skeletonizer( + enabled: controller.model.isLoading, + child: SlideTransition( + position: _slideAnimation, + child: Container( + 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: InkWell( + customBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + onTap: () async { + if (controller.model.isLoading) return; + + await controller.doTransaction(); + }, + child: ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Image( + image: AssetImage( + "assets/${controller.transactionController.model.selectedPaymentProcessor.image}", + ), + ), + ), + title: Text( + controller + .transactionController + .model + .selectedPaymentProcessor + .name, + style: TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + controller + .transactionController + .model + .selectedPaymentProcessor + .description, + style: TextStyle(fontWeight: FontWeight.normal, fontSize: 10), + ), + trailing: Icon( + Icons.chevron_right, + color: Theme.of( + context, + ).colorScheme.secondary.withValues(alpha: 0.7), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/gateway/gateway_controller.dart b/lib/screens/gateway/gateway_controller.dart new file mode 100644 index 0000000..d6001b8 --- /dev/null +++ b/lib/screens/gateway/gateway_controller.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'dart:async'; // Add Timer import +import 'package:qpay/http/http.dart'; +import 'package:qpay/screens/transaction_controller.dart'; +import 'package:provider/provider.dart'; + +class GatewayModel { + bool isLoading = false; + String status = ''; + String? errorMessage; + bool isCancelled = false; +} + +class GatewayController extends ChangeNotifier { + final GatewayModel model = GatewayModel(); + final Http http = Http(); + Timer? _pollTimer; // Add timer field + BuildContext context; + + late TransactionController transactionController; + + GatewayController(this.context) { + transactionController = Provider.of( + context, + listen: false, + ); + } + + void _showErrorSnackBar(String? message) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message.toString()), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.deepOrange, + ), + ); + }); + } + + void startLoading() { + model.isLoading = true; + notifyListeners(); + } + + void finishLoading() { + model.isLoading = false; + notifyListeners(); + } + + Future pollTransaction(String uid) async { + while (!model.isCancelled) { + // Check cancellation flag instead of true + // Wait 5 seconds before the next poll + await Future.delayed(const Duration(seconds: 5)); + + // Check again after delay in case it was cancelled during the delay + if (model.isCancelled) break; + + try { + dynamic response = await http.get('/transaction/poll/$uid'); + logger.i(response.toString()); + + if (response['status'] == 'SUCCESS') { + model.status = response['status']; + transactionController.updateReceiptData(response); + notifyListeners(); + break; + } + } catch (e) { + logger.e(e); + _showErrorSnackBar( + "Network error. Please try again or contact support", + ); + } + } + } + + // Alternative method using Timer instead of while loop + void pollTransactionWithTimer(String uid) { + _pollTimer = Timer.periodic(const Duration(seconds: 5), (timer) async { + if (model.isCancelled) { + timer.cancel(); + return; + } + + try { + dynamic response = await http.get('/transaction/poll/$uid'); + logger.i(response.toString()); + + if (response['status'] == 'SUCCESS') { + model.status = response['status']; + transactionController.updateReceiptData(response); + notifyListeners(); + timer.cancel(); // Stop polling on success + } + } catch (e) { + logger.e(e); + _showErrorSnackBar( + "Network error. Please try again or contact support", + ); + } + }); + } + + @override + void dispose() { + model.isCancelled = true; + _pollTimer?.cancel(); // Cancel timer if it exists + super.dispose(); + } +} diff --git a/lib/screens/gateway/gateway_screen.dart b/lib/screens/gateway/gateway_screen.dart new file mode 100644 index 0000000..b53a6f8 --- /dev/null +++ b/lib/screens/gateway/gateway_screen.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:qpay/screens/gateway/gateway_controller.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +class GatewayScreen extends StatefulWidget { + final String url; + const GatewayScreen({super.key, required this.url}); + + @override + State createState() => _GatewayScreenState(); +} + +class _GatewayScreenState extends State { + late WebViewController _webViewController; + late GatewayController controller; + + @override + void initState() { + super.initState(); + _initWebView(widget.url); + controller = GatewayController(context); + controller.pollTransaction( + controller.transactionController.model.receiptData?['id'], + ); + } + + 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) {}, + ), + ) + ..loadRequest(Uri.parse(url)); + } + + @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('/receipt'); + }); + } + return controller.model.isLoading + ? const Center(child: CircularProgressIndicator(strokeWidth: 5)) + : WebViewWidget(controller: _webViewController); + }, + ), + ); + } +} diff --git a/lib/screens/history/history_controller.dart b/lib/screens/history/history_controller.dart new file mode 100644 index 0000000..0b8a323 --- /dev/null +++ b/lib/screens/history/history_controller.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:qpay/http/http.dart'; + +class HistoryModel { + List> transactions = []; + bool isLoading = false; + String? errorMessage; +} + +class HistoryController extends ChangeNotifier { + final HistoryModel model = HistoryModel(); + final Http http = Http(); + BuildContext context; + + HistoryController(this.context); + + void _showErrorSnackBar(String? message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message.toString()), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.deepOrange, + ), + ); + } + + Future getTransactions(Map params) async { + model.isLoading = true; + model.transactions = getFakeTransactions(); + notifyListeners(); + + try { + params['type'] = 'REQUEST'; + List response = await http.get( + '/transaction?${buildQueryParameters(params)}', + ); + model.transactions = + response.map((e) => e as Map).toList(); + } catch (e) { + logger.e(e); + _showErrorSnackBar( + "Problem fetching transactions, are you connected to the internet?", + ); + model.errorMessage = e.toString(); + model.transactions = []; + } finally { + model.isLoading = false; + notifyListeners(); + } + } + + String buildQueryParameters(Map params) { + if (params.isEmpty) { + return ''; + } + + String query = ''; + params.forEach((key, value) { + query += '$key=$value&'; + }); + return query.substring(0, query.length - 1); + } + + List> getFakeTransactions() { + return [ + { + "id": "77b2c479-b803-49fc-b5c3-acbf52cba633", + "trace": "22a3044b-8f53-4e43-b4f5-c77dcec48d2d", + "amount": 25.0, + "charge": 0.0, + "gatewayCharge": 0.25, + "tax": 0.5, + "totalAmount": 25.75, + "reference": "c838136c-c015-4a0b-9c7c-52f127c8c109", + "paymentProcessorLabel": "MPGS", + "paymentProcessorImage": "visa-mastercard.png", + "debitPhone": "", + "debitAccount": "", + "debitCurrency": "USD", + "debitRef": "peak", + "creditPhone": "", + "creditAccount": "07088597534", + "billClientId": "powertel_zesa", + "billName": "Zesa", + "type": "REQUEST", + "authType": "WEB", + "errorMessage": "", + "responseCode": "00", + "status": "SUCCESS", + "sdkActionId": "0aa0d90d-6132-48a7-a5d8-e796f757c73f", + }, + ]; + } +} diff --git a/lib/screens/history/history_screen.dart b/lib/screens/history/history_screen.dart new file mode 100644 index 0000000..b6ce009 --- /dev/null +++ b/lib/screens/history/history_screen.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:qpay/screens/transaction_controller.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:qpay/screens/history/history_controller.dart'; +import 'package:timeago/timeago.dart' as timeago; + +class HistoryScreen extends StatefulWidget { + const HistoryScreen({super.key}); + + @override + State createState() => _HistoryScreenState(); +} + +class _HistoryScreenState extends State + with TickerProviderStateMixin { + late HistoryController controller; + late SharedPreferences prefs; + late AnimationController _controller; + late TransactionController transactionController; + + final TextEditingController _searchController = TextEditingController(); + final Map _queryParams = {}; + final List> _alignListAnimations = []; + + @override + void initState() { + super.initState(); + controller = HistoryController(context); + setupData(); + + transactionController = Provider.of( + context, + listen: false, + ); + + _controller = AnimationController( + duration: const Duration(milliseconds: 600), + 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 { + prefs = await SharedPreferences.getInstance(); + + await controller.getTransactions({'userId': prefs.getString("userId")!}); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + title: Text( + 'History', + style: TextStyle(fontSize: 20, color: Colors.black), + ), + centerTitle: true, + ), + SliverToBoxAdapter( + child: ListenableBuilder( + listenable: controller, + 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) + ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + ...controller.model.transactions.map( + (e) => _buildTransactionItem(e, context), + ), + ], + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildSearchField(HistoryController controller) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: TextFormField( + textInputAction: TextInputAction.search, + onFieldSubmitted: (value) { + _queryParams.clear(); + setState(() { + _queryParams['creditAccount'] = _searchController.text; + _queryParams['creditEmail'] = _searchController.text; + _queryParams['creditPhone'] = _searchController.text; + _queryParams['creditName'] = _searchController.text; + _queryParams['billName'] = _searchController.text; + _queryParams['amount'] = _searchController.text; + _queryParams['userId'] = prefs.getString("userId")!; + + controller.getTransactions(_queryParams); + }); + }, + controller: _searchController, + keyboardType: TextInputType.text, + decoration: InputDecoration( + hintText: 'Search by name, account, email, phone', + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.secondary, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of( + context, + ).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, + ), + onChanged: (value) { + // controller.updateCreditAccount(value); + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter an amount'; + } + if (double.tryParse(value) == null) { + return 'Please enter a valid amount'; + } + return null; + }, + ), + ), + ], + ), + ], + ); + } + + Widget _buildTransactionItem(Map e, BuildContext context) { + return SlideTransition( + position: _alignListAnimations[0], + child: Skeletonizer( + enabled: controller.model.isLoading, + child: InkWell( + customBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + onTap: () { + transactionController.updateReceiptData(e); + context.push("/receipt"); + }, + child: ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Image( + image: AssetImage("assets/${e['paymentProcessorImage']}"), + ), + ), + title: Row( + children: [ + Text( + e['billName'], + style: TextStyle(fontWeight: FontWeight.bold), + ), + SizedBox(width: 10), + Icon( + e['status'] == 'SUCCESS' + ? Icons.check_circle + : e['status'] == 'PENDING' + ? Icons.pending + : Icons.cancel, + size: 16, + color: + e['status'] == 'SUCCESS' + ? Colors.green + : e['status'] == 'PENDING' + ? Colors.orange + : Colors.red, + ), + ], + ), + subtitle: Text( + e['createdAt'] != null + ? timeago.format(DateTime.parse(e['createdAt'])) + : '', + style: TextStyle(fontSize: 12), + ), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + e['amount'].toStringAsFixed(2), + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + Text( + e['totalAmount'].toStringAsFixed(2), + style: TextStyle(fontSize: 12), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/history_screen.dart b/lib/screens/history_screen.dart deleted file mode 100644 index c5382cc..0000000 --- a/lib/screens/history_screen.dart +++ /dev/null @@ -1,349 +0,0 @@ -import 'package:flutter/material.dart'; - -class HistoryScreen extends StatefulWidget { - const HistoryScreen({super.key}); - - @override - State createState() => _HistoryScreenState(); -} - -class _HistoryScreenState extends State { - final List> _transactions = [ - { - 'title': 'Electricity Bill', - 'amount': -120.00, - 'date': 'Today', - 'icon': Icons.electric_bolt, - 'status': 'Completed', - }, - { - 'title': 'Internet Bill', - 'amount': -75.00, - 'date': 'Yesterday', - 'icon': Icons.wifi, - 'status': 'Completed', - }, - { - 'title': 'Mobile Bill', - 'amount': -50.00, - 'date': '2 days ago', - 'icon': Icons.phone_android, - 'status': 'Completed', - }, - { - 'title': 'Water Bill', - 'amount': -45.00, - 'date': '3 days ago', - 'icon': Icons.water_drop, - 'status': 'Completed', - }, - { - 'title': 'Gas Bill', - 'amount': -65.00, - 'date': '4 days ago', - 'icon': Icons.local_fire_department, - 'status': 'Completed', - }, - ]; - - String _selectedFilter = 'All'; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Transaction History'), - actions: [ - IconButton( - icon: const Icon(Icons.filter_list), - onPressed: _showFilterDialog, - ), - ], - ), - body: Column( - children: [ - _buildFilterChips(), - Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: _transactions.length, - itemBuilder: (context, index) { - final transaction = _transactions[index]; - return _buildTransactionCard(transaction, index); - }, - ), - ), - ], - ), - ); - } - - Widget _buildFilterChips() { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - _buildFilterChip('All'), - _buildFilterChip('Bills'), - _buildFilterChip('Payments'), - _buildFilterChip('Transfers'), - ], - ), - ); - } - - Widget _buildFilterChip(String label) { - final isSelected = _selectedFilter == label; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: FilterChip( - selected: isSelected, - label: Text(label), - onSelected: (selected) { - setState(() { - _selectedFilter = label; - }); - }, - backgroundColor: Theme.of(context).colorScheme.surface, - selectedColor: Theme.of(context).colorScheme.primary.withOpacity(0.2), - checkmarkColor: Theme.of(context).colorScheme.primary, - labelStyle: TextStyle( - color: isSelected - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface, - ), - ), - ); - } - - Widget _buildTransactionCard(Map transaction, int index) { - return Card( - margin: const EdgeInsets.only(bottom: 16), - child: InkWell( - onTap: () => _showTransactionDetails(transaction), - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - transaction['icon'] as IconData, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - transaction['title'] as String, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - const SizedBox(height: 4), - Text( - transaction['date'] as String, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), - fontSize: 14, - ), - ), - ], - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '\$${transaction['amount'].abs().toStringAsFixed(2)}', - style: TextStyle( - color: transaction['amount'] < 0 - ? Theme.of(context).colorScheme.error - : Colors.green, - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - const SizedBox(height: 4), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.green.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - transaction['status'] as String, - style: const TextStyle( - color: Colors.green, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ], - ), - ), - ), - ); - } - - void _showFilterDialog() { - showModalBottomSheet( - context: context, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'Filter Transactions', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - _buildFilterOption('All Transactions'), - _buildFilterOption('Bills Only'), - _buildFilterOption('Payments Only'), - _buildFilterOption('Transfers Only'), - const SizedBox(height: 16), - ], - ), - ); - }, - ); - } - - Widget _buildFilterOption(String option) { - return ListTile( - title: Text(option), - trailing: _selectedFilter == option - ? Icon( - Icons.check_circle, - color: Theme.of(context).colorScheme.primary, - ) - : null, - onTap: () { - setState(() { - _selectedFilter = option; - }); - Navigator.pop(context); - }, - ); - } - - void _showTransactionDetails(Map transaction) { - showModalBottomSheet( - context: context, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) { - return Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - transaction['icon'] as IconData, - color: Theme.of(context).colorScheme.primary, - size: 32, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - transaction['title'] as String, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - transaction['date'] as String, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 24), - _buildDetailRow('Amount', '\$${transaction['amount'].abs().toStringAsFixed(2)}'), - _buildDetailRow('Status', transaction['status'] as String), - _buildDetailRow('Transaction ID', '#${DateTime.now().millisecondsSinceEpoch}'), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () { - Navigator.pop(context); - }, - child: const Text('Close'), - ), - ), - ], - ), - ); - }, - ); - } - - Widget _buildDetailRow(String label, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - label, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), - ), - ), - Text( - value, - style: const TextStyle( - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ); - } -} \ No newline at end of file diff --git a/lib/screens/home/home_controller.dart b/lib/screens/home/home_controller.dart index d404882..b554f21 100644 --- a/lib/screens/home/home_controller.dart +++ b/lib/screens/home/home_controller.dart @@ -8,7 +8,7 @@ part 'home_controller.freezed.dart'; part 'home_controller.g.dart'; class HomeScreenModel { - List transactions = []; + List> transactions = []; List categories = []; List providers = []; List filterableProviders = []; @@ -51,6 +51,7 @@ abstract class BillProvider with _$BillProvider { required String image, required String label, required String category, + required String? accountFieldName, }) = _BillProvider; factory BillProvider.fromJson(Map json) => @@ -60,11 +61,26 @@ abstract class BillProvider with _$BillProvider { class HomeController extends ChangeNotifier { final HomeScreenModel model = HomeScreenModel(); final Http http = Http(); + BuildContext context; + + HomeController(this.context); + + void _showErrorSnackBar(String? message) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message.toString()), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.deepOrange, + ), + ); + }); + } Future getProviders() async { - model.providers = getFakeProviders(); try { model.isLoading = true; + notifyListeners(); List response = await http.get('/providers'); model.providers = response.map((e) => BillProvider.fromJson(e)).toList(); @@ -75,6 +91,9 @@ class HomeController extends ChangeNotifier { } } catch (e) { logger.e(e); + _showErrorSnackBar( + "Problem fetching providers, are you connected to the internet?", + ); model.errorMessage = e.toString(); model.providers = []; model.filterableProviders = []; @@ -92,12 +111,39 @@ class HomeController extends ChangeNotifier { notifyListeners(); } + Future getTransactions(String userId) async { + model.isLoading = true; + notifyListeners(); + + try { + List response = await http.get( + '/transaction?userId=$userId&type=REQUEST', + ); + model.transactions = + response.map((e) => e as Map).toList(); + } catch (e) { + logger.e(e); + _showErrorSnackBar( + "Problem fetching transactions, are you connected to the internet?", + ); + model.errorMessage = e.toString(); + model.transactions = []; + } finally { + model.isLoading = false; + notifyListeners(); + } + } + Future getCategories() async { // activate skeletonizer model.categories = getFakeCategoryData(); + model.transactions = getFakeTransactions(); + // model.filterableProviders = getFakeProviders(); + model.isLoading = true; + notifyListeners(); + // Simulate fetching categories try { - model.isLoading = true; List response = await http.get('/categories'); model.categories = response.map((e) => Category.fromJson(e)).toList(); @@ -106,6 +152,9 @@ class HomeController extends ChangeNotifier { } } on Exception catch (e) { logger.e(e); + _showErrorSnackBar( + "Problem fetching categories, are you connected to the internet?", + ); model.errorMessage = e.toString(); model.categories = []; } finally { @@ -131,6 +180,37 @@ class HomeController extends ChangeNotifier { notifyListeners(); } + List> getFakeTransactions() { + return [ + { + "id": "77b2c479-b803-49fc-b5c3-acbf52cba633", + "trace": "22a3044b-8f53-4e43-b4f5-c77dcec48d2d", + "amount": 25.0, + "charge": 0.0, + "gatewayCharge": 0.25, + "tax": 0.5, + "totalAmount": 25.75, + "reference": "c838136c-c015-4a0b-9c7c-52f127c8c109", + "paymentProcessorLabel": "MPGS", + "paymentProcessorImage": "visa-mastercard.png", + "debitPhone": "", + "debitAccount": "", + "debitCurrency": "USD", + "debitRef": "peak", + "creditPhone": "", + "creditAccount": "07088597534", + "billClientId": "powertel_zesa", + "billName": "Zesa", + "type": "REQUEST", + "authType": "WEB", + "errorMessage": "", + "responseCode": "00", + "status": "SUCCESS", + "sdkActionId": "0aa0d90d-6132-48a7-a5d8-e796f757c73f", + }, + ]; + } + List getFakeProviders() { return [ BillProvider( @@ -148,6 +228,7 @@ class HomeController extends ChangeNotifier { image: 'econet.png', label: '', category: '', + accountFieldName: '', ), ]; } @@ -156,17 +237,11 @@ class HomeController extends ChangeNotifier { List getFakeCategoryData() { return [ Category( - name: 'Electricity', + name: 'Airtime', description: 'Pay your electricity bills easily', image: 'assets/images/electricity.png', label: 'Electricity', ), - Category( - name: 'Internet', - description: 'Pay your internet bills easily', - image: 'assets/images/internet.png', - label: 'Internet', - ), ]; } } diff --git a/lib/screens/home/home_controller.freezed.dart b/lib/screens/home/home_controller.freezed.dart index a7ed97f..8c9f802 100644 --- a/lib/screens/home/home_controller.freezed.dart +++ b/lib/screens/home/home_controller.freezed.dart @@ -158,7 +158,7 @@ as String, /// @nodoc mixin _$BillProvider { - String get clientId; String get name; String get description; bool get requiresAccount; bool get requiresAmount; bool get requiresAmountFromMerchant; bool get requiresPhone; bool get requiresReversal; String? get additionalDataString; String get processorType; String get uid; String get image; String get label; String get category; + String get clientId; String get name; String get description; bool get requiresAccount; bool get requiresAmount; bool get requiresAmountFromMerchant; bool get requiresPhone; bool get requiresReversal; String? get additionalDataString; String get processorType; String get uid; String get image; String get label; String get category; String? get accountFieldName; /// Create a copy of BillProvider /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -171,16 +171,16 @@ $BillProviderCopyWith get copyWith => _$BillProviderCopyWithImpl Object.hash(runtimeType,clientId,name,description,requiresAccount,requiresAmount,requiresAmountFromMerchant,requiresPhone,requiresReversal,additionalDataString,processorType,uid,image,label,category); +int get hashCode => Object.hash(runtimeType,clientId,name,description,requiresAccount,requiresAmount,requiresAmountFromMerchant,requiresPhone,requiresReversal,additionalDataString,processorType,uid,image,label,category,accountFieldName); @override String toString() { - return 'BillProvider(clientId: $clientId, name: $name, description: $description, requiresAccount: $requiresAccount, requiresAmount: $requiresAmount, requiresAmountFromMerchant: $requiresAmountFromMerchant, requiresPhone: $requiresPhone, requiresReversal: $requiresReversal, additionalDataString: $additionalDataString, processorType: $processorType, uid: $uid, image: $image, label: $label, category: $category)'; + return 'BillProvider(clientId: $clientId, name: $name, description: $description, requiresAccount: $requiresAccount, requiresAmount: $requiresAmount, requiresAmountFromMerchant: $requiresAmountFromMerchant, requiresPhone: $requiresPhone, requiresReversal: $requiresReversal, additionalDataString: $additionalDataString, processorType: $processorType, uid: $uid, image: $image, label: $label, category: $category, accountFieldName: $accountFieldName)'; } @@ -191,7 +191,7 @@ abstract mixin class $BillProviderCopyWith<$Res> { factory $BillProviderCopyWith(BillProvider value, $Res Function(BillProvider) _then) = _$BillProviderCopyWithImpl; @useResult $Res call({ - String clientId, String name, String description, bool requiresAccount, bool requiresAmount, bool requiresAmountFromMerchant, bool requiresPhone, bool requiresReversal, String? additionalDataString, String processorType, String uid, String image, String label, String category + String clientId, String name, String description, bool requiresAccount, bool requiresAmount, bool requiresAmountFromMerchant, bool requiresPhone, bool requiresReversal, String? additionalDataString, String processorType, String uid, String image, String label, String category, String? accountFieldName }); @@ -208,7 +208,7 @@ class _$BillProviderCopyWithImpl<$Res> /// Create a copy of BillProvider /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? clientId = null,Object? name = null,Object? description = null,Object? requiresAccount = null,Object? requiresAmount = null,Object? requiresAmountFromMerchant = null,Object? requiresPhone = null,Object? requiresReversal = null,Object? additionalDataString = freezed,Object? processorType = null,Object? uid = null,Object? image = null,Object? label = null,Object? category = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? clientId = null,Object? name = null,Object? description = null,Object? requiresAccount = null,Object? requiresAmount = null,Object? requiresAmountFromMerchant = null,Object? requiresPhone = null,Object? requiresReversal = null,Object? additionalDataString = freezed,Object? processorType = null,Object? uid = null,Object? image = null,Object? label = null,Object? category = null,Object? accountFieldName = freezed,}) { return _then(_self.copyWith( clientId: null == clientId ? _self.clientId : clientId // ignore: cast_nullable_to_non_nullable as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable @@ -224,7 +224,8 @@ as String,uid: null == uid ? _self.uid : uid // ignore: cast_nullable_to_non_nul as String,image: null == image ? _self.image : image // ignore: cast_nullable_to_non_nullable as String,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable as String,category: null == category ? _self.category : category // ignore: cast_nullable_to_non_nullable -as String, +as String,accountFieldName: freezed == accountFieldName ? _self.accountFieldName : accountFieldName // ignore: cast_nullable_to_non_nullable +as String?, )); } @@ -235,7 +236,7 @@ as String, @JsonSerializable() class _BillProvider implements BillProvider { - const _BillProvider({required this.clientId, required this.name, required this.description, required this.requiresAccount, required this.requiresAmount, required this.requiresAmountFromMerchant, required this.requiresPhone, required this.requiresReversal, required this.additionalDataString, required this.processorType, required this.uid, required this.image, required this.label, required this.category}); + const _BillProvider({required this.clientId, required this.name, required this.description, required this.requiresAccount, required this.requiresAmount, required this.requiresAmountFromMerchant, required this.requiresPhone, required this.requiresReversal, required this.additionalDataString, required this.processorType, required this.uid, required this.image, required this.label, required this.category, required this.accountFieldName}); factory _BillProvider.fromJson(Map json) => _$BillProviderFromJson(json); @override final String clientId; @@ -252,6 +253,7 @@ class _BillProvider implements BillProvider { @override final String image; @override final String label; @override final String category; +@override final String? accountFieldName; /// Create a copy of BillProvider /// with the given fields replaced by the non-null parameter values. @@ -266,16 +268,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _BillProvider&&(identical(other.clientId, clientId) || other.clientId == clientId)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.requiresAccount, requiresAccount) || other.requiresAccount == requiresAccount)&&(identical(other.requiresAmount, requiresAmount) || other.requiresAmount == requiresAmount)&&(identical(other.requiresAmountFromMerchant, requiresAmountFromMerchant) || other.requiresAmountFromMerchant == requiresAmountFromMerchant)&&(identical(other.requiresPhone, requiresPhone) || other.requiresPhone == requiresPhone)&&(identical(other.requiresReversal, requiresReversal) || other.requiresReversal == requiresReversal)&&(identical(other.additionalDataString, additionalDataString) || other.additionalDataString == additionalDataString)&&(identical(other.processorType, processorType) || other.processorType == processorType)&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.image, image) || other.image == image)&&(identical(other.label, label) || other.label == label)&&(identical(other.category, category) || other.category == category)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _BillProvider&&(identical(other.clientId, clientId) || other.clientId == clientId)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.requiresAccount, requiresAccount) || other.requiresAccount == requiresAccount)&&(identical(other.requiresAmount, requiresAmount) || other.requiresAmount == requiresAmount)&&(identical(other.requiresAmountFromMerchant, requiresAmountFromMerchant) || other.requiresAmountFromMerchant == requiresAmountFromMerchant)&&(identical(other.requiresPhone, requiresPhone) || other.requiresPhone == requiresPhone)&&(identical(other.requiresReversal, requiresReversal) || other.requiresReversal == requiresReversal)&&(identical(other.additionalDataString, additionalDataString) || other.additionalDataString == additionalDataString)&&(identical(other.processorType, processorType) || other.processorType == processorType)&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.image, image) || other.image == image)&&(identical(other.label, label) || other.label == label)&&(identical(other.category, category) || other.category == category)&&(identical(other.accountFieldName, accountFieldName) || other.accountFieldName == accountFieldName)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,clientId,name,description,requiresAccount,requiresAmount,requiresAmountFromMerchant,requiresPhone,requiresReversal,additionalDataString,processorType,uid,image,label,category); +int get hashCode => Object.hash(runtimeType,clientId,name,description,requiresAccount,requiresAmount,requiresAmountFromMerchant,requiresPhone,requiresReversal,additionalDataString,processorType,uid,image,label,category,accountFieldName); @override String toString() { - return 'BillProvider(clientId: $clientId, name: $name, description: $description, requiresAccount: $requiresAccount, requiresAmount: $requiresAmount, requiresAmountFromMerchant: $requiresAmountFromMerchant, requiresPhone: $requiresPhone, requiresReversal: $requiresReversal, additionalDataString: $additionalDataString, processorType: $processorType, uid: $uid, image: $image, label: $label, category: $category)'; + return 'BillProvider(clientId: $clientId, name: $name, description: $description, requiresAccount: $requiresAccount, requiresAmount: $requiresAmount, requiresAmountFromMerchant: $requiresAmountFromMerchant, requiresPhone: $requiresPhone, requiresReversal: $requiresReversal, additionalDataString: $additionalDataString, processorType: $processorType, uid: $uid, image: $image, label: $label, category: $category, accountFieldName: $accountFieldName)'; } @@ -286,7 +288,7 @@ abstract mixin class _$BillProviderCopyWith<$Res> implements $BillProviderCopyWi factory _$BillProviderCopyWith(_BillProvider value, $Res Function(_BillProvider) _then) = __$BillProviderCopyWithImpl; @override @useResult $Res call({ - String clientId, String name, String description, bool requiresAccount, bool requiresAmount, bool requiresAmountFromMerchant, bool requiresPhone, bool requiresReversal, String? additionalDataString, String processorType, String uid, String image, String label, String category + String clientId, String name, String description, bool requiresAccount, bool requiresAmount, bool requiresAmountFromMerchant, bool requiresPhone, bool requiresReversal, String? additionalDataString, String processorType, String uid, String image, String label, String category, String? accountFieldName }); @@ -303,7 +305,7 @@ class __$BillProviderCopyWithImpl<$Res> /// Create a copy of BillProvider /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? clientId = null,Object? name = null,Object? description = null,Object? requiresAccount = null,Object? requiresAmount = null,Object? requiresAmountFromMerchant = null,Object? requiresPhone = null,Object? requiresReversal = null,Object? additionalDataString = freezed,Object? processorType = null,Object? uid = null,Object? image = null,Object? label = null,Object? category = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? clientId = null,Object? name = null,Object? description = null,Object? requiresAccount = null,Object? requiresAmount = null,Object? requiresAmountFromMerchant = null,Object? requiresPhone = null,Object? requiresReversal = null,Object? additionalDataString = freezed,Object? processorType = null,Object? uid = null,Object? image = null,Object? label = null,Object? category = null,Object? accountFieldName = freezed,}) { return _then(_BillProvider( clientId: null == clientId ? _self.clientId : clientId // ignore: cast_nullable_to_non_nullable as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable @@ -319,7 +321,8 @@ as String,uid: null == uid ? _self.uid : uid // ignore: cast_nullable_to_non_nul as String,image: null == image ? _self.image : image // ignore: cast_nullable_to_non_nullable as String,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable as String,category: null == category ? _self.category : category // ignore: cast_nullable_to_non_nullable -as String, +as String,accountFieldName: freezed == accountFieldName ? _self.accountFieldName : accountFieldName // ignore: cast_nullable_to_non_nullable +as String?, )); } diff --git a/lib/screens/home/home_controller.g.dart b/lib/screens/home/home_controller.g.dart index 39fe3f1..cd8707c 100644 --- a/lib/screens/home/home_controller.g.dart +++ b/lib/screens/home/home_controller.g.dart @@ -36,6 +36,7 @@ _BillProvider _$BillProviderFromJson(Map json) => image: json['image'] as String, label: json['label'] as String, category: json['category'] as String, + accountFieldName: json['accountFieldName'] as String?, ); Map _$BillProviderToJson(_BillProvider instance) => @@ -54,4 +55,5 @@ Map _$BillProviderToJson(_BillProvider instance) => 'image': instance.image, 'label': instance.label, 'category': instance.category, + 'accountFieldName': instance.accountFieldName, }; diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart index 651f1a8..a68a9f7 100644 --- a/lib/screens/home/home_screen.dart +++ b/lib/screens/home/home_screen.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import 'package:timeago/timeago.dart' as timeago; import 'package:qpay/screens/home/home_controller.dart'; -import 'package:qpay/screens/pay/pay_controller.dart'; +import 'package:qpay/screens/transaction_controller.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'dart:async'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -17,34 +20,35 @@ class _HomeScreenState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _alignAnimation; - final List> _animations = []; - final List> _alignListAnimations = []; - + late TransactionController transactionController; late bool _loading = true; - Timer? _loadingTimer; + late SharedPreferences prefs; + late HomeController homeController; - bool get _isLoggedIn => true; + Timer? _loadingTimer; + bool get _isLoggedIn => false; String _selectedFilter = 'All'; - final HomeController homeController = HomeController(); + final List> _animations = []; + final List> _alignListAnimations = []; @override void initState() { super.initState(); - homeController.getCategories(); - homeController.getProviders(); + transactionController = Provider.of( + context, + listen: false, + ); + + // Reset the transaction controller model + transactionController.model = TransactionModel(); _controller = AnimationController( duration: const Duration(milliseconds: 600), vsync: this, )..forward(); - _alignAnimation = Tween( - begin: Alignment.centerLeft, - end: Alignment.center, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); - List tranListIndices = [0, 1]; for (int i = 0; i < tranListIndices.length; i++) { _alignListAnimations.add( @@ -57,17 +61,12 @@ class _HomeScreenState extends State ); } - List buttonIndices = [0, 1, 2]; - 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.075 * i, 1.0, curve: Curves.easeOut), - ), - ), - ); - } + _alignAnimation = Tween( + begin: Alignment.centerLeft, + end: Alignment.center, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + + setupDataAndTransitions(); // Simulate a delay to mimic loading state _loadingTimer = Timer(const Duration(seconds: 1), () { @@ -79,6 +78,35 @@ class _HomeScreenState extends State }); } + Future setupDataAndTransitions() async { + homeController = HomeController(context); + 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("userId") == null) { + prefs.setString("userId", Uuid().v4()); + } + + await homeController.getTransactions(prefs.getString("userId")!); + } + getBoxDecoration(String? clientId) { // BillProvider? provider = homeController.model.selectedProvider; // @@ -108,19 +136,11 @@ class _HomeScreenState extends State ); } - void _showErrorSnackBar(String? message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message.toString()), - behavior: SnackBarBehavior.floating, - ), - ); - } - @override void dispose() { _loadingTimer?.cancel(); _controller.dispose(); + homeController.dispose(); super.dispose(); } @@ -128,184 +148,199 @@ class _HomeScreenState extends State Widget build(BuildContext context) { return Scaffold( body: SafeArea( - child: CustomScrollView( - slivers: [ - SliverAppBar( - floating: true, - title: Image(image: AssetImage('assets/icon.png'), width: 85), - actions: [ - IconButton( - icon: const Icon(Icons.notifications_outlined), - onPressed: () {}, - ), - ], - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: ListenableBuilder( - listenable: homeController, - builder: (context, child) { - if (homeController.model.errorMessage != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _showErrorSnackBar(homeController.model.errorMessage); - }); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (_isLoggedIn) - AlignTransition( - alignment: _alignAnimation, - child: Skeletonizer( - enabled: _loading, - child: Column( - children: [ - Text( - "Vusumuzi Khoza", - style: TextStyle(fontSize: 15), - ), - Text( - "USD 4.02", - style: TextStyle( - fontSize: 30, - fontWeight: FontWeight.bold, + 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), + actions: [ + // IconButton( + // icon: const Icon(Icons.notifications_outlined), + // onPressed: () {}, + // ), + ], + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: ListenableBuilder( + listenable: homeController, + builder: (context, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_isLoggedIn) + AlignTransition( + alignment: _alignAnimation, + child: Skeletonizer( + enabled: _loading, + child: Column( + children: [ + Text( + "Vusumuzi Khoza", + style: TextStyle(fontSize: 15), ), - ), - const SizedBox(height: 20), - ], + Text( + "USD 4.02", + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 20), + ], + ), ), ), + _buildFilterChips(homeController), + SizedBox(height: 10), + Column( + children: [ + ...homeController.model.filterableProviders.map( + (e) => _buildProviderItem(e, context), + ), + ], ), - _buildFilterChips(homeController), - SizedBox(height: 10), - Column( - children: [ - ...homeController.model.filterableProviders.map( - (e) => _buildProviderItem(e, context), - ), - ], - ), - SizedBox(height: 5), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Recent Activity', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - GestureDetector( - onTap: () { - context.go('/history'); - }, - child: const Text( - 'View All', + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text( + 'Recent Activity', style: TextStyle( fontSize: 16, - fontWeight: FontWeight.normal, + fontWeight: FontWeight.bold, ), ), - ), - ], - ), - const SizedBox(height: 20), - Skeletonizer( - enabled: _loading, - child: ListView( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - children: [ - SlideTransition( - position: _alignListAnimations[0], - child: ListTile( - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .primary - .withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: Icon( - Icons.electric_bolt, - color: - Theme.of(context).colorScheme.primary, - ), - ), - title: Text( - 'Electricity Bill', - style: TextStyle( - fontWeight: FontWeight.bold, - ), - ), - subtitle: Text( - 'Paid USD 50.00', - style: TextStyle( - fontWeight: FontWeight.normal, - ), - ), - trailing: Text( - '-USD 50.00', - style: TextStyle( - color: Colors.red, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - SlideTransition( - position: _alignListAnimations[1], - child: ListTile( - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .primary - .withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: Icon( - Icons.phone_android, - color: - Theme.of(context).colorScheme.primary, - ), - ), - title: Text( - 'Airtime Purchase', - style: TextStyle( - fontWeight: FontWeight.bold, - ), - ), - subtitle: Text( - 'Purchased USD 10.00', - style: TextStyle( - fontWeight: FontWeight.normal, - ), - ), - trailing: Text( - '-USD 10.00', - style: TextStyle( - color: Colors.red, - fontWeight: FontWeight.bold, - ), + TextButton( + onPressed: () { + context.go('/history'); + }, + child: Text( + 'View All', + style: TextStyle( + fontSize: 14, + 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) + ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + ...homeController.model.transactions.map( + (e) => _buildTransactionItem(e, context), + ), + ], + ), + ], + ); + }, + ), ), ), + ], + ), + ), + ), + ); + } + + Widget _buildTransactionItem(Map e, BuildContext context) { + return SlideTransition( + position: _alignListAnimations[0], + child: Skeletonizer( + enabled: homeController.model.isLoading, + child: InkWell( + customBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + onTap: () { + transactionController.updateReceiptData(e); + context.push("/receipt"); + }, + child: ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Image( + image: AssetImage("assets/${e['paymentProcessorImage']}"), + ), ), - ], + title: Row( + children: [ + Text( + e['billName'], + style: TextStyle(fontWeight: FontWeight.bold), + ), + SizedBox(width: 10), + Icon( + e['status'] == 'SUCCESS' + ? Icons.check_circle + : e['status'] == 'PENDING' + ? Icons.pending + : Icons.cancel, + size: 16, + color: + e['status'] == 'SUCCESS' + ? Colors.green + : e['status'] == 'PENDING' + ? Colors.orange + : Colors.red, + ), + ], + ), + subtitle: Text( + e['createdAt'] != null + ? timeago.format(DateTime.parse(e['createdAt'])) + : '', + style: TextStyle(fontSize: 12), + ), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + e['amount'].toStringAsFixed(2), + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + Text( + e['totalAmount'].toStringAsFixed(2), + style: TextStyle(fontSize: 12), + ), + ], + ), + ), ), ), ); @@ -317,7 +352,7 @@ class _HomeScreenState extends State Skeletonizer( enabled: homeController.model.isLoading, child: SlideTransition( - position: _alignListAnimations[1], + position: _animations[homeController.model.providers.indexOf(e)], child: Container( decoration: getBoxDecoration(e.clientId), child: InkWell( @@ -326,7 +361,8 @@ class _HomeScreenState extends State ), onTap: () { homeController.updateSelectedProvider(e); - context.go("/make-payment"); + transactionController.updateSelectedProvider(e); + context.push("/recipients"); }, child: ListTile( leading: Container( @@ -345,7 +381,10 @@ class _HomeScreenState extends State ), subtitle: Text( e.description, - style: TextStyle(fontWeight: FontWeight.normal, fontSize: 10), + style: TextStyle( + fontWeight: FontWeight.normal, + fontSize: 10, + ), ), trailing: Icon( Icons.chevron_right, @@ -383,29 +422,42 @@ class _HomeScreenState extends State Category? category, ]) { final isSelected = _selectedFilter == label; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: FilterChip( - selected: isSelected, - label: Text(label), - onSelected: (selected) { - setState(() { - _selectedFilter = label; - }); - if (category != null) { - controller.updateSelectedCategory(category); - } else { - controller.updateSelectedCategory(null); - } - }, - backgroundColor: Theme.of(context).colorScheme.surface, - selectedColor: Theme.of(context).colorScheme.primary.withOpacity(0.2), - checkmarkColor: Theme.of(context).colorScheme.primary, - labelStyle: TextStyle( - color: - isSelected - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface, + return Skeletonizer( + enabled: controller.model.isLoading, + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.2), + ), + ), + selected: isSelected, + label: Text(label), + onSelected: (selected) { + setState(() { + _selectedFilter = label; + }); + if (category != null) { + controller.updateSelectedCategory(category); + } else { + controller.updateSelectedCategory(null); + } + }, + backgroundColor: Theme.of(context).colorScheme.surface, + selectedColor: Theme.of(context).colorScheme.primary.withOpacity(0.2), + checkmarkColor: Theme.of(context).colorScheme.primary, + labelStyle: TextStyle( + color: + isSelected + ? Theme.of( + context, + ).colorScheme.secondary.withValues(alpha: 0.7) + : Theme.of(context).colorScheme.onSurface, + ), ), ), ); diff --git a/lib/screens/pay/pay_controller.dart b/lib/screens/pay/pay_controller.dart index 7c11e0d..305401c 100644 --- a/lib/screens/pay/pay_controller.dart +++ b/lib/screens/pay/pay_controller.dart @@ -1,17 +1,41 @@ +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:qpay/http/http.dart'; import 'package:qpay/screens/home/home_controller.dart' as hc; +import 'package:qpay/screens/transaction_controller.dart'; +import 'package:qpay/screens/transaction_controller.dart' as tc; part 'pay_controller.freezed.dart'; part 'pay_controller.g.dart'; class PayScreenModel { List products = []; - late BillProduct selectedProduct; - dynamic formData = {}; + List paymentProcessors = []; + hc.BillProvider? selectedProvider; + BillProduct? selectedProduct; + PaymentProcessor? selectedPaymentProcessor; bool isLoading = false; String? errorMessage; + String? status; +} + +@freezed +abstract class PaymentProcessor with _$PaymentProcessor { + const factory PaymentProcessor({ + required String name, + required String description, + required String image, + required String accountFieldName, + required String accountFieldLabel, + required String label, + required String authType, + }) = _PaymentProcessor; + + factory PaymentProcessor.fromJson(Map json) => + _$PaymentProcessorFromJson(json); } @freezed @@ -32,13 +56,94 @@ abstract class BillProduct with _$BillProduct { class PayController extends ChangeNotifier { final PayScreenModel model = PayScreenModel(); final Http http = Http(); + late SharedPreferences prefs; + late TransactionController transactionController; + BuildContext context; - void updateSelectedProduct(BillProduct product) { - model.selectedProduct = product; + PayController(this.context) { + transactionController = Provider.of( + context, + listen: false, + ); + model.selectedProvider = transactionController.model.selectedProvider; + model.selectedProduct = transactionController.model.selectedProduct; + } + + void _showErrorSnackBar(String? message) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message.toString()), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.deepOrange, + ), + ); + }); + } + + // formData can come from a repeat transaction + // otherwise build it from elements of the normal flow + Future doTransaction([tc.FormData? formData]) async { + try { + model.isLoading = true; + notifyListeners(); + + prefs = await SharedPreferences.getInstance(); + + transactionController.model.formData = + formData ?? + transactionController.model.formData.copyWith( + type: "CONFIRM", + billClientId: model.selectedProvider?.clientId ?? '', + debitRef: 'peak', + debitCurrency: 'USD', + creditAccount: transactionController.model.formData.creditAccount, + creditPhone: transactionController.model.formData.creditPhone, + billName: model.selectedProvider?.name ?? '', + paymentProcessorLabel: model.selectedPaymentProcessor?.label ?? '', + paymentProcessorName: model.selectedPaymentProcessor?.name ?? '', + paymentProcessorImage: model.selectedPaymentProcessor?.image ?? '', + providerImage: model.selectedProvider?.image ?? '', + providerLabel: model.selectedProvider?.label ?? '', + userId: prefs.getString("userId") ?? '', + creditName: transactionController.model.formData.creditName, + creditEmail: transactionController.model.formData.creditEmail, + productUid: model.selectedProduct?.uid, + ); + + dynamic response = await http.post( + '/transaction', + transactionController.model.formData, + ); + logger.i(response.toString()); + + if (response['status'] == 'SUCCESS') { + model.status = response['status']; + + transactionController.updateConfirmationData(response); + } else { + model.status = response['status']; + model.errorMessage = + response['errorMessage'] ?? + "Problem with the transaction. Please try again or contact support"; + _showErrorSnackBar(model.errorMessage); + } + } on DioException catch (e, s) { + logger.e(s); + _showErrorSnackBar("Network error. Please try again or contact support"); + } catch (e, s) { + logger.e(s); + _showErrorSnackBar( + "Problem processing that transaction. Please try again or contact support", + ); + } + + model.isLoading = false; notifyListeners(); } Future getProducts(String providerId) async { + model.products = getFakeProducts(); try { model.isLoading = true; @@ -48,11 +153,86 @@ class PayController extends ChangeNotifier { model.products = response.map((e) => BillProduct.fromJson(e)).toList(); } catch (e) { logger.e(e); - model.errorMessage = e.toString(); + _showErrorSnackBar( + "Problem fetching products, are you connected to the internet?", + ); } model.isLoading = false; notifyListeners(); } + Future getPaymentProcessors() async { + try { + model.isLoading = true; + model.paymentProcessors = getFakePaymentProcessors(); + notifyListeners(); + + List response = await http.get('/payment-processors'); + model.paymentProcessors = + response.map((e) => PaymentProcessor.fromJson(e)).toList(); + + notifyListeners(); + } catch (e) { + logger.e(e); + _showErrorSnackBar( + "Problem fetching payment processors, are you connected to the internet?", + ); + } + } + + void updateAdditionalRecipientDetails(String name, String email) { + transactionController.model.formData = transactionController.model.formData + .copyWith(creditName: name, creditEmail: email); + notifyListeners(); + } + + void updatePhone(String phone) { + transactionController.model.formData = transactionController.model.formData + .copyWith(creditPhone: phone); + notifyListeners(); + } + + void updateAmount(String amount) { + transactionController.model.formData = transactionController.model.formData + .copyWith(amount: amount); + notifyListeners(); + } + + void updateSelectedProduct(BillProduct product) { + model.selectedProduct = product; + notifyListeners(); + } + + void updateSelectedPaymentProcessor(PaymentProcessor processor) { + model.selectedPaymentProcessor = processor; + notifyListeners(); + } + + List getFakePaymentProcessors() { + return [ + PaymentProcessor( + name: "EcoCash", + description: "Pay using your mobile wallet.", + image: "ecocash.png", + accountFieldName: "phoneNumber", + accountFieldLabel: "EcoCash Phone Number", + label: "ECOCASH", + authType: "REMOTE", + ), + ]; + } + + List getFakeProducts() { + return [ + BillProduct( + uid: '1', + name: 'Product 1', + description: 'Description 1', + displayName: 'Product 1', + defaultAmount: 100, + requiresAmount: true, + ), + ]; + } } diff --git a/lib/screens/pay/pay_controller.freezed.dart b/lib/screens/pay/pay_controller.freezed.dart index 5921476..0acb605 100644 --- a/lib/screens/pay/pay_controller.freezed.dart +++ b/lib/screens/pay/pay_controller.freezed.dart @@ -13,6 +13,157 @@ part of 'pay_controller.dart'; // dart format off T _$identity(T value) => value; +/// @nodoc +mixin _$PaymentProcessor { + + String get name; String get description; String get image; String get accountFieldName; String get accountFieldLabel; String get label; String get authType; +/// Create a copy of PaymentProcessor +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$PaymentProcessorCopyWith get copyWith => _$PaymentProcessorCopyWithImpl(this as PaymentProcessor, _$identity); + + /// Serializes this PaymentProcessor to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is PaymentProcessor&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.image, image) || other.image == image)&&(identical(other.accountFieldName, accountFieldName) || other.accountFieldName == accountFieldName)&&(identical(other.accountFieldLabel, accountFieldLabel) || other.accountFieldLabel == accountFieldLabel)&&(identical(other.label, label) || other.label == label)&&(identical(other.authType, authType) || other.authType == authType)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,description,image,accountFieldName,accountFieldLabel,label,authType); + +@override +String toString() { + return 'PaymentProcessor(name: $name, description: $description, image: $image, accountFieldName: $accountFieldName, accountFieldLabel: $accountFieldLabel, label: $label, authType: $authType)'; +} + + +} + +/// @nodoc +abstract mixin class $PaymentProcessorCopyWith<$Res> { + factory $PaymentProcessorCopyWith(PaymentProcessor value, $Res Function(PaymentProcessor) _then) = _$PaymentProcessorCopyWithImpl; +@useResult +$Res call({ + String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String authType +}); + + + + +} +/// @nodoc +class _$PaymentProcessorCopyWithImpl<$Res> + implements $PaymentProcessorCopyWith<$Res> { + _$PaymentProcessorCopyWithImpl(this._self, this._then); + + final PaymentProcessor _self; + final $Res Function(PaymentProcessor) _then; + +/// Create a copy of PaymentProcessor +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? description = null,Object? image = null,Object? accountFieldName = null,Object? accountFieldLabel = null,Object? label = null,Object? authType = null,}) { + return _then(_self.copyWith( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,image: null == image ? _self.image : image // ignore: cast_nullable_to_non_nullable +as String,accountFieldName: null == accountFieldName ? _self.accountFieldName : accountFieldName // ignore: cast_nullable_to_non_nullable +as String,accountFieldLabel: null == accountFieldLabel ? _self.accountFieldLabel : accountFieldLabel // ignore: cast_nullable_to_non_nullable +as String,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable +as String,authType: null == authType ? _self.authType : authType // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// @nodoc +@JsonSerializable() + +class _PaymentProcessor implements PaymentProcessor { + const _PaymentProcessor({required this.name, required this.description, required this.image, required this.accountFieldName, required this.accountFieldLabel, required this.label, required this.authType}); + factory _PaymentProcessor.fromJson(Map json) => _$PaymentProcessorFromJson(json); + +@override final String name; +@override final String description; +@override final String image; +@override final String accountFieldName; +@override final String accountFieldLabel; +@override final String label; +@override final String authType; + +/// Create a copy of PaymentProcessor +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$PaymentProcessorCopyWith<_PaymentProcessor> get copyWith => __$PaymentProcessorCopyWithImpl<_PaymentProcessor>(this, _$identity); + +@override +Map toJson() { + return _$PaymentProcessorToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _PaymentProcessor&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.image, image) || other.image == image)&&(identical(other.accountFieldName, accountFieldName) || other.accountFieldName == accountFieldName)&&(identical(other.accountFieldLabel, accountFieldLabel) || other.accountFieldLabel == accountFieldLabel)&&(identical(other.label, label) || other.label == label)&&(identical(other.authType, authType) || other.authType == authType)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,description,image,accountFieldName,accountFieldLabel,label,authType); + +@override +String toString() { + return 'PaymentProcessor(name: $name, description: $description, image: $image, accountFieldName: $accountFieldName, accountFieldLabel: $accountFieldLabel, label: $label, authType: $authType)'; +} + + +} + +/// @nodoc +abstract mixin class _$PaymentProcessorCopyWith<$Res> implements $PaymentProcessorCopyWith<$Res> { + factory _$PaymentProcessorCopyWith(_PaymentProcessor value, $Res Function(_PaymentProcessor) _then) = __$PaymentProcessorCopyWithImpl; +@override @useResult +$Res call({ + String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String authType +}); + + + + +} +/// @nodoc +class __$PaymentProcessorCopyWithImpl<$Res> + implements _$PaymentProcessorCopyWith<$Res> { + __$PaymentProcessorCopyWithImpl(this._self, this._then); + + final _PaymentProcessor _self; + final $Res Function(_PaymentProcessor) _then; + +/// Create a copy of PaymentProcessor +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? description = null,Object? image = null,Object? accountFieldName = null,Object? accountFieldLabel = null,Object? label = null,Object? authType = null,}) { + return _then(_PaymentProcessor( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,image: null == image ? _self.image : image // ignore: cast_nullable_to_non_nullable +as String,accountFieldName: null == accountFieldName ? _self.accountFieldName : accountFieldName // ignore: cast_nullable_to_non_nullable +as String,accountFieldLabel: null == accountFieldLabel ? _self.accountFieldLabel : accountFieldLabel // ignore: cast_nullable_to_non_nullable +as String,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable +as String,authType: null == authType ? _self.authType : authType // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + /// @nodoc mixin _$BillProduct { diff --git a/lib/screens/pay/pay_controller.g.dart b/lib/screens/pay/pay_controller.g.dart index cd80089..4995394 100644 --- a/lib/screens/pay/pay_controller.g.dart +++ b/lib/screens/pay/pay_controller.g.dart @@ -6,6 +6,28 @@ part of 'pay_controller.dart'; // JsonSerializableGenerator // ************************************************************************** +_PaymentProcessor _$PaymentProcessorFromJson(Map json) => + _PaymentProcessor( + name: json['name'] as String, + description: json['description'] as String, + image: json['image'] as String, + accountFieldName: json['accountFieldName'] as String, + accountFieldLabel: json['accountFieldLabel'] as String, + label: json['label'] as String, + authType: json['authType'] as String, + ); + +Map _$PaymentProcessorToJson(_PaymentProcessor instance) => + { + 'name': instance.name, + 'description': instance.description, + 'image': instance.image, + 'accountFieldName': instance.accountFieldName, + 'accountFieldLabel': instance.accountFieldLabel, + 'label': instance.label, + 'authType': instance.authType, + }; + _BillProduct _$BillProductFromJson(Map json) => _BillProduct( uid: json['uid'] as String, name: json['name'] as String, diff --git a/lib/screens/pay/pay_screen.dart b/lib/screens/pay/pay_screen.dart index 9580580..838a322 100644 --- a/lib/screens/pay/pay_screen.dart +++ b/lib/screens/pay/pay_screen.dart @@ -1,6 +1,9 @@ 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/screens/pay/pay_controller.dart'; +import 'package:qpay/screens/transaction_controller.dart'; import 'package:skeletonizer/skeletonizer.dart'; class PayScreen extends StatefulWidget { @@ -14,16 +17,64 @@ class _PayScreenState extends State with TickerProviderStateMixin { late AnimationController _controller; late Animation _fadeAnimation; late Animation _slideAnimation; + late PayController controller; + late TransactionController transactionController; + final List> _animations = []; final _formKey = GlobalKey(); final _amountController = TextEditingController(); - final _descriptionController = TextEditingController(); - String _selectedBillType = ''; + final _nameController = TextEditingController(); + final _emailController = TextEditingController(); + final _phoneController = TextEditingController(); + bool showAdditionalRecipientDetails = false; + String selectedCountryCode = '+263'; // Default to ZW + + // Common country codes with flags and names + final List> countryCodes = [ + {'code': '+263', 'name': 'ZW', 'flag': '🇿🇼'}, + {'code': '+27', 'name': 'ZA', 'flag': '🇿🇦'}, + {'code': '+1', 'name': 'US', 'flag': '🇺🇸'}, + {'code': '+44', 'name': 'UK', 'flag': '🇬🇧'}, + {'code': '+61', 'name': 'AU', 'flag': '🇦🇺'}, + {'code': '+91', 'name': 'IN', 'flag': '🇮🇳'}, + {'code': '+86', 'name': 'CN', 'flag': '🇨🇳'}, + {'code': '+81', 'name': 'JP', 'flag': '🇯🇵'}, + {'code': '+49', 'name': 'DE', 'flag': '🇩🇪'}, + {'code': '+33', 'name': 'FR', 'flag': '🇫🇷'}, + {'code': '+39', 'name': 'IT', 'flag': '🇮🇹'}, + {'code': '+34', 'name': 'ES', 'flag': '🇪🇸'}, + {'code': '+7', 'name': 'RU', 'flag': '🇷🇺'}, + {'code': '+55', 'name': 'BR', 'flag': '🇧🇷'}, + {'code': '+52', 'name': 'MX', 'flag': '🇲🇽'}, + {'code': '+971', 'name': 'AE', 'flag': '🇦🇪'}, + {'code': '+966', 'name': 'SA', 'flag': '🇸🇦'}, + {'code': '+234', 'name': 'NG', 'flag': '🇳🇬'}, + {'code': '+254', 'name': 'KE', 'flag': '🇰🇪'}, + {'code': '+233', 'name': 'GH', 'flag': '🇬🇭'}, + {'code': '+256', 'name': 'UG', 'flag': '🇺🇬'}, + ]; @override void initState() { super.initState(); + controller = PayController(context); + controller.getPaymentProcessors(); + + controller.getProducts(controller.model.selectedProvider?.uid ?? ""); + transactionController = Provider.of( + context, + listen: false, + ); + + _nameController.text = + transactionController.model.formData.creditName ?? ''; + _emailController.text = + transactionController.model.formData.creditEmail ?? ''; + + // Initialize phone number and country code + updatePhoneNumber(transactionController.model.formData.creditPhone ?? ''); + _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), @@ -46,45 +97,78 @@ class _PayScreenState extends State with TickerProviderStateMixin { ), ); + List buttonIndices = [0, 1]; + 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), + ), + ), + ); + } + _controller.forward(); } + Future updatePhoneNumber(String phoneNumber) async { + String existingPhone = phoneNumber; + if (existingPhone.isNotEmpty) { + // Try to extract country code from existing phone number + for (var country in countryCodes) { + if (existingPhone.startsWith(country['code']!)) { + setState(() { + selectedCountryCode = country['code']!; + }); + _phoneController.text = existingPhone.substring( + country['code']!.length, + ); + break; + } + } + // If no country code found, use default and set the full number + if (_phoneController.text.isEmpty) { + _phoneController.text = existingPhone; + } + } + } + @override void dispose() { _controller.dispose(); _amountController.dispose(); - _descriptionController.dispose(); + _nameController.dispose(); + _emailController.dispose(); + _phoneController.dispose(); + controller.dispose(); super.dispose(); } - void _showErrorSnackBar(String? message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message.toString()), - behavior: SnackBarBehavior.floating, - ), - ); - } - @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( + leading: + context.canPop() + ? IconButton( + onPressed: () { + context.pop(); + }, + icon: const Icon(Icons.arrow_back), + ) + : SizedBox.shrink(), title: const Text( 'Make Payment', style: TextStyle(color: Colors.black87), ), ), - body: Consumer( - builder: (context, controller, child) { - if (controller.model.errorMessage != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _showErrorSnackBar(controller.model.errorMessage); - }); - } + body: ListenableBuilder( + listenable: controller, + builder: (context, child) { return SingleChildScrollView( child: Padding( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.all(20.0), child: FadeTransition( opacity: _fadeAnimation, child: SlideTransition( @@ -94,14 +178,101 @@ class _PayScreenState extends State with TickerProviderStateMixin { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox(height: 20), - _buildBillProductSelector(controller), - const SizedBox(height: 24), + 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, + ), + 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: 24), - _buildDescriptionField(), - const SizedBox(height: 32), - _buildPayButton(), + const SizedBox(height: 20), + // _buildPayButton(controller), + ...controller.model.paymentProcessors.map( + (e) => _buildPaymentProcessorItem(e, context), + ), ], ), ), @@ -114,51 +285,130 @@ class _PayScreenState extends State with TickerProviderStateMixin { ); } - Widget _buildBillProductSelector(PayController controller) { + Widget _buildPhoneField() { + if (controller.model.selectedProvider?.requiresPhone == false) { + return SizedBox.shrink(); + } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Select Provider Product', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Theme.of(context).colorScheme.primary.withOpacity(0.2), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Notification Phone Number', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), ), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - icon: const Icon(Icons.arrow_drop_down), - items: [ - ...controller.model.products.map( - (e) => DropdownMenuItem( - value: e.uid, - child: Text(e.displayName), - ), - ), - ], - onChanged: (String? newValue) { - if (newValue != null) { - setState(() { - _selectedBillType = newValue; - }); + TextButton( + onPressed: () async { + if (await FlutterContacts.requestPermission()) { + final contact = await FlutterContacts.openExternalPick(); + if (contact != null) { + updatePhoneNumber(contact.phones.first.normalizedNumber); + } } }, + child: Text( + "Fetch from contacts", + style: TextStyle(fontSize: 12, color: Colors.red), + ), ), - ), + ], + ), + const SizedBox(height: 5), + Row( + children: [ + // Country code dropdown + Container( + width: 100, + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.primary.withOpacity(0.2), + ), + borderRadius: BorderRadius.circular(12), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: selectedCountryCode, + isExpanded: true, + icon: const Icon(Icons.arrow_drop_down), + padding: const EdgeInsets.symmetric(horizontal: 8), + items: + countryCodes.map((country) { + return DropdownMenuItem( + value: country['code'], + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(country['flag']!), + const SizedBox(width: 4), + Text( + country['code']!, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ); + }).toList(), + onChanged: (String? newValue) { + if (newValue != null) { + setState(() { + selectedCountryCode = newValue; + }); + } + }, + ), + ), + ), + const SizedBox(width: 8), + // Phone number field + Expanded( + child: TextFormField( + controller: _phoneController, + keyboardType: TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + hintText: 'Enter phone number', + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.secondary, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of( + context, + ).colorScheme.primary.withOpacity(0.2), + ), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a phone number'; + } + // Remove any non-digit characters for validation + String digitsOnly = value.replaceAll(RegExp(r'[^\d]'), ''); + if (digitsOnly.length < 7) { + return 'Phone number must be at least 7 digits'; + } + if (digitsOnly.length > 15) { + return 'Phone number is too long'; + } + return null; + }, + ), + ), + ], ), ], ); } Widget _buildAmountField() { + if (controller.model.selectedProvider?.requiresAmount == false) { + return SizedBox.shrink(); + } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -166,13 +416,24 @@ class _PayScreenState extends State with TickerProviderStateMixin { 'Amount', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), ), - const SizedBox(height: 12), + const SizedBox(height: 5), TextFormField( controller: _amountController, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - prefixIcon: Icon(Icons.attach_money), + keyboardType: TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( hintText: 'Enter amount', + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.secondary, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary.withOpacity(0.2), + ), + ), ), validator: (value) { if (value == null || value.isEmpty) { @@ -188,50 +449,238 @@ class _PayScreenState extends State with TickerProviderStateMixin { ); } - Widget _buildDescriptionField() { + Widget _buildAdditionalRecipientDetails() { + return SlideTransition( + position: _slideAnimation, + child: Column( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Name', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 5), + TextFormField( + controller: _nameController, + keyboardType: TextInputType.text, + decoration: InputDecoration( + hintText: 'Enter recipient\'s name', + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.secondary, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of( + context, + ).colorScheme.primary.withOpacity(0.2), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Email', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 5), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: 'Enter recipient\'s email', + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.secondary, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of( + context, + ).colorScheme.primary.withOpacity(0.2), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Divider(color: Theme.of(context).colorScheme.primary), + ], + ), + ); + } + + Widget _buildBillProductSelector(PayController controller) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox(height: 20), const Text( - 'Description', + 'Provider Product', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), ), - const SizedBox(height: 12), - TextFormField( - controller: _descriptionController, - maxLines: 3, - decoration: const InputDecoration( - hintText: 'Enter description (optional)', - alignLabelWithHint: true, + const SizedBox(height: 5), + Skeletonizer( + enabled: controller.model.isLoading, + child: DropdownButtonHideUnderline( + child: DropdownButtonFormField( + value: controller.model.selectedProduct?.uid, + isExpanded: true, + icon: const Icon(Icons.arrow_drop_down), + decoration: InputDecoration( + hintText: 'Select Product', + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.2), + ), + ), + ), + items: [ + DropdownMenuItem(child: Text("Select Product")), + ...controller.model.products.map( + (e) => DropdownMenuItem( + value: e.uid, + child: Text(e.displayName), + ), + ), + ], + onChanged: (String? newValue) { + if (newValue != null) { + BillProduct product = controller.model.products.firstWhere( + (e) => e.uid == newValue, + ); + controller.updateSelectedProduct(product); + } + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please select a product'; + } + return null; + }, + ), ), ), ], ); } - Widget _buildPayButton() { - return SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () { - if (_formKey.currentState!.validate()) { - // TODO: Implement payment logic - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Payment processed successfully!'), - backgroundColor: Colors.green, + Widget _buildPaymentProcessorItem(PaymentProcessor e, BuildContext context) { + return Column( + children: [ + Skeletonizer( + enabled: controller.model.isLoading, + child: SlideTransition( + position: + _animations[controller.model.paymentProcessors.indexOf(e)], + child: Container( + 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, + ), ), - ); - } - }, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), + child: InkWell( + customBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + onTap: () async { + if (_formKey.currentState!.validate()) { + if (controller.model.isLoading) return; + + controller.updateAmount(_amountController.text); + if (!controller.model.selectedProvider!.requiresAmount) { + controller.updateAmount( + controller.model.selectedProduct!.defaultAmount + .toString(), + ); + } + + // Update phone number with country code + String fullPhoneNumber = + selectedCountryCode + _phoneController.text; + controller.updatePhone(fullPhoneNumber); + + if (showAdditionalRecipientDetails) { + controller.updateAdditionalRecipientDetails( + _nameController.text, + _emailController.text, + ); + } + controller.updateSelectedPaymentProcessor(e); + transactionController.updateSelectedPaymentProcessor(e); + + await controller.doTransaction(); + + if (controller.model.status == "SUCCESS" && + context.mounted) { + context.push('/confirm'); + } + } + }, + child: ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Image(image: AssetImage("assets/${e.image}")), + ), + title: Text( + e.name, + style: TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + e.description, + style: TextStyle( + fontWeight: FontWeight.normal, + fontSize: 10, + ), + ), + trailing: Icon( + Icons.chevron_right, + color: Theme.of( + context, + ).colorScheme.secondary.withValues(alpha: 0.7), + ), + ), + ), + ), + ), ), - child: const Text( - 'Pay Now', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - ), + SizedBox(height: 10), + ], ); } } diff --git a/lib/screens/receipt/receipt_controller.dart b/lib/screens/receipt/receipt_controller.dart new file mode 100644 index 0000000..c7168e0 --- /dev/null +++ b/lib/screens/receipt/receipt_controller.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:provider/provider.dart'; +import 'package:qpay/http/http.dart'; +import 'package:qpay/screens/pay/pay_controller.dart' as pc; +import 'package:qpay/screens/transaction_controller.dart' as tc; +import 'package:qpay/screens/home/home_controller.dart' as hc; + +class ReceiptModel { + Map? receiptData; + bool isLoading = false; + String? errorMessage; +} + +class ReceiptController extends ChangeNotifier { + final ReceiptModel model = ReceiptModel(); + final Http http = Http(); + late tc.TransactionController transactionController; + late SharedPreferences prefs; + + BuildContext context; + + ReceiptController(this.context) { + transactionController = Provider.of( + context, + listen: false, + ); + } + + void _showErrorSnackBar(String? message) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message.toString()), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.deepOrange, + ), + ); + }); + } + + void setLoading(bool value) { + model.isLoading = value; + notifyListeners(); + } + + Future repeatTransaction(BuildContext context) async { + setLoading(true); + // fetch provider & update tran controller + String providerId = model.receiptData?['billClientId'] ?? ''; + Map provider = await http.get( + '/providers/client/$providerId', + ); + transactionController.model.selectedProvider = hc.BillProvider.fromJson( + provider, + ); + + // update tran controller formdata with credit account, email, name, phone & providerLabel + transactionController.model.formData = transactionController.model.formData + .copyWith( + creditAccount: model.receiptData?['creditAccount'] ?? '', + creditPhone: model.receiptData?['creditPhone'] ?? '', + creditName: model.receiptData?['creditName'] ?? '', + creditEmail: model.receiptData?['creditEmail'] ?? '', + ); + + // fetch product and update tran controller formdata + String providerUid = + transactionController.model.selectedProvider?.uid ?? ''; + String productUid = model.receiptData?['productUid'] ?? ''; + + if (productUid.isNotEmpty) { + Map product = await http.get( + '/providers/$providerUid/products/$productUid', + ); + transactionController.model.selectedProduct = pc.BillProduct.fromJson( + product, + ); + } + + // update credit amount, notification phone number, payment processor + transactionController + .model + .formData = transactionController.model.formData.copyWith( + amount: model.receiptData?['amount'].toStringAsFixed(2) ?? '', + creditPhone: model.receiptData?['creditPhone'] ?? '', + paymentProcessorLabel: model.receiptData?['paymentProcessorLabel'] ?? '', + paymentProcessorName: model.receiptData?['paymentProcessorName'] ?? '', + ); + + setLoading(false); + context.push('/make-payment'); + } + + Future getReceiptData(String id) async { + setLoading(true); + try { + Map response = await http.get('/transaction/$id'); + model.receiptData = response; + } catch (e) { + _showErrorSnackBar( + "Problem fetching transaction data, are you connected to the internet?", + ); + } finally { + setLoading(false); + } + } + + void updateReceiptData(Map data) { + model.receiptData = data; + notifyListeners(); + } +} diff --git a/lib/screens/receipt/receipt_screen.dart b/lib/screens/receipt/receipt_screen.dart new file mode 100644 index 0000000..f9f908a --- /dev/null +++ b/lib/screens/receipt/receipt_screen.dart @@ -0,0 +1,670 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:qpay/screens/transaction_controller.dart'; +import 'package:go_router/go_router.dart'; +import 'package:qpay/screens/receipt/receipt_controller.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:widgets_to_image/widgets_to_image.dart'; + +class ReceiptScreen extends StatefulWidget { + const ReceiptScreen({super.key}); + + @override + State createState() => _ReceiptScreenState(); +} + +class _ReceiptScreenState extends State + with TickerProviderStateMixin { + late TransactionController transactionController; + late Animation _fadeAnimation; + late Animation _slideAnimation; + late AnimationController _controller; + late final TabController _tabController; + + late ReceiptController receiptController; + final _formKey = GlobalKey(); + final controller = WidgetsToImageController(); + + @override + void initState() { + super.initState(); + + transactionController = Provider.of( + context, + listen: false, + ); + + receiptController = ReceiptController(context); + + receiptController.getReceiptData( + transactionController.model.receiptData?["id"], + ); + + _tabController = TabController(length: 2, vsync: this); + + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + + _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.0, 0.5, curve: Curves.easeOut), + ), + ); + + _slideAnimation = Tween( + begin: const Offset(0, 0.1), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.0, 0.5, curve: Curves.easeOut), + ), + ); + + _controller.forward(); + } + + Future _captureImage() async { + final image = await controller.capture(); + if (image != null) { + final params = ShareParams( + files: [ + XFile.fromData(image.buffer.asUint8List(), mimeType: 'image/png'), + ], + fileNameOverrides: ['receipt.png'], + ); + SharePlus.instance.share(params); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: ListenableBuilder( + listenable: receiptController, + builder: (context, child) { + return CustomScrollView( + slivers: [ + SliverAppBar( + stretch: true, + stretchTriggerOffset: 100.0, + expandedHeight: 50.0, + surfaceTintColor: Colors.transparent, + floating: true, + leading: + context.canPop() + ? IconButton( + onPressed: () { + context.pop(); + }, + icon: const Icon(Icons.arrow_back), + ) + : SizedBox.shrink(), + title: Text( + "Payment Result", + style: TextStyle(color: Colors.black87), + ), + ), + SliverToBoxAdapter( + child: WidgetsToImage( + controller: controller, + child: Container( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(20.0), + child: FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Theme.of( + context, + ).colorScheme.primary.withOpacity(0.1), + Theme.of(context).colorScheme.tertiary + .withOpacity(0.05), + Theme.of(context).colorScheme.tertiary + .withOpacity(0.15), + ], + stops: [0.0, 0.5, 1.0], + ), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.primary.withOpacity(0.3), + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Theme.of( + context, + ).colorScheme.primary.withOpacity(0.1), + blurRadius: 10, + spreadRadius: 1, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.center, + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Icon( + transactionController + .model + .receiptData?["status"] == + 'SUCCESS' + ? Icons.check_circle + : transactionController + .model + .receiptData?["status"] == + 'PENDING' + ? Icons.pending + : Icons.cancel, + size: 18, + color: + transactionController + .model + .receiptData?["status"] == + 'SUCCESS' + ? Colors.green + : transactionController + .model + .receiptData?["status"] == + 'PENDING' + ? Colors.orange + : Colors.red, + ), + SizedBox(width: 5), + Text( + receiptController + .model + .receiptData?["debitCurrency"] ?? + "", + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(width: 5), + Text( + receiptController + .model + .receiptData?["totalAmount"] + .toStringAsFixed(2) ?? + "", + style: TextStyle(fontSize: 26), + ), + ], + ), + Column( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + SizedBox(height: 5), + // receiptController + // .model + // .receiptData?["providerImage"] != + // null + // ? Image.asset( + // 'assets/${receiptController.model.receiptData?["providerImage"] ?? ''}', + // width: 50, + // ) + // : SizedBox.shrink(), + Text( + receiptController + .model + .receiptData?["billName"] ?? + "", + style: TextStyle(fontSize: 14), + ), + ], + ), + SizedBox(height: 10), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, + children: [ + Column( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + border: Border.all( + color: Theme.of(context) + .colorScheme + .primary + .withOpacity(0.3), + width: 1, + ), + ), + child: + receiptController + .model + .isLoading + ? Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: + AlwaysStoppedAnimation< + Color + >( + Theme.of( + context, + ) + .colorScheme + .primary, + ), + ), + ), + ) + : IconButton( + onPressed: () { + receiptController + .repeatTransaction( + context, + ); + }, + icon: Icon( + Icons.repeat, + color: + Theme.of( + context, + ) + .colorScheme + .primary, + size: 24, + ), + ), + ), + SizedBox(height: 8), + Text( + "Repeat", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + Column( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + border: Border.all( + color: Theme.of(context) + .colorScheme + .primary + .withOpacity(0.3), + width: 1, + ), + ), + child: IconButton( + onPressed: () { + _captureImage(); + }, + icon: Icon( + Icons.share, + color: + Theme.of( + context, + ).colorScheme.primary, + size: 24, + ), + ), + ), + SizedBox(height: 8), + Text( + "Share", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ], + ), + ), + 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.4, + child: TabBarView( + controller: _tabController, + children: [ + Column( + children: [ + const SizedBox(height: 20), + 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((data) { + return 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(), + ], + ), + Column( + children: [ + const SizedBox(height: 20), + Row( + mainAxisAlignment: + MainAxisAlignment + .spaceBetween, + children: [ + Text( + "Status", + style: TextStyle( + fontSize: 16, + fontWeight: + FontWeight.bold, + ), + ), + Text( + transactionController + .model + .receiptData?["status"] ?? + "", + style: TextStyle( + fontSize: 16, + ), + ), + ], + ), + const SizedBox(height: 10), + 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), + 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), + 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), + 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_controller.dart b/lib/screens/recipient/recipients_controller.dart new file mode 100644 index 0000000..f42bbcd --- /dev/null +++ b/lib/screens/recipient/recipients_controller.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:qpay/http/http.dart'; +import 'package:qpay/screens/home/home_controller.dart' as hc; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:qpay/screens/transaction_controller.dart'; + +part 'recipients_controller.freezed.dart'; +part 'recipients_controller.g.dart'; + +class RecipientModel { + String? creditAccount; + String? errorMessage; + hc.BillProvider? selectedProvider; + bool isLoading = false; + List recipients = []; +} + +@freezed +abstract class Recipient with _$Recipient { + const factory Recipient({ + String? uid, + String? name, + String? email, + String? phoneNumber, + String? address, + String? account, + String? initials, + String? latestProviderLabel, + }) = _Recipient; + + factory Recipient.fromJson(Map json) => + _$RecipientFromJson(json); +} + +class RecipientsController extends ChangeNotifier { + final RecipientModel model = RecipientModel(); + final http = Http(); + BuildContext context; + + RecipientsController(this.context) { + model.selectedProvider = + Provider.of( + context, + listen: false, + ).model.selectedProvider; + } + + void _showErrorSnackBar(String? message) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message.toString()), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.deepOrange, + ), + ); + }); + } + + Future getRecipients([Map? params]) async { + model.errorMessage = null; + model.isLoading = true; + model.recipients = getDummyRecipients(); + + notifyListeners(); + + try { + List response = await http.get( + '/recipients/search?${buildQueryParameters(params ?? {})}', + ); + model.recipients.clear(); + model.recipients.addAll(response.map((e) => Recipient.fromJson(e))); + } catch (e) { + _showErrorSnackBar( + "Problem fetching recipients, are you connected to the internet?", + ); + model.errorMessage = e.toString(); + model.recipients = []; + logger.e(e); + notifyListeners(); + } + + model.isLoading = false; + notifyListeners(); + } + + String buildQueryParameters(Map params) { + if (params.isEmpty) { + return ''; + } + + String query = ''; + params.forEach((key, value) { + query += '$key=$value&'; + }); + return query.substring(0, query.length - 1); + } + + void updateCreditAccount(String account) { + model.creditAccount = account; + notifyListeners(); + } + + void addRecipient(Recipient recipient) { + model.recipients.add(recipient); + notifyListeners(); + } + + void removeRecipient(String uid) { + model.recipients.removeWhere((recipient) => recipient.uid == uid); + notifyListeners(); + } + + List getDummyRecipients() { + return [ + Recipient( + uid: '1', + name: 'Vusumuzi Khoza', + phoneNumber: '+263773591219', + email: 'vusumuzi@gmail.com', + address: '123 Main St, Anytown, USA', + account: '1234567890', + initials: 'VK', + latestProviderLabel: 'MTN', + ), + ]; + } +} diff --git a/lib/screens/recipient/recipients_controller.freezed.dart b/lib/screens/recipient/recipients_controller.freezed.dart new file mode 100644 index 0000000..dc76b9c --- /dev/null +++ b/lib/screens/recipient/recipients_controller.freezed.dart @@ -0,0 +1,169 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'recipients_controller.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$Recipient { + + String? get uid; String? get name; String? get email; String? get phoneNumber; String? get address; String? get account; String? get initials; String? get latestProviderLabel; +/// Create a copy of Recipient +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RecipientCopyWith get copyWith => _$RecipientCopyWithImpl(this as Recipient, _$identity); + + /// Serializes this Recipient to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Recipient&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.name, name) || other.name == name)&&(identical(other.email, email) || other.email == email)&&(identical(other.phoneNumber, phoneNumber) || other.phoneNumber == phoneNumber)&&(identical(other.address, address) || other.address == address)&&(identical(other.account, account) || other.account == account)&&(identical(other.initials, initials) || other.initials == initials)&&(identical(other.latestProviderLabel, latestProviderLabel) || other.latestProviderLabel == latestProviderLabel)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,uid,name,email,phoneNumber,address,account,initials,latestProviderLabel); + +@override +String toString() { + return 'Recipient(uid: $uid, name: $name, email: $email, phoneNumber: $phoneNumber, address: $address, account: $account, initials: $initials, latestProviderLabel: $latestProviderLabel)'; +} + + +} + +/// @nodoc +abstract mixin class $RecipientCopyWith<$Res> { + factory $RecipientCopyWith(Recipient value, $Res Function(Recipient) _then) = _$RecipientCopyWithImpl; +@useResult +$Res call({ + String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel +}); + + + + +} +/// @nodoc +class _$RecipientCopyWithImpl<$Res> + implements $RecipientCopyWith<$Res> { + _$RecipientCopyWithImpl(this._self, this._then); + + final Recipient _self; + final $Res Function(Recipient) _then; + +/// Create a copy of Recipient +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? uid = freezed,Object? name = freezed,Object? email = freezed,Object? phoneNumber = freezed,Object? address = freezed,Object? account = freezed,Object? initials = freezed,Object? latestProviderLabel = freezed,}) { + return _then(_self.copyWith( +uid: freezed == uid ? _self.uid : uid // ignore: cast_nullable_to_non_nullable +as String?,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String?,email: freezed == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String?,phoneNumber: freezed == phoneNumber ? _self.phoneNumber : phoneNumber // ignore: cast_nullable_to_non_nullable +as String?,address: freezed == address ? _self.address : address // ignore: cast_nullable_to_non_nullable +as String?,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable +as String?,initials: freezed == initials ? _self.initials : initials // ignore: cast_nullable_to_non_nullable +as String?,latestProviderLabel: freezed == latestProviderLabel ? _self.latestProviderLabel : latestProviderLabel // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// @nodoc +@JsonSerializable() + +class _Recipient implements Recipient { + const _Recipient({this.uid, this.name, this.email, this.phoneNumber, this.address, this.account, this.initials, this.latestProviderLabel}); + factory _Recipient.fromJson(Map json) => _$RecipientFromJson(json); + +@override final String? uid; +@override final String? name; +@override final String? email; +@override final String? phoneNumber; +@override final String? address; +@override final String? account; +@override final String? initials; +@override final String? latestProviderLabel; + +/// Create a copy of Recipient +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$RecipientCopyWith<_Recipient> get copyWith => __$RecipientCopyWithImpl<_Recipient>(this, _$identity); + +@override +Map toJson() { + return _$RecipientToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Recipient&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.name, name) || other.name == name)&&(identical(other.email, email) || other.email == email)&&(identical(other.phoneNumber, phoneNumber) || other.phoneNumber == phoneNumber)&&(identical(other.address, address) || other.address == address)&&(identical(other.account, account) || other.account == account)&&(identical(other.initials, initials) || other.initials == initials)&&(identical(other.latestProviderLabel, latestProviderLabel) || other.latestProviderLabel == latestProviderLabel)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,uid,name,email,phoneNumber,address,account,initials,latestProviderLabel); + +@override +String toString() { + return 'Recipient(uid: $uid, name: $name, email: $email, phoneNumber: $phoneNumber, address: $address, account: $account, initials: $initials, latestProviderLabel: $latestProviderLabel)'; +} + + +} + +/// @nodoc +abstract mixin class _$RecipientCopyWith<$Res> implements $RecipientCopyWith<$Res> { + factory _$RecipientCopyWith(_Recipient value, $Res Function(_Recipient) _then) = __$RecipientCopyWithImpl; +@override @useResult +$Res call({ + String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel +}); + + + + +} +/// @nodoc +class __$RecipientCopyWithImpl<$Res> + implements _$RecipientCopyWith<$Res> { + __$RecipientCopyWithImpl(this._self, this._then); + + final _Recipient _self; + final $Res Function(_Recipient) _then; + +/// Create a copy of Recipient +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? uid = freezed,Object? name = freezed,Object? email = freezed,Object? phoneNumber = freezed,Object? address = freezed,Object? account = freezed,Object? initials = freezed,Object? latestProviderLabel = freezed,}) { + return _then(_Recipient( +uid: freezed == uid ? _self.uid : uid // ignore: cast_nullable_to_non_nullable +as String?,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String?,email: freezed == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String?,phoneNumber: freezed == phoneNumber ? _self.phoneNumber : phoneNumber // ignore: cast_nullable_to_non_nullable +as String?,address: freezed == address ? _self.address : address // ignore: cast_nullable_to_non_nullable +as String?,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable +as String?,initials: freezed == initials ? _self.initials : initials // ignore: cast_nullable_to_non_nullable +as String?,latestProviderLabel: freezed == latestProviderLabel ? _self.latestProviderLabel : latestProviderLabel // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/lib/screens/recipient/recipients_controller.g.dart b/lib/screens/recipient/recipients_controller.g.dart new file mode 100644 index 0000000..afeb606 --- /dev/null +++ b/lib/screens/recipient/recipients_controller.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recipients_controller.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_Recipient _$RecipientFromJson(Map json) => _Recipient( + uid: json['uid'] as String?, + name: json['name'] as String?, + email: json['email'] as String?, + phoneNumber: json['phoneNumber'] as String?, + address: json['address'] as String?, + account: json['account'] as String?, + initials: json['initials'] as String?, + latestProviderLabel: json['latestProviderLabel'] as String?, +); + +Map _$RecipientToJson(_Recipient instance) => + { + 'uid': instance.uid, + 'name': instance.name, + 'email': instance.email, + 'phoneNumber': instance.phoneNumber, + 'address': instance.address, + 'account': instance.account, + 'initials': instance.initials, + 'latestProviderLabel': instance.latestProviderLabel, + }; diff --git a/lib/screens/recipient/recipients_screen.dart b/lib/screens/recipient/recipients_screen.dart new file mode 100644 index 0000000..c06167d --- /dev/null +++ b/lib/screens/recipient/recipients_screen.dart @@ -0,0 +1,378 @@ +import 'dart:async'; + +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/screens/transaction_controller.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:qpay/screens/recipient/recipients_controller.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class RecipientsScreen extends StatefulWidget { + const RecipientsScreen({super.key}); + + @override + State createState() => _RecipientsScreenState(); +} + +class _RecipientsScreenState extends State + with TickerProviderStateMixin { + late SharedPreferences prefs; + late AnimationController _animationController; + late RecipientsController controller; + late TransactionController transactionController; + + final List> _alignListAnimations = []; + final _accountController = TextEditingController(); + final _queryParams = {}; + + @override + void initState() { + super.initState(); + + controller = RecipientsController(context); + setupData(); + + controller.model.creditAccount = _accountController.text; + transactionController = Provider.of( + context, + listen: false, + ); + + _animationController = AnimationController( + duration: const Duration(milliseconds: 600), + 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: _animationController, + curve: Interval(0.075 * i, 1.0, curve: Curves.easeOut), + ), + ), + ); + } + } + + Future setupData() async { + prefs = await SharedPreferences.getInstance(); + _queryParams['userId'] = prefs.getString("userId")!; + controller.getRecipients(_queryParams); + } + + @override + void dispose() { + _animationController.dispose(); + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("Recipients", style: TextStyle(color: Colors.black87)), + ), + body: ListenableBuilder( + listenable: controller, + builder: (context, child) { + 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, + ), + ), + 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), + ], + ), + ), + ); + }, + ), + ); + } + + Widget _buildAccountField(RecipientsController controller) { + String fieldName = + controller.model.selectedProvider?.accountFieldName ?? "Account Number"; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(fieldName, style: TextStyle(fontSize: 16)), + TextButton( + onPressed: () async { + if (await FlutterContacts.requestPermission()) { + final contact = await FlutterContacts.openExternalPick(); + if (contact != null) { + _accountController.text = contact.phones.first.number; + controller.updateCreditAccount(contact.phones.first.number); + } + } + }, + child: Text( + "Fetch from contacts", + style: TextStyle(fontSize: 12, color: Colors.red), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _accountController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'Enter / Search $fieldName', + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.secondary, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of( + context, + ).colorScheme.primary.withOpacity(0.2), + ), + ), + ), + onChanged: (value) { + controller.updateCreditAccount(value); + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter an amount'; + } + if (double.tryParse(value) == null) { + return 'Please enter a valid amount'; + } + return null; + }, + ), + ), + IconButton( + onPressed: () { + _queryParams.clear(); + setState(() { + _queryParams['account'] = _accountController.text; + _queryParams['email'] = _accountController.text; + _queryParams['phoneNumber'] = _accountController.text; + _queryParams['name'] = _accountController.text; + _queryParams['userId'] = prefs.getString("userId")!; + + controller.getRecipients(_queryParams); + }); + }, + icon: Icon( + Icons.search, + color: Theme.of(context).colorScheme.primary, + size: 24, + ), + ), + ], + ), + ], + ); + } + + Widget _buildRecipientItems(RecipientsController controller) { + if (controller.model.recipients.isEmpty && !controller.model.isLoading) { + return Center(child: Text("No recipients yet")); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...controller.model.recipients.map( + (recipient) => Skeletonizer( + enabled: controller.model.isLoading, + child: InkWell( + onTap: () { + String account = recipient.account ?? ''; + if (!transactionController + .model + .selectedProvider! + .requiresAccount) { + account = recipient.phoneNumber ?? ''; + } + transactionController + .model + .formData = transactionController.model.formData.copyWith( + creditAccount: account, + creditName: recipient.name ?? '', + creditPhone: recipient.phoneNumber ?? '', + creditEmail: recipient.email ?? '', + providerLabel: + transactionController.model.formData.providerLabel.isEmpty + ? recipient.latestProviderLabel ?? '' + : transactionController.model.formData.providerLabel, + ); + context.push("/make-payment"); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: EdgeInsets.all(5), + child: SlideTransition( + position: _alignListAnimations[0], + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Text( + recipient.initials ?? 'PK', + style: TextStyle( + fontSize: 25, + fontWeight: FontWeight.bold, + ), + ), + ), + SizedBox(width: 20), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + recipient.latestProviderLabel ?? '', + style: TextStyle(fontWeight: FontWeight.bold), + ), + SizedBox(width: 5), + Text( + recipient.account ?? '', + style: TextStyle(fontWeight: FontWeight.normal), + ), + ], + ), + if ((recipient.phoneNumber != null && + recipient.phoneNumber!.isNotEmpty) || + (recipient.email != null && + recipient.email!.isNotEmpty)) + Row( + spacing: 5, + children: [ + if (recipient.phoneNumber != null && + recipient.phoneNumber!.isNotEmpty) + Text( + recipient.phoneNumber ?? '', + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ), + if (recipient.email != null && + recipient.email!.isNotEmpty) + Text( + recipient.email ?? '', + style: TextStyle( + fontWeight: FontWeight.normal, + ), + ), + ], + ), + if (recipient.name != null && + recipient.name!.isNotEmpty) + Text( + recipient.name ?? '', + style: TextStyle(fontWeight: FontWeight.normal), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart new file mode 100644 index 0000000..06b1a4b --- /dev/null +++ b/lib/screens/splash_screen.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:gif_view/gif_view.dart'; + +class SplashScreen extends StatefulWidget { + final VoidCallback onSplashComplete; + + const SplashScreen({super.key, required this.onSplashComplete}); + + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State + with TickerProviderStateMixin { + late AnimationController _fadeController; + late AnimationController _scaleController; + late Animation _fadeAnimation; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + + // Initialize animation controllers + _fadeController = AnimationController( + duration: const Duration(milliseconds: 1000), + vsync: this, + ); + + _scaleController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + ); + + // Create animations + _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _fadeController, curve: Curves.easeInOut), + ); + + _scaleAnimation = Tween(begin: 0.8, end: 1.0).animate( + CurvedAnimation(parent: _scaleController, curve: Curves.elasticOut), + ); + + // Start animations + _startAnimations(); + } + + void _startAnimations() async { + try { + // Start fade in and scale animations + _fadeController.forward(); + _scaleController.forward(); + + // Wait for 3 seconds (adjust as needed) + await Future.delayed(const Duration(milliseconds: 1250)); + + // Start fade out animation + await _fadeController.reverse(); + + // Call the completion callback + if (mounted) { + widget.onSplashComplete(); + } + } catch (e) { + // If there's an error, still complete the splash screen + if (mounted) { + widget.onSplashComplete(); + } + } + } + + @override + void dispose() { + _fadeController.dispose(); + _scaleController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: AnimatedBuilder( + animation: Listenable.merge([_fadeController, _scaleController]), + builder: (context, child) { + return FadeTransition( + opacity: _fadeAnimation, + child: ScaleTransition( + scale: _scaleAnimation, + child: Center( + child: SizedBox( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: GifView.asset( + 'assets/giphy.gif', + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + frameRate: 30, + fit: BoxFit.contain, + ), + ), + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/screens/transaction_controller.dart b/lib/screens/transaction_controller.dart new file mode 100644 index 0000000..973ca8a --- /dev/null +++ b/lib/screens/transaction_controller.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:qpay/screens/pay/pay_controller.dart'; +import 'package:qpay/screens/home/home_controller.dart'; + +part 'transaction_controller.freezed.dart'; +part 'transaction_controller.g.dart'; + +class TransactionModel { + late Map confirmationData; + late PaymentProcessor selectedPaymentProcessor; + late FormData repeatFormData; + + BillProduct? selectedProduct; + BillProvider? selectedProvider; + Map? receiptData; + FormData formData = FormData( + type: '', + billClientId: '', + debitRef: '', + debitCurrency: '', + amount: '', + debitPhone: '', + debitAccount: '', + creditAccount: '', + creditPhone: '', + creditName: '', + creditEmail: '', + billName: '', + errorMessage: '', + status: 'PENDING', + paymentProcessorLabel: '', + charge: '', + gatewayCharge: '', + tax: '', + totalAmount: '', + paymentProcessorName: '', + paymentProcessorImage: '', + providerImage: '', + userId: '', + providerLabel: '', + ); + + String? errorMessage; +} + +@freezed +abstract class FormData with _$FormData { + const factory FormData({ + required String type, // CONFIRM, REQUEST, REVERSE + required String billClientId, + required String debitRef, + required String debitCurrency, + required String amount, + required String creditAccount, + required String? creditPhone, + required String? creditName, + required String? creditEmail, + required String billName, + required String? errorMessage, + required String status, + required String paymentProcessorLabel, + required String paymentProcessorName, + required String paymentProcessorImage, + required String providerImage, + required String providerLabel, + required String userId, + String? debitPhone, + String? debitAccount, + String? productUid, + String? trace, + String? authType, + String? charge, + String? gatewayCharge, + String? tax, + String? totalAmount, + }) = _FormData; + + factory FormData.fromJson(Map json) => + _$FormDataFromJson(json); +} + +class TransactionController extends ChangeNotifier { + TransactionModel model = TransactionModel(); + + updateSelectedProvider(BillProvider provider) { + model.selectedProvider = provider; + notifyListeners(); + } + + updateSelectedProduct(BillProduct product) { + model.selectedProduct = product; + notifyListeners(); + } + + updateSelectedPaymentProcessor(PaymentProcessor processor) { + model.selectedPaymentProcessor = processor; + notifyListeners(); + } + + updateConfirmationData(Map data) { + model.confirmationData = data; + notifyListeners(); + } + + updateReceiptData(Map data) { + model.receiptData = data; + notifyListeners(); + } + + updateRepeatFormData(FormData data) { + model.repeatFormData = data; + notifyListeners(); + } +} diff --git a/lib/screens/transaction_controller.freezed.dart b/lib/screens/transaction_controller.freezed.dart new file mode 100644 index 0000000..602c635 --- /dev/null +++ b/lib/screens/transaction_controller.freezed.dart @@ -0,0 +1,228 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'transaction_controller.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$FormData { + + String get type;// CONFIRM, REQUEST, REVERSE + String get billClientId; String get debitRef; String get debitCurrency; String get amount; String get creditAccount; String? get creditPhone; String? get creditName; String? get creditEmail; String get billName; String? get errorMessage; String get status; String get paymentProcessorLabel; String get paymentProcessorName; String get paymentProcessorImage; String get providerImage; String get providerLabel; String get userId; String? get debitPhone; String? get debitAccount; String? get productUid; String? get trace; String? get authType; String? get charge; String? get gatewayCharge; String? get tax; String? get totalAmount; +/// Create a copy of FormData +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$FormDataCopyWith get copyWith => _$FormDataCopyWithImpl(this as FormData, _$identity); + + /// Serializes this FormData to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FormData&&(identical(other.type, type) || other.type == type)&&(identical(other.billClientId, billClientId) || other.billClientId == billClientId)&&(identical(other.debitRef, debitRef) || other.debitRef == debitRef)&&(identical(other.debitCurrency, debitCurrency) || other.debitCurrency == debitCurrency)&&(identical(other.amount, amount) || other.amount == amount)&&(identical(other.creditAccount, creditAccount) || other.creditAccount == creditAccount)&&(identical(other.creditPhone, creditPhone) || other.creditPhone == creditPhone)&&(identical(other.creditName, creditName) || other.creditName == creditName)&&(identical(other.creditEmail, creditEmail) || other.creditEmail == creditEmail)&&(identical(other.billName, billName) || other.billName == billName)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.status, status) || other.status == status)&&(identical(other.paymentProcessorLabel, paymentProcessorLabel) || other.paymentProcessorLabel == paymentProcessorLabel)&&(identical(other.paymentProcessorName, paymentProcessorName) || other.paymentProcessorName == paymentProcessorName)&&(identical(other.paymentProcessorImage, paymentProcessorImage) || other.paymentProcessorImage == paymentProcessorImage)&&(identical(other.providerImage, providerImage) || other.providerImage == providerImage)&&(identical(other.providerLabel, providerLabel) || other.providerLabel == providerLabel)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.debitPhone, debitPhone) || other.debitPhone == debitPhone)&&(identical(other.debitAccount, debitAccount) || other.debitAccount == debitAccount)&&(identical(other.productUid, productUid) || other.productUid == productUid)&&(identical(other.trace, trace) || other.trace == trace)&&(identical(other.authType, authType) || other.authType == authType)&&(identical(other.charge, charge) || other.charge == charge)&&(identical(other.gatewayCharge, gatewayCharge) || other.gatewayCharge == gatewayCharge)&&(identical(other.tax, tax) || other.tax == tax)&&(identical(other.totalAmount, totalAmount) || other.totalAmount == totalAmount)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hashAll([runtimeType,type,billClientId,debitRef,debitCurrency,amount,creditAccount,creditPhone,creditName,creditEmail,billName,errorMessage,status,paymentProcessorLabel,paymentProcessorName,paymentProcessorImage,providerImage,providerLabel,userId,debitPhone,debitAccount,productUid,trace,authType,charge,gatewayCharge,tax,totalAmount]); + +@override +String toString() { + return 'FormData(type: $type, billClientId: $billClientId, debitRef: $debitRef, debitCurrency: $debitCurrency, amount: $amount, creditAccount: $creditAccount, creditPhone: $creditPhone, creditName: $creditName, creditEmail: $creditEmail, billName: $billName, errorMessage: $errorMessage, status: $status, paymentProcessorLabel: $paymentProcessorLabel, paymentProcessorName: $paymentProcessorName, paymentProcessorImage: $paymentProcessorImage, providerImage: $providerImage, providerLabel: $providerLabel, userId: $userId, debitPhone: $debitPhone, debitAccount: $debitAccount, productUid: $productUid, trace: $trace, authType: $authType, charge: $charge, gatewayCharge: $gatewayCharge, tax: $tax, totalAmount: $totalAmount)'; +} + + +} + +/// @nodoc +abstract mixin class $FormDataCopyWith<$Res> { + factory $FormDataCopyWith(FormData value, $Res Function(FormData) _then) = _$FormDataCopyWithImpl; +@useResult +$Res call({ + String type, String billClientId, String debitRef, String debitCurrency, String amount, String creditAccount, String? creditPhone, String? creditName, String? creditEmail, String billName, String? errorMessage, String status, String paymentProcessorLabel, String paymentProcessorName, String paymentProcessorImage, String providerImage, String providerLabel, String userId, String? debitPhone, String? debitAccount, String? productUid, String? trace, String? authType, String? charge, String? gatewayCharge, String? tax, String? totalAmount +}); + + + + +} +/// @nodoc +class _$FormDataCopyWithImpl<$Res> + implements $FormDataCopyWith<$Res> { + _$FormDataCopyWithImpl(this._self, this._then); + + final FormData _self; + final $Res Function(FormData) _then; + +/// Create a copy of FormData +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? billClientId = null,Object? debitRef = null,Object? debitCurrency = null,Object? amount = null,Object? creditAccount = null,Object? creditPhone = freezed,Object? creditName = freezed,Object? creditEmail = freezed,Object? billName = null,Object? errorMessage = freezed,Object? status = null,Object? paymentProcessorLabel = null,Object? paymentProcessorName = null,Object? paymentProcessorImage = null,Object? providerImage = null,Object? providerLabel = null,Object? userId = null,Object? debitPhone = freezed,Object? debitAccount = freezed,Object? productUid = freezed,Object? trace = freezed,Object? authType = freezed,Object? charge = freezed,Object? gatewayCharge = freezed,Object? tax = freezed,Object? totalAmount = freezed,}) { + return _then(_self.copyWith( +type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,billClientId: null == billClientId ? _self.billClientId : billClientId // ignore: cast_nullable_to_non_nullable +as String,debitRef: null == debitRef ? _self.debitRef : debitRef // ignore: cast_nullable_to_non_nullable +as String,debitCurrency: null == debitCurrency ? _self.debitCurrency : debitCurrency // ignore: cast_nullable_to_non_nullable +as String,amount: null == amount ? _self.amount : amount // ignore: cast_nullable_to_non_nullable +as String,creditAccount: null == creditAccount ? _self.creditAccount : creditAccount // ignore: cast_nullable_to_non_nullable +as String,creditPhone: freezed == creditPhone ? _self.creditPhone : creditPhone // ignore: cast_nullable_to_non_nullable +as String?,creditName: freezed == creditName ? _self.creditName : creditName // ignore: cast_nullable_to_non_nullable +as String?,creditEmail: freezed == creditEmail ? _self.creditEmail : creditEmail // ignore: cast_nullable_to_non_nullable +as String?,billName: null == billName ? _self.billName : billName // ignore: cast_nullable_to_non_nullable +as String,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable +as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as String,paymentProcessorLabel: null == paymentProcessorLabel ? _self.paymentProcessorLabel : paymentProcessorLabel // ignore: cast_nullable_to_non_nullable +as String,paymentProcessorName: null == paymentProcessorName ? _self.paymentProcessorName : paymentProcessorName // ignore: cast_nullable_to_non_nullable +as String,paymentProcessorImage: null == paymentProcessorImage ? _self.paymentProcessorImage : paymentProcessorImage // ignore: cast_nullable_to_non_nullable +as String,providerImage: null == providerImage ? _self.providerImage : providerImage // ignore: cast_nullable_to_non_nullable +as String,providerLabel: null == providerLabel ? _self.providerLabel : providerLabel // ignore: cast_nullable_to_non_nullable +as String,userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable +as String,debitPhone: freezed == debitPhone ? _self.debitPhone : debitPhone // ignore: cast_nullable_to_non_nullable +as String?,debitAccount: freezed == debitAccount ? _self.debitAccount : debitAccount // ignore: cast_nullable_to_non_nullable +as String?,productUid: freezed == productUid ? _self.productUid : productUid // ignore: cast_nullable_to_non_nullable +as String?,trace: freezed == trace ? _self.trace : trace // ignore: cast_nullable_to_non_nullable +as String?,authType: freezed == authType ? _self.authType : authType // ignore: cast_nullable_to_non_nullable +as String?,charge: freezed == charge ? _self.charge : charge // ignore: cast_nullable_to_non_nullable +as String?,gatewayCharge: freezed == gatewayCharge ? _self.gatewayCharge : gatewayCharge // ignore: cast_nullable_to_non_nullable +as String?,tax: freezed == tax ? _self.tax : tax // ignore: cast_nullable_to_non_nullable +as String?,totalAmount: freezed == totalAmount ? _self.totalAmount : totalAmount // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// @nodoc +@JsonSerializable() + +class _FormData implements FormData { + const _FormData({required this.type, required this.billClientId, required this.debitRef, required this.debitCurrency, required this.amount, required this.creditAccount, required this.creditPhone, required this.creditName, required this.creditEmail, required this.billName, required this.errorMessage, required this.status, required this.paymentProcessorLabel, required this.paymentProcessorName, required this.paymentProcessorImage, required this.providerImage, required this.providerLabel, required this.userId, this.debitPhone, this.debitAccount, this.productUid, this.trace, this.authType, this.charge, this.gatewayCharge, this.tax, this.totalAmount}); + factory _FormData.fromJson(Map json) => _$FormDataFromJson(json); + +@override final String type; +// CONFIRM, REQUEST, REVERSE +@override final String billClientId; +@override final String debitRef; +@override final String debitCurrency; +@override final String amount; +@override final String creditAccount; +@override final String? creditPhone; +@override final String? creditName; +@override final String? creditEmail; +@override final String billName; +@override final String? errorMessage; +@override final String status; +@override final String paymentProcessorLabel; +@override final String paymentProcessorName; +@override final String paymentProcessorImage; +@override final String providerImage; +@override final String providerLabel; +@override final String userId; +@override final String? debitPhone; +@override final String? debitAccount; +@override final String? productUid; +@override final String? trace; +@override final String? authType; +@override final String? charge; +@override final String? gatewayCharge; +@override final String? tax; +@override final String? totalAmount; + +/// Create a copy of FormData +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$FormDataCopyWith<_FormData> get copyWith => __$FormDataCopyWithImpl<_FormData>(this, _$identity); + +@override +Map toJson() { + return _$FormDataToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _FormData&&(identical(other.type, type) || other.type == type)&&(identical(other.billClientId, billClientId) || other.billClientId == billClientId)&&(identical(other.debitRef, debitRef) || other.debitRef == debitRef)&&(identical(other.debitCurrency, debitCurrency) || other.debitCurrency == debitCurrency)&&(identical(other.amount, amount) || other.amount == amount)&&(identical(other.creditAccount, creditAccount) || other.creditAccount == creditAccount)&&(identical(other.creditPhone, creditPhone) || other.creditPhone == creditPhone)&&(identical(other.creditName, creditName) || other.creditName == creditName)&&(identical(other.creditEmail, creditEmail) || other.creditEmail == creditEmail)&&(identical(other.billName, billName) || other.billName == billName)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.status, status) || other.status == status)&&(identical(other.paymentProcessorLabel, paymentProcessorLabel) || other.paymentProcessorLabel == paymentProcessorLabel)&&(identical(other.paymentProcessorName, paymentProcessorName) || other.paymentProcessorName == paymentProcessorName)&&(identical(other.paymentProcessorImage, paymentProcessorImage) || other.paymentProcessorImage == paymentProcessorImage)&&(identical(other.providerImage, providerImage) || other.providerImage == providerImage)&&(identical(other.providerLabel, providerLabel) || other.providerLabel == providerLabel)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.debitPhone, debitPhone) || other.debitPhone == debitPhone)&&(identical(other.debitAccount, debitAccount) || other.debitAccount == debitAccount)&&(identical(other.productUid, productUid) || other.productUid == productUid)&&(identical(other.trace, trace) || other.trace == trace)&&(identical(other.authType, authType) || other.authType == authType)&&(identical(other.charge, charge) || other.charge == charge)&&(identical(other.gatewayCharge, gatewayCharge) || other.gatewayCharge == gatewayCharge)&&(identical(other.tax, tax) || other.tax == tax)&&(identical(other.totalAmount, totalAmount) || other.totalAmount == totalAmount)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hashAll([runtimeType,type,billClientId,debitRef,debitCurrency,amount,creditAccount,creditPhone,creditName,creditEmail,billName,errorMessage,status,paymentProcessorLabel,paymentProcessorName,paymentProcessorImage,providerImage,providerLabel,userId,debitPhone,debitAccount,productUid,trace,authType,charge,gatewayCharge,tax,totalAmount]); + +@override +String toString() { + return 'FormData(type: $type, billClientId: $billClientId, debitRef: $debitRef, debitCurrency: $debitCurrency, amount: $amount, creditAccount: $creditAccount, creditPhone: $creditPhone, creditName: $creditName, creditEmail: $creditEmail, billName: $billName, errorMessage: $errorMessage, status: $status, paymentProcessorLabel: $paymentProcessorLabel, paymentProcessorName: $paymentProcessorName, paymentProcessorImage: $paymentProcessorImage, providerImage: $providerImage, providerLabel: $providerLabel, userId: $userId, debitPhone: $debitPhone, debitAccount: $debitAccount, productUid: $productUid, trace: $trace, authType: $authType, charge: $charge, gatewayCharge: $gatewayCharge, tax: $tax, totalAmount: $totalAmount)'; +} + + +} + +/// @nodoc +abstract mixin class _$FormDataCopyWith<$Res> implements $FormDataCopyWith<$Res> { + factory _$FormDataCopyWith(_FormData value, $Res Function(_FormData) _then) = __$FormDataCopyWithImpl; +@override @useResult +$Res call({ + String type, String billClientId, String debitRef, String debitCurrency, String amount, String creditAccount, String? creditPhone, String? creditName, String? creditEmail, String billName, String? errorMessage, String status, String paymentProcessorLabel, String paymentProcessorName, String paymentProcessorImage, String providerImage, String providerLabel, String userId, String? debitPhone, String? debitAccount, String? productUid, String? trace, String? authType, String? charge, String? gatewayCharge, String? tax, String? totalAmount +}); + + + + +} +/// @nodoc +class __$FormDataCopyWithImpl<$Res> + implements _$FormDataCopyWith<$Res> { + __$FormDataCopyWithImpl(this._self, this._then); + + final _FormData _self; + final $Res Function(_FormData) _then; + +/// Create a copy of FormData +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? billClientId = null,Object? debitRef = null,Object? debitCurrency = null,Object? amount = null,Object? creditAccount = null,Object? creditPhone = freezed,Object? creditName = freezed,Object? creditEmail = freezed,Object? billName = null,Object? errorMessage = freezed,Object? status = null,Object? paymentProcessorLabel = null,Object? paymentProcessorName = null,Object? paymentProcessorImage = null,Object? providerImage = null,Object? providerLabel = null,Object? userId = null,Object? debitPhone = freezed,Object? debitAccount = freezed,Object? productUid = freezed,Object? trace = freezed,Object? authType = freezed,Object? charge = freezed,Object? gatewayCharge = freezed,Object? tax = freezed,Object? totalAmount = freezed,}) { + return _then(_FormData( +type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,billClientId: null == billClientId ? _self.billClientId : billClientId // ignore: cast_nullable_to_non_nullable +as String,debitRef: null == debitRef ? _self.debitRef : debitRef // ignore: cast_nullable_to_non_nullable +as String,debitCurrency: null == debitCurrency ? _self.debitCurrency : debitCurrency // ignore: cast_nullable_to_non_nullable +as String,amount: null == amount ? _self.amount : amount // ignore: cast_nullable_to_non_nullable +as String,creditAccount: null == creditAccount ? _self.creditAccount : creditAccount // ignore: cast_nullable_to_non_nullable +as String,creditPhone: freezed == creditPhone ? _self.creditPhone : creditPhone // ignore: cast_nullable_to_non_nullable +as String?,creditName: freezed == creditName ? _self.creditName : creditName // ignore: cast_nullable_to_non_nullable +as String?,creditEmail: freezed == creditEmail ? _self.creditEmail : creditEmail // ignore: cast_nullable_to_non_nullable +as String?,billName: null == billName ? _self.billName : billName // ignore: cast_nullable_to_non_nullable +as String,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable +as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as String,paymentProcessorLabel: null == paymentProcessorLabel ? _self.paymentProcessorLabel : paymentProcessorLabel // ignore: cast_nullable_to_non_nullable +as String,paymentProcessorName: null == paymentProcessorName ? _self.paymentProcessorName : paymentProcessorName // ignore: cast_nullable_to_non_nullable +as String,paymentProcessorImage: null == paymentProcessorImage ? _self.paymentProcessorImage : paymentProcessorImage // ignore: cast_nullable_to_non_nullable +as String,providerImage: null == providerImage ? _self.providerImage : providerImage // ignore: cast_nullable_to_non_nullable +as String,providerLabel: null == providerLabel ? _self.providerLabel : providerLabel // ignore: cast_nullable_to_non_nullable +as String,userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable +as String,debitPhone: freezed == debitPhone ? _self.debitPhone : debitPhone // ignore: cast_nullable_to_non_nullable +as String?,debitAccount: freezed == debitAccount ? _self.debitAccount : debitAccount // ignore: cast_nullable_to_non_nullable +as String?,productUid: freezed == productUid ? _self.productUid : productUid // ignore: cast_nullable_to_non_nullable +as String?,trace: freezed == trace ? _self.trace : trace // ignore: cast_nullable_to_non_nullable +as String?,authType: freezed == authType ? _self.authType : authType // ignore: cast_nullable_to_non_nullable +as String?,charge: freezed == charge ? _self.charge : charge // ignore: cast_nullable_to_non_nullable +as String?,gatewayCharge: freezed == gatewayCharge ? _self.gatewayCharge : gatewayCharge // ignore: cast_nullable_to_non_nullable +as String?,tax: freezed == tax ? _self.tax : tax // ignore: cast_nullable_to_non_nullable +as String?,totalAmount: freezed == totalAmount ? _self.totalAmount : totalAmount // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/lib/screens/transaction_controller.g.dart b/lib/screens/transaction_controller.g.dart new file mode 100644 index 0000000..01206c3 --- /dev/null +++ b/lib/screens/transaction_controller.g.dart @@ -0,0 +1,67 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'transaction_controller.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_FormData _$FormDataFromJson(Map json) => _FormData( + type: json['type'] as String, + billClientId: json['billClientId'] as String, + debitRef: json['debitRef'] as String, + debitCurrency: json['debitCurrency'] as String, + amount: json['amount'] as String, + creditAccount: json['creditAccount'] as String, + creditPhone: json['creditPhone'] as String?, + creditName: json['creditName'] as String?, + creditEmail: json['creditEmail'] as String?, + billName: json['billName'] as String, + errorMessage: json['errorMessage'] as String?, + status: json['status'] as String, + paymentProcessorLabel: json['paymentProcessorLabel'] as String, + paymentProcessorName: json['paymentProcessorName'] as String, + paymentProcessorImage: json['paymentProcessorImage'] as String, + providerImage: json['providerImage'] as String, + providerLabel: json['providerLabel'] as String, + userId: json['userId'] as String, + debitPhone: json['debitPhone'] as String?, + debitAccount: json['debitAccount'] as String?, + productUid: json['productUid'] as String?, + trace: json['trace'] as String?, + authType: json['authType'] as String?, + charge: json['charge'] as String?, + gatewayCharge: json['gatewayCharge'] as String?, + tax: json['tax'] as String?, + totalAmount: json['totalAmount'] as String?, +); + +Map _$FormDataToJson(_FormData instance) => { + 'type': instance.type, + 'billClientId': instance.billClientId, + 'debitRef': instance.debitRef, + 'debitCurrency': instance.debitCurrency, + 'amount': instance.amount, + 'creditAccount': instance.creditAccount, + 'creditPhone': instance.creditPhone, + 'creditName': instance.creditName, + 'creditEmail': instance.creditEmail, + 'billName': instance.billName, + 'errorMessage': instance.errorMessage, + 'status': instance.status, + 'paymentProcessorLabel': instance.paymentProcessorLabel, + 'paymentProcessorName': instance.paymentProcessorName, + 'paymentProcessorImage': instance.paymentProcessorImage, + 'providerImage': instance.providerImage, + 'providerLabel': instance.providerLabel, + 'userId': instance.userId, + 'debitPhone': instance.debitPhone, + 'debitAccount': instance.debitAccount, + 'productUid': instance.productUid, + 'trace': instance.trace, + 'authType': instance.authType, + 'charge': instance.charge, + 'gatewayCharge': instance.gatewayCharge, + 'tax': instance.tax, + 'totalAmount': instance.totalAmount, +}; diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 3b73f4a..d2d5e46 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -7,7 +7,7 @@ project(runner LANGUAGES CXX) set(BINARY_NAME "qpay") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.example.qpay") +set(APPLICATION_ID "zw.co.qantra.qpay") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..f6f23bf 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..f16b4c3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index b8e2b22..313ef1e 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,13 @@ import FlutterMacOS import Foundation import path_provider_foundation +import share_plus import shared_preferences_foundation +import webview_flutter_wkwebview 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")) + WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 92a9c2c..08a6588 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -385,7 +385,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.qpay.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = zw.co.qantra.qpay.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/qpay.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/qpay"; @@ -399,7 +399,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.qpay.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = zw.co.qantra.qpay.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/qpay.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/qpay"; @@ -413,7 +413,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.qpay.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = zw.co.qantra.qpay.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/qpay.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/qpay"; diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index 18c3842..82fd53f 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -8,7 +8,7 @@ PRODUCT_NAME = qpay // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.example.qpay +PRODUCT_BUNDLE_IDENTIFIER = zw.co.qantra.qpay // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2025 zw.co.qantra. All rights reserved. diff --git a/pubspec.lock b/pubspec.lock index 6300f5e..73125d5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -153,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" crypto: dependency: transitive description: @@ -230,6 +238,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_contacts: + dependency: "direct main" + description: + name: flutter_contacts + sha256: "388d32cd33f16640ee169570128c933b45f3259bddbfae7a100bb49e5ffea9ae" + url: "https://pub.dev" + source: hosted + version: "1.1.9+2" flutter_dotenv: dependency: "direct main" description: @@ -288,6 +304,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + gif_view: + dependency: "direct main" + description: + name: gif_view + sha256: "6e803e6ed892a6a0ff6671554550780a56d053cc1f06504086794001b3cd000e" + url: "https://pub.dev" + source: hosted + version: "0.4.4" glob: dependency: transitive description: @@ -600,6 +624,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: b2961506569e28948d75ec346c28775bb111986bb69dc6a20754a457e3d97fa0 + url: "https://pub.dev" + source: hosted + version: "11.0.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "1032d392bc5d2095a77447a805aa3f804d2ae6a4d5eef5e6ebb3bd94c1bc19ef" + url: "https://pub.dev" + source: hosted + version: "6.0.0" shared_preferences: dependency: "direct main" description: @@ -765,6 +805,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.4" + timeago: + dependency: "direct main" + description: + name: timeago + sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e + url: "https://pub.dev" + source: hosted + version: "3.7.1" timing: dependency: transitive description: @@ -781,6 +829,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" uuid: dependency: "direct main" description: @@ -861,6 +941,54 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba + url: "https://pub.dev" + source: hosted + version: "4.13.0" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: f6e6afef6e234801da77170f7a1847ded8450778caf2fe13979d140484be3678 + url: "https://pub.dev" + source: hosted + version: "4.7.0" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: f0dc2dc3a2b1e3a6abdd6801b9355ebfeb3b8f6cde6b9dc7c9235909c4a1f147 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: a3d461fe3467014e05f3ac4962e5fdde2a4bf44c561cb53e9ae5c586600fdbc3 + url: "https://pub.dev" + source: hosted + version: "3.22.0" + widgets_to_image: + dependency: "direct main" + description: + name: widgets_to_image + sha256: "4e5b7e88bf62c06a91762a9a713096f095361e4c8e81fdd3875cf19cdad73d3e" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" + url: "https://pub.dev" + source: hosted + version: "5.13.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a979eba..7228adb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: qpay -description: "A new Flutter project." +description: "Peak 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 @@ -37,16 +37,22 @@ dependencies: provider: ^6.1.1 go_router: ^13.2.0 intl: ^0.19.0 - shared_preferences: ^2.2.2 + shared_preferences: ^2.5.3 flutter_svg: ^2.0.9 google_fonts: ^6.1.0 uuid: ^4.3.3 skeletonizer: ^1.4.3 dio: ^5.8.0+1 + timeago: ^3.6.1 freezed_annotation: ^3.0.0 json_annotation: ^4.9.0 flutter_dotenv: ^5.2.1 logger: ^2.6.0 + webview_flutter: ^4.13.0 + widgets_to_image: ^2.0.1 + share_plus: ^11.0.0 + flutter_contacts: ^1.1.9+2 + gif_view: ^0.4.0 dev_dependencies: flutter_test: diff --git a/web/index.html b/web/index.html index 11abc5a..d1bc15b 100644 --- a/web/index.html +++ b/web/index.html @@ -19,6 +19,7 @@ + diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..c3384ec 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,12 @@ #include "generated_plugin_registrant.h" +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..01d3836 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + share_plus + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 5f38823..49e8a10 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -89,11 +89,11 @@ BEGIN BEGIN BLOCK "040904e4" BEGIN - VALUE "CompanyName", "com.example" "\0" + VALUE "CompanyName", "zw.co.qantra" "\0" VALUE "FileDescription", "qpay" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "qpay" "\0" - VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 zw.co.qantra. All rights reserved." "\0" VALUE "OriginalFilename", "qpay.exe" "\0" VALUE "ProductName", "qpay" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0"