483 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			483 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
import 'package:flutter/material.dart';
 | 
						|
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
 | 
						|
import 'package:gap/gap.dart';
 | 
						|
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
						|
import 'package:island/widgets/alert.dart';
 | 
						|
import 'package:material_symbols_icons/symbols.dart';
 | 
						|
import 'package:easy_localization/easy_localization.dart';
 | 
						|
import 'package:island/models/wallet.dart';
 | 
						|
import 'package:island/widgets/content/sheet.dart';
 | 
						|
import 'package:island/pods/network.dart';
 | 
						|
import 'package:dio/dio.dart';
 | 
						|
import 'package:local_auth/local_auth.dart';
 | 
						|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
 | 
						|
import 'package:flutter/services.dart';
 | 
						|
import 'package:styled_widget/styled_widget.dart';
 | 
						|
 | 
						|
class PaymentOverlay extends HookConsumerWidget {
 | 
						|
  final SnWalletOrder order;
 | 
						|
  final Function(SnWalletOrder completedOrder)? onPaymentSuccess;
 | 
						|
  final Function(String error)? onPaymentError;
 | 
						|
  final VoidCallback? onCancel;
 | 
						|
  final bool enableBiometric;
 | 
						|
 | 
						|
  const PaymentOverlay({
 | 
						|
    super.key,
 | 
						|
    required this.order,
 | 
						|
    this.onPaymentSuccess,
 | 
						|
    this.onPaymentError,
 | 
						|
    this.onCancel,
 | 
						|
    this.enableBiometric = true,
 | 
						|
  });
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    return Container(
 | 
						|
      decoration: BoxDecoration(
 | 
						|
        color: Theme.of(context).colorScheme.surface,
 | 
						|
        borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
 | 
						|
      ),
 | 
						|
      child: Padding(
 | 
						|
        padding: EdgeInsets.only(
 | 
						|
          bottom: MediaQuery.of(context).viewInsets.bottom,
 | 
						|
        ),
 | 
						|
        child: SheetScaffold(
 | 
						|
          titleText: 'Solarpay',
 | 
						|
          heightFactor: 0.7,
 | 
						|
          child: _PaymentContent(
 | 
						|
            order: order,
 | 
						|
            onPaymentSuccess: onPaymentSuccess,
 | 
						|
            onPaymentError: onPaymentError,
 | 
						|
            onCancel: onCancel,
 | 
						|
            enableBiometric: enableBiometric,
 | 
						|
          ),
 | 
						|
        ),
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  static Future<SnWalletOrder?> show({
 | 
						|
    required BuildContext context,
 | 
						|
    required SnWalletOrder order,
 | 
						|
    bool enableBiometric = true,
 | 
						|
  }) {
 | 
						|
    return showModalBottomSheet<SnWalletOrder>(
 | 
						|
      context: context,
 | 
						|
      isScrollControlled: true,
 | 
						|
      backgroundColor: Colors.transparent,
 | 
						|
      useSafeArea: true,
 | 
						|
      builder:
 | 
						|
          (context) => PaymentOverlay(
 | 
						|
            order: order,
 | 
						|
            enableBiometric: enableBiometric,
 | 
						|
            onPaymentSuccess: (completedOrder) {
 | 
						|
              Navigator.of(context).pop(completedOrder);
 | 
						|
            },
 | 
						|
            onPaymentError: (err) {
 | 
						|
              Navigator.of(context).pop();
 | 
						|
              showErrorAlert(err);
 | 
						|
            },
 | 
						|
            onCancel: () {
 | 
						|
              Navigator.of(context).pop();
 | 
						|
            },
 | 
						|
          ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class _PaymentContent extends ConsumerStatefulWidget {
 | 
						|
  final SnWalletOrder order;
 | 
						|
  final Function(SnWalletOrder)? onPaymentSuccess;
 | 
						|
  final Function(String)? onPaymentError;
 | 
						|
  final VoidCallback? onCancel;
 | 
						|
  final bool enableBiometric;
 | 
						|
 | 
						|
  const _PaymentContent({
 | 
						|
    required this.order,
 | 
						|
    this.onPaymentSuccess,
 | 
						|
    this.onPaymentError,
 | 
						|
    this.onCancel,
 | 
						|
    this.enableBiometric = true,
 | 
						|
  });
 | 
						|
 | 
						|
  @override
 | 
						|
  ConsumerState<_PaymentContent> createState() => _PaymentContentState();
 | 
						|
}
 | 
						|
 | 
						|
class _PaymentContentState extends ConsumerState<_PaymentContent> {
 | 
						|
  static const String _pinStorageKey = 'app_pin_code';
 | 
						|
  static final _secureStorage = FlutterSecureStorage(
 | 
						|
    aOptions: AndroidOptions(encryptedSharedPreferences: true),
 | 
						|
  );
 | 
						|
 | 
						|
  final LocalAuthentication _localAuth = LocalAuthentication();
 | 
						|
 | 
						|
  String _pin = '';
 | 
						|
  bool _isPinMode = true;
 | 
						|
  bool _hasBiometricSupport = false;
 | 
						|
  bool _hasStoredPin = false;
 | 
						|
 | 
						|
  @override
 | 
						|
  void initState() {
 | 
						|
    super.initState();
 | 
						|
    _initializeBiometric();
 | 
						|
  }
 | 
						|
 | 
						|
  @override
 | 
						|
  void dispose() {
 | 
						|
    super.dispose();
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> _initializeBiometric() async {
 | 
						|
    try {
 | 
						|
      // Check if biometric is available
 | 
						|
      final isAvailable = await _localAuth.isDeviceSupported();
 | 
						|
      final canCheckBiometrics = await _localAuth.canCheckBiometrics;
 | 
						|
      _hasBiometricSupport = isAvailable && canCheckBiometrics;
 | 
						|
 | 
						|
      // Check if PIN is stored
 | 
						|
      final storedPin = await _secureStorage.read(key: _pinStorageKey);
 | 
						|
      _hasStoredPin = storedPin != null && storedPin.isNotEmpty;
 | 
						|
 | 
						|
      // Set initial mode based on stored PIN and biometric support
 | 
						|
      if (_hasStoredPin && _hasBiometricSupport && widget.enableBiometric) {
 | 
						|
        _isPinMode = false;
 | 
						|
      } else {
 | 
						|
        _isPinMode = true;
 | 
						|
      }
 | 
						|
 | 
						|
      if (mounted) {
 | 
						|
        setState(() {});
 | 
						|
      }
 | 
						|
    } catch (e) {
 | 
						|
      // Fallback to PIN mode if biometric setup fails
 | 
						|
      _isPinMode = true;
 | 
						|
      if (mounted) {
 | 
						|
        setState(() {});
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  void _onPinSubmit(String pin) {
 | 
						|
    _pin = pin;
 | 
						|
    if (pin.length == 6) {
 | 
						|
      _processPaymentWithPin(pin);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> _processPaymentWithPin(String pin) async {
 | 
						|
    showLoadingModal(context);
 | 
						|
 | 
						|
    try {
 | 
						|
      // Store PIN securely for future biometric authentication
 | 
						|
      if (_hasBiometricSupport && widget.enableBiometric && !_hasStoredPin) {
 | 
						|
        await _secureStorage.write(key: _pinStorageKey, value: pin);
 | 
						|
        _hasStoredPin = true;
 | 
						|
      }
 | 
						|
 | 
						|
      await _makePaymentRequest(pin);
 | 
						|
    } catch (err) {
 | 
						|
      widget.onPaymentError?.call(err.toString());
 | 
						|
      _pin = '';
 | 
						|
    } finally {
 | 
						|
      if (mounted) {
 | 
						|
        hideLoadingModal(context);
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> _authenticateWithBiometric() async {
 | 
						|
    showLoadingModal(context);
 | 
						|
 | 
						|
    try {
 | 
						|
      // Perform biometric authentication
 | 
						|
      final bool didAuthenticate = await _localAuth.authenticate(
 | 
						|
        localizedReason: 'biometricPrompt'.tr(),
 | 
						|
        biometricOnly: true,
 | 
						|
      );
 | 
						|
 | 
						|
      if (didAuthenticate) {
 | 
						|
        // Retrieve stored PIN and process payment
 | 
						|
        final storedPin = await _secureStorage.read(key: _pinStorageKey);
 | 
						|
        if (storedPin != null && storedPin.isNotEmpty) {
 | 
						|
          await _makePaymentRequest(storedPin);
 | 
						|
        } else {
 | 
						|
          // Fallback to PIN mode if no stored PIN
 | 
						|
          _fallbackToPinMode('noStoredPin'.tr());
 | 
						|
        }
 | 
						|
      } else {
 | 
						|
        // Biometric authentication failed, fallback to PIN mode
 | 
						|
        _fallbackToPinMode('biometricAuthFailed'.tr());
 | 
						|
      }
 | 
						|
    } catch (err) {
 | 
						|
      // Handle biometric authentication errors
 | 
						|
      String errorMessage = 'biometricAuthFailed'.tr();
 | 
						|
      if (err is PlatformException) {
 | 
						|
        switch (err.code) {
 | 
						|
          case 'NotAvailable':
 | 
						|
            errorMessage = 'biometricNotAvailable'.tr();
 | 
						|
            break;
 | 
						|
          case 'NotEnrolled':
 | 
						|
            errorMessage = 'biometricNotEnrolled'.tr();
 | 
						|
            break;
 | 
						|
          case 'LockedOut':
 | 
						|
          case 'PermanentlyLockedOut':
 | 
						|
            errorMessage = 'biometricLockedOut'.tr();
 | 
						|
            break;
 | 
						|
          default:
 | 
						|
            errorMessage = 'biometricAuthFailed'.tr();
 | 
						|
        }
 | 
						|
      }
 | 
						|
      _fallbackToPinMode(errorMessage);
 | 
						|
    } finally {
 | 
						|
      if (mounted) {
 | 
						|
        hideLoadingModal(context);
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /// Unified method for making payment requests with PIN
 | 
						|
  Future<void> _makePaymentRequest(String pin) async {
 | 
						|
    try {
 | 
						|
      final client = ref.read(apiClientProvider);
 | 
						|
      final response = await client.post(
 | 
						|
        '/pass/orders/${widget.order.id}/pay',
 | 
						|
        data: {'pin_code': pin},
 | 
						|
      );
 | 
						|
 | 
						|
      final completedOrder = SnWalletOrder.fromJson(response.data);
 | 
						|
      widget.onPaymentSuccess?.call(completedOrder);
 | 
						|
    } catch (err) {
 | 
						|
      String errorMessage = 'paymentFailed'.tr();
 | 
						|
      if (err is DioException) {
 | 
						|
        if (err.response?.statusCode == 403 ||
 | 
						|
            err.response?.statusCode == 401) {
 | 
						|
          // PIN is invalid
 | 
						|
          errorMessage = 'invalidPin'.tr();
 | 
						|
          // If this was a biometric attempt with stored PIN, remove the stored PIN
 | 
						|
          if (!_isPinMode) {
 | 
						|
            await _secureStorage.delete(key: _pinStorageKey);
 | 
						|
            _hasStoredPin = false;
 | 
						|
            _fallbackToPinMode(errorMessage);
 | 
						|
            return;
 | 
						|
          }
 | 
						|
        } else if (err.response?.statusCode == 400) {
 | 
						|
          errorMessage = err.response?.data?['error'] ?? errorMessage;
 | 
						|
        } else {
 | 
						|
          rethrow;
 | 
						|
        }
 | 
						|
      }
 | 
						|
      throw errorMessage;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  void _fallbackToPinMode(String? message) {
 | 
						|
    setState(() {
 | 
						|
      _isPinMode = true;
 | 
						|
    });
 | 
						|
    if (message != null && message.isNotEmpty) {
 | 
						|
      showSnackBar(message);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  String _formatCurrency(int amount, String currency) {
 | 
						|
    final value = amount;
 | 
						|
    return '${value.toStringAsFixed(2)} $currency';
 | 
						|
  }
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context) {
 | 
						|
    return Padding(
 | 
						|
      padding: const EdgeInsets.all(20),
 | 
						|
      child: Column(
 | 
						|
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
						|
        children: [
 | 
						|
          // Order Summary
 | 
						|
          _buildOrderSummary(),
 | 
						|
          const Gap(32),
 | 
						|
 | 
						|
          // Authentication Content
 | 
						|
          Expanded(
 | 
						|
            child: _isPinMode ? _buildPinInput() : _buildBiometricAuth(),
 | 
						|
          ),
 | 
						|
 | 
						|
          // Action Buttons
 | 
						|
          const Gap(24),
 | 
						|
          _buildActionButtons(),
 | 
						|
        ],
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  Widget _buildOrderSummary() {
 | 
						|
    return Container(
 | 
						|
      padding: const EdgeInsets.all(16),
 | 
						|
      decoration: BoxDecoration(
 | 
						|
        color: Theme.of(context).colorScheme.surfaceContainerHighest,
 | 
						|
        borderRadius: BorderRadius.circular(12),
 | 
						|
      ),
 | 
						|
      child: Column(
 | 
						|
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
						|
        children: [
 | 
						|
          Row(
 | 
						|
            children: [
 | 
						|
              Icon(
 | 
						|
                Symbols.receipt,
 | 
						|
                color: Theme.of(context).colorScheme.primary,
 | 
						|
              ),
 | 
						|
              const Gap(8),
 | 
						|
              Text(
 | 
						|
                'paymentSummary'.tr(),
 | 
						|
                style: Theme.of(
 | 
						|
                  context,
 | 
						|
                ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
 | 
						|
              ),
 | 
						|
            ],
 | 
						|
          ),
 | 
						|
          const Gap(12),
 | 
						|
          Row(
 | 
						|
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
						|
            children: [
 | 
						|
              Text(
 | 
						|
                'amount'.tr(),
 | 
						|
                style: Theme.of(context).textTheme.bodyMedium,
 | 
						|
              ),
 | 
						|
              Text(
 | 
						|
                _formatCurrency(widget.order.amount, widget.order.currency),
 | 
						|
                style: Theme.of(
 | 
						|
                  context,
 | 
						|
                ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
 | 
						|
              ),
 | 
						|
            ],
 | 
						|
          ),
 | 
						|
          if (widget.order.remarks != null) ...[
 | 
						|
            const Gap(8),
 | 
						|
            Row(
 | 
						|
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
						|
              children: [
 | 
						|
                Text(
 | 
						|
                  'description'.tr(),
 | 
						|
                  style: Theme.of(
 | 
						|
                    context,
 | 
						|
                  ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
 | 
						|
                ),
 | 
						|
                const Spacer(),
 | 
						|
                Expanded(
 | 
						|
                  flex: 2,
 | 
						|
                  child: Text(
 | 
						|
                    widget.order.remarks!,
 | 
						|
                    style: Theme.of(context).textTheme.bodyMedium,
 | 
						|
                    textAlign: TextAlign.end,
 | 
						|
                  ),
 | 
						|
                ),
 | 
						|
              ],
 | 
						|
            ),
 | 
						|
          ],
 | 
						|
        ],
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  Widget _buildPinInput() {
 | 
						|
    return Column(
 | 
						|
      children: [
 | 
						|
        Text(
 | 
						|
          'enterPinToConfirmPayment'.tr(),
 | 
						|
          style: Theme.of(
 | 
						|
            context,
 | 
						|
          ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
 | 
						|
          textAlign: TextAlign.center,
 | 
						|
        ),
 | 
						|
        const Gap(24),
 | 
						|
        OtpTextField(
 | 
						|
          numberOfFields: 6,
 | 
						|
          borderColor: Theme.of(context).colorScheme.outline,
 | 
						|
          focusedBorderColor: Theme.of(context).colorScheme.primary,
 | 
						|
          showFieldAsBox: true,
 | 
						|
          obscureText: true,
 | 
						|
          keyboardType: TextInputType.number,
 | 
						|
          fieldWidth: 48,
 | 
						|
          fieldHeight: 56,
 | 
						|
          borderRadius: BorderRadius.circular(8),
 | 
						|
          borderWidth: 1,
 | 
						|
          textStyle: Theme.of(
 | 
						|
            context,
 | 
						|
          ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600),
 | 
						|
          onSubmit: _onPinSubmit,
 | 
						|
          onCodeChanged: (String code) {
 | 
						|
            _pin = code;
 | 
						|
            setState(() {});
 | 
						|
          },
 | 
						|
        ),
 | 
						|
      ],
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  Widget _buildBiometricAuth() {
 | 
						|
    return SingleChildScrollView(
 | 
						|
      child:
 | 
						|
          Column(
 | 
						|
            mainAxisAlignment: MainAxisAlignment.center,
 | 
						|
            crossAxisAlignment: CrossAxisAlignment.center,
 | 
						|
            children: [
 | 
						|
              Icon(Symbols.fingerprint, size: 48),
 | 
						|
              const Gap(16),
 | 
						|
              Text(
 | 
						|
                'useBiometricToConfirm'.tr(),
 | 
						|
                style: Theme.of(
 | 
						|
                  context,
 | 
						|
                ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
 | 
						|
                textAlign: TextAlign.center,
 | 
						|
              ),
 | 
						|
              Text(
 | 
						|
                'The biometric data will only be processed on your device',
 | 
						|
                style: Theme.of(context).textTheme.bodyMedium?.copyWith(
 | 
						|
                  color: Theme.of(context).colorScheme.onSurfaceVariant,
 | 
						|
                  fontSize: 11,
 | 
						|
                ),
 | 
						|
                textAlign: TextAlign.center,
 | 
						|
              ).opacity(0.75),
 | 
						|
              const Gap(28),
 | 
						|
              ElevatedButton.icon(
 | 
						|
                onPressed: _authenticateWithBiometric,
 | 
						|
                icon: const Icon(Symbols.fingerprint),
 | 
						|
                label: Text('authenticateNow'.tr()),
 | 
						|
                style: ElevatedButton.styleFrom(
 | 
						|
                  padding: const EdgeInsets.symmetric(
 | 
						|
                    horizontal: 24,
 | 
						|
                    vertical: 12,
 | 
						|
                  ),
 | 
						|
                ),
 | 
						|
              ),
 | 
						|
              TextButton(
 | 
						|
                onPressed: () => _fallbackToPinMode(null),
 | 
						|
                child: Text('usePinInstead'.tr()),
 | 
						|
              ),
 | 
						|
            ],
 | 
						|
          ).center(),
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  Widget _buildActionButtons() {
 | 
						|
    return Row(
 | 
						|
      children: [
 | 
						|
        Expanded(
 | 
						|
          child: OutlinedButton(
 | 
						|
            onPressed: widget.onCancel,
 | 
						|
            child: Text('cancel'.tr()),
 | 
						|
          ),
 | 
						|
        ),
 | 
						|
        if (_isPinMode && _pin.length == 6) ...[
 | 
						|
          const Gap(12),
 | 
						|
          Expanded(
 | 
						|
            child: ElevatedButton(
 | 
						|
              onPressed: () => _processPaymentWithPin(_pin),
 | 
						|
              child: Text('confirm'.tr()),
 | 
						|
            ),
 | 
						|
          ),
 | 
						|
        ],
 | 
						|
      ],
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |