Compare commits

1 Commits

Author SHA1 Message Date
Arseni
f44ef56ff3 WIP: integration with ledger 2026-02-04 02:01:22 +03:00
32 changed files with 1226 additions and 405 deletions

View File

@@ -0,0 +1,67 @@
import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart';
import 'package:pshared/models/payment/source.dart';
import 'package:pshared/provider/payment/source.dart';
class PaymentSourceController extends ChangeNotifier {
PaymentSourceProvider? _provider;
String? _selectedSourceKey;
List<PaymentSource> get sources => _provider?.sources ?? const [];
PaymentSource? get selectedSource {
final key = _selectedSourceKey;
if (key == null) return null;
return sources.firstWhereOrNull((source) => source.key == key);
}
void update(PaymentSourceProvider provider) {
_provider = provider;
final nextSources = provider.sources;
final nextSelectedKey = _resolveSelectedKey(
currentKey: _selectedSourceKey,
sources: nextSources,
);
if (nextSelectedKey == _selectedSourceKey) return;
_selectedSourceKey = nextSelectedKey;
notifyListeners();
}
void selectSource(PaymentSource source) {
if (_selectedSourceKey == source.key) return;
_selectedSourceKey = source.key;
notifyListeners();
}
void selectWalletByRef(String walletRef) {
final source = sources.firstWhereOrNull(
(s) => s.type == PaymentSourceType.wallet && s.id == walletRef,
);
if (source == null) return;
selectSource(source);
}
void selectLedgerByRef(String ledgerAccountRef) {
final source = sources.firstWhereOrNull(
(s) => s.type == PaymentSourceType.ledger && s.id == ledgerAccountRef,
);
if (source == null) return;
selectSource(source);
}
String? _resolveSelectedKey({
required String? currentKey,
required List<PaymentSource> sources,
}) {
if (sources.isEmpty) return null;
if (currentKey != null &&
sources.any((source) => source.key == currentKey)) {
return currentKey;
}
return sources.first.key;
}
}

View File

@@ -0,0 +1,31 @@
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/wallet.dart';
enum PaymentSourceType { wallet, ledger }
class PaymentSource {
final PaymentSourceType type;
final Wallet? wallet;
final LedgerAccount? ledgerAccount;
const PaymentSource._({required this.type, this.wallet, this.ledgerAccount});
const PaymentSource.wallet(Wallet wallet)
: this._(type: PaymentSourceType.wallet, wallet: wallet);
const PaymentSource.ledger(LedgerAccount account)
: this._(type: PaymentSourceType.ledger, ledgerAccount: account);
String get id => switch (type) {
PaymentSourceType.wallet => wallet!.id,
PaymentSourceType.ledger => ledgerAccount!.ledgerAccountRef,
};
String get key => '${type.name}:$id';
String get name => switch (type) {
PaymentSourceType.wallet => wallet!.name,
PaymentSourceType.ledger => ledgerAccount!.name,
};
}

View File

