Added quote expiry-aware flows with auto-refresh

This commit is contained in:
Arseni
2025-12-29 18:38:21 +03:00
parent 4aeb06fd31
commit f3ad4c2d4f
14 changed files with 610 additions and 64 deletions

View File

@@ -6,6 +6,7 @@ import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/payment/provider.dart';
import 'package:pshared/provider/payment/quotation.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/payment/wallets.dart';
@@ -14,6 +15,8 @@ import 'package:pweb/pages/payment_methods/payment_page/body.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/services/posthog.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentPage extends StatefulWidget {
final ValueChanged<Recipient?>? onBack;
@@ -81,8 +84,20 @@ class _PaymentPageState extends State<PaymentPage> {
void _handleSendPayment() {
final flowProvider = context.read<PaymentFlowProvider>();
final paymentProvider = context.read<PaymentProvider>();
final quotationProvider = context.read<QuotationProvider>();
final loc = AppLocalizations.of(context)!;
if (paymentProvider.isLoading) return;
if (!quotationProvider.hasLiveQuote) {
if (quotationProvider.canRequestQuote) {
quotationProvider.refreshNow();
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(quotationProvider.canRequestQuote ? loc.quoteExpired : loc.quoteUnavailable)),
);
return;
}
paymentProvider.pay().then((_) {
PosthogService.paymentInitiated(method: flowProvider.selectedType);
}).catchError((error) {

View File

@@ -1,13 +1,19 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/payment/provider.dart';
import 'package:pshared/provider/payment/quotation.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/pages/dashboard/payouts/form.dart';
import 'package:pweb/models/button_state.dart';
import 'package:pweb/pages/dashboard/payouts/form.dart';
import 'package:pweb/pages/payment_methods/payment_page/back_button.dart';
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart';
import 'package:pweb/pages/payment_methods/payment_page/quote/quote_status.dart';
import 'package:pweb/pages/payment_methods/payment_page/send_button.dart';
import 'package:pweb/pages/payment_methods/widgets/payment_info_section.dart';
import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart';
@@ -94,8 +100,21 @@ class PaymentPageContent extends StatelessWidget {
PaymentInfoSection(dimensions: dimensions),
SizedBox(height: dimensions.paddingLarge),
const PaymentFormWidget(),
SizedBox(height: dimensions.paddingXXXLarge),
SendButton(onPressed: onSend),
SizedBox(height: dimensions.paddingLarge),
const QuoteStatus(),
SizedBox(height: dimensions.paddingXXLarge),
Consumer2<QuotationProvider, PaymentProvider>(
builder: (context, quotation, payment, _) {
final canSend = quotation.hasLiveQuote && !payment.isLoading;
final state = payment.isLoading
? ButtonState.loading
: (canSend ? ButtonState.enabled : ButtonState.disabled);
return SendButton(
onPressed: canSend ? onSend : null,
state: state,
);
},
),
SizedBox(height: dimensions.paddingLarge),
],
),

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:pweb/utils/dimensions.dart';
class QuoteStatusActions extends StatelessWidget {
final bool isLoading;
final bool canRefresh;
final bool autoRefreshEnabled;
final ValueChanged<bool>? onToggleAutoRefresh;
final VoidCallback? onRefresh;
final String autoRefreshLabel;
final String refreshLabel;
final AppDimensions dimensions;
final TextTheme theme;
const QuoteStatusActions({
super.key,
required this.isLoading,
required this.canRefresh,
required this.autoRefreshEnabled,
required this.onToggleAutoRefresh,
required this.onRefresh,
required this.autoRefreshLabel,
required this.refreshLabel,
required this.dimensions,
required this.theme,
});
@override
Widget build(BuildContext context) => Row(
children: [
Expanded(
child: SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: autoRefreshEnabled,
title: Text(autoRefreshLabel, style: theme.bodyMedium),
onChanged: onToggleAutoRefresh,
),
),
TextButton.icon(
onPressed: canRefresh ? onRefresh : null,
icon: isLoading
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
label: Text(refreshLabel),
),
],
);
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:pweb/utils/dimensions.dart';
class QuoteStatusMessage extends StatelessWidget {
final String statusText;
final String? errorText;
final bool showDetails;
final VoidCallback onToggleDetails;
final AppDimensions dimensions;
final TextTheme theme;
final String showLabel;
final String hideLabel;
const QuoteStatusMessage({
super.key,
required this.statusText,
required this.errorText,
required this.showDetails,
required this.onToggleDetails,
required this.dimensions,
required this.theme,
required this.showLabel,
required this.hideLabel,
});
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(statusText, style: theme.bodyMedium),
if (errorText != null) ...[
SizedBox(height: dimensions.paddingSmall),
TextButton(
onPressed: onToggleDetails,
child: Text(showDetails ? hideLabel : showLabel),
),
if (showDetails) ...[
SizedBox(height: dimensions.paddingSmall),
Text(
errorText!,
style: theme.bodySmall,
),
],
],
],
);
}

