Merge pull request 'Top Up Balance logic and Added fixes for routing' (#31) from SEND001 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful

Reviewed-on: #31
This commit was merged in pull request #31.
This commit is contained in:
2025-12-06 23:35:53 +00:00
27 changed files with 972 additions and 175 deletions

View File

@@ -0,0 +1,112 @@
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
class PayoutRoutes {
static const dashboard = 'dashboard';
static const sendPayout = payment;
static const recipients = 'payout-recipients';
static const addRecipient = 'payout-add-recipient';
static const payment = 'payout-payment';
static const settings = 'payout-settings';
static const reports = 'payout-reports';
static const methods = 'payout-methods';
static const editWallet = 'payout-edit-wallet';
static const walletTopUp = 'payout-wallet-top-up';
static const dashboardPath = '/dashboard';
static const recipientsPath = '/dashboard/recipients';
static const addRecipientPath = '/dashboard/recipients/add';
static const paymentPath = '/dashboard/payment';
static const settingsPath = '/dashboard/settings';
static const reportsPath = '/dashboard/reports';
static const methodsPath = '/dashboard/methods';
static const editWalletPath = '/dashboard/methods/edit';
static const walletTopUpPath = '/dashboard/wallet/top-up';
static String nameFor(PayoutDestination destination) {
switch (destination) {
case PayoutDestination.dashboard:
return dashboard;
case PayoutDestination.sendPayout:
return payment;
case PayoutDestination.recipients:
return recipients;
case PayoutDestination.addrecipient:
return addRecipient;
case PayoutDestination.payment:
return payment;
case PayoutDestination.settings:
return settings;
case PayoutDestination.reports:
return reports;
case PayoutDestination.methods:
return methods;
case PayoutDestination.editwallet:
return editWallet;
case PayoutDestination.walletTopUp:
return walletTopUp;
}
}
static String pathFor(PayoutDestination destination) {
switch (destination) {
case PayoutDestination.dashboard:
return dashboardPath;
case PayoutDestination.sendPayout:
return paymentPath;
case PayoutDestination.recipients:
return recipientsPath;
case PayoutDestination.addrecipient:
return addRecipientPath;
case PayoutDestination.payment:
return paymentPath;
case PayoutDestination.settings:
return settingsPath;
case PayoutDestination.reports:
return reportsPath;
case PayoutDestination.methods:
return methodsPath;
case PayoutDestination.editwallet:
return editWalletPath;
case PayoutDestination.walletTopUp:
return walletTopUpPath;
}
}
static PayoutDestination? destinationFor(String? routeName) {
switch (routeName) {
case dashboard:
return PayoutDestination.dashboard;
case sendPayout:
return PayoutDestination.payment;
case recipients:
return PayoutDestination.recipients;
case addRecipient:
return PayoutDestination.addrecipient;
case payment:
return PayoutDestination.payment;
case settings:
return PayoutDestination.settings;
case reports:
return PayoutDestination.reports;
case methods:
return PayoutDestination.methods;
case editWallet:
return PayoutDestination.editwallet;
case walletTopUp:
return PayoutDestination.walletTopUp;
default:
return null;
}
}
}
extension PayoutNavigation on BuildContext {
void goToPayout(PayoutDestination destination) => goNamed(PayoutRoutes.nameFor(destination));
void pushToPayout(PayoutDestination destination) => pushNamed(PayoutRoutes.nameFor(destination));
}

View File

@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/app/router/pages.dart';
import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/pages/address_book/form/page.dart';
import 'package:pweb/pages/address_book/page/page.dart';
import 'package:pweb/pages/dashboard/dashboard.dart';
import 'package:pweb/pages/payment_methods/page.dart';
import 'package:pweb/pages/payout_page/page.dart';
import 'package:pweb/pages/payout_page/wallet/edit/page.dart';
import 'package:pweb/pages/report/page.dart';
import 'package:pweb/pages/settings/profile/page.dart';
import 'package:pweb/pages/wallet_top_up/page.dart';
import 'package:pweb/providers/page_selector.dart';
import 'package:pweb/widgets/error/snackbar.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/widgets/sidebar/page.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
RouteBase payoutShellRoute() => ShellRoute(
builder: (context, state, child) => PageSelector(
child: child,
routerState: state,
),
routes: [
GoRoute(
name: PayoutRoutes.dashboard,
path: routerPage(Pages.dashboard),
pageBuilder: (context, _) => NoTransitionPage(
child: DashboardPage(
onRecipientSelected: (recipient) => context
.read<PageSelectorProvider>()
.selectRecipient(context, recipient),
onGoToPaymentWithoutRecipient: (type) => context
.read<PageSelectorProvider>()
.startPaymentWithoutRecipient(context, type),
onTopUp: (wallet) => context
.read<PageSelectorProvider>()
.openWalletTopUp(context, wallet),
),
),
),
GoRoute(
name: PayoutRoutes.recipients,
path: PayoutRoutes.recipientsPath,
pageBuilder: (context, _) {
final loc = AppLocalizations.of(context)!;
return NoTransitionPage(
child: RecipientAddressBookPage(
onRecipientSelected: (recipient) => context
.read<PageSelectorProvider>()
.selectRecipient(context, recipient, fromList: true),
onAddRecipient: () => context
.read<PageSelectorProvider>()
.goToAddRecipient(context),
onEditRecipient: (recipient) => context
.read<PageSelectorProvider>()
.editRecipient(context, recipient, fromList: true),
onDeleteRecipient: (recipient) => executeActionWithNotification(
context: context,
action: () async =>
context.read<RecipientsProvider>().delete(recipient.id),
successMessage: loc.recipientDeletedSuccessfully,
errorMessage: loc.errorDeleteRecipient,
),
),
);
},
),
GoRoute(
name: PayoutRoutes.addRecipient,
path: PayoutRoutes.addRecipientPath,
pageBuilder: (context, _) {
final selector = context.read<PageSelectorProvider>();
final recipient = selector.recipientProvider.currentObject;
return NoTransitionPage(
child: AdressBookRecipientForm(
recipient: recipient,
onSaved: (_) => selector.selectPage(
context,
PayoutDestination.recipients,
),
),
);
},
),
GoRoute(
name: PayoutRoutes.payment,
path: PayoutRoutes.paymentPath,
pageBuilder: (context, _) => NoTransitionPage(
child: PaymentPage(
onBack: (_) => context
.read<PageSelectorProvider>()
.goBackFromPayment(context),
),
),
),
GoRoute(
name: PayoutRoutes.settings,
path: PayoutRoutes.settingsPath,
pageBuilder: (_, __) => const NoTransitionPage(
child: ProfileSettingsPage(),
),
),
GoRoute(
name: PayoutRoutes.reports,
path: PayoutRoutes.reportsPath,
pageBuilder: (_, __) => const NoTransitionPage(
child: OperationHistoryPage(),
),
),
GoRoute(
name: PayoutRoutes.methods,
path: PayoutRoutes.methodsPath,
pageBuilder: (context, _) => NoTransitionPage(
child: PaymentConfigPage(
onWalletTap: (wallet) => context
.read<PageSelectorProvider>()
.selectWallet(context, wallet),
),
),
),
GoRoute(
name: PayoutRoutes.editWallet,
path: PayoutRoutes.editWalletPath,
pageBuilder: (context, _) {
final provider = context.read<PageSelectorProvider>();
final wallet = provider.walletsProvider.selectedWallet;
final loc = AppLocalizations.of(context)!;
return NoTransitionPage(
child: wallet != null
? WalletEditPage(
onBack: () => provider.goBackFromWalletEdit(context),
)
: Center(child: Text(loc.noWalletSelected)),
);
},
),
GoRoute(
name: PayoutRoutes.walletTopUp,
path: PayoutRoutes.walletTopUpPath,
pageBuilder: (context, _) => NoTransitionPage(
child: WalletTopUpPage(
onBack: () => context
.read<PageSelectorProvider>()
.goBackFromWalletTopUp(context),
),
),
),
],
);

View File

@@ -1,13 +1,14 @@
import 'package:go_router/go_router.dart';
import 'package:pweb/app/router/pages.dart';
import 'package:pweb/app/router/page_params.dart';
import 'package:pweb/app/router/pages.dart';
import 'package:pweb/app/router/payout_shell.dart';
import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/pages/2fa/page.dart';
import 'package:pweb/pages/errors/not_found.dart';
import 'package:pweb/pages/login/page.dart';
import 'package:pweb/pages/signup/page.dart';
import 'package:pweb/pages/verification/page.dart';
import 'package:pweb/widgets/sidebar/page.dart';
import 'package:pweb/pages/login/page.dart';
import 'package:pweb/pages/errors/not_found.dart';
GoRouter createRouter() => GoRouter(
@@ -16,40 +17,33 @@ GoRouter createRouter() => GoRouter(
GoRoute(
name: Pages.root.name,
path: routerPage(Pages.root),
builder: (_, _) => const LoginPage(),
routes: [
GoRoute(
name: Pages.login.name,
path: routerPage(Pages.login),
builder: (_, _) => const LoginPage(),
),
GoRoute(
name: Pages.dashboard.name,
path: routerPage(Pages.dashboard),
builder: (_, _) => const PageSelector(),
),
GoRoute(
name: Pages.sfactor.name,
path: routerPage(Pages.sfactor),
builder: (context, _) => TwoFactorCodePage(
onVerificationSuccess: () {
// trigger organization load
context.goNamed(Pages.dashboard.name);
},
),
),
GoRoute(
name: Pages.signup.name,
path: routerPage(Pages.signup),
builder: (_, _) => const SignUpPage(),
),
GoRoute(
name: Pages.verify.name,
path: '${routerPage(Pages.verify)}${routerAddParam(PageParams.token)}',
builder: (_, state) => AccountVerificationPage(token: state.pathParameters[PageParams.token.name]!),
),
],
builder: (_, __) => const LoginPage(),
),
GoRoute(
name: Pages.login.name,
path: routerPage(Pages.login),
builder: (_, __) => const LoginPage(),
),
GoRoute(
name: Pages.sfactor.name,
path: routerPage(Pages.sfactor),
builder: (context, _) => TwoFactorCodePage(
onVerificationSuccess: () => context.goNamed(PayoutRoutes.dashboard),
),
),
GoRoute(
name: Pages.signup.name,
path: routerPage(Pages.signup),
builder: (_, __) => const SignUpPage(),
),
GoRoute(
name: Pages.verify.name,
path: '${routerPage(Pages.verify)}${routerAddParam(PageParams.token)}',
builder: (_, state) => AccountVerificationPage(
token: state.pathParameters[PageParams.token.name]!,
),
),
payoutShellRoute(),
],
errorBuilder: (_, _) => const NotFoundPage(),
);
errorBuilder: (_, __) => const NotFoundPage(),
);

