diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a70c0f87cc..08a18c67c0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -906,6 +906,12 @@ "paiementPayWithHA": "Pay with HelloAsso", "paiementPending": "Pending", "paiementPersonalBalance": "Personal balance", + "paiementAddFunds": "Add Funds", + "paiementInsufficientFunds": "Insufficient Funds", + "paiementTimeRemaining": "Time Remaining", + "paiementHurryUp": "Hurry up!", + "paiementCompletePayment": "Complete payment", + "paiementConfirmPayment": "Confirm Payment", "paiementPleaseAcceptPopup": "Please allow popups", "paiementPleaseAcceptTOS": "Please accept the Terms of Service.", "paiementPleaseAddDevice": "Please add this device to pay", @@ -952,6 +958,16 @@ "paiementStores": "Stores", "paiementStructureAdmin": "Structure administrator", "paiementSuccededTransaction": "Successful payment", + "paiementConfirmYourPurchase": "Confirm your purchase", + "paiementYourBalance": "Your balance", + "paiementPaymentSuccessful": "Payment successful!", + "paiementPaymentCanceled": "Payment canceled", + "paiementPaymentRequest": "Payment request", + "paiementPaymentRequestAccepted": "Payment request accepted", + "paiementPaymentRequestRefused": "Payment request refused", + "paiementPaymentRequestError": "Error processing payment request", + "paiementAccept": "Accept", + "paiementRefuse": "Refuse", "paiementSuccessfullyAddedStore": "Store successfully added", "paiementSuccessfullyModifiedStore": "Store successfully updated", "paiementThe": "The", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index cb6ced5398..2100ec00ca 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -914,6 +914,12 @@ "paiementPayWithHA": "Payer avec HelloAsso", "paiementPending": "En attente", "paiementPersonalBalance": "Solde personnel", + "paiementAddFunds": "Ajouter des fonds", + "paiementInsufficientFunds": "Fonds insuffisants", + "paiementTimeRemaining": "Temps restant", + "paiementHurryUp": "Dépêchez-vous !", + "paiementCompletePayment": "Finaliser le paiement", + "paiementConfirmPayment": "Confirmer le paiement", "paiementPleaseAcceptPopup": "Veuillez autoriser les popups", "paiementPleaseAcceptTOS": "Veuillez accepter les Conditions Générales d'Utilisation.", "paiementPleaseAddDevice": "Veuillez ajouter cet appareil pour payer", @@ -960,6 +966,16 @@ "paiementStores": "Magasins", "paiementStructureAdmin": "Administrateur de la structure", "paiementSuccededTransaction": "Paiement réussi", + "paiementConfirmYourPurchase": "Confirmer votre achat", + "paiementYourBalance": "Votre solde", + "paiementPaymentSuccessful": "Paiement réussi !", + "paiementPaymentCanceled": "Paiement annulé", + "paiementPaymentRequest": "Demande de paiement", + "paiementPaymentRequestAccepted": "Demande de paiement acceptée", + "paiementPaymentRequestRefused": "Demande de paiement refusée", + "paiementPaymentRequestError": "Erreur lors du traitement de la demande", + "paiementAccept": "Accepter", + "paiementRefuse": "Refuser", "paiementSuccessfullyAddedStore": "Magasin ajoutée avec succès", "paiementSuccessfullyModifiedStore": "Magasin modifiée avec succès", "paiementThe": "Le", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 760e6cec7b..afb31aa700 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4928,6 +4928,42 @@ abstract class AppLocalizations { /// **'Solde personnel'** String get paiementPersonalBalance; + /// No description provided for @paiementAddFunds. + /// + /// In fr, this message translates to: + /// **'Ajouter des fonds'** + String get paiementAddFunds; + + /// No description provided for @paiementInsufficientFunds. + /// + /// In fr, this message translates to: + /// **'Fonds insuffisants'** + String get paiementInsufficientFunds; + + /// No description provided for @paiementTimeRemaining. + /// + /// In fr, this message translates to: + /// **'Temps restant'** + String get paiementTimeRemaining; + + /// No description provided for @paiementHurryUp. + /// + /// In fr, this message translates to: + /// **'Dépêchez-vous !'** + String get paiementHurryUp; + + /// No description provided for @paiementCompletePayment. + /// + /// In fr, this message translates to: + /// **'Finaliser le paiement'** + String get paiementCompletePayment; + + /// No description provided for @paiementConfirmPayment. + /// + /// In fr, this message translates to: + /// **'Confirmer le paiement'** + String get paiementConfirmPayment; + /// No description provided for @paiementPleaseAcceptPopup. /// /// In fr, this message translates to: @@ -5156,6 +5192,60 @@ abstract class AppLocalizations { /// **'Paiement réussi'** String get paiementSuccededTransaction; + /// No description provided for @paiementConfirmYourPurchase. + /// + /// In fr, this message translates to: + /// **'Confirmer votre achat'** + String get paiementConfirmYourPurchase; + + /// No description provided for @paiementYourBalance. + /// + /// In fr, this message translates to: + /// **'Votre solde'** + String get paiementYourBalance; + + /// No description provided for @paiementPaymentSuccessful. + /// + /// In fr, this message translates to: + /// **'Paiement réussi !'** + String get paiementPaymentSuccessful; + + /// No description provided for @paiementPaymentCanceled. + /// + /// In fr, this message translates to: + /// **'Paiement annulé'** + String get paiementPaymentCanceled; + + /// No description provided for @paiementPaymentRequest. + /// + /// In fr, this message translates to: + /// **'Demande de paiement'** + String get paiementPaymentRequest; + + /// No description provided for @paiementPaymentRequestAccepted. + /// + /// In fr, this message translates to: + /// **'Demande de paiement acceptée'** + String get paiementPaymentRequestAccepted; + + /// No description provided for @paiementPaymentRequestRefused. + /// + /// In fr, this message translates to: + /// **'Demande de paiement refusée'** + String get paiementPaymentRequestRefused; + + /// No description provided for @paiementPaymentRequestError. + /// + /// In fr, this message translates to: + /// **'Erreur lors du traitement de la demande'** + String get paiementPaymentRequestError; + + /// No description provided for @paiementRefuse. + /// + /// In fr, this message translates to: + /// **'Refuser'** + String get paiementRefuse; + /// No description provided for @paiementSuccessfullyAddedStore. /// /// In fr, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6657a2dd8b..601a6c0542 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2504,6 +2504,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get paiementPersonalBalance => 'Personal balance'; + @override + String get paiementAddFunds => 'Add Funds'; + + @override + String get paiementInsufficientFunds => 'Insufficient Funds'; + + @override + String get paiementTimeRemaining => 'Time Remaining'; + + @override + String get paiementHurryUp => 'Hurry up!'; + + @override + String get paiementCompletePayment => 'Complete payment'; + + @override + String get paiementConfirmPayment => 'Confirm Payment'; + @override String get paiementPleaseAcceptPopup => 'Please allow popups'; @@ -2624,6 +2642,33 @@ class AppLocalizationsEn extends AppLocalizations { @override String get paiementSuccededTransaction => 'Successful payment'; + @override + String get paiementConfirmYourPurchase => 'Confirm your purchase'; + + @override + String get paiementYourBalance => 'Your balance'; + + @override + String get paiementPaymentSuccessful => 'Payment successful!'; + + @override + String get paiementPaymentCanceled => 'Payment canceled'; + + @override + String get paiementPaymentRequest => 'Payment request'; + + @override + String get paiementPaymentRequestAccepted => 'Payment request accepted'; + + @override + String get paiementPaymentRequestRefused => 'Payment request refused'; + + @override + String get paiementPaymentRequestError => 'Error processing payment request'; + + @override + String get paiementRefuse => 'Refuse'; + @override String get paiementSuccessfullyAddedStore => 'Store successfully added'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 18af78314c..54b359d2b0 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2529,6 +2529,24 @@ class AppLocalizationsFr extends AppLocalizations { @override String get paiementPersonalBalance => 'Solde personnel'; + @override + String get paiementAddFunds => 'Ajouter des fonds'; + + @override + String get paiementInsufficientFunds => 'Fonds insuffisants'; + + @override + String get paiementTimeRemaining => 'Temps restant'; + + @override + String get paiementHurryUp => 'Dépêchez-vous !'; + + @override + String get paiementCompletePayment => 'Finaliser le paiement'; + + @override + String get paiementConfirmPayment => 'Confirmer le paiement'; + @override String get paiementPleaseAcceptPopup => 'Veuillez autoriser les popups'; @@ -2653,6 +2671,34 @@ class AppLocalizationsFr extends AppLocalizations { @override String get paiementSuccededTransaction => 'Paiement réussi'; + @override + String get paiementConfirmYourPurchase => 'Confirmer votre achat'; + + @override + String get paiementYourBalance => 'Votre solde'; + + @override + String get paiementPaymentSuccessful => 'Paiement réussi !'; + + @override + String get paiementPaymentCanceled => 'Paiement annulé'; + + @override + String get paiementPaymentRequest => 'Demande de paiement'; + + @override + String get paiementPaymentRequestAccepted => 'Demande de paiement acceptée'; + + @override + String get paiementPaymentRequestRefused => 'Demande de paiement refusée'; + + @override + String get paiementPaymentRequestError => + 'Erreur lors du traitement de la demande'; + + @override + String get paiementRefuse => 'Refuser'; + @override String get paiementSuccessfullyAddedStore => 'Magasin ajoutée avec succès'; diff --git a/lib/paiement/class/payment_request.dart b/lib/paiement/class/payment_request.dart new file mode 100644 index 0000000000..244e8296cb --- /dev/null +++ b/lib/paiement/class/payment_request.dart @@ -0,0 +1,106 @@ +import 'package:titan/tools/functions.dart'; + +enum RequestStatus { proposed, accepted, refused, expired } + +class PaymentRequest { + final String id; + final String walletId; + final DateTime creation; + final int total; + final String storeId; + final String name; + final String? storeNote; + final String module; + final String objectId; + final RequestStatus status; + final String? transactionId; + + PaymentRequest({ + required this.id, + required this.walletId, + required this.creation, + required this.total, + required this.storeId, + required this.name, + this.storeNote, + required this.module, + required this.objectId, + required this.status, + this.transactionId, + }); + + PaymentRequest.fromJson(Map json) + : id = json['id'], + walletId = json['wallet_id'], + creation = processDateFromAPI(json['creation']), + total = json['total'], + storeId = json['store_id'], + name = json['name'], + storeNote = json['store_note'], + module = json['module'], + objectId = json['object_id'], + status = RequestStatus.values.firstWhere( + (e) => e.toString().split('.').last == json['status'], + ), + transactionId = json['transaction_id']; + + Map toJson() => { + 'id': id, + 'wallet_id': walletId, + 'creation': processDateToAPI(creation), + 'total': total, + 'store_id': storeId, + 'name': name, + 'store_note': storeNote, + 'module': module, + 'object_id': objectId, + 'status': status.toString().split('.').last, + 'transaction_id': transactionId, + }; + + @override + String toString() { + return 'PaymentRequest {id: $id, walletId: $walletId, creation: $creation, total: $total, storeId: $storeId, name: $name, status: $status}'; + } + + PaymentRequest.empty() + : id = '', + walletId = '', + creation = DateTime.now(), + total = 0, + storeId = '', + name = '', + storeNote = null, + module = '', + objectId = '', + status = RequestStatus.proposed, + transactionId = null; + + PaymentRequest copyWith({ + String? id, + String? walletId, + DateTime? creation, + int? total, + String? storeId, + String? name, + String? storeNote, + String? module, + String? objectId, + RequestStatus? status, + String? transactionId, + }) { + return PaymentRequest( + id: id ?? this.id, + walletId: walletId ?? this.walletId, + creation: creation ?? this.creation, + total: total ?? this.total, + storeId: storeId ?? this.storeId, + name: name ?? this.name, + storeNote: storeNote ?? this.storeNote, + module: module ?? this.module, + objectId: objectId ?? this.objectId, + status: status ?? this.status, + transactionId: transactionId ?? this.transactionId, + ); + } +} diff --git a/lib/paiement/class/request_validation.dart b/lib/paiement/class/request_validation.dart new file mode 100644 index 0000000000..646e7ac0cf --- /dev/null +++ b/lib/paiement/class/request_validation.dart @@ -0,0 +1,47 @@ +import 'package:titan/tools/functions.dart'; + +class RequestValidationData { + final String requestId; + final String key; + final DateTime iat; + final int tot; + + RequestValidationData({ + required this.requestId, + required this.key, + required this.iat, + required this.tot, + }); + + Map toJson() => { + 'request_id': requestId, + 'key': key, + 'iat': processDateToAPI(iat), + 'tot': tot, + }; + + @override + String toString() { + return 'RequestValidationData {requestId: $requestId, key: $key, iat: $iat, tot: $tot}'; + } +} + +class RequestValidation extends RequestValidationData { + final String signature; + + RequestValidation({ + required super.requestId, + required super.key, + required super.iat, + required super.tot, + required this.signature, + }); + + @override + Map toJson() => {...super.toJson(), 'signature': signature}; + + @override + String toString() { + return 'RequestValidation {requestId: $requestId, key: $key, iat: $iat, tot: $tot, signature: $signature}'; + } +} diff --git a/lib/paiement/providers/payment_requests_provider.dart b/lib/paiement/providers/payment_requests_provider.dart new file mode 100644 index 0000000000..c214344187 --- /dev/null +++ b/lib/paiement/providers/payment_requests_provider.dart @@ -0,0 +1,46 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:titan/paiement/class/payment_request.dart'; +import 'package:titan/paiement/class/request_validation.dart'; +import 'package:titan/paiement/repositories/requests_repository.dart'; +import 'package:titan/tools/providers/list_notifier.dart'; + +class PaymentRequestsNotifier extends ListNotifier { + final RequestsRepository requestsRepository; + PaymentRequestsNotifier({required this.requestsRepository}) + : super(const AsyncValue.loading()); + + Future>> getRequests() async { + return await loadList(requestsRepository.getRequests); + } + + Future acceptRequest( + PaymentRequest request, + RequestValidation validation, + ) async { + return await update( + (_) => requestsRepository.acceptRequest(request.id, validation), + (requests, request) => + requests..[requests.indexWhere((r) => r.id == request.id)] = request, + request.copyWith(status: RequestStatus.accepted), + ); + } + + Future refuseRequest(PaymentRequest request) async { + return await update( + (_) => requestsRepository.refuseRequest(request.id), + (requests, request) => + requests..[requests.indexWhere((r) => r.id == request.id)] = request, + request.copyWith(status: RequestStatus.refused), + ); + } +} + +final paymentRequestsProvider = + StateNotifierProvider< + PaymentRequestsNotifier, + AsyncValue> + >((ref) { + final requestsRepository = ref.watch(requestsRepositoryProvider); + return PaymentRequestsNotifier(requestsRepository: requestsRepository) + ..getRequests(); + }); diff --git a/lib/paiement/repositories/requests_repository.dart b/lib/paiement/repositories/requests_repository.dart new file mode 100644 index 0000000000..812cbcade0 --- /dev/null +++ b/lib/paiement/repositories/requests_repository.dart @@ -0,0 +1,37 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:titan/auth/providers/openid_provider.dart'; +import 'package:titan/paiement/class/payment_request.dart'; +import 'package:titan/paiement/class/request_validation.dart'; +import 'package:titan/tools/repository/repository.dart'; + +class RequestsRepository extends Repository { + @override + // ignore: overridden_fields + final ext = 'mypayment/'; + + Future> getRequests() async { + return List.from( + (await getList( + suffix: 'requests', + )).map((e) => PaymentRequest.fromJson(e)), + ); + } + + Future acceptRequest( + String requestId, + RequestValidation validation, + ) async { + await create(validation.toJson(), suffix: 'requests/$requestId/accept'); + return true; + } + + Future refuseRequest(String requestId) async { + await create({}, suffix: 'requests/$requestId/refuse'); + return true; + } +} + +final requestsRepositoryProvider = Provider((ref) { + final token = ref.watch(tokenProvider); + return RequestsRepository()..setToken(token); +}); diff --git a/lib/paiement/ui/components/paiment_delegate/confirm_button.dart b/lib/paiement/ui/components/paiment_delegate/confirm_button.dart new file mode 100644 index 0000000000..335e5985db --- /dev/null +++ b/lib/paiement/ui/components/paiment_delegate/confirm_button.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:titan/l10n/app_localizations.dart'; +import 'package:titan/tools/constants.dart'; +import 'package:titan/tools/ui/styleguide/button.dart'; + +const _teal = Color(0xff017f80); +const _tealDark = Color.fromARGB(255, 4, 84, 84); + +class ConfirmButton extends HookWidget { + final VoidCallback onConfirm; + final VoidCallback onCancel; + final int? totalSeconds; + + const ConfirmButton({ + super.key, + required this.onConfirm, + required this.onCancel, + required this.totalSeconds, + }); + + @override + Widget build(BuildContext context) { + final localizeWithContext = AppLocalizations.of(context)!; + final isExpired = useState(false); + + // Expiration timer: runs once over totalSeconds + final expirationController = useAnimationController( + duration: Duration(seconds: totalSeconds ?? 0), + ); + + // Shimmer: repeating fast sweep (1.5s per cycle) + final shimmerController = useAnimationController( + duration: const Duration(milliseconds: 1500), + ); + + useEffect(() { + if (totalSeconds != null && totalSeconds! > 0) { + expirationController.forward(); + } + void listener(AnimationStatus status) { + if (status == AnimationStatus.completed) { + isExpired.value = true; + shimmerController.stop(); + } + } + + expirationController.addStatusListener(listener); + shimmerController.repeat(); + return () { + expirationController.removeStatusListener(listener); + }; + }, [expirationController, shimmerController]); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: isExpired.value ? null : onConfirm, + child: AnimatedBuilder( + animation: shimmerController, + builder: (context, child) { + return Stack( + children: [ + Center( + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 14, + ), + decoration: isExpired.value + ? BoxDecoration( + color: Colors.grey, + borderRadius: BorderRadius.circular(8), + ) + : BoxDecoration( + gradient: const LinearGradient( + colors: [_teal, _tealDark], + ), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + localizeWithContext.paiementConfirmPayment, + textAlign: TextAlign.center, + style: TextStyle( + color: ColorConstants.background, + fontSize: 18, + fontWeight: FontWeight.w900, + ), + ), + ), + ), + if (!isExpired.value) + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LayoutBuilder( + builder: (context, constraints) { + final shimmerWidth = constraints.maxWidth * 0.9; + final startPosition = -shimmerWidth * 1.5; + final endPosition = constraints.maxWidth; + final totalDistance = endPosition - startPosition; + + return Transform.translate( + offset: Offset( + startPosition + + (shimmerController.value * totalDistance), + 0, + ), + child: Container( + width: shimmerWidth, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.white.withOpacity(0.0), + Colors.white.withOpacity(0.2), + Colors.white.withOpacity(0.0), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + ), + ); + }, + ), + ), + ), + ], + ); + }, + ), + ), + const SizedBox(height: 10), + Button.secondary( + text: localizeWithContext.paiementCancel, + onPressed: onCancel, + ), + ], + ); + } +} diff --git a/lib/paiement/ui/components/paiment_delegate/countdown_timer.dart b/lib/paiement/ui/components/paiment_delegate/countdown_timer.dart new file mode 100644 index 0000000000..c63ef9f46f --- /dev/null +++ b/lib/paiement/ui/components/paiment_delegate/countdown_timer.dart @@ -0,0 +1,125 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:titan/l10n/app_localizations.dart'; + +class CountdownTimer extends HookWidget { + final int totalSeconds; + final VoidCallback? onFinished; + + const CountdownTimer({ + super.key, + required this.totalSeconds, + this.onFinished, + }); + + @override + Widget build(BuildContext context) { + final localizeWithContext = AppLocalizations.of(context)!; + final controller = useAnimationController( + duration: Duration(seconds: totalSeconds), + ); + + useEffect(() { + controller.forward(); + void listener(AnimationStatus status) { + if (status == AnimationStatus.completed) { + onFinished?.call(); + } + } + + controller.addStatusListener(listener); + return () => controller.removeStatusListener(listener); + }, [controller]); + + final progress = useAnimation(controller); + final remainingSeconds = (totalSeconds * (1 - progress)).round(); + final minutes = (remainingSeconds ~/ 60).toString().padLeft(2, '0'); + final seconds = (remainingSeconds % 60).toString().padLeft(2, '0'); + + final colorScheme = Theme.of(context).colorScheme; + final urgencyRatio = progress; + final progressColor = Color.lerp( + const Color(0xff017f80), + colorScheme.error, + urgencyRatio, + )!; + final backgroundColor = progressColor.withOpacity(0.1); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: progressColor.withOpacity(0.3), width: 2), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 80, + height: 80, + child: CircularProgressIndicator( + value: 1 - progress, + strokeWidth: 6, + backgroundColor: progressColor.withOpacity(0.2), + valueColor: AlwaysStoppedAnimation(progressColor), + strokeCap: StrokeCap.round, + ), + ), + AnimatedScale( + scale: remainingSeconds <= 10 ? 1.1 : 1.0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$minutes:$seconds', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: progressColor, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + ], + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + localizeWithContext.paiementTimeRemaining, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: progressColor.withOpacity(0.7), + ), + ), + const SizedBox(height: 4), + Text( + remainingSeconds <= 30 + ? localizeWithContext.paiementHurryUp + : localizeWithContext.paiementCompletePayment, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: progressColor, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/paiement/ui/components/paiment_delegate/feedback_overlay.dart b/lib/paiement/ui/components/paiment_delegate/feedback_overlay.dart new file mode 100644 index 0000000000..6e740cf456 --- /dev/null +++ b/lib/paiement/ui/components/paiment_delegate/feedback_overlay.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:titan/l10n/app_localizations.dart'; +import 'package:titan/tools/constants.dart'; + +const _teal = Color(0xff017f80); + +class FeedbackOverlay extends HookWidget { + final bool isSuccess; + const FeedbackOverlay({super.key, required this.isSuccess}); + + @override + Widget build(BuildContext context) { + final controller = useAnimationController( + duration: const Duration(milliseconds: 700), + ); + + useEffect(() { + controller.forward(); + return null; + }, [controller]); + + final scaleAnim = CurvedAnimation( + parent: controller, + curve: Curves.elasticOut, + ); + final fadeAnim = CurvedAnimation( + parent: controller, + curve: const Interval(0.0, 0.4, curve: Curves.easeOut), + ); + + final color = isSuccess ? _teal : ColorConstants.main; + + return Center( + child: FadeTransition( + opacity: fadeAnim, + child: ScaleTransition( + scale: scaleAnim, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + boxShadow: [ + BoxShadow( + color: color.withAlpha(77), + blurRadius: 20, + spreadRadius: 4, + ), + ], + ), + child: Icon( + isSuccess ? Icons.check_rounded : Icons.close_rounded, + color: Colors.white, + size: 44, + ), + ), + const SizedBox(height: 20), + Text( + isSuccess + ? AppLocalizations.of(context)!.paiementPaymentSuccessful + : AppLocalizations.of(context)!.paiementPaymentCanceled, + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/paiement/ui/components/paiment_delegate/paiment_delegate_modal.dart b/lib/paiement/ui/components/paiment_delegate/paiment_delegate_modal.dart new file mode 100644 index 0000000000..c6427d9103 --- /dev/null +++ b/lib/paiement/ui/components/paiment_delegate/paiment_delegate_modal.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:titan/l10n/app_localizations.dart'; +import 'package:titan/paiement/ui/components/paiment_delegate/confirm_button.dart'; +import 'package:titan/paiement/ui/components/paiment_delegate/countdown_timer.dart'; +import 'package:titan/paiement/ui/components/paiment_delegate/feedback_overlay.dart'; +import 'package:titan/paiement/ui/components/paiment_delegate/product_card.dart'; +import 'package:titan/paiement/ui/components/paiment_delegate/wallet_balance_card.dart'; +import 'package:titan/tools/ui/styleguide/bottom_modal_template.dart'; + +enum _ModalState { idle, loading, success, canceled } + +class PaimentDelegateModal extends HookWidget { + final String itemTitle; + final String itemDescription; + final int itemPrice; + final DateTime? itemExpirationDate; + final VoidCallback onConfirm; + final VoidCallback? onRefuse; + const PaimentDelegateModal({ + super.key, + required this.itemTitle, + required this.itemDescription, + required this.itemPrice, + this.itemExpirationDate, + required this.onConfirm, + this.onRefuse, + }); + + @override + Widget build(BuildContext context) { + final state = useState(_ModalState.idle); + final isExpired = useState(false); + final idleHeight = useState(null); + final idleKey = useMemoized(() => GlobalKey()); + final expirationDate = useMemoized( + () => DateTime.now().add(const Duration(minutes: 2)), + ); + final secondsLeft = useMemoized( + () => expirationDate.difference(DateTime.now()).inSeconds, + ); + + // Capture the idle content height after first layout + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final renderBox = + idleKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox != null && idleHeight.value == null) { + idleHeight.value = renderBox.size.height; + } + }); + return null; + }, []); + + Future onValidate() async { + if (isExpired.value || state.value != _ModalState.idle) return; + state.value = _ModalState.loading; + onConfirm(); + await Future.delayed(const Duration(milliseconds: 600)); + if (!context.mounted) return; + state.value = _ModalState.success; + await Future.delayed(const Duration(milliseconds: 1500)); + if (context.mounted) Navigator.of(context).pop(); + } + + Future onCancel() async { + if (state.value != _ModalState.idle) return; + if (onRefuse != null) { + state.value = _ModalState.loading; + onRefuse!(); + return; + } + state.value = _ModalState.canceled; + await Future.delayed(const Duration(milliseconds: 1500)); + if (context.mounted) Navigator.of(context).pop(); + } + + final showIdle = + state.value == _ModalState.idle || state.value == _ModalState.loading; + + return BottomModalTemplate( + title: AppLocalizations.of(context)!.paiementConfirmYourPurchase, + child: AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: showIdle + ? SingleChildScrollView( + key: const ValueKey('idle'), + child: Column( + key: idleKey, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ProductCard( + title: itemTitle, + description: itemDescription, + priceInCents: itemPrice, + ), + if (secondsLeft > 0) ...[ + const SizedBox(height: 20), + CountdownTimer( + totalSeconds: secondsLeft, + onFinished: () => isExpired.value = true, + ), + ], + const SizedBox(height: 20), + const WalletBalanceCard(), + const SizedBox(height: 24), + ConfirmButton( + totalSeconds: secondsLeft, + onConfirm: onValidate, + onCancel: onCancel, + ), + ], + ), + ) + : SizedBox( + height: idleHeight.value, + child: FeedbackOverlay( + key: ValueKey(state.value), + isSuccess: state.value == _ModalState.success, + ), + ), + ), + ), + ); + } +} diff --git a/lib/paiement/ui/components/paiment_delegate/product_card.dart b/lib/paiement/ui/components/paiment_delegate/product_card.dart new file mode 100644 index 0000000000..f627a2d887 --- /dev/null +++ b/lib/paiement/ui/components/paiment_delegate/product_card.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:titan/tools/constants.dart'; + +const _teal = Color(0xff017f80); +const _tealDark = Color.fromARGB(255, 4, 84, 84); + +class ProductCard extends StatelessWidget { + final String title; + final String description; + final int priceInCents; + + const ProductCard({ + super.key, + required this.title, + required this.description, + required this.priceInCents, + }); + + @override + Widget build(BuildContext context) { + final priceFormatter = NumberFormat.currency( + locale: 'fr_FR', + symbol: '€', + decimalDigits: 2, + ); + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + children: [ + Text( + title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: ColorConstants.tertiary, + ), + ), + const SizedBox(height: 6), + Text( + description, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: ColorConstants.tertiary.withAlpha(153), + height: 1.4, + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [_teal, _tealDark]), + borderRadius: BorderRadius.circular(30), + boxShadow: [ + BoxShadow( + color: _teal.withAlpha(77), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Text( + priceFormatter.format(priceInCents / 100), + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/paiement/ui/components/paiment_delegate/wallet_balance_card.dart b/lib/paiement/ui/components/paiment_delegate/wallet_balance_card.dart new file mode 100644 index 0000000000..9aca7709fc --- /dev/null +++ b/lib/paiement/ui/components/paiment_delegate/wallet_balance_card.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:titan/l10n/app_localizations.dart'; +import 'package:titan/paiement/providers/my_wallet_provider.dart'; +import 'package:titan/tools/ui/styleguide/list_item_template.dart'; + +const _teal = Color(0xff017f80); + +class WalletBalanceCard extends ConsumerWidget { + const WalletBalanceCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final wallet = ref.watch(myWalletProvider); + final priceFormatter = NumberFormat.currency( + locale: 'fr_FR', + symbol: '€', + decimalDigits: 2, + ); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: ListItemTemplate( + title: AppLocalizations.of(context)!.paiementYourBalance, + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: _teal.withAlpha(26), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.account_balance_wallet_rounded, + color: _teal, + size: 20, + ), + ), + trailing: wallet.when( + data: (w) => Text( + priceFormatter.format(w.balance / 100), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: _teal, + ), + ), + loading: () => const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + error: (_, __) => const Text( + '—', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: _teal, + ), + ), + ), + ), + ); + } +} diff --git a/lib/paiement/ui/pages/main_page/main_page.dart b/lib/paiement/ui/pages/main_page/main_page.dart index 2738ca2131..569b07504c 100644 --- a/lib/paiement/ui/pages/main_page/main_page.dart +++ b/lib/paiement/ui/pages/main_page/main_page.dart @@ -1,16 +1,23 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:titan/l10n/app_localizations.dart'; import 'package:titan/navigation/ui/scroll_to_hide_navbar.dart'; +import 'package:titan/paiement/class/payment_request.dart'; +import 'package:titan/paiement/class/request_validation.dart'; import 'package:titan/paiement/providers/has_accepted_tos_provider.dart'; import 'package:titan/paiement/providers/my_wallet_provider.dart'; +import 'package:titan/paiement/providers/payment_requests_provider.dart'; import 'package:titan/paiement/providers/tos_provider.dart'; import 'package:titan/paiement/providers/is_payment_admin.dart'; import 'package:titan/paiement/providers/my_history_provider.dart'; import 'package:titan/paiement/providers/my_stores_provider.dart'; import 'package:titan/paiement/providers/register_provider.dart'; import 'package:titan/paiement/providers/should_display_tos_dialog.dart'; +import 'package:titan/paiement/tools/key_service.dart'; +import 'package:titan/paiement/ui/components/paiment_delegate/paiment_delegate_modal.dart'; import 'package:titan/paiement/ui/pages/main_page/account_card/account_card.dart'; import 'package:titan/paiement/ui/pages/main_page/tos_dialog.dart'; import 'package:titan/paiement/ui/pages/main_page/account_card/last_transactions.dart'; @@ -22,6 +29,7 @@ import 'package:titan/tools/functions.dart'; import 'package:titan/tools/providers/path_forwarding_provider.dart'; import 'package:titan/tools/ui/builders/async_child.dart'; import 'package:titan/tools/ui/layouts/refresher.dart'; +import 'package:titan/tools/ui/styleguide/bottom_modal_template.dart'; class PaymentMainPage extends HookConsumerWidget { const PaymentMainPage({super.key}); @@ -48,6 +56,9 @@ class PaymentMainPage extends HookConsumerWidget { final myWalletNotifier = ref.read(myWalletProvider.notifier); final isAdmin = ref.watch(isStructureAdminProvider); final flipped = useState(true); + final paymentRequests = ref.watch(paymentRequestsProvider); + final paymentRequestsNotifier = ref.read(paymentRequestsProvider.notifier); + final hasShownRequestModal = useState(false); ref.listen(pathForwardingProvider, (previous, next) async { final params = next.queryParameters; @@ -84,6 +95,105 @@ class PaymentMainPage extends HookConsumerWidget { } } + Future showRequestModal(PaymentRequest request) async { + final keyService = KeyService(); + await showCustomBottomModal( + context: context, + ref: ref, + modal: PaimentDelegateModal( + itemTitle: request.name, + itemDescription: request.storeNote ?? '', + itemPrice: request.total, + onConfirm: () async { + final keyId = await keyService.getKeyId(); + final keyPair = await keyService.getKeyPair(); + if (keyId == null || keyPair == null) { + if (context.mounted) { + Navigator.of(context).pop(); + displayToast( + context, + TypeMsg.error, + AppLocalizations.of(context)!.paiementPaymentRequestError, + ); + } + return; + } + final now = DateTime.now(); + final validationData = RequestValidationData( + requestId: request.id, + key: keyId, + iat: now, + tot: request.total, + ); + final dataToSign = jsonEncode(validationData.toJson()); + final signature = await keyService.signMessage( + keyPair, + dataToSign.codeUnits, + ); + final validation = RequestValidation( + requestId: request.id, + key: keyId, + iat: now, + tot: request.total, + signature: base64Encode(signature.bytes), + ); + final success = await paymentRequestsNotifier.acceptRequest( + request, + validation, + ); + if (context.mounted) { + Navigator.of(context).pop(); + displayToast( + context, + success ? TypeMsg.msg : TypeMsg.error, + success + ? AppLocalizations.of( + context, + )!.paiementPaymentRequestAccepted + : AppLocalizations.of(context)!.paiementPaymentRequestError, + ); + if (success) { + await myHistoryNotifier.getHistory(); + await myWalletNotifier.getMyWallet(); + } + } + }, + onRefuse: () async { + final success = await paymentRequestsNotifier.refuseRequest( + request, + ); + if (context.mounted) { + Navigator.of(context).pop(); + displayToast( + context, + success ? TypeMsg.msg : TypeMsg.error, + success + ? AppLocalizations.of( + context, + )!.paiementPaymentRequestRefused + : AppLocalizations.of(context)!.paiementPaymentRequestError, + ); + } + }, + ), + ); + } + + useEffect(() { + paymentRequests.whenData((requests) { + final pendingRequests = requests + .where((r) => r.status == RequestStatus.proposed) + .toList(); + if (pendingRequests.isNotEmpty && !hasShownRequestModal.value) { + hasShownRequestModal.value = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + showRequestModal(pendingRequests.first); + }); + } + }); + return null; + }, [paymentRequests]); + tos.maybeWhen( orElse: () {}, error: (e, s) async { @@ -145,6 +255,8 @@ class PaymentMainPage extends HookConsumerWidget { await myHistoryNotifier.getHistory(); await myWalletNotifier.getMyWallet(); await tosNotifier.getTOS(); + hasShownRequestModal.value = false; + await paymentRequestsNotifier.getRequests(); }, child: Column( children: [