@@ -1,4 +1,3 @@
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/models/payment/asset.dart'; import 'package:pshared/models/payment/asset.dart';
import 'package:pshared/models/payment/chain_network.dart'; import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/models/payment/currency_pair.dart'; import 'package:pshared/models/payment/currency_pair.dart';
@@ -9,66 +8,109 @@ import 'package:pshared/models/payment/kind.dart';
import 'package:pshared/models/payment/methods/card.dart'; import 'package:pshared/models/payment/methods/card.dart';
import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/iban.dart'; import 'package:pshared/models/payment/methods/iban.dart';
import 'package:pshared/models/payment/methods/ledger.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart'; import 'package:pshared/models/payment/methods/managed_wallet.dart';
import 'package:pshared/models/payment/methods/russian_bank.dart'; import 'package:pshared/models/payment/methods/russian_bank.dart';
import 'package:pshared/models/payment/source.dart';
import 'package:pshared/models/payment/methods/type.dart'; import 'package:pshared/models/payment/methods/type.dart';
import 'package:pshared/models/money.dart'; import 'package:pshared/models/money.dart';
import 'package:pshared/models/payment/settlement_mode.dart'; import 'package:pshared/models/payment/settlement_mode.dart';
import 'package:pshared/models/payment/intent.dart'; import 'package:pshared/models/payment/intent.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/currency.dart';
class QuotationIntentBuilder { class QuotationIntentBuilder {
PaymentIntent? build({ PaymentIntent? build({
required PaymentAmountProvider payment, required PaymentAmountProvider payment,
required WalletsController wallets, required PaymentSourceController sources,
required PaymentFlowProvider flow, required PaymentFlowProvider flow,
required RecipientsProvider recipients, required RecipientsProvider recipients,
}) { }) {
final selectedWallet = wallets.selectedWallet; final selectedSource = sources.selectedSource;
final paymentData = flow.selectedPaymentData; final paymentData = flow.selectedPaymentData;
final selectedMethod = flow.selectedMethod; final selectedMethod = flow.selectedMethod;
if (selectedWallet == null || paymentData == null) return null; if (selectedSource == null || paymentData == null) return null;
final customer = _buildCustomer( final customer = _buildCustomer(
recipient: recipients.currentObject, recipient: recipients.currentObject,
method: selectedMethod, method: selectedMethod,
data: paymentData, data: paymentData,
); );
final sourceCurrency = _resolveSourceCurrency(selectedSource);
if (sourceCurrency == null) return null;
final targetCurrency = _resolveTargetCurrency(paymentData);
final fxIntent = _buildFxIntent(
baseCurrency: sourceCurrency,
quoteCurrency: targetCurrency,
);
final amount = Money( final amount = Money(
amount: payment.amount.toString(), amount: payment.amount.toString(),
// TODO: adapt to possible other sources currency: sourceCurrency,
currency: currencyCodeToString(selectedWallet.currency),
);
final fxIntent = FxIntent(
pair: CurrencyPair(
base: currencyCodeToString(selectedWallet.currency),
quote: 'RUB', // TODO: exentd target currencies
),
side: FxSide.sellBaseBuyQuote,
); );
return PaymentIntent( return PaymentIntent(
kind: PaymentKind.payout, kind: PaymentKind.payout,
amount: amount, amount: amount,
destination: paymentData, destination: paymentData,
source: ManagedWalletPaymentMethod( source: _buildSourceEndpoint(selectedSource),
managedWalletRef: selectedWallet.id,
asset: PaymentAsset(
tokenSymbol: selectedWallet.tokenSymbol ?? '',
chain: selectedWallet.network ?? ChainNetwork.unspecified,
)
),
fx: fxIntent, fx: fxIntent,
settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource, settlementMode: payment.payerCoversFee
settlementCurrency: _resolveSettlementCurrency(amount: amount, fx: fxIntent), ? SettlementMode.fixReceived
: SettlementMode.fixSource,
settlementCurrency: _resolveSettlementCurrency(
amount: amount,
fx: fxIntent,
),
customer: customer, customer: customer,
); );
} }
String _resolveTargetCurrency(PaymentMethodData destination) {
// Current payout flow is RUB-settlement oriented.
// Avoid requesting unsupported self-pairs (e.g. RUB/RUB).
return 'RUB';
}
FxIntent? _buildFxIntent({
required String baseCurrency,
required String quoteCurrency,
}) {
final base = baseCurrency.trim().toUpperCase();
final quote = quoteCurrency.trim().toUpperCase();
if (base.isEmpty || quote.isEmpty || base == quote) {
return null;
}
return FxIntent(
pair: CurrencyPair(base: base, quote: quote),
side: FxSide.sellBaseBuyQuote,
);
}
String? _resolveSourceCurrency(PaymentSource source) {
return switch (source.type) {
PaymentSourceType.wallet => currencyCodeToString(source.wallet!.currency),
PaymentSourceType.ledger => source.ledgerAccount?.currency,
};
}
PaymentMethodData _buildSourceEndpoint(PaymentSource source) {
return switch (source.type) {
PaymentSourceType.wallet => ManagedWalletPaymentMethod(
managedWalletRef: source.wallet!.id,
asset: PaymentAsset(
tokenSymbol: source.wallet?.tokenSymbol ?? '',
chain: source.wallet?.network ?? ChainNetwork.unspecified,
),
),
PaymentSourceType.ledger => LedgerPaymentMethod(
ledgerAccountRef: source.ledgerAccount!.ledgerAccountRef,
),
};
}
String _resolveSettlementCurrency({ String _resolveSettlementCurrency({
required Money amount, required Money amount,
required FxIntent? fx, required FxIntent? fx,
@@ -85,8 +127,12 @@ class QuotationIntentBuilder {
case FxSide.unspecified: case FxSide.unspecified:
break; break;
} }
if (amount.currency == pair.base && pair.quote.isNotEmpty) return pair.quote; if (amount.currency == pair.base && pair.quote.isNotEmpty) {
if (amount.currency == pair.quote && pair.base.isNotEmpty) return pair.base; return pair.quote;
}
if (amount.currency == pair.quote && pair.base.isNotEmpty) {
return pair.base;
}
if (pair.quote.isNotEmpty) return pair.quote; if (pair.quote.isNotEmpty) return pair.quote;
if (pair.base.isNotEmpty) return pair.base; if (pair.base.isNotEmpty) return pair.base;
} }
@@ -111,8 +157,9 @@ class QuotationIntentBuilder {
: name.trim().split(RegExp(r'\s+')); : name.trim().split(RegExp(r'\s+'));
final firstName = parts.isNotEmpty ? parts.first : null; final firstName = parts.isNotEmpty ? parts.first : null;
final lastName = parts.length >= 2 ? parts.last : null; final lastName = parts.length >= 2 ? parts.last : null;
final middleName = final middleName = parts.length > 2
parts.length > 2 ? parts.sublist(1, parts.length - 1).join(' ') : null; ? parts.sublist(1, parts.length - 1).join(' ')
: null;
return Customer( return Customer(
id: id, id: id,
@@ -139,7 +186,9 @@ class QuotationIntentBuilder {
return iban.accountHolder.trim(); return iban.accountHolder.trim();
} }
final bank = method?.bankAccountData ?? (data is RussianBankAccountPaymentMethod ? data : null); final bank =
method?.bankAccountData ??
(data is RussianBankAccountPaymentMethod ? data : null);
if (bank != null && bank.recipientName.trim().isNotEmpty) { if (bank != null && bank.recipientName.trim().isNotEmpty) {
return bank.recipientName.trim(); return bank.recipientName.trim();
} }

View File

@@ -7,7 +7,7 @@ import 'package:logging/logging.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:pshared/api/requests/payment/quote.dart'; import 'package:pshared/api/requests/payment/quote.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/data/mapper/payment/intent/payment.dart'; import 'package:pshared/data/mapper/payment/intent/payment.dart';
import 'package:pshared/models/asset.dart'; import 'package:pshared/models/asset.dart';
import 'package:pshared/models/payment/intent.dart'; import 'package:pshared/models/payment/intent.dart';
@@ -23,19 +23,22 @@ import 'package:pshared/provider/payment/quotation/intent_builder.dart';
import 'package:pshared/service/payment/quotation.dart'; import 'package:pshared/service/payment/quotation.dart';
import 'package:pshared/utils/exception.dart'; import 'package:pshared/utils/exception.dart';
class QuotationProvider extends ChangeNotifier { class QuotationProvider extends ChangeNotifier {
static final _logger = Logger('provider.payment.quotation'); static final _logger = Logger('provider.payment.quotation');
Resource<PaymentQuote> _quotation = Resource(data: null, isLoading: false, error: null); Resource<PaymentQuote> _quotation = Resource(
data: null,
isLoading: false,
error: null,
);
late OrganizationsProvider _organizations; late OrganizationsProvider _organizations;
bool _isLoaded = false; bool _isLoaded = false;
PaymentIntent? _lastIntent; PaymentIntent? _lastIntent;
final QuotationIntentBuilder _intentBuilder = QuotationIntentBuilder(); final QuotationIntentBuilder _intentBuilder = QuotationIntentBuilder();
void update( void update(
OrganizationsProvider venue, OrganizationsProvider venue,
PaymentAmountProvider payment, PaymentAmountProvider payment,
WalletsController wallets, PaymentSourceController sources,
PaymentFlowProvider flow, PaymentFlowProvider flow,
RecipientsProvider recipients, RecipientsProvider recipients,
PaymentMethodsProvider _, PaymentMethodsProvider _,
@@ -43,7 +46,7 @@ class QuotationProvider extends ChangeNotifier {
_organizations = venue; _organizations = venue;
final intent = _intentBuilder.build( final intent = _intentBuilder.build(
payment: payment, payment: payment,
wallets: wallets, sources: sources,
flow: flow, flow: flow,
recipients: recipients, recipients: recipients,
); );
@@ -58,7 +61,8 @@ class QuotationProvider extends ChangeNotifier {
bool get isLoading => _quotation.isLoading; bool get isLoading => _quotation.isLoading;
Exception? get error => _quotation.error; Exception? get error => _quotation.error;
bool get canRefresh => _lastIntent != null; bool get canRefresh => _lastIntent != null;
bool get isReady => _isLoaded && !_quotation.isLoading && _quotation.error == null; bool get isReady =>
_isLoaded && !_quotation.isLoading && _quotation.error == null;
DateTime? get quoteExpiresAt { DateTime? get quoteExpiresAt {
final expiresAtUnixMs = quotation?.fxQuote?.expiresAtUnixMs; final expiresAtUnixMs = quotation?.fxQuote?.expiresAtUnixMs;
@@ -66,10 +70,10 @@ class QuotationProvider extends ChangeNotifier {
return DateTime.fromMillisecondsSinceEpoch(expiresAtUnixMs, isUtc: true); return DateTime.fromMillisecondsSinceEpoch(expiresAtUnixMs, isUtc: true);
} }
Asset? get fee => _assetFromMoney(quotation?.expectedFeeTotal); Asset? get fee => _assetFromMoney(quotation?.expectedFeeTotal);
Asset? get total => _assetFromMoney(quotation?.debitAmount); Asset? get total => _assetFromMoney(quotation?.debitAmount);
Asset? get recipientGets => _assetFromMoney(quotation?.expectedSettlementAmount); Asset? get recipientGets =>
_assetFromMoney(quotation?.expectedSettlementAmount);
Asset? _assetFromMoney(Money? money) { Asset? _assetFromMoney(Money? money) {
if (money == null) return null; if (money == null) return null;
@@ -88,26 +92,32 @@ class QuotationProvider extends ChangeNotifier {
} }
Future<PaymentQuote?> getQuotation(PaymentIntent intent) async { Future<PaymentQuote?> getQuotation(PaymentIntent intent) async {
if (!_organizations.isOrganizationSet) throw StateError('Organization is not set'); if (!_organizations.isOrganizationSet) {
throw StateError('Organization is not set');
}
_lastIntent = intent; _lastIntent = intent;
try { try {
_setResource(_quotation.copyWith(isLoading: true, error: null)); _setResource(_quotation.copyWith(isLoading: true, error: null));
final response = await QuotationService.getQuotation( final response = await QuotationService.getQuotation(
_organizations.current.id, _organizations.current.id,
QuotePaymentRequest( QuotePaymentRequest(
idempotencyKey: Uuid().v4(), idempotencyKey: Uuid().v4(),
intent: intent.toDTO(), intent: intent.toDTO(),
), ),
); );
_isLoaded = true; _isLoaded = true;
_setResource(_quotation.copyWith(data: response, isLoading: false, error: null)); _setResource(
_quotation.copyWith(data: response, isLoading: false, error: null),
);
} catch (e, st) { } catch (e, st) {
_logger.warning('Failed to get quotation', e, st); _logger.warning('Failed to get quotation', e, st);
_setResource(_quotation.copyWith( _setResource(
data: null, _quotation.copyWith(
error: toException(e), data: null,
isLoading: false, error: toException(e),
)); isLoading: false,
),
);
} }
return _quotation.data; return _quotation.data;
} }

View File

@@ -0,0 +1,33 @@
import 'package:flutter/foundation.dart';
import 'package:pshared/models/payment/source.dart';
import 'package:pshared/provider/ledger.dart';
import 'package:pshared/provider/payment/wallets.dart';
class PaymentSourceProvider extends ChangeNotifier {
List<PaymentSource> _sources = const [];
List<PaymentSource> get sources => _sources;
void update(
WalletsProvider walletsProvider,
LedgerAccountsProvider ledgerProvider,
) {
final nextSources = <PaymentSource>[
...walletsProvider.wallets.map(PaymentSource.wallet),
...ledgerProvider.accounts.map(PaymentSource.ledger),
];
final currentKeys = _sources
.map((source) => source.key)
.toList(growable: false);
final nextKeys = nextSources
.map((source) => source.key)
.toList(growable: false);
if (listEquals(currentKeys, nextKeys)) return;
_sources = nextSources;
notifyListeners();
}
}

View File

@@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/models/payment/source.dart';
import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/organizations.dart'; import 'package:pshared/provider/organizations.dart';
@@ -26,6 +28,7 @@ import 'package:pweb/pages/dashboard/dashboard.dart';
import 'package:pweb/pages/invitations/page.dart'; import 'package:pweb/pages/invitations/page.dart';
import 'package:pweb/pages/payment_methods/page.dart'; import 'package:pweb/pages/payment_methods/page.dart';
import 'package:pweb/pages/payout_page/page.dart'; import 'package:pweb/pages/payout_page/page.dart';
import 'package:pweb/pages/payout_page/wallet/edit/ledger_page.dart';
import 'package:pweb/pages/payout_page/wallet/edit/page.dart'; import 'package:pweb/pages/payout_page/wallet/edit/page.dart';
import 'package:pweb/pages/report/page.dart'; import 'package:pweb/pages/report/page.dart';
import 'package:pweb/pages/settings/profile/page.dart'; import 'package:pweb/pages/settings/profile/page.dart';
@@ -42,45 +45,80 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
RouteBase payoutShellRoute() => ShellRoute( RouteBase payoutShellRoute() => ShellRoute(
builder: (context, state, child) => MultiProvider( builder: (context, state, child) => MultiProvider(
providers: [ providers: [
ChangeNotifierProxyProvider2<OrganizationsProvider, RecipientsProvider, PaymentMethodsProvider>( ChangeNotifierProxyProvider2<
OrganizationsProvider,
RecipientsProvider,
PaymentMethodsProvider
>(
create: (_) => PaymentMethodsProvider(), create: (_) => PaymentMethodsProvider(),
update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients), update: (context, organizations, recipients, provider) =>
provider!..updateProviders(organizations, recipients),
), ),
ChangeNotifierProxyProvider2<OrganizationsProvider, RecipientsProvider, RecipientMethodsCacheProvider>( ChangeNotifierProxyProvider2<
OrganizationsProvider,
RecipientsProvider,
RecipientMethodsCacheProvider
>(
create: (_) => RecipientMethodsCacheProvider(), create: (_) => RecipientMethodsCacheProvider(),
update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients), update: (context, organizations, recipients, provider) =>
provider!..updateProviders(organizations, recipients),
), ),
ChangeNotifierProxyProvider2<RecipientsProvider, PaymentMethodsProvider, PaymentFlowProvider>( ChangeNotifierProxyProvider2<
create: (_) => PaymentFlowProvider(initialType: enabledPaymentTypes.first), RecipientsProvider,
update: (context, recipients, methods, provider) => provider!..update( PaymentMethodsProvider,
PaymentFlowProvider
>(
create: (_) =>
PaymentFlowProvider(initialType: enabledPaymentTypes.first),
update: (context, recipients, methods, provider) =>
provider!..update(recipients, methods),
),
ChangeNotifierProvider(create: (_) => PaymentAmountProvider()),
ChangeNotifierProxyProvider6<
OrganizationsProvider,
PaymentAmountProvider,
PaymentSourceController,
PaymentFlowProvider,
RecipientsProvider,
PaymentMethodsProvider,
QuotationProvider
>(
create: (_) => QuotationProvider(),
update:
(
_,
organization,
payment,
sources,
flow,
recipients,
methods,
provider,
) => provider!
..update(
organization,
payment,
sources,
flow,
recipients, recipients,
methods, methods,
), ),
), ),
ChangeNotifierProvider(
create: (_) => PaymentAmountProvider(),
),
ChangeNotifierProxyProvider6<OrganizationsProvider, PaymentAmountProvider, WalletsController, PaymentFlowProvider, RecipientsProvider, PaymentMethodsProvider, QuotationProvider>(
create: (_) => QuotationProvider(),
update: (_, organization, payment, wallet, flow, recipients, methods, provider) =>
provider!..update(organization, payment, wallet, flow, recipients, methods),
),
ChangeNotifierProxyProvider<QuotationProvider, QuotationController>( ChangeNotifierProxyProvider<QuotationProvider, QuotationController>(
create: (_) => QuotationController(), create: (_) => QuotationController(),
update: (_, quotation, controller) => controller!..update(quotation), update: (_, quotation, controller) => controller!..update(quotation),
), ),
ChangeNotifierProxyProvider2<OrganizationsProvider, QuotationProvider, PaymentProvider>( ChangeNotifierProxyProvider2<
OrganizationsProvider,
QuotationProvider,
PaymentProvider
>(
create: (_) => PaymentProvider(), create: (_) => PaymentProvider(),
update: (context, organization, quotation, provider) => provider!..update( update: (context, organization, quotation, provider) =>
organization, provider!..update(organization, quotation),
quotation,
),
), ),
], ],
child: PageSelector( child: PageSelector(child: child, routerState: state),
child: child,
routerState: state,
),
), ),
routes: [ routes: [
GoRoute( GoRoute(
@@ -206,6 +244,11 @@ RouteBase payoutShellRoute() => ShellRoute(
wallet, wallet,
returnTo: PayoutDestination.methods, returnTo: PayoutDestination.methods,
), ),
onLedgerTap: (ledgerAccountRef) => _openLedgerEdit(
context,
ledgerAccountRef,
returnTo: PayoutDestination.methods,
),
), ),
), ),
), ),
@@ -213,8 +256,7 @@ RouteBase payoutShellRoute() => ShellRoute(
name: PayoutRoutes.editWallet, name: PayoutRoutes.editWallet,
path: PayoutRoutes.editWalletPath, path: PayoutRoutes.editWalletPath,
pageBuilder: (context, state) { pageBuilder: (context, state) {
final walletsProvider = context.read<WalletsController>(); final source = context.read<PaymentSourceController>().selectedSource;
final wallet = walletsProvider.selectedWallet;
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
final fallbackDestination = PayoutRoutes.fallbackFromState( final fallbackDestination = PayoutRoutes.fallbackFromState(
state, state,
@@ -222,11 +264,15 @@ RouteBase payoutShellRoute() => ShellRoute(
); );
return NoTransitionPage( return NoTransitionPage(
child: wallet != null child: switch (source?.type) {
? WalletEditPage( PaymentSourceType.wallet => WalletEditPage(
onBack: () => _popOrGo(context, fallbackDestination), onBack: () => _popOrGo(context, fallbackDestination),
) ),
: Center(child: Text(loc.noWalletSelected)), PaymentSourceType.ledger => LedgerEditPage(
onBack: () => _popOrGo(context, fallbackDestination),
),
null => Center(child: Text(loc.noWalletSelected)),
},
); );
}, },
), ),
@@ -256,16 +302,10 @@ void _startPayment(
required PayoutDestination returnTo, required PayoutDestination returnTo,
}) { }) {
context.read<RecipientsProvider>().setCurrentObject(recipient?.id); context.read<RecipientsProvider>().setCurrentObject(recipient?.id);
context.pushToPayment( context.pushToPayment(paymentType: paymentType, returnTo: returnTo);
paymentType: paymentType,
returnTo: returnTo,
);
} }
void _openAddRecipient( void _openAddRecipient(BuildContext context, {Recipient? recipient}) {
BuildContext context, {
Recipient? recipient,
}) {
context.read<RecipientsProvider>().setCurrentObject(recipient?.id); context.read<RecipientsProvider>().setCurrentObject(recipient?.id);
context.pushNamed(PayoutRoutes.addRecipient); context.pushNamed(PayoutRoutes.addRecipient);
} }
@@ -276,6 +316,16 @@ void _openWalletEdit(
required PayoutDestination returnTo, required PayoutDestination returnTo,
}) { }) {
context.read<WalletsController>().selectWallet(wallet); context.read<WalletsController>().selectWallet(wallet);
context.read<PaymentSourceController>().selectWalletByRef(wallet.id);
context.pushToEditWallet(returnTo: returnTo);
}
void _openLedgerEdit(
BuildContext context,
String ledgerAccountRef, {
required PayoutDestination returnTo,
}) {
context.read<PaymentSourceController>().selectLedgerByRef(ledgerAccountRef);
context.pushToEditWallet(returnTo: returnTo); context.pushToEditWallet(returnTo: returnTo);
} }
@@ -285,6 +335,7 @@ void _openWalletTopUp(
required PayoutDestination returnTo, required PayoutDestination returnTo,
}) { }) {
context.read<WalletsController>().selectWallet(wallet); context.read<WalletsController>().selectWallet(wallet);
context.read<PaymentSourceController>().selectWalletByRef(wallet.id);
context.pushToWalletTopUp(returnTo: returnTo); context.pushToWalletTopUp(returnTo: returnTo);
} }

View File

@@ -10,6 +10,7 @@ import 'package:logging/logging.dart';
import 'package:pshared/config/constants.dart'; import 'package:pshared/config/constants.dart';
import 'package:pshared/controllers/balance_mask/ledger_accounts.dart'; import 'package:pshared/controllers/balance_mask/ledger_accounts.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/provider/locale.dart'; import 'package:pshared/provider/locale.dart';
import 'package:pshared/provider/permissions.dart'; import 'package:pshared/provider/permissions.dart';
import 'package:pshared/provider/account.dart'; import 'package:pshared/provider/account.dart';
@@ -19,6 +20,7 @@ import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/email_verification.dart'; import 'package:pshared/provider/email_verification.dart';
import 'package:pshared/provider/ledger.dart'; import 'package:pshared/provider/ledger.dart';
import 'package:pshared/provider/payment/source.dart';
import 'package:pshared/provider/payment/wallets.dart'; import 'package:pshared/provider/payment/wallets.dart';
import 'package:pshared/provider/payment/payments.dart'; import 'package:pshared/provider/payment/payments.dart';
import 'package:pshared/provider/invitations.dart'; import 'package:pshared/provider/invitations.dart';
@@ -28,20 +30,23 @@ import 'package:pshared/service/payment/wallets.dart';
import 'package:pweb/app/app.dart'; import 'package:pweb/app/app.dart';
import 'package:pweb/pages/invitations/widgets/list/view_model.dart'; import 'package:pweb/pages/invitations/widgets/list/view_model.dart';
import 'package:pweb/app/timeago.dart'; import 'package:pweb/app/timeago.dart';
import 'package:pweb/providers/operatioins.dart';
import 'package:pweb/providers/two_factor.dart'; import 'package:pweb/providers/two_factor.dart';
import 'package:pweb/providers/upload_history.dart'; import 'package:pweb/providers/upload_history.dart';
import 'package:pweb/providers/wallet_transactions.dart'; import 'package:pweb/providers/wallet_transactions.dart';
import 'package:pweb/services/operations.dart';
import 'package:pweb/services/payments/history.dart'; import 'package:pweb/services/payments/history.dart';
import 'package:pweb/services/posthog.dart'; import 'package:pweb/services/posthog.dart';
import 'package:pweb/services/wallet_transactions.dart'; import 'package:pweb/services/wallet_transactions.dart';
import 'package:pweb/providers/account.dart'; import 'package:pweb/providers/account.dart';
void _setupLogging() { void _setupLogging() {
Logger.root.level = Level.ALL; Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) { Logger.root.onRecord.listen((record) {
// ignore: avoid_print // ignore: avoid_print
print('${record.level.name}: ${record.time}: ${record.loggerName}: ${record.message}'); print(
'${record.level.name}: ${record.time}: ${record.loggerName}: ${record.message}',
);
}); });
} }
@@ -50,7 +55,6 @@ void main() async {
await Constants.initialize(); await Constants.initialize();
await PosthogService.initialize(); await PosthogService.initialize();
_setupLogging(); _setupLogging();
setUrlStrategy(PathUrlStrategy()); setUrlStrategy(PathUrlStrategy());
@@ -62,54 +66,74 @@ void main() async {
ChangeNotifierProvider(create: (_) => LocaleProvider(null)), ChangeNotifierProvider(create: (_) => LocaleProvider(null)),
ChangeNotifierProxyProvider<LocaleProvider, AccountProvider>( ChangeNotifierProxyProvider<LocaleProvider, AccountProvider>(
create: (_) => PwebAccountProvider(), create: (_) => PwebAccountProvider(),
update: (context, localeProvider, provider) => provider!..updateProvider(localeProvider), update: (context, localeProvider, provider) =>
provider!..updateProvider(localeProvider),
), ),
ChangeNotifierProxyProvider<AccountProvider, TwoFactorProvider>( ChangeNotifierProxyProvider<AccountProvider, TwoFactorProvider>(
create: (_) => TwoFactorProvider(), create: (_) => TwoFactorProvider(),
update: (context, accountProvider, provider) => provider!..update(accountProvider), update: (context, accountProvider, provider) =>
provider!..update(accountProvider),
), ),
ChangeNotifierProvider(create: (_) => OrganizationsProvider()), ChangeNotifierProvider(create: (_) => OrganizationsProvider()),
ChangeNotifierProxyProvider<OrganizationsProvider, PermissionsProvider>( ChangeNotifierProxyProvider<OrganizationsProvider, PermissionsProvider>(
create: (_) => PermissionsProvider(), create: (_) => PermissionsProvider(),
update: (context, orgnization, provider) => provider!..update(orgnization), update: (context, orgnization, provider) =>
provider!..update(orgnization),
), ),
ChangeNotifierProxyProvider<OrganizationsProvider, EmployeesProvider>( ChangeNotifierProxyProvider<OrganizationsProvider, EmployeesProvider>(
create: (_) => EmployeesProvider(), create: (_) => EmployeesProvider(),
update: (context, organizations, provider) => provider!..updateProviders(organizations), update: (context, organizations, provider) =>
provider!..updateProviders(organizations),
), ),
ChangeNotifierProxyProvider<OrganizationsProvider, PaymentsProvider>( ChangeNotifierProxyProvider<OrganizationsProvider, PaymentsProvider>(
create: (_) => PaymentsProvider(), create: (_) => PaymentsProvider(),
update: (context, organizations, provider) => provider!..update(organizations), update: (context, organizations, provider) =>
provider!..update(organizations),
), ),
ChangeNotifierProvider(create: (_) => EmailVerificationProvider()), ChangeNotifierProvider(create: (_) => EmailVerificationProvider()),
ChangeNotifierProvider( ChangeNotifierProvider(
create: (_) => UploadHistoryProvider(service: MockUploadHistoryService())..load(), create: (_) =>
UploadHistoryProvider(service: MockUploadHistoryService())
..load(),
), ),
ChangeNotifierProxyProvider<OrganizationsProvider, RecipientsProvider>( ChangeNotifierProxyProvider<OrganizationsProvider, RecipientsProvider>(
create: (_) => RecipientsProvider(), create: (_) => RecipientsProvider(),
update: (context, organizations, provider) => provider!..updateProviders(organizations), update: (context, organizations, provider) =>
provider!..updateProviders(organizations),
), ),
ChangeNotifierProxyProvider<OrganizationsProvider, InvitationsProvider>( ChangeNotifierProxyProvider<OrganizationsProvider, InvitationsProvider>(
create: (_) => InvitationsProvider(), create: (_) => InvitationsProvider(),
update: (context, organizations, provider) => provider!..updateProviders(organizations), update: (context, organizations, provider) =>
provider!..updateProviders(organizations),
), ),
ChangeNotifierProxyProvider2<OrganizationsProvider, RecipientsProvider, PaymentMethodsProvider>( ChangeNotifierProxyProvider2<
create: (_) => PaymentMethodsProvider(), OrganizationsProvider,
update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients), RecipientsProvider,
), PaymentMethodsProvider
ChangeNotifierProvider( >(
create: (_) => InvitationListViewModel(), create: (_) => PaymentMethodsProvider(),
update: (context, organizations, recipients, provider) =>
provider!..updateProviders(organizations, recipients),
), ),
ChangeNotifierProvider(create: (_) => InvitationListViewModel()),
ChangeNotifierProxyProvider<OrganizationsProvider, WalletsProvider>( ChangeNotifierProxyProvider<OrganizationsProvider, WalletsProvider>(
create: (_) => WalletsProvider(ApiWalletsService()), create: (_) => WalletsProvider(ApiWalletsService()),
update: (context, organizations, provider) => provider!..update(organizations), update: (context, organizations, provider) =>
provider!..update(organizations),
), ),
ChangeNotifierProxyProvider<OrganizationsProvider, LedgerAccountsProvider>( ChangeNotifierProxyProvider<
OrganizationsProvider,
LedgerAccountsProvider
>(
create: (_) => LedgerAccountsProvider(LedgerService()), create: (_) => LedgerAccountsProvider(LedgerService()),
update: (context, organizations, provider) => provider!..update(organizations), update: (context, organizations, provider) =>
provider!..update(organizations),
), ),
ChangeNotifierProxyProvider<LedgerAccountsProvider, LedgerBalanceMaskController>( ChangeNotifierProxyProvider<
LedgerAccountsProvider,
LedgerBalanceMaskController
>(
create: (_) => LedgerBalanceMaskController(), create: (_) => LedgerBalanceMaskController(),
update: (context, ledger, controller) => controller!..update(ledger), update: (context, ledger, controller) => controller!..update(ledger),
), ),
@@ -117,12 +141,33 @@ void main() async {
create: (_) => WalletsController(), create: (_) => WalletsController(),
update: (_, wallets, controller) => controller!..update(wallets), update: (_, wallets, controller) => controller!..update(wallets),
), ),
ChangeNotifierProxyProvider2<
WalletsProvider,
LedgerAccountsProvider,
PaymentSourceProvider
>(
create: (_) => PaymentSourceProvider(),
update: (_, wallets, ledger, provider) =>
provider!..update(wallets, ledger),
),
ChangeNotifierProxyProvider<
PaymentSourceProvider,
PaymentSourceController
>(
create: (_) => PaymentSourceController(),
update: (_, sources, controller) => controller!..update(sources),
),
ChangeNotifierProvider( ChangeNotifierProvider(
create: (_) => WalletTransactionsProvider(MockWalletTransactionsService())..load(), create: (_) =>
WalletTransactionsProvider(MockWalletTransactionsService())
..load(),
),
ChangeNotifierProvider(
create: (_) =>
OperationProvider(OperationService())..loadOperations(),
), ),
], ],
child: const PayApp(), child: const PayApp(),
), ),
); );
} }