View File

@@ -21,6 +21,10 @@ extension WalletUiMapper on domain.WalletModel {
currency: currency,
isHidden: true,
calculatedAt: balance?.calculatedAt ?? DateTime.now(),
depositAddress: depositAddress,
network: asset.chain,
tokenSymbol: asset.tokenSymbol,
contractAddress: asset.contractAddress,
);
}
}

View File

@@ -469,6 +469,17 @@
"walletNameUpdateFailed": "Failed to update wallet name",
"walletNameSaved": "Wallet name saved",
"topUpBalance": "Top Up Balance",
"walletTopUpTitle": "Add funds to wallet",
"walletTopUpDetailsTitle": "Funding details",
"walletTopUpDescription": "Send funds to this address to increase your wallet balance.",
"walletTopUpAssetLabel": "Asset",
"walletTopUpNetworkLabel": "Network",
"walletTopUpAddressLabel": "Deposit address",
"walletTopUpQrLabel": "QR code for deposit",
"walletTopUpHint": "Only send funds on the specified network. Deposits may take a few minutes to confirm.",
"walletTopUpUnavailable": "Top-up details are unavailable for this wallet yet.",
"copyAddress": "Copy address",
"addressCopied": "Address copied",
"addFunctionality": "Add functionality",
"walletHistoryEmpty": "No history yet",
"colType": "Type",

