redesigned payment page + a lot of fixes

This commit is contained in:
Arseni
2026-02-21 21:55:20 +03:00
parent a68aa2abff
commit 0c6fa03aba
208 changed files with 4062 additions and 2217 deletions

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
class AddPaymentMethodTile extends StatelessWidget {
final String label;
final VoidCallback onTap;
const AddPaymentMethodTile({
super.key,
required this.label,
required this.onTap,
});
static const double _borderRadius = 12;
static const double _iconSize = 18;
static const double _minWidth = 150;
static const EdgeInsets _padding = EdgeInsets.all(12);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final borderColor = theme.colorScheme.primary.withValues(alpha: 0.45);
return ConstrainedBox(
constraints: const BoxConstraints(minWidth: _minWidth),
child: Material(
color: theme.colorScheme.onSecondary,
borderRadius: BorderRadius.circular(_borderRadius),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(_borderRadius),
child: Container(
padding: _padding,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_borderRadius),
border: Border.all(color: borderColor),
),
child: Row(
children: [
Icon(Icons.add, size: _iconSize, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.primary,
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
class AddRecipientTile extends StatelessWidget {
final String label;
final VoidCallback onTap;
const AddRecipientTile({
super.key,
required this.label,
required this.onTap,
});
static const double _avatarRadius = 20;
static const double _tileSize = 80;
static const double _verticalSpacing = 6;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
hoverColor: theme.colorScheme.primaryContainer,
child: SizedBox(
width: _tileSize,
height: _tileSize,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(
radius: _avatarRadius,
backgroundColor: theme.colorScheme.primaryContainer,
child: Icon(
Icons.add,
color: theme.colorScheme.primary,
size: 20,
),
),
const SizedBox(height: _verticalSpacing),
Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(fontSize: 12),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
class PaymentBackButton extends StatelessWidget {
final ValueChanged<Recipient?>? onBack;
final Recipient? recipient;
final PayoutDestination fallbackDestination;
const PaymentBackButton({
super.key,
required this.onBack,
required this.recipient,
required this.fallbackDestination,
});
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.topLeft,
child: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
if (onBack != null) {
onBack!(recipient);
} else {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
context.goToPayout(fallbackDestination);
}
}
},
),
);
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pweb/pages/payout_page/send/widgets/section/title.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class SelectedRecipientCard extends StatelessWidget {
final AppDimensions dimensions;
final Recipient recipient;
final VoidCallback onClear;
const SelectedRecipientCard({
super.key,
required this.dimensions,
required this.recipient,
required this.onClear,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: EdgeInsets.all(dimensions.paddingMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(loc.recipient),
SizedBox(height: dimensions.paddingSmall),
Row(
children: [
CircleAvatar(
child: Text(recipient.name.substring(0, 1).toUpperCase()),
),
SizedBox(width: dimensions.paddingMedium),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(recipient.name, style: theme.textTheme.titleMedium),
if (recipient.email.isNotEmpty)
Text(
recipient.email,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
),
],
),
),
TextButton(
onPressed: onClear,
child: Text(loc.chooseAnotherRecipient),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/widgets/payment/source_wallet_selector.dart';
class PaymentMethodSelector extends StatelessWidget {
final ValueChanged<Wallet> onMethodChanged;
const PaymentMethodSelector({
super.key,
required this.onMethodChanged,
});
@override
Widget build(BuildContext context) => Consumer<WalletsController>(
builder: (context, provider, _) => SourceWalletSelector(
walletsController: provider,
onChanged: onMethodChanged,
),
);
}

View File

@@ -0,0 +1,10 @@
import 'package:pshared/models/recipient/payment_method_draft.dart';
import 'package:pweb/utils/payment/label.dart';
String? buildPaymentInfoDetailsText(RecipientMethodDraft entry) {
final method = entry.existing;
if (method == null) return null;
return getPaymentMethodMaskedValue(method);
}

View File

@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/payout_page/send/widgets/section/title.dart';
import 'package:pweb/models/state/visibility.dart';
import 'package:pweb/utils/dimensions.dart';
class PaymentInfoHeader extends StatelessWidget {
final AppDimensions dimensions;
final String title;
final VisibilityState visibility;
const PaymentInfoHeader({
super.key,
required this.dimensions,
required this.title,
required this.visibility,
});
@override
Widget build(BuildContext context) {
if (visibility != VisibilityState.visible) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(title),
SizedBox(height: dimensions.paddingSmall),
],
);
}
}

View File

@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/payment_method_draft.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/panel.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/selector_row.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/details_builder.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/header.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/methods_state.dart';
import 'package:pweb/models/state/control_state.dart';
import 'package:pweb/models/state/visibility.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/utils/payment/availability.dart';
class PaymentInfoMethodsSection extends StatelessWidget {
final AppDimensions dimensions;
final String title;
final VisibilityState titleVisibility;
final String detailsLabel;
final PaymentInfoMethodsState state;
final VoidCallback? onAddMethod;
final VisibilityState paymentDetailsVisibility;
final VoidCallback onTogglePaymentDetails;
final ValueChanged<RecipientMethodDraft> onEntrySelected;
const PaymentInfoMethodsSection({
super.key,
required this.dimensions,
required this.title,
required this.titleVisibility,
required this.detailsLabel,
required this.state,
required this.onAddMethod,
required this.paymentDetailsVisibility,
required this.onTogglePaymentDetails,
required this.onEntrySelected,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PaymentInfoHeader(
dimensions: dimensions,
title: title,
visibility: titleVisibility,
),
PaymentMethodSelectorRow(
types: state.types,
selectedType: state.selectedType,
selectedIndex: state.hasSelection ? state.selectedIndex : null,
methods: state.methodsMap,
detailsBuilder: buildPaymentInfoDetailsText,
onSelected: _handleSelected,
onAddPressed: onAddMethod,
disabledTypes: disabledPaymentTypes,
),
if (state.hasSelection) ...[
SizedBox(height: dimensions.paddingSmall),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: onTogglePaymentDetails,
icon: Icon(
paymentDetailsVisibility == VisibilityState.visible
? Icons.expand_less
: Icons.expand_more,
),
label: Text(detailsLabel),
),
),
if (paymentDetailsVisibility == VisibilityState.visible) ...[
SizedBox(height: dimensions.paddingSmall),
PaymentMethodPanel(
selectedType: state.selectedType,
selectedIndex: state.selectedIndex!,
entries: state.selectedEntries,
onRemove: (_) {},
onChanged: (_, _) {},
editState: ControlState.disabled,
deleteVisibility: VisibilityState.hidden,
),
],
],
],
);
}
void _handleSelected(PaymentType type, int index) {
final entries = state.methodsMap[type] ?? const <RecipientMethodDraft>[];
if (index < 0 || index >= entries.length) return;
onEntrySelected(entries[index]);
}
}

View File

@@ -0,0 +1,59 @@
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/payment_method_draft.dart';
import 'package:pshared/provider/payment/flow.dart';
class PaymentInfoMethodsState {
final List<PaymentType> types;
final Map<PaymentType, List<RecipientMethodDraft>> methodsMap;
final PaymentType selectedType;
final List<RecipientMethodDraft> selectedEntries;
final int? selectedIndex;
const PaymentInfoMethodsState({
required this.types,
required this.methodsMap,
required this.selectedType,
required this.selectedEntries,
required this.selectedIndex,
});
bool get hasSelection => selectedIndex != null && selectedIndex! >= 0;
}
PaymentInfoMethodsState buildPaymentInfoMethodsState({
required PaymentFlowProvider flowProvider,
required List<PaymentType> types,
}) {
final methods = flowProvider.methodsForRecipient;
final selectedMethod = flowProvider.selectedMethod;
final methodsMap = <PaymentType, List<RecipientMethodDraft>>{};
for (final method in methods) {
methodsMap.putIfAbsent(method.type, () => []).add(
RecipientMethodDraft(
type: method.type,
existing: method,
),
);
}
final fallbackType = methods.isNotEmpty
? methods.first.type
: (types.isNotEmpty ? types.first : PaymentType.bankAccount);
final selectedType = selectedMethod?.type ?? fallbackType;
final selectedEntries =
methodsMap[selectedType] ?? const <RecipientMethodDraft>[];
final selectedIndex = selectedMethod == null
? null
: selectedEntries.indexWhere(
(entry) => entry.existing?.id == selectedMethod.id,
);
return PaymentInfoMethodsState(
types: types,
methodsMap: methodsMap,
selectedType: selectedType,
selectedEntries: selectedEntries,
selectedIndex: selectedIndex,
);
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/selector_row.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/header.dart';
import 'package:pweb/models/state/visibility.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/utils/payment/availability.dart';
class PaymentInfoNoMethodsSection extends StatelessWidget {
final AppDimensions dimensions;
final String title;
final VisibilityState titleVisibility;
final String emptyMessage;
final List<PaymentType> types;
final VoidCallback? onAddMethod;
const PaymentInfoNoMethodsSection({
super.key,
required this.dimensions,
required this.title,
required this.titleVisibility,
required this.emptyMessage,
required this.types,
required this.onAddMethod,
});
@override
Widget build(BuildContext context) {
final fallbackType = types.isNotEmpty ? types.first : PaymentType.bankAccount;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PaymentInfoHeader(
dimensions: dimensions,
title: title,
visibility: titleVisibility,
),
Text(emptyMessage),
if (onAddMethod != null) ...[
SizedBox(height: dimensions.paddingMedium),
PaymentMethodSelectorRow(
types: types,
selectedType: fallbackType,
selectedIndex: null,
methods: const {},
onSelected: (_, _) {},
onAddPressed: onAddMethod,
disabledTypes: disabledPaymentTypes,
),
],
],
);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/header.dart';
import 'package:pweb/models/state/visibility.dart';
import 'package:pweb/utils/dimensions.dart';
class PaymentInfoNoRecipientSection extends StatelessWidget {
final AppDimensions dimensions;
final String title;
final VisibilityState titleVisibility;
const PaymentInfoNoRecipientSection({
super.key,
required this.dimensions,
required this.title,
required this.titleVisibility,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PaymentInfoHeader(
dimensions: dimensions,
title: title,
visibility: titleVisibility,
),
],
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/methods_section.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/methods_state.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/no_methods.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/no_recipient.dart';
import 'package:pweb/models/state/visibility.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/utils/payment/availability.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentInfoSection extends StatelessWidget {
final AppDimensions dimensions;
final VisibilityState titleVisibility;
final VoidCallback? onAddMethod;
final VisibilityState paymentDetailsVisibility;
final VoidCallback onTogglePaymentDetails;
const PaymentInfoSection({
super.key,
required this.dimensions,
this.titleVisibility = VisibilityState.visible,
this.onAddMethod,
required this.paymentDetailsVisibility,
required this.onTogglePaymentDetails,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final flowProvider = context.watch<PaymentFlowProvider>();
if (!flowProvider.hasRecipient) {
return PaymentInfoNoRecipientSection(
dimensions: dimensions,
title: loc.paymentInfo,
titleVisibility: titleVisibility,
);
}
final methods = flowProvider.methodsForRecipient;
final types = visiblePaymentTypes;
if (methods.isEmpty) {
return PaymentInfoNoMethodsSection(
dimensions: dimensions,
title: loc.paymentInfo,
titleVisibility: titleVisibility,
emptyMessage: loc.recipientNoPaymentDetails,
types: types,
onAddMethod: onAddMethod,
);
}
final state = buildPaymentInfoMethodsState(
flowProvider: flowProvider,
types: types,
);
return PaymentInfoMethodsSection(
dimensions: dimensions,
title: loc.paymentInfo,
titleVisibility: titleVisibility,
detailsLabel: loc.paymentMethodDetails,
state: state,
onAddMethod: onAddMethod,
paymentDetailsVisibility: paymentDetailsVisibility,
onTogglePaymentDetails: onTogglePaymentDetails,
onEntrySelected: (entry) {
final existing = entry.existing;
if (existing != null) {
flowProvider.selectMethod(existing);
}
},
);
}
}

View File

@@ -0,0 +1 @@
export 'package:pweb/pages/payout_page/send/widgets/payment_info/section.dart';

View File

@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/pages/address_book/page/search.dart';
import 'package:pweb/pages/payout_page/send/widgets/card.dart';
import 'package:pweb/pages/payout_page/send/widgets/search.dart';
import 'package:pweb/pages/payout_page/send/widgets/section/title.dart';
import 'package:pweb/pages/payout_page/send/widgets/add_recipient_tile.dart';
import 'package:pweb/pages/dashboard/payouts/single/address_book/short_list.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class RecipientSection extends StatelessWidget {
final Recipient? recipient;
final AppDimensions dimensions;
final RecipientsProvider recipientProvider;
final String searchQuery;
final List<Recipient> filteredRecipients;
final TextEditingController searchController;
final FocusNode searchFocusNode;
final ValueChanged<String> onSearchChanged;
final ValueChanged<Recipient> onRecipientSelected;
final VoidCallback onRecipientCleared;
final VoidCallback onAddRecipient;
const RecipientSection({
super.key,
required this.recipient,
required this.dimensions,
required this.recipientProvider,
required this.searchQuery,
required this.filteredRecipients,
required this.searchController,
required this.searchFocusNode,
required this.onSearchChanged,
required this.onRecipientSelected,
required this.onRecipientCleared,
required this.onAddRecipient,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
if (recipient != null) {
return SelectedRecipientCard(
dimensions: dimensions,
recipient: recipient!,
onClear: onRecipientCleared,
);
}
return AnimatedBuilder(
animation: recipientProvider,
builder: (context, _) {
final hasQuery = searchQuery.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(loc.recipient),
SizedBox(height: dimensions.paddingSmall),
RecipientSearchField(
controller: searchController,
onChanged: onSearchChanged,
focusNode: searchFocusNode,
),
if (hasQuery) ...[
SizedBox(height: dimensions.paddingMedium),
RecipientSearchResults(
dimensions: dimensions,
recipientProvider: recipientProvider,
results: filteredRecipients,
onRecipientSelected: onRecipientSelected,
),
] else ...[
SizedBox(height: dimensions.paddingMedium),
ShortListAddressBookPayout(
recipients: recipientProvider.recipients,
onSelected: onRecipientSelected,
trailing: AddRecipientTile(
label: loc.addRecipient,
onTap: onAddRecipient,
),
),
],
],
);
},
);
}
}

View File

@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/section.dart';
import 'package:pweb/pages/payout_page/send/widgets/recipient/section.dart';
import 'package:pweb/pages/payout_page/send/widgets/section/card.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/models/state/visibility.dart';
class PaymentRecipientDetailsCard extends StatelessWidget {
final AppDimensions dimensions;
final Recipient? recipient;
final RecipientsProvider recipientProvider;
final String searchQuery;
final List<Recipient> filteredRecipients;
final TextEditingController searchController;
final FocusNode searchFocusNode;
final ValueChanged<String> onSearchChanged;
final ValueChanged<Recipient> onRecipientSelected;
final VoidCallback onRecipientCleared;
final VoidCallback onAddRecipient;
final VoidCallback onAddPaymentMethod;
final VisibilityState paymentDetailsVisibility;
final VoidCallback onTogglePaymentDetails;
const PaymentRecipientDetailsCard({
super.key,
required this.dimensions,
required this.recipient,
required this.recipientProvider,
required this.searchQuery,
required this.filteredRecipients,
required this.searchController,
required this.searchFocusNode,
required this.onSearchChanged,
required this.onRecipientSelected,
required this.onRecipientCleared,
required this.onAddRecipient,
required this.onAddPaymentMethod,
required this.paymentDetailsVisibility,
required this.onTogglePaymentDetails,
});
@override
Widget build(BuildContext context) {
return PaymentSectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RecipientSection(
recipient: recipient,
dimensions: dimensions,
recipientProvider: recipientProvider,
searchQuery: searchQuery,
filteredRecipients: filteredRecipients,
searchController: searchController,
searchFocusNode: searchFocusNode,
onSearchChanged: onSearchChanged,
onRecipientSelected: onRecipientSelected,
onRecipientCleared: onRecipientCleared,
onAddRecipient: onAddRecipient,
),
SizedBox(height: dimensions.paddingMedium),
PaymentInfoSection(
dimensions: dimensions,
titleVisibility: VisibilityState.hidden,
onAddMethod: onAddPaymentMethod,
paymentDetailsVisibility: paymentDetailsVisibility,
onTogglePaymentDetails: onTogglePaymentDetails,
),
],
),
);
}
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class RecipientSearchResults extends StatelessWidget {
final AppDimensions dimensions;
final RecipientsProvider recipientProvider;
final List<Recipient> results;
final ValueChanged<Recipient> onRecipientSelected;
const RecipientSearchResults({
super.key,
required this.dimensions,
required this.recipientProvider,
required this.results,
required this.onRecipientSelected,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
if (recipientProvider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (recipientProvider.error != null) {
return Text(
loc.notificationError(recipientProvider.error ?? loc.noErrorInformation),
style: TextStyle(color: Theme.of(context).colorScheme.error),
);
}
if (recipientProvider.recipients.isEmpty) {
return Text(loc.noRecipientsYet);
}
if (results.isEmpty) {
return Text(loc.noRecipientsFound);
}
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 240),
child: ListView.separated(
shrinkWrap: true,
itemCount: results.length,
separatorBuilder: (_, _) => SizedBox(height: dimensions.paddingSmall),
itemBuilder: (context, index) {
final recipient = results[index];
return ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
),
leading: CircleAvatar(
child: Text(recipient.name.substring(0, 1).toUpperCase()),
),
title: Text(recipient.name),
subtitle: Text(recipient.email),
trailing: const Icon(Icons.arrow_forward_ios_rounded, size: 16),
onTap: () => onRecipientSelected(recipient),
);
},
),
);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:pweb/utils/dimensions.dart';
class PaymentSectionCard extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
const PaymentSectionCard({
super.key,
required this.child,
this.padding,
});
@override
Widget build(BuildContext context) {
final dimensions = AppDimensions();
final theme = Theme.of(context);
return Material(
elevation: dimensions.elevationSmall,
borderRadius: BorderRadius.circular(dimensions.borderRadiusMedium),
color: theme.colorScheme.onSecondary,
clipBehavior: Clip.antiAlias,
child: Padding(
padding: padding ?? EdgeInsets.all(dimensions.paddingLarge),
child: child,
),
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
class SectionTitle extends StatelessWidget {
final String title;
const SectionTitle(this.title, {super.key});
@override
Widget build(BuildContext context) => Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
);
}

View File

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

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/dashboard/payouts/form.dart';
import 'package:pweb/pages/payout_page/send/widgets/send_button.dart';
import 'package:pweb/pages/payout_page/send/widgets/section/card.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/widgets/cooldown_hint.dart';
import 'package:pweb/models/state/control_state.dart';
class PaymentSendCard extends StatelessWidget {
final AppDimensions dimensions;
final ControlState sendState;
final int cooldownRemainingSeconds;
final VoidCallback onSend;
const PaymentSendCard({
super.key,
required this.dimensions,
required this.sendState,
required this.cooldownRemainingSeconds,
required this.onSend,
});
@override
Widget build(BuildContext context) {
return PaymentSectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const PaymentFormWidget(),
SizedBox(height: dimensions.paddingXLarge),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SendButton(
onPressed: onSend,
state: sendState,
),
if (sendState == ControlState.disabled &&
cooldownRemainingSeconds > 0) ...[
SizedBox(height: dimensions.paddingSmall),
CooldownHint(seconds: cooldownRemainingSeconds),
],
],
),
],
),
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/payout_page/send/widgets/method_selector.dart';
import 'package:pweb/pages/payout_page/send/widgets/section/title.dart';
import 'package:pweb/pages/payout_page/send/widgets/section/card.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/widgets/refresh_balance/wallet.dart';
class PaymentSourceOfFundsCard extends StatelessWidget {
final AppDimensions dimensions;
final String title;
final ValueChanged<Wallet> onWalletSelected;
const PaymentSourceOfFundsCard({
super.key,
required this.dimensions,
required this.title,
required this.onWalletSelected,
});
@override
Widget build(BuildContext context) {
return PaymentSectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: SectionTitle(title)),
Consumer<WalletsController>(
builder: (context, provider, _) {
final selectedWalletId = provider.selectedWallet?.id;
if (selectedWalletId == null) {
return const SizedBox.shrink();
}
return WalletBalanceRefreshButton(walletRef: selectedWalletId);
},
),
],
),
SizedBox(height: dimensions.paddingSmall),
PaymentMethodSelector(
onMethodChanged: onWalletSelected,
),
],
),
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
class PaymentMethodsLoadingView extends StatelessWidget {
const PaymentMethodsLoadingView({super.key});
@override
Widget build(BuildContext context) {
return const Center(child: CircularProgressIndicator());
}
}
class PaymentMethodsErrorView extends StatelessWidget {
final String message;
const PaymentMethodsErrorView({super.key, required this.message});
@override
Widget build(BuildContext context) {
return Center(child: Text(message));
}
}