View File

@@ -16,10 +16,92 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class BalanceWidget extends StatelessWidget { class BalanceWidget extends StatelessWidget {
final ValueChanged<Wallet> onTopUp; final ValueChanged<Wallet> onTopUp;
const BalanceWidget({ const BalanceWidget({super.key, required this.onTopUp});
super.key,
required this.onTopUp, @override
}); Widget build(BuildContext context) => _BalanceWidgetBody(onTopUp: onTopUp);
}
class _BalanceWidgetBody extends StatefulWidget {
final ValueChanged<Wallet> onTopUp;
const _BalanceWidgetBody({required this.onTopUp});
@override
State<_BalanceWidgetBody> createState() => _BalanceWidgetBodyState();
}
class _BalanceWidgetBodyState extends State<_BalanceWidgetBody> {
WalletsController? _walletsController;
LedgerAccountsProvider? _ledgerProvider;
CarouselIndexController? _carouselController;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final nextWallets = context.read<WalletsController>();
final nextLedger = context.read<LedgerAccountsProvider>();
final nextCarousel = context.read<CarouselIndexController>();
if (!identical(_walletsController, nextWallets)) {
_walletsController?.removeListener(_syncSelection);
_walletsController = nextWallets;
_walletsController?.addListener(_syncSelection);
}
if (!identical(_ledgerProvider, nextLedger)) {
_ledgerProvider?.removeListener(_syncSelection);
_ledgerProvider = nextLedger;
_ledgerProvider?.addListener(_syncSelection);
}
if (!identical(_carouselController, nextCarousel)) {
_carouselController?.removeListener(_syncSelection);
_carouselController = nextCarousel;
_carouselController?.addListener(_syncSelection);
}
WidgetsBinding.instance.addPostFrameCallback((_) => _syncSelection());
}
@override
void dispose() {
_walletsController?.removeListener(_syncSelection);
_ledgerProvider?.removeListener(_syncSelection);
_carouselController?.removeListener(_syncSelection);
super.dispose();
}
void _syncSelection() {
final walletsController = _walletsController;
final carousel = _carouselController;
final ledgerProvider = _ledgerProvider;
if (walletsController == null ||
carousel == null ||
ledgerProvider == null) {
return;
}
final items = <BalanceItem>[
...walletsController.wallets.map(BalanceItem.wallet),
...ledgerProvider.accounts.map(BalanceItem.ledger),
const BalanceItem.addAction(),
];
if (items.isEmpty) return;
final safeIndex = carousel.index.clamp(0, items.length - 1);
if (safeIndex != carousel.index) {
carousel.setIndex(safeIndex, items.length);
return;
}
final current = items[safeIndex];
if (!current.isWallet) return;
final wallet = current.wallet!;
if (walletsController.selectedWallet?.id != wallet.id) {
walletsController.selectWallet(wallet);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -30,7 +112,8 @@ class BalanceWidget extends StatelessWidget {
final wallets = walletsController.wallets; final wallets = walletsController.wallets;
final accounts = ledgerProvider.accounts; final accounts = ledgerProvider.accounts;
final isLoading = walletsController.isLoading && final isLoading =
walletsController.isLoading &&
ledgerProvider.isLoading && ledgerProvider.isLoading &&
wallets.isEmpty && wallets.isEmpty &&
accounts.isEmpty; accounts.isEmpty;
@@ -49,19 +132,7 @@ class BalanceWidget extends StatelessWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
// Ensure index is always valid when list changes final index = carousel.index.clamp(0, items.length - 1);
carousel.setIndex(carousel.index, items.length);
final index = carousel.index;
final current = items[index];
// Single source of truth: controller
if (current.isWallet) {
final wallet = current.wallet!;
if (walletsController.selectedWallet?.id != wallet.id) {
walletsController.selectWallet(wallet);
}
}
final carouselWidget = BalanceCarousel( final carouselWidget = BalanceCarousel(
items: items, items: items,
@@ -73,7 +144,7 @@ class BalanceWidget extends StatelessWidget {
walletsController.selectWallet(next.wallet!); walletsController.selectWallet(next.wallet!);
} }
}, },
onTopUp: onTopUp, onTopUp: widget.onTopUp,
); );
if (wallets.isEmpty && accounts.isEmpty) { if (wallets.isEmpty && accounts.isEmpty) {

View File

@@ -2,12 +2,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/models/payment/source.dart';
import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/currency.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentAmountWidget extends StatefulWidget { class PaymentAmountWidget extends StatefulWidget {
const PaymentAmountWidget({super.key}); const PaymentAmountWidget({super.key});
@@ -32,7 +33,8 @@ class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
super.dispose(); super.dispose();
} }
double? _parseAmount(String value) => double.tryParse(value.replaceAll(',', '.')); double? _parseAmount(String value) =>
double.tryParse(value.replaceAll(',', '.'));
void _syncTextWithAmount(double amount) { void _syncTextWithAmount(double amount) {
final parsedText = _parseAmount(_controller.text); final parsedText = _parseAmount(_controller.text);
@@ -58,14 +60,28 @@ class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final amount = context.select<PaymentAmountProvider, double>((provider) => provider.amount); final amount = context.select<PaymentAmountProvider, double>(
(provider) => provider.amount,
);
final source = context.watch<PaymentSourceController>().selectedSource;
_syncTextWithAmount(amount); _syncTextWithAmount(amount);
final sourceCurrency = switch (source?.type) {
null => null,
PaymentSourceType.wallet => currencyCodeToString(
source!.wallet!.currency,
),
PaymentSourceType.ledger =>
source!.ledgerAccount?.currency.trim().toUpperCase(),
};
final amountLabel = sourceCurrency == null || sourceCurrency.isEmpty
? AppLocalizations.of(context)!.amount
: '${AppLocalizations.of(context)!.amount} ($sourceCurrency)';
return TextField( return TextField(
controller: _controller, controller: _controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration( decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.amount, labelText: amountLabel,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
onChanged: _onChanged, onChanged: _onChanged,

View File

@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/models/currency.dart';
import 'package:pshared/models/payment/source.dart';
import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/currency.dart';
import 'package:pweb/pages/dashboard/payouts/summary/fee.dart'; import 'package:pweb/pages/dashboard/payouts/summary/fee.dart';
@@ -10,24 +12,43 @@ import 'package:pweb/pages/dashboard/payouts/summary/recipient_receives.dart';
import 'package:pweb/pages/dashboard/payouts/summary/sent_amount.dart'; import 'package:pweb/pages/dashboard/payouts/summary/sent_amount.dart';
import 'package:pweb/pages/dashboard/payouts/summary/total.dart'; import 'package:pweb/pages/dashboard/payouts/summary/total.dart';
class PaymentSummary extends StatelessWidget { class PaymentSummary extends StatelessWidget {
final double spacing; final double spacing;
const PaymentSummary({super.key, required this.spacing}); const PaymentSummary({super.key, required this.spacing});
Currency _currencyForSource(PaymentSource? source) {
if (source == null) return Currency.usdt;
return switch (source.type) {
PaymentSourceType.wallet => source.wallet!.currency,
PaymentSourceType.ledger => () {
final code = source.ledgerAccount?.currency.trim().toUpperCase() ?? '';
try {
return currencyStringToCode(code);
} catch (_) {
return Currency.rub;
}
}(),
};
}
@override @override
Widget build(BuildContext context) => Align( Widget build(BuildContext context) {
alignment: Alignment.center, final source = context.watch<PaymentSourceController>().selectedSource;
child: Column( final sentCurrency = _currencyForSource(source);
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ return Align(
PaymentSentAmountRow(currency: currencyStringToCode(context.read<WalletsController>().selectedWallet?.tokenSymbol ?? 'USDT')), alignment: Alignment.center,
const PaymentFeeRow(), child: Column(
const PaymentRecipientReceivesRow(), crossAxisAlignment: CrossAxisAlignment.stretch,
SizedBox(height: spacing), children: [
const PaymentTotalRow(), PaymentSentAmountRow(currency: sentCurrency),
], const PaymentFeeRow(),
), const PaymentRecipientReceivesRow(),
); SizedBox(height: spacing),
const PaymentTotalRow(),
],
),
);
}
} }

View File

@@ -79,9 +79,9 @@ class InvitationFormFields extends StatelessWidget {
SizedBox( SizedBox(
width: _fieldWidth, width: _fieldWidth,
child: TextFormField( child: TextFormField(
controller: firstNameController, controller: lastNameController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: loc.firstName, labelText: loc.lastName,
prefixIcon: const Icon(Icons.person_outline), prefixIcon: const Icon(Icons.person_outline),
), ),
), ),
@@ -89,9 +89,9 @@ class InvitationFormFields extends StatelessWidget {
SizedBox( SizedBox(
width: _fieldWidth, width: _fieldWidth,
child: TextFormField( child: TextFormField(
controller: lastNameController, controller: firstNameController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: loc.lastName, labelText: loc.firstName,
prefixIcon: const Icon(Icons.person_outline), prefixIcon: const Icon(Icons.person_outline),
), ),
), ),

View File

@@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/payment/provider.dart'; import 'package:pshared/provider/payment/provider.dart';
import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/pmethods.dart';
@@ -45,7 +45,9 @@ class _PaymentPageState extends State<PaymentPage> {
_searchController = TextEditingController(); _searchController = TextEditingController();
_searchFocusNode = FocusNode(); _searchFocusNode = FocusNode();
WidgetsBinding.instance.addPostFrameCallback((_) => _initializePaymentPage()); WidgetsBinding.instance.addPostFrameCallback(
(_) => _initializePaymentPage(),
);
} }
@override @override
@@ -126,7 +128,7 @@ class _PaymentPageState extends State<PaymentPage> {
searchQuery: _query, searchQuery: _query,
filteredRecipients: filteredRecipients, filteredRecipients: filteredRecipients,
methodsProvider: methodsProvider, methodsProvider: methodsProvider,
onWalletSelected: context.read<WalletsController>().selectWallet, onSourceSelected: context.read<PaymentSourceController>().selectSource,
searchController: _searchController, searchController: _searchController,
searchFocusNode: _searchFocusNode, searchFocusNode: _searchFocusNode,
onSearchChanged: _handleSearchChanged, onSearchChanged: _handleSearchChanged,

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/source.dart';
import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
@@ -11,7 +11,6 @@ import 'package:pweb/pages/payment_methods/payment_page/page.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentPageBody extends StatelessWidget { class PaymentPageBody extends StatelessWidget {
final ValueChanged<Recipient?>? onBack; final ValueChanged<Recipient?>? onBack;
final Recipient? recipient; final Recipient? recipient;
@@ -20,7 +19,7 @@ class PaymentPageBody extends StatelessWidget {
final String searchQuery; final String searchQuery;
final List<Recipient> filteredRecipients; final List<Recipient> filteredRecipients;
final PaymentMethodsProvider methodsProvider; final PaymentMethodsProvider methodsProvider;
final ValueChanged<Wallet> onWalletSelected; final ValueChanged<PaymentSource> onSourceSelected;
final PayoutDestination fallbackDestination; final PayoutDestination fallbackDestination;
final TextEditingController searchController; final TextEditingController searchController;
final FocusNode searchFocusNode; final FocusNode searchFocusNode;
@@ -38,7 +37,7 @@ class PaymentPageBody extends StatelessWidget {
required this.searchQuery, required this.searchQuery,
required this.filteredRecipients, required this.filteredRecipients,
required this.methodsProvider, required this.methodsProvider,
required this.onWalletSelected, required this.onSourceSelected,
required this.fallbackDestination, required this.fallbackDestination,
required this.searchController, required this.searchController,
required this.searchFocusNode, required this.searchFocusNode,
@@ -58,7 +57,9 @@ class PaymentPageBody extends StatelessWidget {
if (methodsProvider.error != null) { if (methodsProvider.error != null) {
return PaymentMethodsErrorView( return PaymentMethodsErrorView(
message: loc.notificationError(methodsProvider.error ?? loc.noErrorInformation), message: loc.notificationError(
methodsProvider.error ?? loc.noErrorInformation,
),
); );
} }
@@ -69,7 +70,7 @@ class PaymentPageBody extends StatelessWidget {
recipientProvider: recipientProvider, recipientProvider: recipientProvider,
searchQuery: searchQuery, searchQuery: searchQuery,
filteredRecipients: filteredRecipients, filteredRecipients: filteredRecipients,
onWalletSelected: onWalletSelected, onSourceSelected: onSourceSelected,
fallbackDestination: fallbackDestination, fallbackDestination: fallbackDestination,
searchController: searchController, searchController: searchController,
searchFocusNode: searchFocusNode, searchFocusNode: searchFocusNode,

View File

@@ -1,134 +0,0 @@
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:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/provider.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/send_button.dart';
import 'package:pweb/pages/dashboard/payouts/form.dart';
import 'package:pweb/pages/payment_methods/widgets/payment_info_section.dart';
import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart';
import 'package:pweb/pages/payment_methods/widgets/section_title.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/widgets/refresh_balance/wallet.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentPageContent extends StatelessWidget {
final ValueChanged<Recipient?>? onBack;
final Recipient? recipient;
final Recipient? previousRecipient;
final RecipientsProvider recipientProvider;
final String searchQuery;
final List<Recipient> filteredRecipients;
final ValueChanged<Wallet> onWalletSelected;
final PayoutDestination fallbackDestination;
final TextEditingController searchController;
final FocusNode searchFocusNode;
final ValueChanged<String> onSearchChanged;
final ValueChanged<Recipient> onRecipientSelected;
final VoidCallback onRecipientCleared;
final VoidCallback onSend;
const PaymentPageContent({
super.key,
required this.onBack,
required this.recipient,
required this.previousRecipient,
required this.recipientProvider,
required this.searchQuery,
required this.filteredRecipients,
required this.onWalletSelected,
required this.fallbackDestination,
required this.searchController,
required this.searchFocusNode,
required this.onSearchChanged,
required this.onRecipientSelected,
required this.onRecipientCleared,
required this.onSend,
});
@override
Widget build(BuildContext context) {
final dimensions = AppDimensions();
final loc = AppLocalizations.of(context)!;
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: dimensions.maxContentWidth),
child: Material(
elevation: dimensions.elevationSmall,
borderRadius: BorderRadius.circular(dimensions.borderRadiusMedium),
color: Theme.of(context).colorScheme.onSecondary,
child: Padding(
padding: EdgeInsets.all(dimensions.paddingLarge),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PaymentBackButton(
onBack: onBack,
recipient: recipient,
fallbackDestination: fallbackDestination,
),
SizedBox(height: dimensions.paddingSmall),
PaymentHeader(),
SizedBox(height: dimensions.paddingXXLarge),
Row(
children: [
Expanded(child: SectionTitle(loc.sourceOfFunds)),
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,
),
SizedBox(height: dimensions.paddingXLarge),
RecipientSection(
recipient: recipient,
previousRecipient: previousRecipient,
dimensions: dimensions,
recipientProvider: recipientProvider,
searchQuery: searchQuery,
filteredRecipients: filteredRecipients,
searchController: searchController,
searchFocusNode: searchFocusNode,
onSearchChanged: onSearchChanged,
onRecipientSelected: onRecipientSelected,
onRecipientCleared: onRecipientCleared,
),
SizedBox(height: dimensions.paddingXLarge),
PaymentInfoSection(dimensions: dimensions),
SizedBox(height: dimensions.paddingLarge),
const PaymentFormWidget(),
SizedBox(height: dimensions.paddingXXXLarge),
SendButton(onPressed: onSend),
SizedBox(height: dimensions.paddingLarge),
],
),
),
),
),
),
);
}
}

View File

@@ -2,26 +2,74 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/source.dart';
import 'package:pweb/utils/payment/dropdown.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentMethodSelector extends StatelessWidget { class PaymentMethodSelector extends StatelessWidget {
final ValueChanged<Wallet> onMethodChanged; final ValueChanged<PaymentSource> onMethodChanged;
const PaymentMethodSelector({ const PaymentMethodSelector({super.key, required this.onMethodChanged});
@override
Widget build(BuildContext context) => Consumer<PaymentSourceController>(
builder: (context, provider, _) => PaymentMethodDropdown(
methods: provider.sources,
selectedMethod: provider.selectedSource,
onChanged: onMethodChanged,
),
);
}
class PaymentMethodDropdown extends StatelessWidget {
final List<PaymentSource> methods;
final ValueChanged<PaymentSource> onChanged;
final PaymentSource? selectedMethod;
const PaymentMethodDropdown({
super.key, super.key,
required this.onMethodChanged, required this.methods,
required this.onChanged,
this.selectedMethod,
}); });
@override @override
Widget build(BuildContext context) => Consumer<WalletsController>( Widget build(BuildContext context) => DropdownButtonFormField<PaymentSource>(
builder: (context, provider, _) => PaymentMethodDropdown( dropdownColor: Theme.of(context).colorScheme.onSecondary,
methods: provider.wallets, initialValue: _getSelectedMethod(),
selectedMethod: provider.selectedWallet, decoration: InputDecoration(
onChanged: onMethodChanged, labelText: AppLocalizations.of(context)!.whereGetMoney,
), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
); ),
items: methods
.map(
(method) => DropdownMenuItem<PaymentSource>(
value: method,
child: Text(_labelForSource(context, method)),
),
)
.toList(),
onChanged: (value) {
if (value != null) {
onChanged(value);
}
},
);
PaymentSource? _getSelectedMethod() {
if (selectedMethod != null) return selectedMethod;
if (methods.isEmpty) return null;
return methods.first;
}
String _labelForSource(BuildContext context, PaymentSource source) {
final name = source.name.trim();
final loc = AppLocalizations.of(context)!;
if (name.isNotEmpty) return name;
return switch (source.type) {
PaymentSourceType.wallet => loc.paymentTypeManagedWallet,
PaymentSourceType.ledger => loc.paymentTypeLedger,
};
}
} }

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/source.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
@@ -17,7 +17,6 @@ import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentPageContent extends StatelessWidget { class PaymentPageContent extends StatelessWidget {
final ValueChanged<Recipient?>? onBack; final ValueChanged<Recipient?>? onBack;
final Recipient? recipient; final Recipient? recipient;
@@ -25,7 +24,7 @@ class PaymentPageContent extends StatelessWidget {
final RecipientsProvider recipientProvider; final RecipientsProvider recipientProvider;
final String searchQuery; final String searchQuery;
final List<Recipient> filteredRecipients; final List<Recipient> filteredRecipients;
final ValueChanged<Wallet> onWalletSelected; final ValueChanged<PaymentSource> onSourceSelected;
final PayoutDestination fallbackDestination; final PayoutDestination fallbackDestination;
final TextEditingController searchController; final TextEditingController searchController;
final FocusNode searchFocusNode; final FocusNode searchFocusNode;
@@ -42,7 +41,7 @@ class PaymentPageContent extends StatelessWidget {
required this.recipientProvider, required this.recipientProvider,
required this.searchQuery, required this.searchQuery,
required this.filteredRecipients, required this.filteredRecipients,
required this.onWalletSelected, required this.onSourceSelected,
required this.fallbackDestination, required this.fallbackDestination,
required this.searchController, required this.searchController,
required this.searchFocusNode, required this.searchFocusNode,
@@ -56,7 +55,7 @@ class PaymentPageContent extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dimensions = AppDimensions(); final dimensions = AppDimensions();
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
return Align( return Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: ConstrainedBox( child: ConstrainedBox(
@@ -82,9 +81,7 @@ class PaymentPageContent extends StatelessWidget {
SizedBox(height: dimensions.paddingXXLarge), SizedBox(height: dimensions.paddingXXLarge),
SectionTitle(loc.sourceOfFunds), SectionTitle(loc.sourceOfFunds),
SizedBox(height: dimensions.paddingSmall), SizedBox(height: dimensions.paddingSmall),
PaymentMethodSelector( PaymentMethodSelector(onMethodChanged: onSourceSelected),
onMethodChanged: onWalletSelected,
),
SizedBox(height: dimensions.paddingXLarge), SizedBox(height: dimensions.paddingXLarge),
RecipientSection( RecipientSection(
recipient: recipient, recipient: recipient,

View File

@@ -10,11 +10,15 @@ import 'package:pweb/pages/payout_page/wallet/wigets.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentConfigPage extends StatelessWidget { class PaymentConfigPage extends StatelessWidget {
final Function(Wallet) onWalletTap; final Function(Wallet) onWalletTap;
final Function(String ledgerAccountRef) onLedgerTap;
const PaymentConfigPage({super.key, required this.onWalletTap}); const PaymentConfigPage({
super.key,
required this.onWalletTap,
required this.onLedgerTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -26,14 +30,21 @@ class PaymentConfigPage extends StatelessWidget {
} }
if (provider.error != null) { if (provider.error != null) {
return Center(child: Text(loc.notificationError(provider.error ?? loc.noErrorInformation))); return Center(
child: Text(
loc.notificationError(provider.error ?? loc.noErrorInformation),
),
);
} }
return Column( return Column(
children: [ children: [
MethodsWidget(), MethodsWidget(),
Expanded( Expanded(
child: WalletWidgets(onWalletTap: onWalletTap), child: WalletWidgets(
onWalletTap: onWalletTap,
onLedgerTap: onLedgerTap,
),
), ),
], ],
); );

View File

@@ -1,10 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pweb/pages/payout_page/wallet/edit/buttons/send.dart'; import 'package:pweb/pages/payout_page/wallet/edit/buttons/send.dart';
import 'package:pweb/pages/payout_page/wallet/edit/buttons/top_up.dart'; import 'package:pweb/pages/payout_page/wallet/edit/buttons/top_up.dart';
import 'package:pshared/provider/payment/wallets.dart';
class ButtonsWalletWidget extends StatelessWidget { class ButtonsWalletWidget extends StatelessWidget {
@@ -12,25 +9,17 @@ class ButtonsWalletWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<WalletsProvider>();
if (provider.wallets.isEmpty) return const SizedBox.shrink();
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
Expanded( Expanded(child: SendPayoutButton()),
child: SendPayoutButton(), VerticalDivider(
), color: Theme.of(context).colorScheme.primary,
VerticalDivider( thickness: 1,
color: Theme.of(context).colorScheme.primary, width: 10,
thickness: 1, ),
width: 10, Expanded(child: TopUpButton()),
), ],
Expanded(
child: TopUpButton(),
),
],
); );
} }
} }

View File

@@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/models/payment/source.dart';
import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/payment/type.dart';
import 'package:pweb/app/router/payout_routes.dart'; import 'package:pweb/app/router/payout_routes.dart';
@@ -18,20 +19,18 @@ class SendPayoutButton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
return ElevatedButton( return ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(shadowColor: null, elevation: 0),
shadowColor: null,
elevation: 0,
),
onPressed: () { onPressed: () {
final wallets = context.read<WalletsController>(); final source = context.read<PaymentSourceController>().selectedSource;
final wallet = wallets.selectedWallet; if (source == null) return;
final paymentType = switch (source.type) {
if (wallet != null) { PaymentSourceType.wallet => PaymentType.wallet,
context.pushToPayment( PaymentSourceType.ledger => PaymentType.ledger,
paymentType: PaymentType.wallet, };
returnTo: PayoutDestination.editwallet, context.pushToPayment(
); paymentType: paymentType,
} returnTo: PayoutDestination.editwallet,
);
}, },
child: Text(loc.payoutNavSendPayout), child: Text(loc.payoutNavSendPayout),
); );

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/payment/source.dart';
import 'package:pweb/app/router/payout_routes.dart'; import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/widgets/sidebar/destinations.dart';
@@ -10,23 +10,20 @@ import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
class TopUpButton extends StatelessWidget{ class TopUpButton extends StatelessWidget {
const TopUpButton({super.key}); const TopUpButton({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
return ElevatedButton( return ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(shadowColor: null, elevation: 0),
shadowColor: null,
elevation: 0,
),
onPressed: () { onPressed: () {
final wallet = context.read<WalletsController>().selectedWallet; final source = context.read<PaymentSourceController>().selectedSource;
if (wallet == null) { if (source == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(loc.noWalletSelected)), context,
); ).showSnackBar(SnackBar(content: Text(loc.noWalletSelected)));
return; return;
} }
context.pushToWalletTopUp(returnTo: PayoutDestination.editwallet); context.pushToWalletTopUp(returnTo: PayoutDestination.editwallet);

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/ledger_accounts.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pweb/pages/payout_page/wallet/ledger/format.dart';
import 'package:pweb/widgets/refresh_balance/ledger.dart';
class LedgerEditBalanceRow extends StatelessWidget {
final LedgerAccount account;
const LedgerEditBalanceRow({super.key, required this.account});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Consumer<LedgerBalanceMaskController>(
builder: (context, controller, _) {
final isMasked = controller.isBalanceMasked(account.ledgerAccountRef);
final money = account.balance?.balance;
final displayBalance = money == null
? '--'
: isMasked
? formatMaskedLedgerBalance(money.currency)
: formatLedgerBalance(
amount: money.amount,
currency: money.currency,
);
return Row(
children: [
Expanded(
child: Row(
children: [
Text(
displayBalance,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 12),
GestureDetector(
onTap: () =>
controller.toggleBalanceMask(account.ledgerAccountRef),
child: Icon(
isMasked ? Icons.visibility_off : Icons.visibility,
size: 24,
),
),
],
),
),
LedgerBalanceRefreshButton(
ledgerAccountRef: account.ledgerAccountRef,
),
],
);
},
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class LedgerEditCopyableRow extends StatelessWidget {
final String title;
final String value;
const LedgerEditCopyableRow({
super.key,
required this.title,
required this.value,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.bodySmall),
const SizedBox(height: 2),
Text(value, style: theme.textTheme.bodyLarge),
],
),
),
IconButton(
icon: const Icon(Icons.copy),
iconSize: 18,
onPressed: () => Clipboard.setData(ClipboardData(text: value)),
),
],
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pweb/pages/payout_page/wallet/edit/buttons/buttons.dart';
import 'package:pweb/pages/payout_page/wallet/edit/ledger/balance_row.dart';
import 'package:pweb/pages/payout_page/wallet/edit/ledger/copyable_row.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class LedgerEditView extends StatelessWidget {
final LedgerAccount account;
final VoidCallback onBack;
const LedgerEditView({
super.key,
required this.account,
required this.onBack,
});
@override
Widget build(BuildContext context) {
final dimensions = AppDimensions();
final loc = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: dimensions.maxContentWidth),
child: Material(
elevation: dimensions.elevationSmall,
color: theme.colorScheme.onSecondary,
borderRadius: BorderRadius.circular(dimensions.borderRadiusMedium),
child: Padding(
padding: EdgeInsets.all(dimensions.paddingLarge),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
),
Text(
loc.paymentTypeLedger,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
if (account.currency.trim().isNotEmpty)
Text(
account.currency,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
LedgerEditBalanceRow(account: account),
const SizedBox(height: 12),
LedgerEditCopyableRow(
title: loc.ledgerAccountRef,
value: account.ledgerAccountRef,
),
const SizedBox(height: 8),
LedgerEditCopyableRow(
title: 'Account code',
value: account.accountCode,
),
const SizedBox(height: 24),
const ButtonsWalletWidget(),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/provider/ledger.dart';
import 'package:pweb/pages/payout_page/wallet/edit/ledger/view.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class LedgerEditPage extends StatelessWidget {
final VoidCallback onBack;
const LedgerEditPage({super.key, required this.onBack});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final source = context.watch<PaymentSourceController>().selectedSource;
if (source == null || source.ledgerAccount == null) {
return Center(child: Text(loc.noWalletSelected));
}
final accountRef = source.ledgerAccount!.ledgerAccountRef;
return Consumer<LedgerAccountsProvider>(
builder: (context, provider, _) {
final account = provider.accounts.firstWhereOrNull(
(item) => item.ledgerAccountRef == accountRef,
);
if (account == null) {
return Center(child: Text(loc.noWalletSelected));
}
return LedgerEditView(account: account, onBack: onBack);
},
);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pweb/pages/payout_page/wallet/ledger/card_body.dart';
class LedgerWalletCard extends StatelessWidget {
final LedgerAccount account;
final VoidCallback onTap;
const LedgerWalletCard({
super.key,
required this.account,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: theme.cardTheme.elevation ?? 4,
color: theme.colorScheme.onSecondary,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: Container(
padding: const EdgeInsets.only(left: 50, top: 16, bottom: 16),
child: Row(
spacing: 3,
children: [
const CircleAvatar(
radius: 24,
child: Icon(Icons.account_balance, size: 28),
),
const SizedBox(width: 16),
Expanded(child: LedgerCardBody(account: account)),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/ledger_accounts.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pweb/pages/payout_page/wallet/ledger/format.dart';
import 'package:pweb/widgets/refresh_balance/ledger.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class LedgerCardBody extends StatelessWidget {
final LedgerAccount account;
const LedgerCardBody({super.key, required this.account});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final loc = AppLocalizations.of(context)!;
return Consumer<LedgerBalanceMaskController>(
builder: (context, controller, _) {
final isMasked = controller.isBalanceMasked(account.ledgerAccountRef);
final money = account.balance?.balance;
final displayBalance = money == null
? '--'
: isMasked
? formatMaskedLedgerBalance(money.currency)
: formatLedgerBalance(
amount: money.amount,
currency: money.currency,
);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Text(
displayBalance,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(width: 12),
GestureDetector(
onTap: () => controller.toggleBalanceMask(
account.ledgerAccountRef,
),
child: Icon(
isMasked ? Icons.visibility_off : Icons.visibility,
size: 24,
color: theme.colorScheme.onSurface,
),
),
],
),
LedgerBalanceRefreshButton(
ledgerAccountRef: account.ledgerAccountRef,
),
],
),
Text(
loc.paymentTypeLedger,
style: theme.textTheme.bodyLarge!.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
account.accountCode,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
},
);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:pshared/utils/currency.dart';
String formatLedgerBalance({required String amount, required String currency}) {
final parsed = double.tryParse(amount);
if (parsed == null) return '$amount $currency';
try {
final symbol = currencyCodeToSymbol(currencyStringToCode(currency));
if (symbol.trim().isEmpty) return '${amountToString(parsed)} $currency';
return '${amountToString(parsed)} $symbol';
} catch (_) {
return '${amountToString(parsed)} $currency';
}
}
String formatMaskedLedgerBalance(String currency) {
final normalized = currency.trim();
if (normalized.isEmpty) return '••••';
try {
final symbol = currencyCodeToSymbol(currencyStringToCode(normalized));
if (symbol.trim().isEmpty) return '•••• $normalized';
return '•••• $symbol';
} catch (_) {
return '•••• $normalized';
}
}

View File

@@ -3,42 +3,58 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/provider/ledger.dart';
import 'package:pshared/provider/payment/wallets.dart'; import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/pages/payout_page/wallet/card.dart'; import 'package:pweb/pages/payout_page/wallet/card.dart';
import 'package:pweb/pages/payout_page/wallet/ledger/card.dart';
class WalletWidgets extends StatelessWidget { class WalletWidgets extends StatelessWidget {
final void Function(Wallet) onWalletTap; final void Function(Wallet) onWalletTap;
final void Function(String ledgerAccountRef) onLedgerTap;
const WalletWidgets({super.key, required this.onWalletTap}); const WalletWidgets({
super.key,
required this.onWalletTap,
required this.onLedgerTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<WalletsProvider>(); final provider = context.watch<WalletsProvider>();
final ledgerProvider = context.watch<LedgerAccountsProvider>();
final wallets = provider.wallets; final wallets = provider.wallets;
final accounts = ledgerProvider.accounts;
return GridView.builder( return GridView.builder(
scrollDirection: Axis.vertical, scrollDirection: Axis.vertical,
physics: AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisCount: 2,
mainAxisSpacing: 12, mainAxisSpacing: 12,
crossAxisSpacing: 12, crossAxisSpacing: 12,
childAspectRatio: 3, childAspectRatio: 3,
), ),
itemCount: wallets.length, itemCount: wallets.length + accounts.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final wallet = wallets[index]; if (index < wallets.length) {
final wallet = wallets[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: WalletCard(wallet: wallet, onTap: () => onWalletTap(wallet)),
);
}
final account = accounts[index - wallets.length];
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0), padding: const EdgeInsets.symmetric(vertical: 6.0),
child: WalletCard( child: LedgerWalletCard(
wallet: wallet, account: account,
onTap: () => onWalletTap(wallet), onTap: () => onLedgerTap(account.ledgerAccountRef),
), ),
); );
}, },
); );
} }
} }

View File

@@ -2,15 +2,16 @@ import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
class WalletTopUpHeader extends StatelessWidget { class WalletTopUpHeader extends StatelessWidget {
final VoidCallback onBack; final VoidCallback onBack;
final String? tokenSymbol; final String? tokenSymbol;
final String? sourceLabel;
const WalletTopUpHeader({ const WalletTopUpHeader({
super.key, super.key,
required this.onBack, required this.onBack,
this.tokenSymbol, this.tokenSymbol,
this.sourceLabel,
}); });
@override @override
@@ -20,24 +21,18 @@ class WalletTopUpHeader extends StatelessWidget {
final symbol = tokenSymbol?.trim(); final symbol = tokenSymbol?.trim();
final subtitle = [ final subtitle = [
loc.paymentTypeCryptoWallet, sourceLabel ?? loc.paymentTypeCryptoWallet,
if (symbol != null && symbol.isNotEmpty) symbol, if (symbol != null && symbol.isNotEmpty) symbol,
].join(' · '); ].join(' · ');
return Row( return Row(
children: [ children: [
IconButton( IconButton(icon: const Icon(Icons.arrow_back), onPressed: onBack),
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(loc.walletTopUpTitle, style: theme.textTheme.titleLarge),
loc.walletTopUpTitle,
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
subtitle, subtitle,

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/ledger/account.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/dimensions.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class LedgerTopUpContent extends StatelessWidget {
final LedgerAccount account;
final VoidCallback onBack;
const LedgerTopUpContent({
super.key,
required this.account,
required this.onBack,
});
@override
Widget build(BuildContext context) {
final dimensions = AppDimensions();
final theme = Theme.of(context);
final loc = AppLocalizations.of(context)!;
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,
tokenSymbol: account.currency,
sourceLabel: loc.paymentTypeLedger,
),
SizedBox(height: dimensions.paddingLarge),
WalletTopUpMeta(
assetLabel: account.currency,
walletId: account.accountCode,
idLabel: loc.ledgerAccountRef,
),
SizedBox(height: dimensions.paddingXLarge),
WalletTopUpDetails(
address: account.ledgerAccountRef,
dimensions: dimensions,
),
],
),
),
),
),
),
),
);
}
}

View File

@@ -10,12 +10,14 @@ class WalletTopUpMeta extends StatelessWidget {
final String assetLabel; final String assetLabel;
final String walletId; final String walletId;
final String? network; final String? network;
final String? idLabel;
const WalletTopUpMeta({ const WalletTopUpMeta({
super.key, super.key,
required this.assetLabel, required this.assetLabel,
required this.walletId, required this.walletId,
this.network, this.network,
this.idLabel,
}); });
@override @override
@@ -27,10 +29,16 @@ class WalletTopUpMeta extends StatelessWidget {
spacing: dimensions.paddingLarge, spacing: dimensions.paddingLarge,
runSpacing: dimensions.paddingLarge, runSpacing: dimensions.paddingLarge,
children: [ children: [
WalletTopUpInfoChip(label: loc.walletTopUpAssetLabel, value: assetLabel), WalletTopUpInfoChip(
label: loc.walletTopUpAssetLabel,
value: assetLabel,
),
if (network != null && network!.isNotEmpty) if (network != null && network!.isNotEmpty)
WalletTopUpInfoChip(label: loc.walletTopUpNetworkLabel, value: network!), WalletTopUpInfoChip(
WalletTopUpInfoChip(label: loc.walletId, value: walletId), label: loc.walletTopUpNetworkLabel,
value: network!,
),
WalletTopUpInfoChip(label: idLabel ?? loc.walletId, value: walletId),
], ],
); );
} }

View File

@@ -1,10 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/models/payment/source.dart';
import 'package:pshared/provider/ledger.dart';
import 'package:pweb/pages/wallet_top_up/content.dart'; import 'package:pweb/pages/wallet_top_up/content.dart';
import 'package:pweb/pages/wallet_top_up/ledger_content.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -17,27 +22,55 @@ class WalletTopUpPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
final source = context.watch<PaymentSourceController>().selectedSource;
return Consumer<WalletsController>(builder: (context, provider, child) { if (source == null) {
if (provider.isLoading) { return Center(child: Text(loc.noWalletSelected));
return const Center(child: CircularProgressIndicator()); }
}
if (provider.error != null) { return switch (source.type) {
return Center( PaymentSourceType.wallet => Consumer<WalletsController>(
child: Text(loc.notificationError(provider.error.toString())), builder: (context, provider, child) {
); if (provider.isLoading) {
} return const Center(child: CircularProgressIndicator());
}
final wallet = provider.selectedWallet; if (provider.error != null) {
if (wallet == null) { return Center(
return Center(child: Text(loc.noWalletSelected)); child: Text(loc.notificationError(provider.error.toString())),
} );
}
return WalletTopUpContent( final wallet = provider.selectedWallet;
wallet: wallet, if (wallet == null) {
onBack: onBack, return Center(child: Text(loc.noWalletSelected));
); }
});
return WalletTopUpContent(wallet: wallet, onBack: onBack);
},
),
PaymentSourceType.ledger => Consumer<LedgerAccountsProvider>(
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 account = provider.accounts.firstWhereOrNull(
(item) => item.ledgerAccountRef == source.id,
);
if (account == null) {
return Center(child: Text(loc.noWalletSelected));
}
return LedgerTopUpContent(account: account, onBack: onBack);
},
),
};
} }
} }