App/lib/widgets/payment/payment_overlay.dart

480 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();
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;
// Automatically trigger biometric authentication
WidgetsBinding.instance.addPostFrameCallback((_) {
_authenticateWithBiometric();
});
} 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(),
options: const AuthenticationOptions(
biometricOnly: true,
stickyAuth: 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(
'/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;
}
}
throw errorMessage;
}
}
void _fallbackToPinMode(String? message) {
setState(() {
_isPinMode = true;
});
if (message != null && message.isNotEmpty) {
showSnackBar(context, message);
}
}
String _formatCurrency(int amount, String currency) {
final value = amount / 100.0;
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(
'enterPinToConfirm'.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 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()),
),
),
],
],
);
}
}