diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f78dd5..5f41945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 2.0.2 + + +- Remove node.js Example +- update the returned payment model desc +- payment view return bool instead of model + ## 2.0.1 - Remove Get Payment Method diff --git a/README.md b/README.md index 4d59503..3fd906a 100644 --- a/README.md +++ b/README.md @@ -60,40 +60,9 @@ curl -X POST https://api.moosyl.com/payment-request \ }' ``` -#### Node.js Example: - -```javascript -const axios = require("axios"); - -async function createPaymentRequest() { - try { - const response = await axios.post( - "https://api.moosyl.com/payment-request", - { - phoneNumber: "+22212345678", // Optional - transactionId: "your-unique-transaction-id", - amount: 5000, // Amount in MRU - }, - { - headers: { - Authorization: "YOUR_SECRET_API_KEY", - "Content-Type": "application/json", - }, - } - ); - - console.log("Payment Request Created:", response.data); - } catch (error) { - console.error("Error creating payment request:", error.response.data); - } -} - -createPaymentRequest(); -``` - --- -### Step 2: Display the Payment View +### Step 2: Register Localization Before displaying the payment UI, make sure your `MaterialApp` registers the Moosyl localization delegates and supported locales: @@ -101,29 +70,45 @@ Before displaying the payment UI, make sure your `MaterialApp` registers the Moo return MaterialApp( localizationsDelegates: MoosylLocalization.localizationsDelegates, supportedLocales: MoosylLocalization.supportedLocales, + locale: const Locale('en'), // Optional: use the device locale by default. home: const PaymentScreen(), ); ``` -Use `MoosylFlutter.show()` to open the payment flow. It returns `PaymentSuccess?` — non-null on success, `null` when the user closes without paying: +--- + +### Step 3: Open the Full Payment View + +Use `MoosylFlutter.show()` when you want Moosyl to own the checkout payment UI. It returns `bool?`: a non-null value when the payment flow finishes, or `null` when the user closes the view without completing payment. + +Set `isFullPage` to `true` for a pushed route or `false` for a bottom sheet. ```dart import 'package:flutter/material.dart'; import 'package:moosyl_flutter/moosyl.dart'; class PaymentScreen extends StatelessWidget { + const PaymentScreen({super.key}); + @override Widget build(BuildContext context) { return ElevatedButton( onPressed: () async { - final payment = await MoosylFlutter.show( + final isSuccess = await MoosylFlutter.show( context, publishableApiKey: 'YOUR_PUBLISHABLE_API_KEY', transactionId: 'TRANSACTION_ID', // From your backend - isFullPage: true, // false for bottom sheet + isFullPage: true, // false for bottom sheet + items: const [ + MoosylPaymentSummaryItem(amount: 5000, label: 'amountToPay'), + MoosylPaymentSummaryItem(amount: 0, label: 'tax'), + ], + isMasriviInBottomSheet: false, + masriviPhoneNumber: '+22212345678', ); - if (payment != null) { - print('Payment successful! id=${payment.id} amount=${payment.amount}'); + + if (isSuccess != null) { + print('Payment finished. isSuccess=$isSuccess'); } }, child: const Text('Pay'), @@ -132,7 +117,156 @@ class PaymentScreen extends StatelessWidget { } ``` -**Parameters:** `publishableApiKey`, `transactionId`, `isFullPage` (true = full page, false = bottom sheet), `amountToPay`, `tax`. +#### `MoosylFlutter.show()` Options + +| Option | Type | Required | Description | +| --- | --- | --- | --- | +| `publishableApiKey` | `String` | Yes | Your Moosyl publishable API key. | +| `transactionId` | `String` | Yes | The transaction ID returned by your backend payment request. | +| `items` | `List?` | No | Summary rows shown in the payment view. When supplied, their total is validated against the payment request amount. Known localized labels include `amountToPay`, `tax`, `total`, and `totalAmount`; custom labels are shown as provided. | +| `isFullPage` | `bool` | No | Defaults to `true`. Use `false` to show the flow in a bottom sheet. | +| `isMasriviInBottomSheet` | `bool` | No | Controls whether Masrivi opens as bottom-sheet content inside the payment flow. | +| `masriviPhoneNumber` | `String?` | No | Optional phone number used to prefill the Masrivi payment page. | + +--- + +## Embedded Payment Methods + +Use `MoosylPaymentMethods` when your app owns the checkout screen and you only want Moosyl to load, display, select, and continue with payment methods. + +```dart +class EmbeddedPaymentScreen extends StatefulWidget { + const EmbeddedPaymentScreen({super.key}); + + @override + State createState() => _EmbeddedPaymentScreenState(); +} + +class _EmbeddedPaymentScreenState extends State { + final _controller = MoosylPaymentMethodsController(); + ConfigurationListDataInner? _selectedMethod; + bool _continueLoading = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final selectedTitle = _selectedMethod == null + ? 'platform' + : PaymentMethodTypes.fromString(_selectedMethod!.type).title(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MoosylPaymentMethods( + controller: _controller, + publishableApiKey: 'YOUR_PUBLISHABLE_API_KEY', + transactionId: 'TRANSACTION_ID', + selectedMethodId: _selectedMethod?.id, + onSelectMethod: (method) { + setState(() => _selectedMethod = method); + }, + onContinueLoadingChange: (loading) { + setState(() => _continueLoading = loading); + }, + onPaymentSuccess: (isSuccess) async { + print('Payment finished. isSuccess=$isSuccess'); + }, + primaryColor: const Color(0xFFF55E1E), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: _selectedMethod == null || _continueLoading + ? null + : _controller.continuePayment, + child: Text( + _continueLoading ? 'Loading...' : 'Continue with $selectedTitle', + ), + ), + ], + ); + } +} +``` + +--- + +## Custom Payment Method UI + +Pass `renderMethod` to draw each payment method row with your own widgets. Call `props.onSelect()` from your custom row so Moosyl can track the selected method. + +```dart +MoosylPaymentMethods( + controller: _controller, + publishableApiKey: 'YOUR_PUBLISHABLE_API_KEY', + transactionId: 'TRANSACTION_ID', + selectedMethodId: selectedMethod?.id, + onSelectMethod: (method) { + setState(() => selectedMethod = method); + }, + onContinueLoadingChange: (loading) { + setState(() => continueLoading = loading); + }, + onPaymentSuccess: (isSuccess) async { + print('Payment finished. isSuccess=$isSuccess'); + }, + primaryColor: const Color(0xFFF55E1E), + renderMethod: (context, props) { + return ListTile( + onTap: props.onSelect, + leading: props.type.icon.apply(size: 36), + title: Text(props.title), + subtitle: Text(props.method.type), + trailing: props.isSelected ? const Icon(Icons.check_circle) : null, + ); + }, +) +``` + +`MoosylPaymentMethodRenderProps` gives your row everything it needs: + +| Property | Description | +| --- | --- | +| `method` | Raw `ConfigurationListDataInner` payment method data from Moosyl. | +| `type` | Parsed `PaymentMethodTypes` value. | +| `title` | Localized payment method title. | +| `isSelected` | Whether this row is currently selected. | +| `selectedMethodId` | Currently selected method ID, if any. | +| `isRTL` | Whether the surrounding layout direction is RTL. | +| `onSelect` | Callback your custom UI should call when the row is selected. | + +--- + +## `MoosylPaymentMethods` Options + +| Option | Type | Description | +| --- | --- | --- | +| `publishableApiKey` | `String` | Required. Your Moosyl publishable API key. | +| `transactionId` | `String?` | Transaction ID used by `controller.continuePayment()`. | +| `amountToPay` | `double` | Amount used for validation when continuing. Defaults to `0.0`. | +| `tax` | `double` | Tax amount used with `amountToPay` when `totalAmount` is not supplied. Defaults to `0.0`. | +| `totalAmount` | `double?` | Total expected amount. Defaults to `amountToPay + tax`. | +| `selectedMethodId` | `String?` | Controlled selected method ID. | +| `onSelectMethod` | `ValueChanged?` | Called whenever a method is selected. | +| `onPaymentSuccess` | `FutureOr Function(bool)?` | Called when payment succeeds or reports a final success status. | +| `onPaymentError` | `ValueChanged?` | Called when loading or continuing payment fails. | +| `onContinueLoadingChange` | `ValueChanged?` | Called when `continuePayment()` starts and finishes. | +| `controller` | `MoosylPaymentMethodsController?` | Lets your screen call `continuePayment()` and read `selectedMethod`. Dispose it from your state object. | +| `primaryColor` | `Color?` | Accent color for default rows and dialogs. Defaults to the theme primary color. | +| `renderMethod` | `MoosylPaymentMethodBuilder?` | Custom builder for each payment method row. | +| `loadingComponent` | `Widget?` | Custom widget shown while payment methods are loading. | +| `loadingBuilder` | `MoosylPaymentMethodsLoadingBuilder?` | Builder for custom loading content with locale, color, and RTL props. | +| `showDefaultTitle` | `bool` | Whether the default UI includes its section title. Defaults to `true`. | +| `isMasriviInBottomSheet` | `bool` | Whether Masrivi opens in a bottom sheet from custom platform UIs. Defaults to `true`. | +| `masriviPresentation` | `MasriviWebViewPresentation` | Presentation used when `isMasriviInBottomSheet` is `false`. Defaults to `MasriviWebViewPresentation.fullPage`. | +| `masriviPhoneNumber` | `String?` | Optional phone number used to prefill Masrivi. | +| `masriviBottomSheetHeight` | `double` | Height factor for Masrivi bottom-sheet content. Defaults to `0.88`. | + +For a complete working demo with the full payment view, embedded platforms, and custom payment method rows, see [`example/lib/main.dart`](example/lib/main.dart). For detailed API documentation, visit the [Moosyl Flutter API Documentation](https://pub.dev/documentation/moosyl_flutter/latest/moosyl_flutter/moosyl_flutter-library.html). diff --git a/assets/icons/amanty.png b/assets/icons/amanty.png index 83ff398..1921577 100644 Binary files a/assets/icons/amanty.png and b/assets/icons/amanty.png differ diff --git a/assets/icons/bankily.png b/assets/icons/bankily.png index bba4aac..395f19c 100644 Binary files a/assets/icons/bankily.png and b/assets/icons/bankily.png differ diff --git a/assets/icons/bci_pay.png b/assets/icons/bci_pay.png index ac0c1a9..7de0d94 100644 Binary files a/assets/icons/bci_pay.png and b/assets/icons/bci_pay.png differ diff --git a/assets/icons/bim_bank.png b/assets/icons/bim_bank.png index 12773a8..90d223e 100644 Binary files a/assets/icons/bim_bank.png and b/assets/icons/bim_bank.png differ diff --git a/assets/icons/masrivi.png b/assets/icons/masrivi.png index 089f45c..8ca47d0 100644 Binary files a/assets/icons/masrivi.png and b/assets/icons/masrivi.png differ diff --git a/assets/icons/sedad.png b/assets/icons/sedad.png index 0c8accd..295e589 100644 Binary files a/assets/icons/sedad.png and b/assets/icons/sedad.png differ diff --git a/example/lib/main.dart b/example/lib/main.dart index aa1417d..d9ce50e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -5,17 +5,22 @@ import 'package:moosyl_flutter/moosyl.dart'; import 'payment_success_dialog.dart'; +const _apiKey = 'your_api_key'; +const _transactionId = 'transaction_id'; +const _primaryColor = Color(0xFFF55E1E); + void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); + static const colorScheme = ColorScheme.light( - primary: Color.fromARGB(255, 244, 68, 147), + primary: _primaryColor, onPrimary: Color(0xFFFFFFFF), - surface: Color(0xFFF0F0F0), - onSurface: Color(0xFF000000), + surface: Color(0xFFF7F7F7), + onSurface: Color(0xFF111111), secondary: Color(0xFFEAF1FF), onSecondary: Color(0xFF000000), error: Color(0xFFCE2C2C), @@ -25,7 +30,7 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: 'Moosyl Demo', + title: 'Moosyl Demo', localizationsDelegates: MoosylLocalization.localizationsDelegates, supportedLocales: MoosylLocalization.supportedLocales, locale: const Locale('en'), @@ -33,59 +38,600 @@ class MyApp extends StatelessWidget { colorScheme: colorScheme, useMaterial3: true, ), - home: const MyHomePage(title: 'Moosyl Demo Home Page'), + home: const DemoHomePage(), ); } } -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); +enum DemoMode { + menu, + embeddedPlatforms, + customPlatforms, +} - final String title; +class DemoHomePage extends StatefulWidget { + const DemoHomePage({super.key}); @override - State createState() => _MyHomePageState(); + State createState() => _DemoHomePageState(); } -class _MyHomePageState extends State { - void _openPaymentFlow() async { - final payment = await MoosylFlutter.show( - context, - publishableApiKey: 'your publishable api key', - transactionId: 'your transaction id', - isFullPage: false, - ); +class _DemoHomePageState extends State { + final _embeddedController = MoosylPaymentMethodsController(); + final _customController = MoosylPaymentMethodsController(); + + DemoMode _demoMode = DemoMode.menu; + ConfigurationListDataInner? _selectedEmbeddedMethod; + ConfigurationListDataInner? _selectedCustomMethod; + bool _embeddedContinueLoading = false; + bool _customContinueLoading = false; + + @override + void dispose() { + _embeddedController.dispose(); + _customController.dispose(); + super.dispose(); + } + + Future _openMoosylView() async { + final isSuccess = await MoosylFlutter.show(context, + publishableApiKey: _apiKey, + transactionId: _transactionId, + isFullPage: true, + items: [ + const MoosylPaymentSummaryItem(amount: 5, label: 'amount'), + const MoosylPaymentSummaryItem(amount: 0, label: 'tax') + ]); if (!mounted) return; - if (payment != null) { - await showPaymentSuccessDialog(context, payment: payment); - print( - 'Payment was successful! id=${payment.id} amount=${payment.amount} status=${payment.status}'); + if (isSuccess != null) { + await _showSuccess(isSuccess); } } + Future _showSuccess(bool isSuccess) async { + await showPaymentSuccessDialog(context, isSuccess: isSuccess); + print('Payment was successful! isSuccess=$isSuccess'); + } + + void _showMenu() { + setState(() { + _demoMode = DemoMode.menu; + }); + } + + @override + Widget build(BuildContext context) { + return switch (_demoMode) { + DemoMode.menu => _MenuScreen( + onOpenMoosylView: _openMoosylView, + onOpenEmbeddedPlatforms: () { + setState(() { + _demoMode = DemoMode.embeddedPlatforms; + }); + }, + onOpenCustomPlatforms: () { + setState(() { + _demoMode = DemoMode.customPlatforms; + }); + }, + ), + DemoMode.embeddedPlatforms => _EmbeddedPlatformsScreen( + controller: _embeddedController, + selectedMethod: _selectedEmbeddedMethod, + continueLoading: _embeddedContinueLoading, + onBack: _showMenu, + onSelectMethod: (method) { + setState(() { + _selectedEmbeddedMethod = method; + }); + }, + onContinueLoadingChange: (loading) { + setState(() { + _embeddedContinueLoading = loading; + }); + }, + onPaymentSuccess: _showSuccess, + ), + DemoMode.customPlatforms => _CustomPlatformsScreen( + controller: _customController, + selectedMethod: _selectedCustomMethod, + continueLoading: _customContinueLoading, + onBack: _showMenu, + onSelectMethod: (method) { + setState(() { + _selectedCustomMethod = method; + }); + }, + onContinueLoadingChange: (loading) { + setState(() { + _customContinueLoading = loading; + }); + }, + onPaymentSuccess: _showSuccess, + ), + }; + } +} + +class _MenuScreen extends StatelessWidget { + const _MenuScreen({ + required this.onOpenMoosylView, + required this.onOpenEmbeddedPlatforms, + required this.onOpenCustomPlatforms, + }); + + final VoidCallback onOpenMoosylView; + final VoidCallback onOpenEmbeddedPlatforms; + final VoidCallback onOpenCustomPlatforms; + @override Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Moosyl Demo')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 14, + children: [ + _DemoButton( + label: 'Test MoosylView', + onPressed: onOpenMoosylView, + ), + _DemoButton( + label: 'Test embedded platforms', + onPressed: onOpenEmbeddedPlatforms, + outlined: true, + ), + _DemoButton( + label: 'Test custom methods UI', + onPressed: onOpenCustomPlatforms, + outlined: true, + ), + ], + ), + ), + ), + ); + } +} + +class _EmbeddedPlatformsScreen extends StatelessWidget { + const _EmbeddedPlatformsScreen({ + required this.controller, + required this.selectedMethod, + required this.continueLoading, + required this.onBack, + required this.onSelectMethod, + required this.onContinueLoadingChange, + required this.onPaymentSuccess, + }); + + final MoosylPaymentMethodsController controller; + final ConfigurationListDataInner? selectedMethod; + final bool continueLoading; + final VoidCallback onBack; + final ValueChanged onSelectMethod; + final ValueChanged onContinueLoadingChange; + final Future Function(bool isSuccess) onPaymentSuccess; + + @override + Widget build(BuildContext context) { + final selectedTitle = selectedMethod == null + ? 'platform' + : PaymentMethodTypes.fromString(selectedMethod!.type).title(context); + return Scaffold( appBar: AppBar( - title: Text(widget.title), + title: const Text('Embedded Platforms'), + leading: IconButton( + onPressed: onBack, + icon: const Icon(Icons.arrow_back), + ), ), - body: Center( - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - foregroundColor: Theme.of(context).colorScheme.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + body: ListView( + padding: const EdgeInsets.all(20), + children: [ + Text( + 'This screen owns the checkout UI and only uses Moosyl to load and select the platform.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 20), + Text( + 'Payment method', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + MoosylPaymentMethods( + controller: controller, + publishableApiKey: _apiKey, + transactionId: _transactionId, + selectedMethodId: selectedMethod?.id, + onSelectMethod: onSelectMethod, + onContinueLoadingChange: onContinueLoadingChange, + onPaymentSuccess: onPaymentSuccess, + primaryColor: _primaryColor, + ), + const SizedBox(height: 20), + _DemoButton( + label: + continueLoading ? 'Loading...' : 'Continue with $selectedTitle', + onPressed: selectedMethod == null || continueLoading + ? null + : controller.continuePayment, + ), + ], + ), + ); + } +} + +class _CustomPlatformsScreen extends StatelessWidget { + const _CustomPlatformsScreen({ + required this.controller, + required this.selectedMethod, + required this.continueLoading, + required this.onBack, + required this.onSelectMethod, + required this.onContinueLoadingChange, + required this.onPaymentSuccess, + }); + + final MoosylPaymentMethodsController controller; + final ConfigurationListDataInner? selectedMethod; + final bool continueLoading; + final VoidCallback onBack; + final ValueChanged onSelectMethod; + final ValueChanged onContinueLoadingChange; + final Future Function(bool isSuccess) onPaymentSuccess; + + @override + Widget build(BuildContext context) { + final selectedTitle = selectedMethod == null + ? 'payment method' + : PaymentMethodTypes.fromString(selectedMethod!.type).title(context); + + return Scaffold( + backgroundColor: const Color(0xFFF6F0E8), + appBar: AppBar( + backgroundColor: const Color(0xFFF6F0E8), + title: const Text('Custom Checkout'), + leading: IconButton( + onPressed: onBack, + icon: const Icon(Icons.arrow_back), + ), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + children: [ + const _CheckoutSectionTitle(step: '1', title: 'Delivery address'), + _CheckoutCard( + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.location_on_outlined), + title: const Text('Mohamed Ahmed'), + subtitle: + const Text('Tevragh Zeina, Nouakchott\n+222 47 12 34 56'), + trailing: TextButton( + onPressed: () {}, + child: const Text('Change'), + ), + ), + ), + const SizedBox(height: 24), + const _CheckoutSectionTitle(step: '2', title: 'Products'), + const _CheckoutCard( + child: Column( + children: [ + _ProductRow( + title: 'Floral Midi Dress', + subtitle: 'Pink • M • 1 x 520 MRU', + price: '520 MRU', + ), + Divider(height: 28), + _ProductRow( + title: 'Cargo Wide-Leg Pants', + subtitle: 'Beige • L • 1 x 680 MRU', + price: '680 MRU', + ), + ], + ), + ), + const SizedBox(height: 24), + const _CheckoutSectionTitle(step: '3', title: 'Payment method'), + MoosylPaymentMethods( + controller: controller, + publishableApiKey: _apiKey, + transactionId: _transactionId, + selectedMethodId: selectedMethod?.id, + onSelectMethod: onSelectMethod, + onContinueLoadingChange: onContinueLoadingChange, + onPaymentSuccess: onPaymentSuccess, + primaryColor: _primaryColor, + renderMethod: (context, props) => _CustomMethodRow(props: props), + ), + ], + ), + bottomNavigationBar: SafeArea( + minimum: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: SizedBox( + height: 64, + child: FilledButton.icon( + onPressed: selectedMethod == null || continueLoading + ? null + : controller.continuePayment, + icon: selectedMethod == null + ? const Icon(Icons.payments_outlined) + : PaymentMethodTypes.fromString(selectedMethod!.type) + .icon + .apply(size: 32), + label: Text( + continueLoading + ? 'Loading...' + : 'Pay 2,470 MRU with $selectedTitle', + textAlign: TextAlign.center, + ), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF171713), + foregroundColor: Colors.white, + disabledBackgroundColor: const Color(0xFF171713).withValues( + alpha: 0.45, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), ), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - textStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 16, + ), + ), + ), + ); + } +} + +class _CustomMethodRow extends StatelessWidget { + const _CustomMethodRow({required this.props}); + + final MoosylPaymentMethodRenderProps props; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: props.onSelect, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + constraints: const BoxConstraints(minHeight: 78), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: props.isSelected + ? const Color(0xFF171713) + : const Color(0xFFEEE9E2), + width: props.isSelected ? 2 : 1, + ), + boxShadow: const [ + BoxShadow( + color: Color(0x12000000), + blurRadius: 10, + offset: Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: + props.isSelected ? const Color(0xFF171713) : Colors.white, + border: Border.all(color: const Color(0xFFD1D1CD)), + shape: BoxShape.circle, ), + child: props.isSelected + ? const Icon(Icons.check, color: Colors.white, size: 20) + : null, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + props.title, + textAlign: TextAlign.right, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + props.type == PaymentMethodTypes.bankily + ? 'Confirm with passcode' + : props.type == PaymentMethodTypes.masrivi + ? 'Mauritel Money' + : 'Payment wallet', + textAlign: TextAlign.right, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + const SizedBox(width: 14), + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: const Color(0xFFF4F4F1), + borderRadius: BorderRadius.circular(16), + ), + child: Center(child: props.type.icon.apply(size: 46)), + ), + ], ), - onPressed: _openPaymentFlow, - child: const Text('Test payment flow'), ), ), ); } } + +class _DemoButton extends StatelessWidget { + const _DemoButton({ + required this.label, + required this.onPressed, + this.outlined = false, + }); + + final String label; + final VoidCallback? onPressed; + final bool outlined; + + @override + Widget build(BuildContext context) { + final shape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ); + + if (outlined) { + return OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + foregroundColor: _primaryColor, + side: const BorderSide(color: _primaryColor, width: 2), + shape: shape, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + ), + child: Text(label), + ); + } + + return FilledButton( + onPressed: onPressed, + style: FilledButton.styleFrom( + backgroundColor: _primaryColor, + foregroundColor: Colors.white, + shape: shape, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + ), + child: Text(label), + ); + } +} + +class _CheckoutSectionTitle extends StatelessWidget { + const _CheckoutSectionTitle({ + required this.step, + required this.title, + }); + + final String step; + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(width: 10), + Container( + width: 30, + height: 22, + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xFFEBE5DC), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + step, + style: const TextStyle(fontWeight: FontWeight.w800), + ), + ), + ], + ), + ); + } +} + +class _CheckoutCard extends StatelessWidget { + const _CheckoutCard({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(22), + boxShadow: const [ + BoxShadow( + color: Color(0x12000000), + blurRadius: 10, + offset: Offset(0, 4), + ), + ], + ), + child: child, + ); + } +} + +class _ProductRow extends StatelessWidget { + const _ProductRow({ + required this.title, + required this.subtitle, + required this.price, + }); + + final String title; + final String subtitle; + final String price; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text( + price, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const Spacer(), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(title, style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 6), + Text(subtitle, style: Theme.of(context).textTheme.bodySmall), + ], + ), + const SizedBox(width: 14), + Container( + width: 58, + height: 58, + decoration: BoxDecoration( + color: const Color(0xFFF2CDD8), + borderRadius: BorderRadius.circular(16), + ), + ), + ], + ); + } +} diff --git a/example/lib/payment_success_dialog.dart b/example/lib/payment_success_dialog.dart index 6b69b10..2783764 100644 --- a/example/lib/payment_success_dialog.dart +++ b/example/lib/payment_success_dialog.dart @@ -1,12 +1,11 @@ import 'package:flutter/material.dart'; import 'package:moosyl_flutter/l10n/generated/moosyl_localization.dart'; -import 'package:moosyl_flutter/moosyl.dart' show PaymentSuccess; /// Shows a dialog with a green check icon, payment success message, and summary. /// Returns a [Future] that completes when the dialog is dismissed. Future showPaymentSuccessDialog( BuildContext context, { - required PaymentSuccess payment, + required bool isSuccess, }) async { if (!context.mounted) return; final l10n = MoosylLocalization.of(context)!; @@ -43,19 +42,9 @@ Future showPaymentSuccessDialog( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _SummaryRow( - label: l10n.paymentId, - value: payment.id, - ), - const SizedBox(height: 8), - _SummaryRow( - label: l10n.amountToPay, - value: '${payment.amount} MRU', - ), - const SizedBox(height: 8), _SummaryRow( label: l10n.status, - value: payment.status, + value: isSuccess.toString(), ), ], ), diff --git a/example/pubspec.lock b/example/pubspec.lock index e31cd2f..75a0ec3 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -275,7 +275,7 @@ packages: path: ".." relative: true source: path - version: "2.0.1" + version: "2.0.2" nested: dependency: transitive description: @@ -332,6 +332,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.2" + shimmer: + dependency: transitive + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 092d222..e9ea1c4 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,30 +1,14 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:example/main.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. + testWidgets('shows Moosyl demo menu actions', (WidgetTester tester) async { await tester.pumpWidget(const MyApp()); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + expect(find.text('Moosyl Demo'), findsWidgets); + expect(find.text('Test MoosylView'), findsOneWidget); + expect(find.text('Test embedded platforms'), findsOneWidget); + expect(find.text('Test custom methods UI'), findsOneWidget); }); } diff --git a/lib/moosyl.dart b/lib/moosyl.dart index d0c118d..3a46314 100644 --- a/lib/moosyl.dart +++ b/lib/moosyl.dart @@ -1,5 +1,20 @@ +export 'package:moosyl/moosyl.dart' show ConfigurationListDataInner; + export 'src/pages/home.dart'; +export 'src/pages/masrivi_view.dart' + show + MasriviView, + MasriviWebViewPresentation, + normalizeMasriviPhoneNumberForPrefill; +export 'src/pages/payment_methods_view.dart' + show + MoosylPaymentMethodBuilder, + MoosylPaymentMethodRenderProps, + MoosylPaymentMethodsLoadingBuilder, + MoosylPaymentMethodsLoadingProps, + MoosylPaymentMethods, + MoosylPaymentMethodsController; export 'src/models/payment_method_model.dart'; -export 'src/models/payment_success.dart'; +export 'src/models/payment_summary_item.dart'; export 'src/moosyl_flutter.dart'; export 'l10n/moosyl_localization.dart'; diff --git a/lib/src/models/payment_success.dart b/lib/src/models/payment_success.dart deleted file mode 100644 index 15ffece..0000000 --- a/lib/src/models/payment_success.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:moosyl/moosyl.dart'; - -/// Unified model for payment success, used across Sedad, Bankily, and Masrivi. -/// -/// Normalizes [PaymentGetData] and [PostPayment200Response] into a common shape -/// for display in the success dialog and for [onPaymentSuccess] callback. -class PaymentSuccess { - /// Creates a new [PaymentSuccess] instance. - /// - /// * [id]: The ID of the payment. - /// * [amount]: The amount of the payment. - /// * [status]: The status of the payment. - const PaymentSuccess({ - required this.id, - required this.amount, - required this.status, - }); - - /// Payment ID. - final String id; - - /// Payment amount (e.g. in MRU). - final int amount; - - /// Payment status (e.g. 'completed'). - final String status; - - /// Creates from [PaymentGetData] (Sedad - from getPayment). - factory PaymentSuccess.fromPaymentRequestGetData(PaymentRequestGetData data) { - return PaymentSuccess( - id: data.id, - amount: data.amount, - status: 'completed', - ); - } - - /// Creates from [PostPayment200Response] (Bankily - from postPayment). - /// [amountFallback] is used when amount is not in the response (e.g. from payment request). - factory PaymentSuccess.fromPostPaymentResponse( - PostPayment200Response response, { - required int amountFallback, - }) { - final amount = response.metadata?.asMap['amount'] != null - ? (response.metadata!.asMap['amount'] is int - ? response.metadata!.asMap['amount'] as int - : int.tryParse(response.metadata!.asMap['amount'].toString()) ?? - amountFallback) - : amountFallback; - return PaymentSuccess( - id: response.id, - amount: amount, - status: response.status, - ); - } -} diff --git a/lib/src/models/payment_summary_item.dart b/lib/src/models/payment_summary_item.dart new file mode 100644 index 0000000..cd1dd7b --- /dev/null +++ b/lib/src/models/payment_summary_item.dart @@ -0,0 +1,17 @@ +/// A summary row shown in the Moosyl payment flow. +class MoosylPaymentSummaryItem { + /// Creates a payment summary item. + const MoosylPaymentSummaryItem({ + required this.label, + required this.amount, + }); + + /// Label shown for this row. + /// + /// Known keys are localized: `amountToPay`, `tax`, `total`, and + /// `totalAmount`. Other values are shown as provided. + final String label; + + /// Amount for this row in MRU. + final double amount; +} diff --git a/lib/src/moosyl_flutter.dart b/lib/src/moosyl_flutter.dart index fe986f2..1dcabe9 100644 --- a/lib/src/moosyl_flutter.dart +++ b/lib/src/moosyl_flutter.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:moosyl_flutter/src/models/payment_success.dart'; +import 'package:moosyl_flutter/src/models/payment_summary_item.dart'; import 'package:moosyl_flutter/src/pages/moosyl_view.dart'; /// Convenience class to open the payment flow. @@ -13,24 +13,29 @@ class MoosylFlutter { MoosylFlutter._(); /// Opens the payment flow. Returns [PaymentSuccess] on success, `null` when closed without payment. - static Future show( + /// + /// When [items] is supplied, the summary total is validated against the + /// payment request amount. + static Future show( BuildContext context, { required String publishableApiKey, required String transactionId, - double amountToPay = 0.0, - double tax = 0.0, + List? items, bool isFullPage = true, + bool isMasriviInBottomSheet = false, + String? masriviPhoneNumber, }) async { if (isFullPage) { - return Navigator.push( + return Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (ctx) => MoosylView( publishableApiKey: publishableApiKey, transactionId: transactionId, - amountToPay: amountToPay, - tax: tax, + items: items, isFullPage: true, + isMasriviInBottomSheet: isMasriviInBottomSheet, + masriviPhoneNumber: masriviPhoneNumber, onBackPress: () => Navigator.pop(ctx, null), onPaymentSuccess: (payment) async { Navigator.pop(ctx, payment); @@ -39,7 +44,7 @@ class MoosylFlutter { ), ); } else { - return showModalBottomSheet( + return showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, @@ -48,15 +53,18 @@ class MoosylFlutter { minChildSize: 0.5, maxChildSize: 1, builder: (ctx, scrollController) => Container( - decoration: BoxDecoration( + decoration: const BoxDecoration( + color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), + clipBehavior: Clip.antiAlias, child: MoosylView( publishableApiKey: publishableApiKey, transactionId: transactionId, - amountToPay: amountToPay, - tax: tax, + items: items, isFullPage: false, + isMasriviInBottomSheet: isMasriviInBottomSheet, + masriviPhoneNumber: masriviPhoneNumber, onBackPress: () => Navigator.pop(ctx, null), onPaymentSuccess: (payment) async { Navigator.pop(ctx, payment); diff --git a/lib/src/pages/home.dart b/lib/src/pages/home.dart index 6cd5409..42d3a01 100644 --- a/lib/src/pages/home.dart +++ b/lib/src/pages/home.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; -import 'package:moosyl_flutter/src/models/payment_success.dart'; import 'package:moosyl_flutter/src/pages/moosyl_view.dart'; /// [Moosyl] provides a widget that handles the payment process. @@ -20,7 +19,7 @@ class Moosyl extends HookWidget { final Widget Function(VoidCallback open)? inputBuilder; /// Optional callback to be triggered upon successful payment with [PaymentSuccess]. - final FutureOr Function(PaymentSuccess payment)? onPaymentSuccess; + final FutureOr Function(bool isSuccess)? onPaymentSuccess; /// When true, shows as full page. When false, shows as bottom sheet. final bool isFullPage; @@ -35,8 +34,11 @@ class Moosyl extends HookWidget { BuildContext context, { required String publishableApiKey, required String transactionId, - final FutureOr Function(PaymentSuccess payment)? onPaymentSuccess, + final FutureOr Function(bool isSuccess)? onPaymentSuccess, bool isFullPage = false, + bool isMasriviInBottomSheet = true, + String? masriviPhoneNumber, + double masriviBottomSheetHeight = 0.88, }) { showBarModalBottomSheet( context: context, @@ -45,6 +47,8 @@ class Moosyl extends HookWidget { transactionId: transactionId, onPaymentSuccess: onPaymentSuccess, isFullPage: isFullPage, + isMasriviInBottomSheet: isMasriviInBottomSheet, + masriviPhoneNumber: masriviPhoneNumber, ), ); } @@ -62,8 +66,20 @@ class Moosyl extends HookWidget { this.inputBuilder, this.onPaymentSuccess, this.isFullPage = false, + this.isMasriviInBottomSheet = false, + this.masriviPhoneNumber, + this.masriviBottomSheetHeight = 0.88, }); + /// When true, Masrivi opens inside bottom-sheet content. + final bool isMasriviInBottomSheet; + + /// Optional phone number to prefill when the selected method opens Masrivi. + final String? masriviPhoneNumber; + + /// Height factor used when Masrivi is rendered as bottom-sheet content. + final double masriviBottomSheetHeight; + @override Widget build(BuildContext context) { // If an input builder is provided, use it to build the custom input UI. @@ -77,6 +93,8 @@ class Moosyl extends HookWidget { transactionId: transactionId, onPaymentSuccess: onPaymentSuccess, isFullPage: isFullPage, + isMasriviInBottomSheet: isMasriviInBottomSheet, + masriviPhoneNumber: masriviPhoneNumber, ), ); }, @@ -89,6 +107,8 @@ class Moosyl extends HookWidget { transactionId: transactionId, onPaymentSuccess: onPaymentSuccess, isFullPage: isFullPage, + isMasriviInBottomSheet: isMasriviInBottomSheet, + masriviPhoneNumber: masriviPhoneNumber, ); } } diff --git a/lib/src/pages/masrivi_view.dart b/lib/src/pages/masrivi_view.dart index d069f05..c779509 100644 --- a/lib/src/pages/masrivi_view.dart +++ b/lib/src/pages/masrivi_view.dart @@ -1,13 +1,37 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; -import 'package:moosyl_flutter/src/models/payment_success.dart'; -import 'package:moosyl_flutter/src/services/get_payment_request_service.dart'; import 'package:webview_flutter/webview_flutter.dart'; /// Base URL for Masrivi payment web view. const String _masriviPayBaseUrl = 'https://payments.moosyl.com/masrivi/pay'; +/// How the Masrivi WebView should be presented. +enum MasriviWebViewPresentation { + /// Display as a regular full page with an app bar. + fullPage, + + /// Display as bottom-sheet content with a grab handle. + bottomSheet, +} + +/// Returns an 8-digit local Masrivi phone number, or an empty string if invalid. +String normalizeMasriviPhoneNumberForPrefill(String? phoneNumber) { + final compactPhoneNumber = phoneNumber + ?.trim() + .replaceAll(RegExp(r'[\s().-]'), '') + .replaceFirst(RegExp(r'^00'), '+'); + + if (compactPhoneNumber == null || compactPhoneNumber.isEmpty) return ''; + + final localPhoneNumber = compactPhoneNumber.startsWith('+222') + ? compactPhoneNumber.substring(4) + : compactPhoneNumber; + + return RegExp(r'^\d{8}$').hasMatch(localPhoneNumber) ? localPhoneNumber : ''; +} + /// A view that displays Masrivi payment in a WebView. /// /// Loads the Masrivi pay URL and listens for navigation to success/decline URLs. @@ -22,6 +46,8 @@ class MasriviView extends StatelessWidget { /// * [onPaymentSuccess]: The callback to call when the payment is successful. /// * [onBackPress]: The callback to call when the back button is pressed. /// * [onPaymentDeclined]: The callback to call when the payment is declined. + /// * [presentation]: Whether to render as a full page or bottom sheet. + /// * [phoneNumber]: Optional phone number to prefill in the Masrivi page. const MasriviView({ super.key, required this.publishableApiKey, @@ -30,6 +56,8 @@ class MasriviView extends StatelessWidget { this.onPaymentSuccess, this.onBackPress, this.onPaymentDeclined, + this.presentation = MasriviWebViewPresentation.fullPage, + this.phoneNumber, }); /// The API key for authenticating the payment. @@ -42,7 +70,7 @@ class MasriviView extends StatelessWidget { final String configurationId; /// Callback when payment is successful (URL contains "/success"). - final FutureOr Function(PaymentSuccess payment)? onPaymentSuccess; + final FutureOr Function(bool isSuccess)? onPaymentSuccess; /// Callback when the back button is pressed. final VoidCallback? onBackPress; @@ -51,6 +79,12 @@ class MasriviView extends StatelessWidget { /// Called after going back to the payment method list. Use to show an error message. final VoidCallback? onPaymentDeclined; + /// Display as a full page or bottom-sheet content. + final MasriviWebViewPresentation presentation; + + /// Phone number to prefill in the Masrivi page when a matching input is found. + final String? phoneNumber; + /// Builds the Masrivi pay URL with query parameters. String get _payUrl { final uri = Uri.parse(_masriviPayBaseUrl).replace( @@ -72,6 +106,8 @@ class MasriviView extends StatelessWidget { onPaymentSuccess: onPaymentSuccess, onBackPress: onBackPress ?? () => Navigator.of(context).pop(), onPaymentDeclined: onPaymentDeclined, + presentation: presentation, + phoneNumber: phoneNumber, ); } } @@ -84,14 +120,18 @@ class _MasriviWebView extends StatefulWidget { this.onPaymentSuccess, required this.onBackPress, this.onPaymentDeclined, + required this.presentation, + this.phoneNumber, }); final String payUrl; final String publishableApiKey; final String transactionId; - final FutureOr Function(PaymentSuccess payment)? onPaymentSuccess; + final FutureOr Function(bool isSuccess)? onPaymentSuccess; final VoidCallback onBackPress; final VoidCallback? onPaymentDeclined; + final MasriviWebViewPresentation presentation; + final String? phoneNumber; @override State<_MasriviWebView> createState() => _MasriviWebViewState(); @@ -99,7 +139,7 @@ class _MasriviWebView extends StatefulWidget { class _MasriviWebViewState extends State<_MasriviWebView> { late final WebViewController _controller; - + bool _outcomeHandled = false; @override void initState() { super.initState(); @@ -108,50 +148,158 @@ class _MasriviWebViewState extends State<_MasriviWebView> { ..setNavigationDelegate( NavigationDelegate( onNavigationRequest: (NavigationRequest request) { + if (_outcomeHandled) return NavigationDecision.prevent; + if (request.url.contains('/success')) { + _outcomeHandled = true; widget.onBackPress(); - _fetchPaymentAndNotify(); + unawaited(_fetchPaymentAndNotify()); return NavigationDecision.prevent; } else if (request.url.contains('/decline')) { + _outcomeHandled = true; widget.onBackPress(); widget.onPaymentDeclined?.call(); return NavigationDecision.prevent; } else if (request.url.contains('/cancel')) { + _outcomeHandled = true; widget.onBackPress(); return NavigationDecision.prevent; } return NavigationDecision.navigate; }, + onPageFinished: (_) { + unawaited(_injectInputScript()); + }, ), ) ..loadRequest(Uri.parse(widget.payUrl)); } + String _buildMasriviInputScript() { + final phoneValue = + normalizeMasriviPhoneNumberForPrefill(widget.phoneNumber); + + return ''' + (function () { + var phoneNumber = ${jsonEncode(phoneValue)}; + + function setNativeValue(input, value) { + var descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), 'value'); + if (descriptor && descriptor.set) { + descriptor.set.call(input, value); + } else { + input.value = value; + } + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + } + + function tryFocus() { + var input = document.querySelector('input[name="client[number]"]'); + if (!input) return false; + if (input.readOnly || input.disabled) return false; + + if (phoneNumber && input.value !== phoneNumber) { + setNativeValue(input, phoneNumber); + } + + input.click(); + input.focus(); + if (input.setSelectionRange && input.value) { + var end = input.value.length; + input.setSelectionRange(end, end); + } + return true; + } + + if (tryFocus()) return; + + var observer = new MutationObserver(function () { + if (tryFocus()) observer.disconnect(); + }); + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['readonly', 'disabled'] + }); +})(); +true; + '''; + } + + Future _injectInputScript() async { + try { + await _controller.runJavaScript(_buildMasriviInputScript()); + } catch (_) { + // The page may navigate away before the delayed injection finishes. + } + } + Future _fetchPaymentAndNotify() async { final onSuccess = widget.onPaymentSuccess; if (onSuccess == null) return; try { - final paymentData = - await GetPaymentRequestService(widget.publishableApiKey) - .get(widget.transactionId); - final payment = PaymentSuccess.fromPaymentRequestGetData(paymentData); - await onSuccess(payment); + final isSuccess = true; + await onSuccess(isSuccess); } catch (_) { - await onSuccess(PaymentSuccess( - id: widget.transactionId, - amount: 0, - status: 'completed', - )); + await onSuccess(false); } } @override Widget build(BuildContext context) { + final webView = WebViewWidget(controller: _controller); + + if (widget.presentation == MasriviWebViewPresentation.bottomSheet) { + final heightFactor = (0.88).clamp(0.2, 1.0).toDouble(); + + return LayoutBuilder( + builder: (context, constraints) { + final targetHeight = + MediaQuery.of(context).size.height * heightFactor; + final maxHeight = constraints.hasBoundedHeight + ? constraints.maxHeight + : targetHeight; + final height = targetHeight.clamp(0.0, maxHeight).toDouble(); + + return Align( + alignment: Alignment.bottomCenter, + child: SizedBox( + height: height, + width: double.infinity, + child: Material( + color: Colors.white, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(18)), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + const SizedBox(height: 10), + Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFD6D6D6), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 6), + Expanded(child: webView), + ], + ), + ), + ), + ); + }, + ); + } + return Scaffold( appBar: AppBar( leading: BackButton(onPressed: widget.onBackPress), ), - body: WebViewWidget(controller: _controller), + body: webView, ); } } diff --git a/lib/src/pages/moosyl_view.dart b/lib/src/pages/moosyl_view.dart index 88be48e..cab8c1e 100644 --- a/lib/src/pages/moosyl_view.dart +++ b/lib/src/pages/moosyl_view.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:moosyl_flutter/l10n/generated/moosyl_localization.dart'; -import 'package:moosyl_flutter/src/models/payment_success.dart'; +import 'package:moosyl_flutter/src/models/payment_summary_item.dart'; import 'package:moosyl_flutter/src/pages/masrivi_view.dart'; import 'package:moosyl_flutter/src/pages/payment_methods_view.dart'; import 'package:moosyl_flutter/src/providers/get_payment_methods_provider.dart'; @@ -23,9 +23,10 @@ class MoosylView extends StatelessWidget { required this.transactionId, this.onPaymentSuccess, this.onBackPress, - this.amountToPay = 0.0, - this.tax = 0.0, + this.items, this.isFullPage = true, + this.isMasriviInBottomSheet = true, + this.masriviPhoneNumber, }); /// The API key for authenticating the payment transaction. @@ -36,30 +37,41 @@ class MoosylView extends StatelessWidget { /// Optional callback invoked when the payment is successful with [PaymentSuccess]. /// The caller is responsible for closing the route (e.g. Navigator.pop). - final FutureOr Function(PaymentSuccess payment)? onPaymentSuccess; + final FutureOr Function(bool isSuccess)? onPaymentSuccess; /// Callback when the back arrow is pressed on the payment method selection page. final VoidCallback? onBackPress; - /// Amount to pay (displayed in summary). Defaults to 0. - final double amountToPay; - - /// Tax amount (displayed in summary). Defaults to 0. - final double tax; + /// Summary rows displayed under the payment methods. + /// + /// When supplied, the sum of all item amounts is validated against the + /// payment request amount before continuing. + final List? items; /// When true, payment method selection shows as full page. When false, shows as bottom sheet. final bool isFullPage; + /// When true, Masrivi opens inside bottom-sheet content. + final bool isMasriviInBottomSheet; + + /// Optional phone number to prefill when the selected method opens Masrivi. + final String? masriviPhoneNumber; + @override Widget build(BuildContext context) { + final totalAmount = _calculateTotalAmount( + items: items, + ); + return Material( + color: Colors.white, child: MultiProvider( providers: [ ChangeNotifierProvider( create: (_) => GetPaymentMethodsProvider( publishableApiKey: publishableApiKey, transactionId: transactionId, - totalAmount: amountToPay + tax, + totalAmount: totalAmount, ), ), ], @@ -74,9 +86,8 @@ class MoosylView extends StatelessWidget { if (selectedModeOfPayment == null) { return SelectPaymentMethodPage( onBackPress: onBackPress, - amountToPay: amountToPay, - tax: tax, - totalAmount: amountToPay + tax, + items: items, + totalAmount: totalAmount, transactionId: transactionId, onPaymentSuccess: onPaymentSuccess, isFullPage: isFullPage, @@ -90,6 +101,11 @@ class MoosylView extends StatelessWidget { onBackPress: () => context .read() .setPaymentMethod(null), + presentation: isMasriviInBottomSheet + ? MasriviWebViewPresentation.bottomSheet + : MasriviWebViewPresentation.fullPage, + phoneNumber: + masriviPhoneNumber ?? provider.paymentRequest?.phoneNumber, onPaymentDeclined: () { final l10n = MoosylLocalization.of(context); if (l10n != null && context.mounted) { @@ -129,3 +145,16 @@ class MoosylView extends StatelessWidget { ); } } + +double _calculateTotalAmount({ + required List? items, +}) { + if (items != null) { + return items.fold( + 0, + (total, item) => total + item.amount, + ); + } + + return 0; +} diff --git a/lib/src/pages/payment_dialogs.dart b/lib/src/pages/payment_dialogs.dart new file mode 100644 index 0000000..a9ea034 --- /dev/null +++ b/lib/src/pages/payment_dialogs.dart @@ -0,0 +1,205 @@ +part of 'payment_methods_view.dart'; + +Future _showPaymentDialogForMethod( + BuildContext context, { + required String publishableApiKey, + required String transactionId, + required ConfigurationListDataInner method, + required Color primaryColor, + required FutureOr Function(bool isSuccess)? onPaymentSuccess, +}) async { + final type = PaymentMethodTypes.fromString(method.type); + + if (type == PaymentMethodTypes.bankily) { + _showBankilyDialog( + context, + publishableApiKey: publishableApiKey, + transactionId: transactionId, + method: method, + onPaymentSuccess: onPaymentSuccess, + ); + } else if (type == PaymentMethodTypes.sedad || + type == PaymentMethodTypes.bimBank) { + await _showSedadDialog( + context, + publishableApiKey: publishableApiKey, + transactionId: transactionId, + method: method, + onPaymentSuccess: onPaymentSuccess, + ); + } +} + +Future _showSedadDialog( + BuildContext context, { + required String publishableApiKey, + required String transactionId, + required ConfigurationListDataInner method, + required FutureOr Function(bool isSuccess)? onPaymentSuccess, +}) async { + final payProvider = PayProvider( + publishableApiKey: publishableApiKey, + transactionId: transactionId, + method: method, + onPaymentSuccess: (payment) async => await onPaymentSuccess?.call(payment), + ); + final getPaymentMethodsProvider = context.read(); + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center( + child: CircularProgressIndicator(), + ), + ); + + final paymentCode = await payProvider.getPaymentCodeForSedad(); + + if (context.mounted) { + Navigator.of(context).pop(); + } + if (!context.mounted) return; + + if (paymentCode == null || paymentCode.isEmpty) { + if (payProvider.error != null) { + Feedbacks.flushBar( + context: context, + message: ExceptionMapper.getErrorMessage(payProvider.error, context), + error: true, + ); + } + return; + } + + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + payProvider.onBeforePaymentSuccess = () { + Navigator.of(dialogContext).pop(); + getPaymentMethodsProvider.setPaymentMethod(null); + }; + return ChangeNotifierProvider.value( + value: payProvider, + child: _DialogWithPayProvider( + payProvider: payProvider, + builder: (paymentRequest) => SedadView( + paymentCodeDisplay: paymentCode, + paymentRequest: paymentRequest, + onClose: () { + Navigator.of(dialogContext).pop(); + getPaymentMethodsProvider.setPaymentMethod(null); + }, + ), + ), + ); + }, + ).then((_) { + getPaymentMethodsProvider.setPaymentMethod(null); + }); +} + +void _showBankilyDialog( + BuildContext context, { + required String publishableApiKey, + required String transactionId, + required ConfigurationListDataInner method, + required FutureOr Function(bool isSuccess)? onPaymentSuccess, +}) { + final payProvider = PayProvider( + publishableApiKey: publishableApiKey, + transactionId: transactionId, + method: method, + onPaymentSuccess: (payment) async => await onPaymentSuccess?.call(payment), + ); + final getPaymentMethodsProvider = context.read(); + + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + payProvider.onBeforePaymentSuccess = () { + Navigator.of(dialogContext).pop(); + getPaymentMethodsProvider.setPaymentMethod(null); + }; + return ChangeNotifierProvider.value( + value: payProvider, + child: _DialogWithPayProvider( + payProvider: payProvider, + builder: (_) => BankilyView( + method: method, + publishableApiKey: publishableApiKey, + transactionId: transactionId, + paymentCodeDisplay: payProvider.paymentCode, + onClose: () { + Navigator.of(dialogContext).pop(); + getPaymentMethodsProvider.setPaymentMethod(null); + }, + ), + ), + ); + }, + ).then((_) { + getPaymentMethodsProvider.setPaymentMethod(null); + }); +} + +class _DialogWithPayProvider extends StatelessWidget { + const _DialogWithPayProvider({ + required this.payProvider, + required this.builder, + }); + + final PayProvider payProvider; + final Widget Function(dynamic paymentRequest) builder; + + @override + Widget build(BuildContext context) { + return Builder( + builder: (context) { + final provider = context.watch(); + + if (provider.paymentRequest == null) { + if (provider.isLoading) { + return Dialog( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text(MoosylLocalization.of(context)?.sending ?? ''), + ], + ), + ), + ); + } + if (provider.error != null) { + return Dialog( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(provider.error.toString()), + const SizedBox(height: 16), + TextButton( + onPressed: provider.getPaymentRequest, + child: + Text(MoosylLocalization.of(context)?.retry ?? ''), + ), + ], + ), + ), + ); + } + } + + return provider.paymentRequest != null + ? builder(provider.paymentRequest!) + : const SizedBox.shrink(); + }, + ); + } +} diff --git a/lib/src/pages/payment_methods.dart b/lib/src/pages/payment_methods.dart new file mode 100644 index 0000000..438620e --- /dev/null +++ b/lib/src/pages/payment_methods.dart @@ -0,0 +1,778 @@ +part of 'payment_methods_view.dart'; + +/// Data passed to [MoosylPaymentMethodBuilder] for each payment method row. +class MoosylPaymentMethodRenderProps { + /// Creates render props for a payment method row. + const MoosylPaymentMethodRenderProps({ + required this.method, + required this.type, + required this.title, + required this.isSelected, + required this.selectedMethodId, + required this.isRTL, + required this.onSelect, + }); + + /// Raw method from the Moosyl Dart SDK. + final ConfigurationListDataInner method; + + /// Parsed payment method type. + final PaymentMethodTypes type; + + /// Localized method title. + final String title; + + /// Whether this method is selected. + final bool isSelected; + + /// Currently selected method id, if any. + final String? selectedMethodId; + + /// Whether the surrounding directionality is RTL. + final bool isRTL; + + /// Selects this method. + final VoidCallback onSelect; +} + +/// Builds a custom payment method row. +typedef MoosylPaymentMethodBuilder = Widget Function( + BuildContext context, + MoosylPaymentMethodRenderProps props, +); + +/// Data passed to [MoosylPaymentMethodsLoadingBuilder]. +class MoosylPaymentMethodsLoadingProps { + /// Creates render props for the payment methods loading state. + const MoosylPaymentMethodsLoadingProps({ + required this.primaryColor, + required this.locale, + required this.isRTL, + }); + + /// Accent color used by the default UI. + final Color primaryColor; + + /// Active locale from the surrounding localization. + final Locale locale; + + /// Whether the surrounding directionality is RTL. + final bool isRTL; +} + +/// Builds custom loading content for [MoosylPaymentMethods]. +typedef MoosylPaymentMethodsLoadingBuilder = Widget Function( + BuildContext context, + MoosylPaymentMethodsLoadingProps props, +); + +/// Controller for [MoosylPaymentMethods]. +class MoosylPaymentMethodsController extends ChangeNotifier { + _MoosylPaymentMethodsContentState? _state; + + void _attach(_MoosylPaymentMethodsContentState state) { + _state = state; + } + + void _detach(_MoosylPaymentMethodsContentState state) { + if (_state == state) { + _state = null; + } + } + + /// Continues with the currently selected payment method. + Future continuePayment() async { + await _state?._continuePayment(); + } + + /// Returns the currently selected method, if mounted and selected. + ConfigurationListDataInner? get selectedMethod => _state?._selectedMethod; + + void _selectionDidChange() { + notifyListeners(); + } +} + +/// Standalone payment method picker that can be embedded in custom checkout UI. +/// +/// Use [controller] to trigger [MoosylPaymentMethodsController.continuePayment] +/// from a button owned by your screen. +class MoosylPaymentMethods extends StatelessWidget { + /// Creates a reusable payment method picker. + const MoosylPaymentMethods({ + super.key, + required this.publishableApiKey, + this.transactionId, + this.amountToPay = 0.0, + this.tax = 0.0, + this.totalAmount, + this.selectedMethodId, + this.onSelectMethod, + this.onPaymentSuccess, + this.onPaymentError, + this.onContinueLoadingChange, + this.controller, + this.primaryColor, + this.renderMethod, + this.loadingComponent, + this.loadingBuilder, + this.showDefaultTitle = true, + this.isMasriviInBottomSheet = true, + this.masriviPresentation = MasriviWebViewPresentation.fullPage, + this.masriviPhoneNumber, + this.masriviBottomSheetHeight = 0.88, + }); + + /// Publishable API key used to load payment methods. + final String publishableApiKey; + + /// Transaction id used by [MoosylPaymentMethodsController.continuePayment]. + final String? transactionId; + + /// Amount to pay, used for validation when continuing. + final double amountToPay; + + /// Tax amount, used with [amountToPay] when [totalAmount] is not supplied. + final double tax; + + /// Total expected amount. Defaults to [amountToPay] + [tax]. + final double? totalAmount; + + /// Controlled selected method id. + final String? selectedMethodId; + + /// Called whenever a method is selected. + final ValueChanged? onSelectMethod; + + /// Called when payment succeeds. + final FutureOr Function(bool isSuccess)? onPaymentSuccess; + + /// Called when continuing fails. + final ValueChanged? onPaymentError; + + /// Called when continuePayment starts or finishes. + final ValueChanged? onContinueLoadingChange; + + /// Controller used by host UI to continue payment. + final MoosylPaymentMethodsController? controller; + + /// Accent color for the default rows and dialogs. + final Color? primaryColor; + + /// Custom row builder. Call `props.onSelect()` inside the returned widget. + final MoosylPaymentMethodBuilder? renderMethod; + + /// Custom loading widget shown while payment methods are loading. + final Widget? loadingComponent; + + /// Custom loading builder shown while payment methods are loading. + final MoosylPaymentMethodsLoadingBuilder? loadingBuilder; + + /// Whether the default UI should include the section title. + final bool showDefaultTitle; + + /// When true, Masrivi opens inside a bottom sheet from custom platform UIs. + final bool isMasriviInBottomSheet; + + /// How Masrivi should be presented when this picker opens the WebView. + final MasriviWebViewPresentation masriviPresentation; + + /// Optional phone number to prefill when the selected method opens Masrivi. + final String? masriviPhoneNumber; + + /// Height factor used when Masrivi is rendered as bottom-sheet content. + final double masriviBottomSheetHeight; + + @override + Widget build(BuildContext context) { + try { + context.read(); + return _MoosylPaymentMethodsContent( + selectedMethodId: selectedMethodId, + onSelectMethod: onSelectMethod, + onPaymentSuccess: onPaymentSuccess, + onPaymentError: onPaymentError, + onContinueLoadingChange: onContinueLoadingChange, + controller: controller, + primaryColor: primaryColor, + renderMethod: renderMethod, + loadingComponent: loadingComponent, + loadingBuilder: loadingBuilder, + showDefaultTitle: showDefaultTitle, + isMasriviInBottomSheet: isMasriviInBottomSheet, + masriviPresentation: masriviPresentation, + masriviPhoneNumber: masriviPhoneNumber, + masriviBottomSheetHeight: masriviBottomSheetHeight, + ); + } on ProviderNotFoundException catch (_) { + return ChangeNotifierProvider( + create: (_) => GetPaymentMethodsProvider( + publishableApiKey: publishableApiKey, + transactionId: transactionId ?? '', + totalAmount: totalAmount ?? amountToPay + tax, + ), + child: _MoosylPaymentMethodsContent( + selectedMethodId: selectedMethodId, + onSelectMethod: onSelectMethod, + onPaymentSuccess: onPaymentSuccess, + onPaymentError: onPaymentError, + onContinueLoadingChange: onContinueLoadingChange, + controller: controller, + primaryColor: primaryColor, + renderMethod: renderMethod, + loadingComponent: loadingComponent, + loadingBuilder: loadingBuilder, + showDefaultTitle: showDefaultTitle, + isMasriviInBottomSheet: isMasriviInBottomSheet, + masriviPresentation: masriviPresentation, + masriviPhoneNumber: masriviPhoneNumber, + masriviBottomSheetHeight: masriviBottomSheetHeight, + ), + ); + } + } +} + +class _MoosylPaymentMethodsContent extends StatefulWidget { + const _MoosylPaymentMethodsContent({ + required this.selectedMethodId, + required this.onSelectMethod, + required this.onPaymentSuccess, + required this.onPaymentError, + required this.onContinueLoadingChange, + required this.controller, + required this.primaryColor, + required this.renderMethod, + required this.loadingComponent, + required this.loadingBuilder, + required this.showDefaultTitle, + required this.isMasriviInBottomSheet, + required this.masriviPresentation, + required this.masriviPhoneNumber, + required this.masriviBottomSheetHeight, + }); + + final String? selectedMethodId; + final ValueChanged? onSelectMethod; + final FutureOr Function(bool isSuccess)? onPaymentSuccess; + final ValueChanged? onPaymentError; + final ValueChanged? onContinueLoadingChange; + final MoosylPaymentMethodsController? controller; + final Color? primaryColor; + final MoosylPaymentMethodBuilder? renderMethod; + final Widget? loadingComponent; + final MoosylPaymentMethodsLoadingBuilder? loadingBuilder; + final bool showDefaultTitle; + final bool isMasriviInBottomSheet; + final MasriviWebViewPresentation masriviPresentation; + final String? masriviPhoneNumber; + final double masriviBottomSheetHeight; + + @override + State<_MoosylPaymentMethodsContent> createState() => + _MoosylPaymentMethodsContentState(); +} + +class _MoosylPaymentMethodsContentState + extends State<_MoosylPaymentMethodsContent> { + String? _continueError; + + @override + void initState() { + super.initState(); + widget.controller?._attach(this); + } + + @override + void didUpdateWidget(covariant _MoosylPaymentMethodsContent oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller?._detach(this); + widget.controller?._attach(this); + } + } + + @override + void dispose() { + widget.controller?._detach(this); + super.dispose(); + } + + ConfigurationListDataInner? get _selectedMethod { + final provider = context.read(); + final selectedId = widget.selectedMethodId ?? provider.pendingSelection?.id; + for (final method in provider.methods) { + if (method.id == selectedId) { + return method; + } + } + return null; + } + + Future _continuePayment() async { + final provider = context.read(); + final pendingSelection = _selectedMethod; + final transactionId = provider.transactionId; + + if (pendingSelection == null) { + return; + } + + if (transactionId.isEmpty) { + setState(() { + _continueError = 'Transaction ID is required to continue payment.'; + }); + return; + } + + setState(() { + _continueError = null; + }); + widget.onContinueLoadingChange?.call(true); + + try { + final methodToShow = + await provider.setPaymentMethodWithValidation(pendingSelection); + if (!mounted) return; + + if (methodToShow == null) { + final selected = provider.selected; + if (selected == null) { + return; + } + + final masriviPresentation = widget.isMasriviInBottomSheet + ? MasriviWebViewPresentation.bottomSheet + : widget.masriviPresentation; + + final masriviView = MasriviView( + publishableApiKey: provider.publishableApiKey, + transactionId: provider.transactionId, + configurationId: selected.id, + onPaymentSuccess: widget.onPaymentSuccess, + onBackPress: () => Navigator.of(context).pop(), + presentation: masriviPresentation, + phoneNumber: + widget.masriviPhoneNumber ?? provider.paymentRequest?.phoneNumber, + ); + + if (masriviPresentation == MasriviWebViewPresentation.bottomSheet) { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => masriviView, + ); + } else { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => masriviView), + ); + } + + if (mounted) { + provider.setPaymentMethod(null); + } + return; + } + + final primaryColor = + widget.primaryColor ?? Theme.of(context).colorScheme.primary; + await _showPaymentDialogForMethod( + context, + publishableApiKey: provider.publishableApiKey, + transactionId: provider.transactionId, + method: methodToShow, + primaryColor: primaryColor, + onPaymentSuccess: widget.onPaymentSuccess, + ); + } catch (error) { + widget.onPaymentError?.call(error); + if (mounted) { + setState(() { + _continueError = error.toString(); + }); + } + } finally { + widget.onContinueLoadingChange?.call(false); + } + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final localizationHelper = MoosylLocalization.of(context)!; + final textTheme = Theme.of(context).textTheme; + final primaryColor = + widget.primaryColor ?? Theme.of(context).colorScheme.primary; + final isRTL = Directionality.of(context) == TextDirection.rtl; + + if (provider.isLoading) { + final loadingProps = MoosylPaymentMethodsLoadingProps( + primaryColor: primaryColor, + locale: Localizations.localeOf(context), + isRTL: isRTL, + ); + final loadingContent = widget.loadingBuilder?.call( + context, + loadingProps, + ) ?? + widget.loadingComponent ?? + _PaymentMethodsLoadingSkeleton( + isRTL: isRTL, + showTitle: widget.showDefaultTitle, + ); + + return widget.renderMethod == null + ? AppContainer(child: loadingContent) + : loadingContent; + } + + if (provider.error != null) { + final error = provider.error!; + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onPaymentError?.call(error); + }); + return AppErrorWidget( + message: ExceptionMapper.getErrorMessage(error, context), + onRetry: provider.getMethods, + ); + } + + if (provider.methods.isEmpty) { + return AppContainer( + padding: const EdgeInsets.all(24), + border: widget.renderMethod == null + ? Border.all(color: Colors.grey.shade300) + : null, + borderRadius: BorderRadius.circular(8), + child: Text( + localizationHelper.paymentMethod, + textAlign: TextAlign.center, + ), + ); + } + + final selectedId = widget.selectedMethodId ?? provider.pendingSelection?.id; + final selectionErrorMessage = provider.selectionError != null + ? SelectionErrorType.fromStr(provider.selectionError!) + .message(localizationHelper) + : null; + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.renderMethod == null && widget.showDefaultTitle) ...[ + Text( + localizationHelper.chooseHowYouWouldLikeToPay, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ), + const SizedBox(height: 10), + ], + ...provider.methods.map((method) { + final type = PaymentMethodTypes.fromString(method.type); + final isSelected = selectedId == method.id; + final props = MoosylPaymentMethodRenderProps( + method: method, + type: type, + title: type.title(context), + isSelected: isSelected, + selectedMethodId: selectedId, + isRTL: isRTL, + onSelect: () { + if (widget.selectedMethodId == null) { + provider.setPendingSelection(method); + } + widget.onSelectMethod?.call(method); + widget.controller?._selectionDidChange(); + }, + ); + + if (widget.renderMethod != null) { + return widget.renderMethod!(context, props); + } + + return _MethodRow( + method: method, + isSelected: isSelected, + onTap: props.onSelect, + primaryColor: primaryColor, + ); + }), + if (selectionErrorMessage != null || _continueError != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + selectionErrorMessage ?? _continueError!, + style: textTheme.bodyMedium?.copyWith(color: Colors.red), + ), + ), + ], + ); + + return widget.renderMethod == null ? AppContainer(child: content) : content; + } +} + +class _PaymentMethodsLoadingSkeleton extends StatelessWidget { + const _PaymentMethodsLoadingSkeleton({ + required this.isRTL, + required this.showTitle, + }); + + final bool isRTL; + final bool showTitle; + + @override + Widget build(BuildContext context) { + final localizationHelper = MoosylLocalization.of(context); + final textTheme = Theme.of(context).textTheme; + + return Directionality( + textDirection: isRTL ? TextDirection.rtl : TextDirection.ltr, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (showTitle && localizationHelper != null) ...[ + Text( + localizationHelper.chooseHowYouWouldLikeToPay, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ), + const SizedBox(height: 10), + ], + ...List.generate( + 3, + (_) => const _PaymentMethodsLoadingSkeletonRow(), + ), + ], + ), + ); + } +} + +class _PaymentMethodsLoadingSkeletonRow extends StatelessWidget { + const _PaymentMethodsLoadingSkeletonRow(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFEDF3FF)), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + _LoadingShimmerBlock( + width: 48, + height: 48, + borderRadius: BorderRadius.circular(6), + ), + const SizedBox(width: 12), + Expanded( + child: Align( + alignment: AlignmentDirectional.centerStart, + child: FractionallySizedBox( + widthFactor: 0.68, + alignment: AlignmentDirectional.centerStart, + child: _LoadingShimmerBlock( + height: 18, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(width: 12), + _LoadingShimmerBlock( + width: 20, + height: 20, + borderRadius: BorderRadius.circular(9), + ), + ], + ), + ), + ); + } +} + +class _LoadingShimmerBlock extends StatelessWidget { + const _LoadingShimmerBlock({ + required this.height, + required this.borderRadius, + this.width, + }); + + final double? width; + final double height; + final BorderRadius borderRadius; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: borderRadius, + child: Shimmer.fromColors( + baseColor: const Color(0xFFECEFF1), + highlightColor: const Color.fromRGBO(255, 255, 255, 0.55), + child: ColoredBox( + color: Colors.white, + child: SizedBox(width: width, height: height), + ), + ), + ); + } +} + +class _MethodRow extends StatelessWidget { + const _MethodRow({ + required this.method, + required this.isSelected, + required this.onTap, + this.primaryColor, + }); + + final ConfigurationListDataInner method; + final bool isSelected; + final VoidCallback onTap; + final Color? primaryColor; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final effectivePrimary = + primaryColor ?? Theme.of(context).colorScheme.primary; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: TweenAnimationBuilder( + tween: Tween(end: isSelected ? 1 : 0), + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + builder: (context, value, _) { + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + top: -2, + right: -2, + bottom: -2, + left: -2, + child: IgnorePointer( + child: Transform.scale( + scale: 0.98 + (0.02 * value), + child: Opacity( + opacity: value, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: effectivePrimary, + width: 2, + ), + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ), + ), + Transform.scale( + scale: 1 - (0.0005 * value), + child: Material( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + child: InkWell( + splashColor: Colors.transparent, + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 12, + ), + decoration: BoxDecoration( + border: Border.all(color: Color(0xFFEDF3FF)), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(0xFFF7F8FB), + border: Border.all( + color: const Color(0xFFEDF3FF), + ), + borderRadius: BorderRadius.circular(6), + ), + clipBehavior: Clip.antiAlias, + child: Center( + child: PaymentMethodTypes.fromString(method.type) + .icon + .apply(size: 40), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + PaymentMethodTypes.fromString(method.type) + .title(context), + style: textTheme.titleMedium?.copyWith( + color: const Color(0xFF111111), + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 12), + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: effectivePrimary, + width: 1.5, + ), + ), + child: Center( + child: Transform.scale( + scale: 0.4 + (0.6 * value), + child: Opacity( + opacity: value, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: effectivePrimary, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/src/pages/payment_methods_view.dart b/lib/src/pages/payment_methods_view.dart index edc78ea..6e69881 100644 --- a/lib/src/pages/payment_methods_view.dart +++ b/lib/src/pages/payment_methods_view.dart @@ -1,13 +1,15 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart' show NumberFormat; import 'package:moosyl/moosyl.dart'; import 'package:moosyl_flutter/l10n/generated/moosyl_localization.dart'; import 'package:moosyl_flutter/src/helpers/exception_handling/exception_mapper.dart'; import 'package:moosyl_flutter/src/models/payment_method_model.dart'; -import 'package:moosyl_flutter/src/models/payment_success.dart'; +import 'package:moosyl_flutter/src/models/payment_summary_item.dart'; import 'package:moosyl_flutter/src/models/selection_error.dart'; import 'package:moosyl_flutter/src/pages/bankily_view.dart'; +import 'package:moosyl_flutter/src/pages/masrivi_view.dart'; import 'package:moosyl_flutter/src/pages/sedad_view.dart'; import 'package:moosyl_flutter/src/providers/get_payment_methods_provider.dart'; import 'package:moosyl_flutter/src/providers/pay_provider.dart'; @@ -16,6 +18,11 @@ import 'package:moosyl_flutter/src/widgets/container.dart'; import 'package:moosyl_flutter/src/widgets/error_widget.dart'; import 'package:moosyl_flutter/src/widgets/feedback.dart'; import 'package:provider/provider.dart'; +import 'package:shimmer/shimmer.dart'; + +part 'payment_dialogs.dart'; +part 'payment_methods.dart'; +part 'payment_summary.dart'; /// A widget that displays the available payment methods for selection. /// @@ -27,8 +34,7 @@ class SelectPaymentMethodPage extends StatelessWidget { const SelectPaymentMethodPage({ super.key, this.onBackPress, - this.amountToPay = 0.0, - this.tax = 0.0, + this.items, this.totalAmount = 0.0, required this.transactionId, this.onPaymentSuccess, @@ -41,27 +47,23 @@ class SelectPaymentMethodPage extends StatelessWidget { /// Callback when the back arrow is pressed. final VoidCallback? onBackPress; - /// The amount to pay (displayed in summary). - final double amountToPay; - - /// The tax amount (displayed in summary). - final double tax; + /// Summary rows displayed under the payment methods. + final List? items; - /// The total amount including tax (displayed on the pay button). + /// The expected total amount from [items]. final double totalAmount; /// The transaction ID (displayed in the payment request). final String transactionId; /// Callback when payment succeeds (for Sedad/Bankily dialogs). - final FutureOr Function(PaymentSuccess payment)? onPaymentSuccess; + final FutureOr Function(bool isSuccess)? onPaymentSuccess; @override Widget build(BuildContext context) { return _SelectPaymentMethodContent( onBackPress: onBackPress, - amountToPay: amountToPay, - tax: tax, + items: items, totalAmount: totalAmount, transactionId: transactionId, onPaymentSuccess: onPaymentSuccess, @@ -70,12 +72,10 @@ class SelectPaymentMethodPage extends StatelessWidget { } } -/// Full page or bottom sheet content for payment method selection. class _SelectPaymentMethodContent extends StatelessWidget { const _SelectPaymentMethodContent({ required this.onBackPress, - required this.amountToPay, - required this.tax, + required this.items, required this.totalAmount, required this.transactionId, required this.onPaymentSuccess, @@ -83,11 +83,10 @@ class _SelectPaymentMethodContent extends StatelessWidget { }); final VoidCallback? onBackPress; - final double amountToPay; - final double tax; + final List? items; final double totalAmount; final String transactionId; - final FutureOr Function(PaymentSuccess payment)? onPaymentSuccess; + final FutureOr Function(bool isSuccess)? onPaymentSuccess; final bool isFullPage; @override @@ -97,24 +96,29 @@ class _SelectPaymentMethodContent extends StatelessWidget { final localizationHelper = MoosylLocalization.of(context)!; final textTheme = Theme.of(context).textTheme; - if (provider.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - if (provider.error != null) { - return AppErrorWidget( + final errorView = AppErrorWidget( message: ExceptionMapper.getErrorMessage(provider.error, context), onRetry: provider.getMethods, ); + return isFullPage + ? errorView + : ColoredBox( + color: Colors.white, + child: errorView, + ); } final selectionErrorMessage = provider.selectionError != null ? SelectionErrorType.fromStr(provider.selectionError!) .message(localizationHelper) : null; - - final methods = provider.methods; final pendingSelection = provider.pendingSelection; + final summaryItems = items ?? const []; + final shouldShowSummary = items != null; + final displayTotal = provider.paymentRequest?.amount ?? + (totalAmount > 0 ? totalAmount : _calculateSummaryTotal(summaryItems)); + final displayTotalText = '${_formatAmount(displayTotal)} MRU'; final bodyContent = Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -128,114 +132,54 @@ class _SelectPaymentMethodContent extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: 8), - AppContainer( - padding: const EdgeInsets.all(16), - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - child: RadioGroup( - groupValue: pendingSelection, - onChanged: (value) { - if (value != null) provider.setPendingSelection(value); - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - Text(localizationHelper.chooseHowYouWouldLikeToPay, - style: textTheme.titleMedium), - const SizedBox(height: 10), - ...methods.asMap().entries.map((e) { - final method = e.value; - return _MethodRow( - method: method, - isSelected: pendingSelection?.id == method.id, - onTap: () => provider.setPendingSelection(method), - ); - }), - ], - ), - ), + MoosylPaymentMethods( + publishableApiKey: provider.publishableApiKey, + transactionId: provider.transactionId, + totalAmount: totalAmount, + showDefaultTitle: !isFullPage, ), ], ), ), ), - SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (selectionErrorMessage != null) - Text(selectionErrorMessage, - style: textTheme.bodyMedium?.copyWith(color: Colors.red)), - const SizedBox(height: 16), - AppContainer( - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 12), - color: Colors.grey.shade100, - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (amountToPay > 0 && tax > 0) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(localizationHelper.amountToPay, - style: textTheme.bodyLarge), - Text( - '${amountToPay > 0 ? amountToPay.toStringAsFixed(0) : (provider.paymentRequest?.amount.toStringAsFixed(0) ?? '0')} MRU', - style: textTheme.bodyLarge - ?.copyWith(fontWeight: FontWeight.bold)), - ], - ), - if (tax > 0) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(localizationHelper.tax, - style: textTheme.bodyLarge), - Text('${tax.toStringAsFixed(0)} MRU', - style: textTheme.bodyLarge - ?.copyWith(fontWeight: FontWeight.bold)), - ], - ), - Divider(color: Colors.grey.shade300), - ], - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(localizationHelper.totalAmount, - style: textTheme.bodyLarge), - Text( - '${totalAmount > 0 ? totalAmount.toStringAsFixed(0) : (provider.paymentRequest?.amount.toStringAsFixed(0) ?? '0')} MRU', - style: textTheme.bodyLarge - ?.copyWith(fontWeight: FontWeight.bold)), - ], - ), - ], - )), - AppButton( - minHeight: 50, - labelText: provider.isValidating - ? localizationHelper.sending - : localizationHelper.pay, - onPressed: pendingSelection == null || provider.isValidating - ? null - : () => _onPayPressed( - context, - provider, - pendingSelection, - effectivePrimary, - ), - ), - ], + if (!provider.isLoading) + SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (selectionErrorMessage != null) + Text( + selectionErrorMessage, + style: textTheme.bodyMedium?.copyWith(color: Colors.red), + ), + const SizedBox(height: 16), + if (shouldShowSummary) + _PaymentSummary( + items: summaryItems, + totalText: displayTotalText, + localization: localizationHelper, + ), + AppButton( + minHeight: 60, + borderRadius: BorderRadius.circular(18), + labelText: provider.isValidating + ? localizationHelper.sending + : localizationHelper.pay, + suffixLabelText: displayTotalText, + onPressed: pendingSelection == null || provider.isValidating + ? null + : () => _onPayPressed( + context, + provider, + pendingSelection, + effectivePrimary, + ), + ), + ], + ), ), - ), ], ), ); @@ -245,7 +189,19 @@ class _SelectPaymentMethodContent extends StatelessWidget { backgroundColor: Colors.white, appBar: AppBar( backgroundColor: Colors.white, - title: Text(localizationHelper.choosePaymentMethods), + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: false, + leadingWidth: onBackPress != null ? 48 : 0, + titleSpacing: onBackPress != null ? 0 : 16, + title: Text( + localizationHelper.chooseHowYouWouldLikeToPay, + style: textTheme.titleMedium?.copyWith( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Colors.black, + ), + ), leading: onBackPress != null ? IconButton( onPressed: onBackPress, @@ -258,7 +214,10 @@ class _SelectPaymentMethodContent extends StatelessWidget { ); } - return bodyContent; + return ColoredBox( + color: Colors.white, + child: bodyContent, + ); } Future _onPayPressed( @@ -270,287 +229,17 @@ class _SelectPaymentMethodContent extends StatelessWidget { final methodToShow = await provider.setPaymentMethodWithValidation( pendingSelection, ); - if (!context.mounted) return; - - if (methodToShow == null) { - // Masrivi etc: provider.selected is set, MoosylView will show the method's view. + if (!context.mounted || methodToShow == null) { return; } - final publishableApiKey = provider.publishableApiKey; - final transactionId = provider.transactionId; - - if (PaymentMethodTypes.fromString(methodToShow.type) == - PaymentMethodTypes.bankily) { - _showBankilyDialog( - context, - publishableApiKey: publishableApiKey, - transactionId: transactionId, - method: methodToShow, - ); - } else if (PaymentMethodTypes.fromString(methodToShow.type) == - PaymentMethodTypes.sedad || - PaymentMethodTypes.fromString(methodToShow.type) == - PaymentMethodTypes.bimBank) { - await _showSedadDialog( - context, - publishableApiKey: publishableApiKey, - transactionId: transactionId, - method: methodToShow, - primaryColor: effectivePrimary, - ); - } - } - - Future _showSedadDialog( - BuildContext context, { - required String publishableApiKey, - required String transactionId, - required ConfigurationListDataInner method, - required Color primaryColor, - }) async { - final payProvider = PayProvider( - publishableApiKey: publishableApiKey, - transactionId: transactionId, - method: method, - onPaymentSuccess: (payment) async => - await onPaymentSuccess?.call(payment), - ); - final getPaymentMethodsProvider = context.read(); - - // Show loading while fetching payment code. - showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => const Center( - child: CircularProgressIndicator(), - ), - ); - - // Call pay() to get the payment code before showing the Sedad dialog. - final paymentCode = await payProvider.getPaymentCodeForSedad(); - - if (context.mounted) { - Navigator.of(context).pop(); // Dismiss loading dialog - } - if (!context.mounted) return; - if (paymentCode == null || paymentCode.isEmpty) { - if (context.mounted && payProvider.error != null) { - Feedbacks.flushBar( - context: context, - message: ExceptionMapper.getErrorMessage(payProvider.error, context), - error: true, - ); - } - return; - } - - showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) { - payProvider.onBeforePaymentSuccess = () { - Navigator.of(dialogContext).pop(); - getPaymentMethodsProvider.setPaymentMethod(null); - }; - return ChangeNotifierProvider.value( - value: payProvider, - child: _DialogWithPayProvider( - payProvider: payProvider, - builder: (paymentRequest) => SedadView( - paymentCodeDisplay: paymentCode, - paymentRequest: paymentRequest, - onClose: () { - Navigator.of(dialogContext).pop(); - getPaymentMethodsProvider.setPaymentMethod(null); - }, - ), - ), - ); - }, - ).then((_) { - getPaymentMethodsProvider.setPaymentMethod(null); - }); - } - - void _showBankilyDialog( - BuildContext context, { - required String publishableApiKey, - required String transactionId, - required ConfigurationListDataInner method, - }) { - final payProvider = PayProvider( - publishableApiKey: publishableApiKey, - transactionId: transactionId, - method: method, - onPaymentSuccess: (payment) async => - await onPaymentSuccess?.call(payment), - ); - final getPaymentMethodsProvider = context.read(); - - showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) { - payProvider.onBeforePaymentSuccess = () { - Navigator.of(dialogContext).pop(); - getPaymentMethodsProvider.setPaymentMethod(null); - }; - return ChangeNotifierProvider.value( - value: payProvider, - child: _DialogWithPayProvider( - payProvider: payProvider, - builder: (_) => BankilyView( - method: method, - publishableApiKey: publishableApiKey, - transactionId: transactionId, - paymentCodeDisplay: payProvider.paymentCode, - onClose: () { - Navigator.of(dialogContext).pop(); - getPaymentMethodsProvider.setPaymentMethod(null); - }, - ), - ), - ); - }, - ).then((_) { - getPaymentMethodsProvider.setPaymentMethod(null); - }); - } -} - -/// Shows loading until [PayProvider] has payment request, then builds content. -class _DialogWithPayProvider extends StatelessWidget { - const _DialogWithPayProvider({ - required this.payProvider, - required this.builder, - }); - - final PayProvider payProvider; - final Widget Function(dynamic paymentRequest) builder; - - @override - Widget build(BuildContext context) { - return Builder( - builder: (context) { - final provider = context.watch(); - - if (provider.paymentRequest == null) { - if (provider.isLoading) { - return Dialog( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text(MoosylLocalization.of(context)?.sending ?? ''), - ], - ), - ), - ); - } - if (provider.error != null) { - return Dialog( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(provider.error.toString()), - const SizedBox(height: 16), - TextButton( - onPressed: provider.getPaymentRequest, - child: Text( - MoosylLocalization.of(context)?.retry ?? 'Retry'), - ), - ], - ), - ), - ); - } - } - - return provider.paymentRequest != null - ? builder(provider.paymentRequest!) - : const SizedBox.shrink(); - }, - ); - } -} - -class _MethodRow extends StatelessWidget { - const _MethodRow({ - required this.method, - required this.isSelected, - required this.onTap, - }); - - final ConfigurationListDataInner method; - final bool isSelected; - - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final localizationHelper = MoosylLocalization.of(context)!; - final textTheme = Theme.of(context).textTheme; - final primaryColor = Theme.of(context).colorScheme.primary; - - return InkWell( - onTap: onTap, - child: AppContainer( - padding: const EdgeInsetsDirectional.all(10), - border: - Border.all(color: isSelected ? primaryColor : Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - child: Row( - children: [ - // Icon - square bordered - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - ), - clipBehavior: Clip.antiAlias, - child: Center( - child: PaymentMethodTypes.fromString(method.type) - .icon - .apply(size: 40), - ), - ), - const SizedBox(width: 16), - // Name + subtitle - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - PaymentMethodTypes.fromString(method.type).title(context), - style: textTheme.titleMedium, - ), - const SizedBox(height: 4), - Text( - isSelected - ? localizationHelper.selected - : localizationHelper.tapToUse, - style: textTheme.bodyMedium, - ), - ], - ), - ), - // Radio - Radio( - value: method, - fillColor: WidgetStateProperty.all(primaryColor), - ), - ], - ), - ), + await _showPaymentDialogForMethod( + context, + publishableApiKey: provider.publishableApiKey, + transactionId: provider.transactionId, + method: methodToShow, + primaryColor: effectivePrimary, + onPaymentSuccess: onPaymentSuccess, ); } } diff --git a/lib/src/pages/payment_summary.dart b/lib/src/pages/payment_summary.dart new file mode 100644 index 0000000..93db367 --- /dev/null +++ b/lib/src/pages/payment_summary.dart @@ -0,0 +1,116 @@ +part of 'payment_methods_view.dart'; + +class _PaymentSummary extends StatelessWidget { + const _PaymentSummary({ + required this.items, + required this.totalText, + required this.localization, + }); + + final List items; + final String totalText; + final MoosylLocalization localization; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final labelStyle = textTheme.bodyLarge?.copyWith( + color: Colors.grey.shade700, + ); + final valueStyle = textTheme.bodyLarge?.copyWith( + color: const Color(0xFF111111), + ); + final totalStyle = textTheme.bodyLarge?.copyWith( + color: const Color(0xFF111111), + fontWeight: FontWeight.w600, + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ...items.map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _PaymentSummaryRow( + label: _localizedSummaryLabel(item.label, localization), + value: '${_formatAmount(item.amount)} MRU', + labelStyle: labelStyle, + valueStyle: valueStyle, + ), + ), + ), + if (items.isNotEmpty) + Divider( + height: 18, + thickness: 2, + color: Colors.grey.shade300, + ), + _PaymentSummaryRow( + label: localization.totalAmount, + value: totalText, + labelStyle: totalStyle, + valueStyle: totalStyle, + ), + ], + ), + ); + } +} + +class _PaymentSummaryRow extends StatelessWidget { + const _PaymentSummaryRow({ + required this.label, + required this.value, + required this.labelStyle, + required this.valueStyle, + }); + + final String label; + final String value; + final TextStyle? labelStyle; + final TextStyle? valueStyle; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text(label, style: labelStyle), + ), + const SizedBox(width: 8), + Text(value, style: valueStyle), + ], + ); + } +} + +double _calculateSummaryTotal(List items) { + return items.fold( + 0, + (total, item) => total + item.amount, + ); +} + +String _formatAmount(num value) { + return NumberFormat.decimalPattern('fr_FR').format(value.round()); +} + +String _localizedSummaryLabel( + String label, + MoosylLocalization localization, +) { + switch (label) { + case 'amountToPay': + return localization.amountToPay; + case 'tax': + return localization.tax; + case 'total': + case 'totalAmount': + return localization.totalAmount; + default: + return label; + } +} diff --git a/lib/src/providers/get_payment_methods_provider.dart b/lib/src/providers/get_payment_methods_provider.dart index f4b0aab..c094447 100644 --- a/lib/src/providers/get_payment_methods_provider.dart +++ b/lib/src/providers/get_payment_methods_provider.dart @@ -31,11 +31,13 @@ class GetPaymentMethodsProvider extends ChangeNotifier { /// Constructs a [GetPaymentMethodsProvider]. GetPaymentMethodsProvider({ required this.publishableApiKey, - required this.transactionId, + this.transactionId = '', required this.totalAmount, }) { getMethods(); - getPaymentRequest(); + if (transactionId.isNotEmpty) { + getPaymentRequest(); + } } /// Holds any error messages that occur during method retrieval. @@ -85,6 +87,7 @@ class GetPaymentMethodsProvider extends ChangeNotifier { } final paymentRequest = result.result!; + this.paymentRequest = paymentRequest; if (paymentRequest.amount == 0) { selectionError = 'paymentRequestFullyPaid'; @@ -189,6 +192,10 @@ class GetPaymentMethodsProvider extends ChangeNotifier { /// Updates the payment request details and notifies listeners when the data changes. void getPaymentRequest() async { + if (transactionId.isEmpty) { + return; + } + final result = await ErrorHandlers.catchErrors( () => GetPaymentRequestService(publishableApiKey).get(transactionId), showFlashBar: false, diff --git a/lib/src/providers/pay_provider.dart b/lib/src/providers/pay_provider.dart index 34cd818..87cc8cd 100644 --- a/lib/src/providers/pay_provider.dart +++ b/lib/src/providers/pay_provider.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:moosyl/moosyl.dart'; import 'package:moosyl_flutter/src/helpers/exception_handling/error_handlers.dart'; -import 'package:moosyl_flutter/src/models/payment_success.dart'; import 'package:moosyl_flutter/src/services/get_payment_request_service.dart'; import 'package:moosyl_flutter/src/services/pay_service.dart'; @@ -21,7 +20,7 @@ class PayProvider extends ChangeNotifier { final ConfigurationListDataInner method; /// Callback function that gets called on successful payment with [PaymentSuccess]. - final FutureOr Function(PaymentSuccess payment)? onPaymentSuccess; + final FutureOr Function(bool isSuccess)? onPaymentSuccess; /// Optional callback called before [onPaymentSuccess] when payment completes. /// Use this to close the dialog before invoking the success callback. @@ -137,13 +136,9 @@ class PayProvider extends ChangeNotifier { notifyListeners(); if (result.result?.metadata?.asMap['provider'] == 'bankily') { if (result.result!.status == 'completed') { - final amount = paymentRequest?.amount ?? 0; - final payment = PaymentSuccess.fromPostPaymentResponse( - result.result!, - amountFallback: amount, - ); + final isSuccess = true; onBeforePaymentSuccess?.call(); - onPaymentSuccess?.call(payment); + onPaymentSuccess?.call(isSuccess); } else { error = 'PaymentNotCompleted'; return notifyListeners(); @@ -178,9 +173,9 @@ class PayProvider extends ChangeNotifier { isLoading = false; if (result.result!.amount == 0) { - final payment = PaymentSuccess.fromPaymentRequestGetData(result.result!); + final isSuccess = true; onBeforePaymentSuccess?.call(); - onPaymentSuccess?.call(payment); + onPaymentSuccess?.call(isSuccess); } else { error = 'PaymentNotCompleted'; return notifyListeners(); diff --git a/lib/src/widgets/buttons.dart b/lib/src/widgets/buttons.dart index 2471624..4148fba 100644 --- a/lib/src/widgets/buttons.dart +++ b/lib/src/widgets/buttons.dart @@ -15,7 +15,9 @@ enum AppButtonStyle { class AppButton extends StatelessWidget { final String labelText; + final String? suffixLabelText; final Widget? leading; + final Widget? trailing; final VoidCallback? onPressed; final EdgeInsetsGeometry margin; final bool disabled, loading; @@ -32,8 +34,10 @@ class AppButton extends StatelessWidget { const AppButton({ super.key, required this.labelText, + this.suffixLabelText, this.onPressed, this.leading, + this.trailing, this.margin = const EdgeInsets.only(top: 16), this.disabled = false, this.loading = false, @@ -93,32 +97,71 @@ class AppButton extends StatelessWidget { padding: padding, ); - final button = loading || leading != null - ? ElevatedButton.icon( + final labelStyle = Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 16, + color: fg, + ); + final suffix = trailing ?? + (suffixLabelText == null + ? null + : Text( + suffixLabelText!, + style: labelStyle.copyWith(fontWeight: FontWeight.w600), + )); + + final button = suffix != null + ? ElevatedButton( onPressed: _isDisabled ? () {} : onPressed ?? () {}, - icon: loading - ? SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(color: fg), - ) - : leading, style: buttonStyle, - label: Text(labelText, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 16, - color: fg, - )), + child: Row( + children: [ + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (loading || leading != null) ...[ + loading + ? SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(color: fg), + ) + : leading!, + const SizedBox(width: 8), + ], + Flexible( + child: Text( + labelText, + overflow: TextOverflow.ellipsis, + style: labelStyle, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + suffix, + ], + ), ) - : ElevatedButton( - onPressed: _isDisabled ? () {} : onPressed ?? () {}, - style: buttonStyle, - child: Text(labelText, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 16, - color: fg, - )), - ); + : loading || leading != null + ? ElevatedButton.icon( + onPressed: _isDisabled ? () {} : onPressed ?? () {}, + icon: loading + ? SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(color: fg), + ) + : leading, + style: buttonStyle, + label: Text(labelText, style: labelStyle), + ) + : ElevatedButton( + onPressed: _isDisabled ? () {} : onPressed ?? () {}, + style: buttonStyle, + child: Text(labelText, style: labelStyle), + ); return Padding( padding: margin, diff --git a/pubspec.yaml b/pubspec.yaml index 49bc45b..3d3714e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: moosyl_flutter description: "The Moosyl Flutter SDK is a powerful tool for integrating payment solutions with Mauritania's popular banking apps, such as Bankily, Sedad, and Masrivi" homepage: https://github.com/SoftwareSavants/moosyl_flutter -version: 2.0.1 +version: 2.0.2 license: MIT @@ -19,6 +19,7 @@ dependencies: intl: ^0.20.2 http: ^1.2.2 provider: ^6.1.2 + shimmer: ^3.0.0 modal_bottom_sheet: ^3.0.0 file_picker: ^8.1.2 mime: ^2.0.0