View File

@@ -470,6 +470,17 @@
"walletNameUpdateFailed": "Не удалось обновить название кошелька",
"walletNameSaved": "Название кошелька сохранено",
"topUpBalance": "Пополнить баланс",
"walletTopUpTitle": "Пополнение кошелька",
"walletTopUpDetailsTitle": "Данные для пополнения",
"walletTopUpDescription": "Отправьте средства на этот адрес, чтобы пополнить баланс кошелька.",
"walletTopUpAssetLabel": "Актив",
"walletTopUpNetworkLabel": "Сеть",
"walletTopUpAddressLabel": "Адрес для пополнения",
"walletTopUpQrLabel": "QR-код для пополнения",
"walletTopUpHint": "Отправляйте средства только в указанной сети. Подтверждение может занять несколько минут.",
"walletTopUpUnavailable": "Данные для пополнения пока недоступны для этого кошелька.",
"copyAddress": "Скопировать адрес",
"addressCopied": "Адрес скопирован",
"addFunctionality": "Добавить функциональность",
"walletHistoryEmpty": "История пуста",
"colType": "Тип",

View File

@@ -9,6 +9,10 @@ class Wallet {
final Currency currency;
final bool isHidden;
final DateTime calculatedAt;
final String? depositAddress;
final String? network;
final String? tokenSymbol;
final String? contractAddress;
Wallet({
required this.id,
@@ -18,6 +22,10 @@ class Wallet {
required this.currency,
required this.calculatedAt,
this.isHidden = true,
this.depositAddress,
this.network,
this.tokenSymbol,
this.contractAddress,
});
Wallet copyWith({
@@ -27,6 +35,10 @@ class Wallet {
Currency? currency,
String? walletUserID,
bool? isHidden,
String? depositAddress,
String? network,
String? tokenSymbol,
String? contractAddress,
}) => Wallet(
id: id ?? this.id,
name: name ?? this.name,
@@ -35,5 +47,9 @@ class Wallet {
walletUserID: walletUserID ?? this.walletUserID,
isHidden: isHidden ?? this.isHidden,
calculatedAt: calculatedAt,
depositAddress: depositAddress ?? this.depositAddress,
network: network ?? this.network,
tokenSymbol: tokenSymbol ?? this.tokenSymbol,
contractAddress: contractAddress ?? this.contractAddress,
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pweb/models/wallet.dart';
import 'package:pweb/pages/dashboard/buttons/balance/carousel.dart';
import 'package:pweb/providers/wallets.dart';
@@ -9,7 +10,9 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class BalanceWidget extends StatelessWidget {
const BalanceWidget({super.key});
final ValueChanged<Wallet> onTopUp;
const BalanceWidget({super.key, required this.onTopUp});
@override
Widget build(BuildContext context) {
@@ -30,6 +33,7 @@ class BalanceWidget extends StatelessWidget {
WalletCarousel(
wallets: wallets,
onWalletChanged: walletsProvider.selectWallet,
onTopUp: onTopUp,
);
}
}

View File

@@ -12,10 +12,12 @@ import 'package:pweb/providers/wallets.dart';
class WalletCard extends StatelessWidget {
final Wallet wallet;
final VoidCallback onTopUp;
const WalletCard({
super.key,
required this.wallet,
required this.onTopUp,
});
@override
@@ -43,7 +45,7 @@ class WalletCard extends StatelessWidget {
),
BalanceAddFunds(
onTopUp: () {
// TODO: Implement top-up functionality
onTopUp();
},
),
],
@@ -51,4 +53,4 @@ class WalletCard extends StatelessWidget {
),
);
}
}
}

View File

@@ -12,11 +12,13 @@ import 'package:pweb/providers/carousel.dart';
class WalletCarousel extends StatefulWidget {
final List<Wallet> wallets;
final ValueChanged<Wallet> onWalletChanged;
final ValueChanged<Wallet> onTopUp;
const WalletCarousel({
super.key,
required this.wallets,
required this.onWalletChanged,
required this.onTopUp,
});
@override
@@ -33,6 +35,11 @@ class _WalletCarouselState extends State<WalletCarousel> {
_pageController = PageController(
viewportFraction: WalletCardConfig.viewportFraction,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (widget.wallets.isNotEmpty) {
widget.onWalletChanged(widget.wallets[_currentPage]);
}
});
}
@override
@@ -83,7 +90,10 @@ class _WalletCarouselState extends State<WalletCarousel> {
itemBuilder: (context, index) {
return Padding(
padding: WalletCardConfig.cardPadding,
child: WalletCard(wallet: widget.wallets[index]),
child: WalletCard(
wallet: widget.wallets[index],
onTopUp: () => widget.onTopUp(widget.wallets[index]),
),
);
},
),
@@ -110,4 +120,4 @@ class _WalletCarouselState extends State<WalletCarousel> {
],
);
}
}
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pweb/models/wallet.dart';
import 'package:pweb/pages/dashboard/buttons/balance/balance.dart';
import 'package:pweb/pages/dashboard/buttons/buttons.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/title.dart';
@@ -22,11 +23,13 @@ class AppSpacing {
class DashboardPage extends StatefulWidget {
final ValueChanged<Recipient> onRecipientSelected;
final void Function(PaymentType type) onGoToPaymentWithoutRecipient;
final ValueChanged<Wallet> onTopUp;
const DashboardPage({
super.key,
required this.onRecipientSelected,
required this.onGoToPaymentWithoutRecipient,
required this.onTopUp,
});
@override
@@ -75,7 +78,9 @@ class _DashboardPageState extends State<DashboardPage> {
],
),
const SizedBox(height: AppSpacing.medium),
BalanceWidget(),
BalanceWidget(
onTopUp: widget.onTopUp,
),
const SizedBox(height: AppSpacing.small),
if (_showContainerMultiple) TitleMultiplePayout(),
const SizedBox(height: AppSpacing.medium),

View File

@@ -62,7 +62,7 @@ class _PaymentPageState extends State<PaymentPage> {
final recipientProvider = context.read<RecipientsProvider>();
recipientProvider.setCurrentObject(recipient.id);
pageSelector.selectRecipient(recipient);
pageSelector.selectRecipient(context, recipient);
_flowProvider.reset(pageSelector);
_clearSearchField();
}
@@ -72,7 +72,7 @@ class _PaymentPageState extends State<PaymentPage> {
final recipientProvider = context.read<RecipientsProvider>();
recipientProvider.setCurrentObject(null);
pageSelector.selectRecipient(null);
pageSelector.selectRecipient(context, null);
_flowProvider.reset(pageSelector);
_clearSearchField();
}

View File

@@ -141,7 +141,7 @@ class PaymentBackButton extends StatelessWidget {
if (onBack != null) {
onBack!(pageSelector.selectedRecipient);
} else {
pageSelector.goBackFromPayment();
pageSelector.goBackFromPayment(context);
}
},
),