View File

@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/quotation.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pweb/pages/payment_methods/payment_page/quote/actions.dart';
import 'package:pweb/pages/payment_methods/payment_page/quote/message.dart';
import 'package:pweb/utils/dimensions.dart';
class QuoteStatus extends StatefulWidget {
const QuoteStatus({super.key});
@override
State<QuoteStatus> createState() => _QuoteStatusState();
}
class _QuoteStatusState extends State<QuoteStatus> {
bool _showDetails = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dimensions = AppDimensions();
final loc = AppLocalizations.of(context)!;
return Consumer<QuotationProvider>(
builder: (context, provider, _) {
final statusText = _statusText(provider, loc);
final canRefresh = provider.canRequestQuote && !provider.isLoading;
final refreshLabel = provider.isLoading ? loc.quoteUpdating : loc.retry;
final error = provider.error;
final backgroundColor = theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3);
return Container(
width: double.infinity,
padding: EdgeInsets.all(dimensions.paddingMedium),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
QuoteStatusMessage(
statusText: statusText,
errorText: error?.toString(),
showDetails: _showDetails,
onToggleDetails: () => setState(() => _showDetails = !_showDetails),
dimensions: dimensions,
theme: theme.textTheme,
showLabel: loc.showDetails,
hideLabel: loc.hideDetails,
),
SizedBox(height: dimensions.paddingSmall),
QuoteStatusActions(
isLoading: provider.isLoading,
canRefresh: canRefresh,
autoRefreshEnabled: provider.autoRefreshEnabled,
onToggleAutoRefresh: provider.canRequestQuote
? (value) => context.read<QuotationProvider>().setAutoRefresh(value)
: null,
onRefresh: canRefresh ? () => context.read<QuotationProvider>().refreshNow() : null,
autoRefreshLabel: loc.quoteAutoRefresh,
refreshLabel: refreshLabel,
dimensions: dimensions,
theme: theme.textTheme,
),
],
),
);
},
);
}
String _statusText(QuotationProvider provider, AppLocalizations loc) {
if (provider.error != null) {
return loc.quoteErrorGeneric;
}
if (!provider.canRequestQuote) {
return loc.quoteUnavailable;
}
if (provider.isLoading) {
return loc.quoteUpdating;
}
if (provider.hasLiveQuote) {
final remaining = provider.timeToExpire;
if (remaining != null) {
return loc.quoteExpiresIn(_formatDuration(remaining));
}
}
if (provider.hasQuoteForCurrentIntent) {
return loc.quoteExpired;
}
return loc.quoteUnavailable;
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes;
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
if (duration.inHours > 0) {
final hours = duration.inHours;
final mins = minutes.remainder(60).toString().padLeft(2, '0');
return '$hours:$mins:$seconds';
}
return '$minutes:$seconds';
}
}

View File

@@ -1,19 +1,31 @@
import 'package:flutter/material.dart';
import 'package:pweb/models/button_state.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class SendButton extends StatelessWidget {
final VoidCallback onPressed;
final VoidCallback? onPressed;
final ButtonState state;
const SendButton({super.key, required this.onPressed});
const SendButton({
super.key,
required this.onPressed,
this.state = ButtonState.enabled,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dimensions = AppDimensions();
final isLoading = state == ButtonState.loading;
final isActive = state == ButtonState.enabled && onPressed != null && !isLoading;
final backgroundColor = isActive
? theme.colorScheme.primary
: theme.colorScheme.primary.withValues(alpha: 0.5);
final textColor = theme.colorScheme.onSecondary.withValues(alpha: isActive ? 1 : 0.7);
return Center(
child: SizedBox(
@@ -21,24 +33,33 @@ class SendButton extends StatelessWidget {
height: dimensions.buttonHeight,
child: InkWell(
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
onTap: onPressed,
onTap: isActive ? onPressed : null,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.primary,
color: backgroundColor,
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
),
child: Center(
child: Text(
AppLocalizations.of(context)!.send,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSecondary,
fontWeight: FontWeight.w600,
),
),
child: isLoading
? SizedBox(
height: dimensions.iconSizeSmall,
width: dimensions.iconSizeSmall,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(textColor),
),
)
: Text(
AppLocalizations.of(context)!.send,
style: theme.textTheme.bodyLarge?.copyWith(
color: textColor,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
);
}
}
}