View File

@@ -25,7 +25,7 @@ class SendPayoutButton extends StatelessWidget {
final wallet = walletsProvider.selectedWallet;
if (wallet != null) {
pageSelectorProvider.startPaymentFromWallet(wallet);
pageSelectorProvider.startPaymentFromWallet(context, wallet);
}
},
child: Text(loc.payoutNavSendPayout),

View File

@@ -1,5 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pweb/providers/page_selector.dart';
import 'package:pweb/providers/wallets.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -15,9 +19,14 @@ class TopUpButton extends StatelessWidget{
elevation: 0,
),
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(loc.addFunctionality)),
);
final wallet = context.read<WalletsProvider>().selectedWallet;
if (wallet == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(loc.noWalletSelected)),
);
return;
}
context.read<PageSelectorProvider>().openWalletTopUp(context, wallet);
},
child: Text(loc.topUpBalance),
);

View File

@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class WalletTopUpAddressBlock extends StatelessWidget {
final String address;
final AppDimensions dimensions;
const WalletTopUpAddressBlock({
super.key,
required this.address,
required this.dimensions,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final loc = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
loc.walletTopUpAddressLabel,
style: theme.textTheme.titleSmall,
),
TextButton.icon(
icon: const Icon(Icons.copy, size: 16),
label: Text(loc.copyAddress),
onPressed: () {
Clipboard.setData(ClipboardData(text: address));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(loc.addressCopied)),
);
},
),
],
),
const SizedBox(height: 6),
Container(
width: double.infinity,
padding: EdgeInsets.all(dimensions.paddingMedium),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
border: Border.all(color: theme.colorScheme.outlineVariant),
color: theme.colorScheme.surfaceVariant.withOpacity(0.4),
),
child: SelectableText(
address,
style: theme.textTheme.bodyLarge?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
SizedBox(height: dimensions.paddingLarge),
Text(
loc.walletTopUpQrLabel,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
Container(
padding: EdgeInsets.all(dimensions.paddingMedium),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
border: Border.all(color: theme.colorScheme.outlineVariant),
color: theme.colorScheme.surfaceVariant.withOpacity(0.4),
),
child: Center(
child: QrImageView(
data: address,
backgroundColor: theme.colorScheme.onSecondary,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: theme.colorScheme.onSurface,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: theme.colorScheme.onSurface,
),
size: 220,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:pweb/models/wallet.dart';
import 'package:pweb/pages/wallet_top_up/details.dart';
import 'package:pweb/pages/wallet_top_up/header.dart';
import 'package:pweb/pages/wallet_top_up/meta.dart';
import 'package:pweb/utils/currency.dart';
import 'package:pweb/utils/dimensions.dart';
class WalletTopUpContent extends StatelessWidget {
final Wallet wallet;
final VoidCallback onBack;
const WalletTopUpContent({
super.key,
required this.wallet,
required this.onBack,
});
@override
Widget build(BuildContext context) {
final dimensions = AppDimensions();
final theme = Theme.of(context);
final address = _resolveAddress(wallet);
final network = wallet.network?.trim();
final assetLabel = wallet.tokenSymbol ?? currencyCodeToSymbol(wallet.currency);
return Align(
alignment: Alignment.topCenter,
child: SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 960),
child: Padding(
padding: EdgeInsets.symmetric(vertical: dimensions.paddingLarge),
child: Material(
elevation: dimensions.elevationSmall,
color: theme.colorScheme.onSecondary,
borderRadius: BorderRadius.circular(dimensions.borderRadiusMedium),
child: Padding(
padding: EdgeInsets.all(dimensions.paddingXLarge),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
WalletTopUpHeader(
onBack: onBack,
walletName: wallet.name,
),
SizedBox(height: dimensions.paddingLarge),
WalletTopUpMeta(
assetLabel: assetLabel,
network: network,
walletId: wallet.walletUserID,
),
SizedBox(height: dimensions.paddingXLarge),
WalletTopUpDetails(
address: address,
dimensions: dimensions,
),
],
),
),
),
),
),
),
);
}
String? _resolveAddress(Wallet wallet) {
final candidate = wallet.depositAddress?.trim();
if (candidate == null || candidate.isEmpty) return null;
return candidate;
}
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/wallet_top_up/address_block.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class WalletTopUpDetails extends StatelessWidget {
final String? address;
final AppDimensions dimensions;
const WalletTopUpDetails({
super.key,
required this.address,
required this.dimensions,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final loc = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.walletTopUpDetailsTitle,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 6),
Text(
loc.walletTopUpDescription,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
SizedBox(height: dimensions.paddingLarge),
if (address == null || address!.isEmpty)
Text(
loc.walletTopUpUnavailable,
style: theme.textTheme.bodyMedium,
)
else ...[
WalletTopUpAddressBlock(
address: address!,
dimensions: dimensions,
),
SizedBox(height: dimensions.paddingLarge),
Text(
loc.walletTopUpHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
);
}
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class WalletTopUpHeader extends StatelessWidget {
final VoidCallback onBack;
final String walletName;
const WalletTopUpHeader({
super.key,
required this.onBack,
required this.walletName,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final loc = AppLocalizations.of(context)!;
return Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.walletTopUpTitle,
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
walletName,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
],
);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:pweb/utils/dimensions.dart';
class WalletTopUpInfoChip extends StatelessWidget {
final String label;
final String value;
const WalletTopUpInfoChip({
super.key,
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dimensions = AppDimensions();
return Container(
padding: EdgeInsets.all(dimensions.paddingMedium),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
border: Border.all(color: theme.colorScheme.outlineVariant),
color: theme.colorScheme.surfaceVariant.withOpacity(0.4),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
value,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/wallet_top_up/info_chip.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class WalletTopUpMeta extends StatelessWidget {
final String assetLabel;
final String walletId;
final String? network;
const WalletTopUpMeta({
super.key,
required this.assetLabel,
required this.walletId,
this.network,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final dimensions = AppDimensions();
return Wrap(
spacing: dimensions.paddingLarge,
runSpacing: dimensions.paddingLarge,
children: [
WalletTopUpInfoChip(label: loc.walletTopUpAssetLabel, value: assetLabel),
if (network != null && network!.isNotEmpty)
WalletTopUpInfoChip(label: loc.walletTopUpNetworkLabel, value: network!),
WalletTopUpInfoChip(label: loc.walletId, value: walletId),
],
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pweb/pages/wallet_top_up/content.dart';
import 'package:pweb/providers/wallets.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class WalletTopUpPage extends StatelessWidget {
final VoidCallback onBack;
const WalletTopUpPage({super.key, required this.onBack});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return Consumer<WalletsProvider>(
builder: (context, provider, child) {
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (provider.error != null) {
return Center(
child: Text(loc.notificationError(provider.error.toString())),
);
}
final wallet = provider.selectedWallet;
if (wallet == null) {
return Center(child: Text(loc.noWalletSelected));
}
return WalletTopUpContent(
wallet: wallet,
onBack: onBack,
);
},
);
}
}

View File

@@ -12,6 +12,7 @@ import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/models/wallet.dart';
import 'package:pweb/providers/wallets.dart';
//import 'package:pweb/services/amplitude.dart';
import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
@@ -41,44 +42,93 @@ class PageSelectorProvider extends ChangeNotifier {
methodsProvider = methodsProv;
}
void selectPage(PayoutDestination dest) {
_selected = dest;
void syncDestination(PayoutDestination destination) {
if (_selected == destination) return;
_selected = destination;
notifyListeners();
}
void selectRecipient(Recipient? recipient, {bool fromList = false}) {
void selectPage(
BuildContext context,
PayoutDestination dest, {
bool replace = true,
}) {
_selected = dest;
notifyListeners();
_navigateTo(context, dest, replace: replace);
}
void selectRecipient(
BuildContext context,
Recipient? recipient, {
bool fromList = false,
}) {
final previousDestination = _selected;
recipientProvider.setCurrentObject(recipient?.id);
_cameFromRecipientList = fromList;
_setPreviousDestination();
_selected = PayoutDestination.payment;
notifyListeners();
if (previousDestination != PayoutDestination.payment) {
_navigateTo(context, PayoutDestination.payment, replace: false);
}
}
void editRecipient(Recipient? recipient, {bool fromList = false}) {
void editRecipient(
BuildContext context,
Recipient? recipient, {
bool fromList = false,
}) {
final previousDestination = _selected;
recipientProvider.setCurrentObject(recipient?.id);
_cameFromRecipientList = fromList;
_selected = PayoutDestination.addrecipient;
notifyListeners();
if (previousDestination != PayoutDestination.addrecipient) {
_navigateTo(context, PayoutDestination.addrecipient, replace: false);
}
}
void goToAddRecipient() {
void goToAddRecipient(BuildContext context) {
// AmplitudeService.recipientAddStarted();
final previousDestination = _selected;
recipientProvider.setCurrentObject(null);
_selected = PayoutDestination.addrecipient;
_cameFromRecipientList = false;
notifyListeners();
if (previousDestination != PayoutDestination.addrecipient) {
_navigateTo(context, PayoutDestination.addrecipient, replace: false);
}
}
void startPaymentWithoutRecipient(PaymentType type) {
void startPaymentWithoutRecipient(
BuildContext context,
PaymentType type,
) {
final previousDestination = _selected;
recipientProvider.setCurrentObject(null);
_type = type;
_cameFromRecipientList = false;
_setPreviousDestination();
_selected = PayoutDestination.payment;
notifyListeners();
if (previousDestination != PayoutDestination.payment) {
_navigateTo(context, PayoutDestination.payment, replace: false);
}
}
void goBackFromPayment() {
void goBackFromPayment(BuildContext context) {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
_navigateTo(
context,
_previousDestination ??
(_cameFromRecipientList
? PayoutDestination.recipients
: PayoutDestination.dashboard),
);
}
_selected = _previousDestination ??
(_cameFromRecipientList
? PayoutDestination.recipients
@@ -89,22 +139,55 @@ class PageSelectorProvider extends ChangeNotifier {
notifyListeners();
}
void goBackFromWalletEdit() {
selectPage(PayoutDestination.methods);
void goBackFromWalletEdit(BuildContext context) {
selectPage(context, PayoutDestination.methods);
}
void selectWallet(Wallet wallet) {
void selectWallet(BuildContext context, Wallet wallet) {
final previousDestination = _selected;
walletsProvider.selectWallet(wallet);
_selected = PayoutDestination.editwallet;
notifyListeners();
if (previousDestination != PayoutDestination.editwallet) {
_navigateTo(context, PayoutDestination.editwallet, replace: false);
}
}
void startPaymentFromWallet(Wallet wallet) {
void startPaymentFromWallet(BuildContext context, Wallet wallet) {
final previousDestination = _selected;
_type = PaymentType.wallet;
_cameFromRecipientList = false;
_setPreviousDestination();
_selected = PayoutDestination.payment;
notifyListeners();
if (previousDestination != PayoutDestination.payment) {
_navigateTo(context, PayoutDestination.payment, replace: false);
}
}
void openWalletTopUp(BuildContext context, Wallet wallet) {
final previousDestination = _selected;
_setPreviousDestination();
walletsProvider.selectWallet(wallet);
_selected = PayoutDestination.walletTopUp;
notifyListeners();
if (previousDestination != PayoutDestination.walletTopUp) {
_navigateTo(context, PayoutDestination.walletTopUp, replace: false);
}
}
void goBackFromWalletTopUp(BuildContext context) {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
_navigateTo(
context,
_previousDestination ?? PayoutDestination.dashboard,
);
}
_selected = _previousDestination ?? PayoutDestination.dashboard;
_previousDestination = null;
notifyListeners();
}
PaymentMethod? getPaymentMethodForWallet(Wallet wallet) {
@@ -113,8 +196,7 @@ class PageSelectorProvider extends ChangeNotifier {
}
return methodsProvider.methods.firstWhereOrNull(
(method) => method.type == PaymentType.wallet &&
(method.description?.contains(wallet.walletUserID) ?? false),
(method) => method.type == PaymentType.wallet && (method.description?.contains(wallet.walletUserID) ?? false),
);
}
@@ -159,11 +241,24 @@ class PageSelectorProvider extends ChangeNotifier {
}
void _setPreviousDestination() {
if (_selected != PayoutDestination.payment) {
if (_selected != PayoutDestination.payment &&
_selected != PayoutDestination.walletTopUp) {
_previousDestination = _selected;
}
}
void _navigateTo(
BuildContext context,
PayoutDestination destination, {
bool replace = true,
}) {
if (replace) {
context.goToPayout(destination);
} else {
context.pushToPayout(destination);
}
}
Recipient? get selectedRecipient => recipientProvider.currentObject;
Wallet? get selectedWallet => walletsProvider.selectedWallet;
}

View File

@@ -1,6 +1,5 @@
import 'package:pshared/service/wallet.dart' as shared_wallet_service;
import 'package:pweb/models/currency.dart';
import 'package:pweb/models/wallet.dart';
import 'package:pweb/data/mappers/wallet_ui.dart';
@@ -10,27 +9,6 @@ abstract class WalletsService {
Future<double> getBalance(String organizationRef, String walletRef);
}
class MockWalletsService implements WalletsService {
final List<Wallet> _wallets = [
Wallet(id: '1124', walletUserID: 'WA-12345667', name: 'Main Wallet', balance: 10000000.0, currency: Currency.rub, calculatedAt: DateTime.now()),
Wallet(id: '2124', walletUserID: 'WA-76654321', name: 'Savings', balance: 2500.5, currency: Currency.usd, calculatedAt: DateTime.now()),
];
@override
Future<List<Wallet>> getWallets(String _) async {
return _wallets;
}
@override
Future<double> getBalance(String _, String walletRef) async {
final wallet = _wallets.firstWhere(
(w) => w.id == walletRef,
orElse: () => throw Exception('Wallet not found'),
);
return wallet.balance;
}
}
class ApiWalletsService implements WalletsService {
@override
Future<List<Wallet>> getWallets(String organizationRef) async {

View File

@@ -12,7 +12,8 @@ enum PayoutDestination {
methods(Icons.credit_card, 'methods'),
payment(Icons.payment, 'payout'),
addrecipient(Icons.app_registration, 'add recipient'),
editwallet(Icons.wallet, 'edit wallet');
editwallet(Icons.wallet, 'edit wallet'),
walletTopUp(Icons.qr_code_2_outlined, 'wallet top up');
const PayoutDestination(this.icon, this.labelKey);
@@ -41,6 +42,8 @@ enum PayoutDestination {
return loc.addRecipient;
case PayoutDestination.editwallet:
return loc.editWallet;
case PayoutDestination.walletTopUp:
return loc.walletTopUpTitle;
}
}
}

View File

@@ -2,31 +2,29 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'package:pshared/models/resources.dart';
import 'package:pshared/provider/permissions.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/pages/address_book/form/page.dart';
import 'package:pweb/pages/address_book/page/page.dart';
import 'package:pweb/pages/loader.dart';
import 'package:pweb/pages/payment_methods/page.dart';
import 'package:pweb/pages/payout_page/page.dart';
import 'package:pweb/pages/payout_page/wallet/edit/page.dart';
import 'package:pweb/pages/report/page.dart';
import 'package:pweb/pages/settings/profile/page.dart';
import 'package:pweb/pages/dashboard/dashboard.dart';
import 'package:pweb/providers/page_selector.dart';
import 'package:pweb/utils/logout.dart';
import 'package:pweb/widgets/appbar/app_bar.dart';
import 'package:pweb/widgets/error/snackbar.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/widgets/sidebar/sidebar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pweb/app/router/payout_routes.dart';
class PageSelector extends StatelessWidget {
const PageSelector({super.key});
final Widget child;
final GoRouterState routerState;
const PageSelector({
super.key,
required this.child,
required this.routerState,
});
@override
Widget build(BuildContext context) => PageViewLoader(
@@ -36,88 +34,29 @@ class PageSelector extends StatelessWidget {
final provider = context.watch<PageSelectorProvider>();
final loc = AppLocalizations.of(context)!;
final bool restrictedAccess = !permissions.canRead(ResourceType.chainWallets);
final allowedDestinations = restrictedAccess
? <PayoutDestination>{
PayoutDestination.settings,
PayoutDestination.methods,
PayoutDestination.editwallet,
PayoutDestination.walletTopUp,
}
: PayoutDestination.values.toSet();
final selected = allowedDestinations.contains(provider.selected)
? provider.selected
final routeDestination = _destinationFromState(routerState) ?? provider.selected;
final selected = allowedDestinations.contains(routeDestination)
? routeDestination
: (restrictedAccess ? PayoutDestination.settings : PayoutDestination.dashboard);
if (selected != provider.selected) {
WidgetsBinding.instance.addPostFrameCallback((_) => provider.selectPage(selected));
if (selected != routeDestination) {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.goToPayout(selected);
});
}
Widget content;
switch (selected) {
case PayoutDestination.dashboard:
content = DashboardPage(
onRecipientSelected: (recipient) => provider.selectRecipient(recipient),
onGoToPaymentWithoutRecipient: provider.startPaymentWithoutRecipient,
);
break;
case PayoutDestination.recipients:
content = RecipientAddressBookPage(
onRecipientSelected: (recipient) =>
provider.selectRecipient(recipient, fromList: true),
onAddRecipient: provider.goToAddRecipient,
onEditRecipient: provider.editRecipient,
onDeleteRecipient: (recipient) => executeActionWithNotification(
context: context,
action: () async => context.read<RecipientsProvider>().delete(recipient.id),
successMessage: loc.recipientDeletedSuccessfully,
errorMessage: loc.errorDeleteRecipient,
),
);
break;
case PayoutDestination.addrecipient:
final recipient = provider.recipientProvider.currentObject;
content = AdressBookRecipientForm(
recipient: recipient,
onSaved: (_) => provider.selectPage(PayoutDestination.recipients),
);
break;
case PayoutDestination.payment:
content = PaymentPage(
onBack: (_) => provider.goBackFromPayment(),
);
break;
case PayoutDestination.settings:
content = ProfileSettingsPage();
break;
case PayoutDestination.reports:
content = OperationHistoryPage();
break;
case PayoutDestination.methods:
content = PaymentConfigPage(
onWalletTap: provider.selectWallet,
);
break;
case PayoutDestination.editwallet:
final wallet = provider.walletsProvider.selectedWallet;
content = wallet != null
? WalletEditPage(
onBack: provider.goBackFromWalletEdit,
)
: Center(child: Text(loc.noWalletSelected));
break;
default:
content = Text(selected.name);
if (provider.selected != selected) {
provider.syncDestination(selected);
}
return Scaffold(
@@ -134,14 +73,49 @@ class PageSelector extends StatelessWidget {
children: [
PayoutSidebar(
selected: selected,
onSelected: provider.selectPage,
onSelected: context.goToPayout,
onLogout: () => logoutUtil(context),
),
Expanded(child: content),
Expanded(child: child),
],
),
),
);
},
));
PayoutDestination? _destinationFromState(GoRouterState state) {
final byName = PayoutRoutes.destinationFor(state.name);
if (byName != null) return byName;
final location = state.matchedLocation;
if (location.startsWith(PayoutRoutes.editWalletPath)) {
return PayoutDestination.editwallet;
}
if (location.startsWith(PayoutRoutes.walletTopUpPath)) {
return PayoutDestination.walletTopUp;
}
if (location.startsWith(PayoutRoutes.methodsPath)) {
return PayoutDestination.methods;
}
if (location.startsWith(PayoutRoutes.paymentPath)) {
return PayoutDestination.payment;
}
if (location.startsWith(PayoutRoutes.addRecipientPath)) {
return PayoutDestination.addrecipient;
}
if (location.startsWith(PayoutRoutes.recipientsPath)) {
return PayoutDestination.recipients;
}
if (location.startsWith(PayoutRoutes.settingsPath)) {
return PayoutDestination.settings;
}
if (location.startsWith(PayoutRoutes.reportsPath)) {
return PayoutDestination.reports;
}
if (location.startsWith(PayoutRoutes.dashboardPath)) {
return PayoutDestination.dashboard;
}
return null;
}
}

View File

@@ -67,6 +67,7 @@ dependencies:
syncfusion_flutter_charts: ^31.2.10
flutter_multi_formatter: ^2.13.7
dotted_border: ^3.1.0
qr_flutter: ^4.1.0
@@ -96,7 +97,7 @@ flutter:
# To add assets to your application, add an assets section, like this:
assets:
- resources/logo.png
- resources/icon.png
- resources/logo.si
# An image asset can refer to one or more resolution-specific "variants", see