accounts creation
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery 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/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful

This commit is contained in:
Stephan D
2026-01-23 00:13:43 +01:00
parent b677d37b99
commit 218c4e20b3
64 changed files with 1641 additions and 338 deletions

View File

@@ -0,0 +1,32 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/describable.dart';
import 'package:pshared/data/dto/ledger/type.dart';
part 'create.g.dart';
@JsonSerializable()
class CreateLedgerAccountRequest {
final Map<String, String>? metadata;
final String currency;
final bool allowNegative;
final bool isSettlement;
final DescribableDTO describable;
final String? ownerRef;
final LedgerAccountTypeDTO accountType;
const CreateLedgerAccountRequest({
this.metadata,
required this.currency,
required this.allowNegative,
required this.isSettlement,
required this.describable,
required this.accountType,
this.ownerRef,
});
factory CreateLedgerAccountRequest.fromJson(Map<String, dynamic> json) => _$CreateLedgerAccountRequestFromJson(json);
Map<String, dynamic> toJson() => _$CreateLedgerAccountRequestToJson(this);
}

View File

@@ -1,6 +1,5 @@
import 'package:json_annotation/json_annotation.dart';
part 'base.g.dart';

View File

@@ -0,0 +1,25 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/describable.dart';
import 'package:pshared/data/dto/wallet/chain_asset.dart';
part 'create.g.dart';
@JsonSerializable()
class CreateWalletRequest {
final Map<String, String>? metadata;
final DescribableDTO describable;
final String? ownerRef;
final ChainAssetDTO asset;
const CreateWalletRequest({
this.metadata,
required this.asset,
required this.describable,
this.ownerRef,
});
factory CreateWalletRequest.fromJson(Map<String, dynamic> json) => _$CreateWalletRequestFromJson(json);
Map<String, dynamic> toJson() => _$CreateWalletRequestToJson(this);
}

View File

@@ -60,22 +60,23 @@ class WalletsController with ChangeNotifier {
String? get selectedWalletRef => _selectedWalletRef;
void selectWallet(Wallet wallet) => selectWalletByRef(wallet.id);
void selectWallet(Wallet wallet, {bool allowHidden = false}) =>
selectWalletByRef(wallet.id, allowHidden: allowHidden);
void selectWalletByRef(String walletRef) {
void selectWalletByRef(String walletRef, {bool allowHidden = false}) {
if (_selectedWalletRef == walletRef) return;
// Prevent selecting a hidden wallet
if (!_visibleWalletRefs.contains(walletRef)) return;
if (!allowHidden && !_visibleWalletRefs.contains(walletRef)) return;
_selectedWalletRef = walletRef;
notifyListeners();
}
/// Toggle wallet visibility
void toggleVisibility(String walletId) {
final existed = _visibleWalletRefs.remove(walletId);
if (!existed) _visibleWalletRefs.add(walletId);
void toggleVisibility(String accountRef) {
final existed = _visibleWalletRefs.remove(accountRef);
if (!existed) _visibleWalletRefs.add(accountRef);
_selectedWalletRef = _resolveSelectedId(
currentRef: _selectedWalletRef,

View File

@@ -2,6 +2,8 @@ import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/describable.dart';
import 'package:pshared/data/dto/ledger/balance.dart';
import 'package:pshared/data/dto/ledger/status.dart';
import 'package:pshared/data/dto/ledger/type.dart';
part 'account.g.dart';
@@ -12,9 +14,9 @@ class LedgerAccountDTO {
final String organizationRef;
final String? ownerRef;
final String accountCode;
final String accountType;
final LedgerAccountTypeDTO accountType;
final String currency;
final String status;
final LedgerAccountStatusDTO status;
final bool allowNegative;
final bool isSettlement;
final Map<String, String>? metadata;

View File

@@ -0,0 +1,13 @@
import 'package:json_annotation/json_annotation.dart';
enum LedgerAccountStatusDTO {
@JsonValue('unspecified')
unspecified,
@JsonValue('active')
active,
@JsonValue('frozen')
frozen,
}

View File

@@ -0,0 +1,19 @@
import 'package:json_annotation/json_annotation.dart';
enum LedgerAccountTypeDTO {
@JsonValue('unspecified')
unspecified,
@JsonValue('asset')
asset,
@JsonValue('liability')
liability,
@JsonValue('revenue')
revenue,
@JsonValue('expense')
expense,
}

View File

@@ -1,20 +1,21 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/wallet/chain_asset.dart';
part 'asset.g.dart';
@JsonSerializable()
class WalletAssetDTO {
final String chain;
final String tokenSymbol;
class WalletAssetDTO extends ChainAssetDTO {
final String contractAddress;
const WalletAssetDTO({
required this.chain,
required this.tokenSymbol,
required super.chain,
required super.tokenSymbol,
required this.contractAddress,
});
factory WalletAssetDTO.fromJson(Map<String, dynamic> json) => _$WalletAssetDTOFromJson(json);
@override
Map<String, dynamic> toJson() => _$WalletAssetDTOToJson(this);
}

View File

@@ -0,0 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
part 'chain_asset.g.dart';
@JsonSerializable()
class ChainAssetDTO {
final String chain;
final String tokenSymbol;
const ChainAssetDTO({
required this.chain,
required this.tokenSymbol,
});
factory ChainAssetDTO.fromJson(Map<String, dynamic> json) => _$ChainAssetDTOFromJson(json);
Map<String, dynamic> toJson() => _$ChainAssetDTOToJson(this);
}

View File

@@ -1,6 +1,8 @@
import 'package:pshared/data/dto/ledger/account.dart';
import 'package:pshared/data/mapper/describable.dart';
import 'package:pshared/data/mapper/ledger/balance.dart';
import 'package:pshared/data/mapper/ledger/status.dart';
import 'package:pshared/data/mapper/ledger/type.dart';
import 'package:pshared/models/ledger/account.dart';
@@ -10,9 +12,9 @@ extension LedgerAccountDTOMapper on LedgerAccountDTO {
organizationRef: organizationRef,
ownerRef: ownerRef,
accountCode: accountCode,
accountType: accountType,
accountType: accountType.toDomain(),
currency: currency,
status: status,
status: status.toDomain(),
allowNegative: allowNegative,
isSettlement: isSettlement,
metadata: metadata,
@@ -29,9 +31,9 @@ extension LedgerAccountModelMapper on LedgerAccount {
organizationRef: organizationRef,
ownerRef: ownerRef,
accountCode: accountCode,
accountType: accountType,
accountType: accountType.toDTO(),
currency: currency,
status: status,
status: status.toDTO(),
allowNegative: allowNegative,
isSettlement: isSettlement,
metadata: metadata,

View File

@@ -0,0 +1,29 @@
import 'package:pshared/data/dto/ledger/status.dart';
import 'package:pshared/models/ledger/status.dart';
extension LedgerAccountStatusDTOMapper on LedgerAccountStatusDTO {
LedgerAccountStatus toDomain() {
switch (this) {
case LedgerAccountStatusDTO.unspecified:
return LedgerAccountStatus.unspecified;
case LedgerAccountStatusDTO.active:
return LedgerAccountStatus.active;
case LedgerAccountStatusDTO.frozen:
return LedgerAccountStatus.frozen;
}
}
}
extension LedgerAccountStatusMapper on LedgerAccountStatus {
LedgerAccountStatusDTO toDTO() {
switch (this) {
case LedgerAccountStatus.unspecified:
return LedgerAccountStatusDTO.unspecified;
case LedgerAccountStatus.active:
return LedgerAccountStatusDTO.active;
case LedgerAccountStatus.frozen:
return LedgerAccountStatusDTO.frozen;
}
}
}

View File

@@ -0,0 +1,37 @@
import 'package:pshared/data/dto/ledger/type.dart';
import 'package:pshared/models/ledger/type.dart';
extension LedgerAccountTypeDTOMapper on LedgerAccountTypeDTO {
LedgerAccountType toDomain() {
switch (this) {
case LedgerAccountTypeDTO.unspecified:
return LedgerAccountType.unspecified;
case LedgerAccountTypeDTO.asset:
return LedgerAccountType.asset;
case LedgerAccountTypeDTO.liability:
return LedgerAccountType.liability;
case LedgerAccountTypeDTO.revenue:
return LedgerAccountType.revenue;
case LedgerAccountTypeDTO.expense:
return LedgerAccountType.expense;
}
}
}
extension LedgerAccountTypeModelMapper on LedgerAccountType {
LedgerAccountTypeDTO toDTO() {
switch (this) {
case LedgerAccountType.unspecified:
return LedgerAccountTypeDTO.unspecified;
case LedgerAccountType.asset:
return LedgerAccountTypeDTO.asset;
case LedgerAccountType.liability:
return LedgerAccountTypeDTO.liability;
case LedgerAccountType.revenue:
return LedgerAccountTypeDTO.revenue;
case LedgerAccountType.expense:
return LedgerAccountTypeDTO.expense;
}
}
}

View File

@@ -0,0 +1,18 @@
import 'package:pshared/data/dto/wallet/chain_asset.dart';
import 'package:pshared/data/mapper/payment/enums.dart';
import 'package:pshared/models/wallet/chain_asset.dart';
extension ChainAssetDTOMapper on ChainAssetDTO {
ChainAsset toDomain() => ChainAsset(
chain: chainNetworkFromValue(chain),
tokenSymbol: tokenSymbol,
);
}
extension ChainAssetMapper on ChainAsset {
ChainAssetDTO toDTO() => ChainAssetDTO(
chain: chainNetworkToValue(chain),
tokenSymbol: tokenSymbol,
);
}

View File

@@ -1,5 +1,7 @@
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/ledger/balance.dart';
import 'package:pshared/models/ledger/status.dart';
import 'package:pshared/models/ledger/type.dart';
class LedgerAccount implements Describable {
@@ -7,9 +9,9 @@ class LedgerAccount implements Describable {
final String organizationRef;
final String? ownerRef;
final String accountCode;
final String accountType;
final LedgerAccountType accountType;
final String currency;
final String status;
final LedgerAccountStatus status;
final bool allowNegative;
final bool isSettlement;
final Map<String, String>? metadata;

View File

@@ -0,0 +1,5 @@
enum LedgerAccountStatus {
unspecified,
active,
frozen,
}

View File

@@ -0,0 +1,7 @@
enum LedgerAccountType {
unspecified,
asset,
liability,
revenue,
expense,
}

View File

@@ -1,14 +1,12 @@
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/models/wallet/chain_asset.dart';
class WalletAsset {
final ChainNetwork chain;
final String tokenSymbol;
class WalletAsset extends ChainAsset {
final String contractAddress;
const WalletAsset({
required this.chain,
required this.tokenSymbol,
required super.chain,
required super.tokenSymbol,
required this.contractAddress,
});
}

View File

@@ -0,0 +1,12 @@
import 'package:pshared/models/payment/chain_network.dart';
class ChainAsset {
final ChainNetwork chain;
final String tokenSymbol;
const ChainAsset({
required this.chain,
required this.tokenSymbol,
});
}

View File

@@ -6,6 +6,7 @@ import 'package:pshared/models/organization/employee.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/accounts/employees.dart';
import 'package:pshared/utils/exception.dart';
class EmployeesProvider extends ChangeNotifier {
@@ -46,10 +47,7 @@ class EmployeesProvider extends ChangeNotifier {
error: null,
);
} catch (e) {
_employees = _employees.copyWith(
error: e is Exception ? e : Exception('Unknown error: ${e.toString()}'),
isLoading: false,
);
_employees = _employees.copyWith(error: toException(e), isLoading: false);
}
notifyListeners();

View File

@@ -4,8 +4,10 @@ import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/currency.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/resource.dart';
@@ -159,6 +161,30 @@ class LedgerAccountsProvider with ChangeNotifier {
}
}
Future<void> create({
required Describable describable,
required Currency currency,
String? ownerRef,
}) async {
final org = _organizations;
if (org == null || !org.isOrganizationSet) return;
_applyResource(_resource.copyWith(isLoading: true, error: null), notify: true);
try {
await _service.create(
organizationRef: org.current.id,
currency: currency,
describable: describable,
ownerRef: ownerRef,
);
await loadAccountsWithBalances();
} catch (e) {
_applyResource(_resource.copyWith(isLoading: false, error: toException(e)), notify: true);
rethrow;
}
}
// ---------- internals ----------
void _applyResource(Resource<List<LedgerAccount>> newResource, {required bool notify}) {

View File

@@ -5,7 +5,9 @@ import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/models/wallet/chain_asset.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/payment/wallets.dart';
@@ -159,6 +161,30 @@ class WalletsProvider with ChangeNotifier {
}
}
Future<void> create({
required Describable describable,
required ChainAsset asset,
required String? ownerRef,
}) async {
final org = _organizations;
if (org == null || !org.isOrganizationSet) return;
_applyResource(_resource.copyWith(isLoading: true, error: null), notify: true);
try {
await _service.create(
organizationRef: org.current.id,
describable: describable,
asset: asset,
ownerRef: ownerRef,
);
await loadWalletsWithBalances();
} catch (e) {
_applyResource(_resource.copyWith(isLoading: false, error: toException(e)), notify: true);
rethrow;
}
}
// ---------- internals ----------
void _applyResource(Resource<List<Wallet>> newResource, {required bool notify}) {

View File

@@ -1,11 +1,18 @@
import 'package:pshared/api/requests/ledger/create.dart';
import 'package:pshared/api/responses/ledger/accounts.dart';
import 'package:pshared/api/responses/ledger/balance.dart';
import 'package:pshared/data/mapper/describable.dart';
import 'package:pshared/data/mapper/ledger/account.dart';
import 'package:pshared/data/mapper/ledger/balance.dart';
import 'package:pshared/data/mapper/ledger/type.dart';
import 'package:pshared/models/currency.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/ledger/balance.dart';
import 'package:pshared/models/ledger/type.dart';
import 'package:pshared/service/authorization/service.dart';
import 'package:pshared/service/services.dart';
import 'package:pshared/utils/currency.dart';
class LedgerService {
@@ -29,4 +36,22 @@ class LedgerService {
);
return LedgerBalanceResponse.fromJson(json).balance.toDomain();
}
Future<void> create({
required String organizationRef,
required Describable describable,
required String? ownerRef,
required Currency currency,
}) async => AuthorizationService.getPOSTResponse(
_objectType,
'/$organizationRef',
CreateLedgerAccountRequest(
describable: describable.toDTO(),
ownerRef: ownerRef,
allowNegative: false,
isSettlement: false,
accountType: LedgerAccountType.asset.toDTO(),
currency: currencyCodeToString(currency),
).toJson(),
);
}

View File

@@ -1,11 +1,19 @@
import 'package:pshared/data/mapper/wallet/ui.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/models/wallet/chain_asset.dart';
import 'package:pshared/service/wallet.dart' as shared_wallet_service;
abstract class WalletsService {
Future<List<Wallet>> getWallets(String organizationRef);
Future<double> getBalance(String organizationRef, String walletRef);
Future<void> create({
required String organizationRef,
required Describable describable,
required ChainAsset asset,
required String? ownerRef,
});
}
class ApiWalletsService implements WalletsService {
@@ -24,4 +32,17 @@ class ApiWalletsService implements WalletsService {
final amount = balance.available?.amount;
return amount == null ? 0 : double.tryParse(amount) ?? 0;
}
@override
Future<void> create({
required String organizationRef,
required Describable describable,
required ChainAsset asset,
required String? ownerRef,
}) => shared_wallet_service.WalletService.create(
organizationRef: organizationRef,
describable: describable,
asset: asset,
ownerRef: ownerRef,
);
}

View File

@@ -1,7 +1,12 @@
import 'package:pshared/api/requests/wallet/create.dart';
import 'package:pshared/api/responses/wallet_balance.dart';
import 'package:pshared/api/responses/wallets.dart';
import 'package:pshared/data/mapper/describable.dart';
import 'package:pshared/data/mapper/wallet/chain_asset.dart';
import 'package:pshared/data/mapper/wallet/response.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/wallet/balance.dart';
import 'package:pshared/models/wallet/chain_asset.dart';
import 'package:pshared/models/wallet/wallet.dart';
import 'package:pshared/service/authorization/service.dart';
import 'package:pshared/service/services.dart';
@@ -28,4 +33,19 @@ class WalletService {
);
return WalletBalanceResponse.fromJson(json).toDomain();
}
static Future<void> create({
required String organizationRef,
required Describable describable,
required ChainAsset asset,
required String? ownerRef,
}) async => AuthorizationService.getPOSTResponse(
_objectType,
'/$organizationRef',
CreateWalletRequest(
asset: asset.toDTO(),
describable: describable.toDTO(),
ownerRef: ownerRef,
).toJson(),
);
}

View File

@@ -279,7 +279,7 @@ void _openWalletEdit(
Wallet wallet, {
required PayoutDestination returnTo,
}) {
context.read<WalletsController>().selectWallet(wallet);
context.read<WalletsController>().selectWallet(wallet, allowHidden: true);
context.pushToEditWallet(returnTo: returnTo);
}
@@ -288,7 +288,7 @@ void _openWalletTopUp(
Wallet wallet, {
required PayoutDestination returnTo,
}) {
context.read<WalletsController>().selectWallet(wallet);
context.read<WalletsController>().selectWallet(wallet, allowHidden: true);
context.pushToWalletTopUp(returnTo: returnTo);
}

View File

@@ -608,6 +608,8 @@
"noRecipientsFound": "No recipients found for this query.",
"sourceOfFunds": "Source of funds",
"walletTopUp": "Top up",
"errorCreateManagedWallet": "Failed to create managed wallet.",
"errorCreateLedgerAccount": "Failed to create ledger account.",
"englishLanguage": "English",
"russianLanguage": "Russian",
"germanLanguage": "German"

View File

@@ -609,6 +609,8 @@
"noRecipientsFound": "Получатели по запросу не найдены.",
"sourceOfFunds": "Источник средств",
"walletTopUp": "Пополнение",
"errorCreateManagedWallet": "Не удалось создать управляемый кошелек.",
"errorCreateLedgerAccount": "Не удалось создать счет бухгалтерской книги.",
"englishLanguage": "Английский",
"russianLanguage": "Русский",
"germanLanguage": "Немецкий"

View File

@@ -16,8 +16,10 @@ import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/accounts/employees.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/ledger.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pshared/provider/invitations.dart';
import 'package:pshared/service/ledger.dart';
import 'package:pshared/service/payment/wallets.dart';
import 'package:pweb/app/app.dart';
@@ -96,6 +98,10 @@ void main() async {
create: (_) => WalletsProvider(ApiWalletsService()),
update: (context, organizations, provider) => provider!..update(organizations),
),
ChangeNotifierProxyProvider<OrganizationsProvider, LedgerAccountsProvider>(
create: (_) => LedgerAccountsProvider(LedgerService()),
update: (context, organizations, provider) => provider!..update(organizations),
),
ChangeNotifierProxyProvider<WalletsProvider, WalletsController>(
create: (_) => WalletsController(),
update: (_, wallets, controller) => controller!..update(wallets),
@@ -111,4 +117,4 @@ void main() async {
),
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pweb/utils/text_field_styles.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class AssetTypeField extends StatelessWidget {
final PaymentType value;
final ValueChanged<PaymentType?>? onChanged;
const AssetTypeField({
super.key,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return DropdownButtonFormField<PaymentType>(
initialValue: value,
decoration: getInputDecoration(context, l10n.paymentType, true),
items: [
DropdownMenuItem(
value: PaymentType.managedWallet,
child: Text(l10n.paymentTypeManagedWallet),
),
DropdownMenuItem(
value: PaymentType.ledger,
child: Text(l10n.paymentTypeLedger),
),
],
onChanged: onChanged,
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
class DialogCancelButton extends StatelessWidget {
final String label;
final bool isSaving;
final VoidCallback onCancel;
const DialogCancelButton({
super.key,
required this.label,
required this.isSaving,
required this.onCancel,
});
@override
Widget build(BuildContext context) => TextButton(
onPressed: isSaving ? null : onCancel,
child: Text(label),
);
}

View File

@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:dotted_border/dotted_border.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/dialog.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class AddBalanceCard extends StatelessWidget {
final VoidCallback? onTap;
const AddBalanceCard({super.key, this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final textTheme = theme.textTheme;
final loc = AppLocalizations.of(context)!;
final borderRadius = BorderRadius.circular(WalletCardConfig.borderRadius);
final subtitle = '${loc.paymentTypeLedger} / ${loc.paymentTypeManagedWallet}';
final effectiveOnTap = onTap ?? () => showAddBalanceDialog(context);
return ClipRRect(
borderRadius: borderRadius,
child: DottedBorder(
options: RoundedRectDottedBorderOptions(
radius: Radius.circular(WalletCardConfig.borderRadius),
dashPattern: const [8, 5],
strokeWidth: 1.6,
color: colorScheme.primary.withAlpha(110),
),
child: Material(
color: colorScheme.primary.withAlpha(14),
child: InkWell(
onTap: effectiveOnTap,
child: SizedBox.expand(
child: Padding(
padding: WalletCardConfig.contentPadding,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primary.withAlpha(28),
),
child: Icon(
Icons.add_rounded,
size: 28,
color: colorScheme.primary,
),
),
const SizedBox(height: 8),
Text(
loc.actionAddNew,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
subtitle,
textAlign: TextAlign.center,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,8 @@
import 'package:pshared/models/currency.dart';
import 'package:pshared/models/payment/chain_network.dart';
const String orgOwnerRef = '';
const Currency managedCurrencyDefault = Currency.usdt;
const Currency ledgerCurrencyDefault = Currency.rub;
const ChainNetwork managedNetworkDefault = ChainNetwork.tronMainnet;

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:pweb/utils/text_field_styles.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class DescriptionField extends StatelessWidget {
final TextEditingController controller;
const DescriptionField({
super.key,
required this.controller,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return TextFormField(
controller: controller,
decoration: getInputDecoration(context, '${l10n.comment} (${l10n.optional})', true),
style: getTextFieldStyle(context, true),
minLines: 2,
maxLines: 3,
);
}
}

View File

@@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/currency.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/wallet/chain_asset.dart';
import 'package:pshared/provider/accounts/employees.dart';
import 'package:pshared/provider/ledger.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/form.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/constants.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/cancel.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/submit.dart';
import 'package:pweb/utils/error/snackbar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
Future<void> showAddBalanceDialog(BuildContext context) => showDialog<void>(
context: context,
builder: (dialogContext) => const AddBalanceDialog(),
);
class AddBalanceDialog extends StatefulWidget {
const AddBalanceDialog({super.key});
@override
State<AddBalanceDialog> createState() => _AddBalanceDialogState();
}
class _AddBalanceDialogState extends State<AddBalanceDialog> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
PaymentType _assetType = PaymentType.managedWallet;
String _ownerRef = orgOwnerRef;
Currency _managedCurrency = managedCurrencyDefault;
ChainNetwork _network = managedNetworkDefault;
Currency _ledgerCurrency = ledgerCurrencyDefault;
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
void _setAssetType(PaymentType? value) {
if (value == null) return;
setState(() => _assetType = value);
}
void _setOwnerRef(String? value) {
if (value == null) return;
setState(() => _ownerRef = value);
}
void _setManagedCurrency(Currency? value) {
if (value == null) return;
setState(() => _managedCurrency = value);
}
void _setNetwork(ChainNetwork? value) {
if (value == null) return;
setState(() => _network = value);
}
void _setLedgerCurrency(Currency? value) {
if (value == null) return;
setState(() => _ledgerCurrency = value);
}
Future<void> _submit() async {
final form = _formKey.currentState;
if (form == null || !form.validate()) return;
final l10n = AppLocalizations.of(context)!;
final name = _nameController.text.trim();
final description = _descriptionController.text.trim();
final employees = context.read<EmployeesProvider>().employees;
final effectiveOwnerRef = employees.any((employee) => employee.id == _ownerRef) ? _ownerRef : orgOwnerRef;
final isOrgWallet = effectiveOwnerRef == orgOwnerRef;
final owner = isOrgWallet ? null : employees.firstWhereOrNull((employee) => employee.id == effectiveOwnerRef);
final errorMessage = _assetType == PaymentType.managedWallet
? l10n.errorCreateManagedWallet
: l10n.errorCreateLedgerAccount;
final result = await executeActionWithNotification<bool>(
context: context,
errorMessage: errorMessage,
action: () async {
if (_assetType == PaymentType.managedWallet) {
await context.read<WalletsProvider>().create(
describable: newDescribable(name: name, description: description),
asset: ChainAsset(chain: _network, tokenSymbol: currencyCodeToString(_managedCurrency)),
ownerRef: owner?.id,
);
} else {
await context.read<LedgerAccountsProvider>().create(
describable: newDescribable(name: name, description: description),
currency: _ledgerCurrency,
ownerRef: owner?.id,
);
}
return true;
},
);
if (result == true && mounted) {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final orgName = context.select<OrganizationsProvider, String?>(
(provider) => provider.isOrganizationSet ? provider.current.name : null,
);
final employeesProvider = context.watch<EmployeesProvider>();
final employees = employeesProvider.employees;
final isSaving = context.watch<WalletsProvider>().isLoading ||
context.watch<LedgerAccountsProvider>().isLoading;
final ownerItems = <DropdownMenuItem<String>>[
DropdownMenuItem(
value: orgOwnerRef,
child: Text(orgName ?? l10n.companyName),
),
...employees.map((employee) => DropdownMenuItem(
value: employee.id,
child: Text(employee.fullName.isNotEmpty ? employee.fullName : employee.login),
)),
];
final resolvedOwnerRef = ownerItems.any((item) => item.value == _ownerRef)
? _ownerRef
: orgOwnerRef;
return AlertDialog(
title: Text(l10n.actionAddNew),
content: AddBalanceForm(
formKey: _formKey,
assetType: _assetType,
isSaving: isSaving,
ownerItems: ownerItems,
ownerValue: resolvedOwnerRef,
onAssetTypeChanged: _setAssetType,
onOwnerChanged: _setOwnerRef,
nameController: _nameController,
descriptionController: _descriptionController,
managedCurrency: _managedCurrency,
network: _network,
ledgerCurrency: _ledgerCurrency,
onManagedCurrencyChanged: _setManagedCurrency,
onNetworkChanged: _setNetwork,
onLedgerCurrencyChanged: _setLedgerCurrency,
showEmployeesLoading: employeesProvider.isLoading,
),
actions: [
DialogCancelButton(
label: l10n.cancel,
isSaving: isSaving,
onCancel: () => Navigator.of(context).pop(),
),
DialogSubmitButton(
label: l10n.add,
isSaving: isSaving,
onSubmit: _submit,
),
],
);
}
}

View File

@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
class EmployeesLoadingIndicator extends StatelessWidget {
const EmployeesLoadingIndicator({super.key});
@override
Widget build(BuildContext context) => const Padding(
padding: EdgeInsets.only(top: 4),
child: LinearProgressIndicator(minHeight: 2),
);
}

View File

@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/currency.dart';
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/asset_type_field.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/description.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/employees_loading_indicator.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/ledger_fields.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/managed_wallet_fields.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/name.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/owner.dart';
class AddBalanceForm extends StatelessWidget {
final GlobalKey<FormState> formKey;
final PaymentType assetType;
final bool isSaving;
final List<DropdownMenuItem<String>> ownerItems;
final String ownerValue;
final ValueChanged<PaymentType?> onAssetTypeChanged;
final ValueChanged<String?> onOwnerChanged;
final TextEditingController nameController;
final TextEditingController descriptionController;
final Currency managedCurrency;
final ChainNetwork network;
final Currency ledgerCurrency;
final ValueChanged<Currency?> onManagedCurrencyChanged;
final ValueChanged<ChainNetwork?> onNetworkChanged;
final ValueChanged<Currency?> onLedgerCurrencyChanged;
final bool showEmployeesLoading;
const AddBalanceForm({
super.key,
required this.formKey,
required this.assetType,
required this.isSaving,
required this.ownerItems,
required this.ownerValue,
required this.onAssetTypeChanged,
required this.onOwnerChanged,
required this.nameController,
required this.descriptionController,
required this.managedCurrency,
required this.network,
required this.ledgerCurrency,
required this.onManagedCurrencyChanged,
required this.onNetworkChanged,
required this.onLedgerCurrencyChanged,
required this.showEmployeesLoading,
});
@override
Widget build(BuildContext context) => Form(
key: formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: [
AssetTypeField(
value: assetType,
onChanged: isSaving ? null : onAssetTypeChanged,
),
OwnerField(
value: ownerValue,
items: ownerItems,
onChanged: isSaving ? null : onOwnerChanged,
),
NameField(controller: nameController),
DescriptionField(controller: descriptionController),
if (assetType == PaymentType.managedWallet)
ManagedWalletFields(
currency: managedCurrency,
network: network,
onCurrencyChanged: isSaving ? null : onManagedCurrencyChanged,
onNetworkChanged: isSaving ? null : onNetworkChanged,
)
else
LedgerFields(
currency: ledgerCurrency,
onCurrencyChanged: isSaving ? null : onLedgerCurrencyChanged,
),
if (showEmployeesLoading) const EmployeesLoadingIndicator(),
],
),
),
);
}

View File

@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/currency.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/constants.dart';
import 'package:pweb/utils/text_field_styles.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class LedgerFields extends StatelessWidget {
final Currency currency;
final ValueChanged<Currency?>? onCurrencyChanged;
const LedgerFields({
super.key,
required this.currency,
required this.onCurrencyChanged,
});
@override
Widget build(BuildContext context) => DropdownButtonFormField<Currency>(
initialValue: currency,
decoration: getInputDecoration(context, AppLocalizations.of(context)!.currency, true),
items: [
DropdownMenuItem(
value: ledgerCurrencyDefault,
child: Text(currencyCodeToString(ledgerCurrencyDefault)),
),
],
onChanged: onCurrencyChanged,
);
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/currency.dart';
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/l10n/chain.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/constants.dart';
import 'package:pweb/utils/text_field_styles.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class ManagedWalletFields extends StatelessWidget {
final Currency currency;
final ChainNetwork network;
final ValueChanged<Currency?>? onCurrencyChanged;
final ValueChanged<ChainNetwork?>? onNetworkChanged;
const ManagedWalletFields({
super.key,
required this.currency,
required this.network,
required this.onCurrencyChanged,
required this.onNetworkChanged,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
spacing: 12,
children: [
DropdownButtonFormField<Currency>(
initialValue: currency,
decoration: getInputDecoration(context, l10n.currency, true),
items: [
DropdownMenuItem(
value: managedCurrencyDefault,
child: Text(currencyCodeToString(managedCurrencyDefault)),
),
],
onChanged: onCurrencyChanged,
),
DropdownButtonFormField<ChainNetwork>(
initialValue: network,
decoration: getInputDecoration(context, l10n.walletTopUpNetworkLabel, true),
items: [
DropdownMenuItem(
value: managedNetworkDefault,
child: Text(managedNetworkDefault.localizedName(context)),
),
],
onChanged: onNetworkChanged,
),
],
);
}
}

View File

@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:pweb/utils/text_field_styles.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class NameField extends StatelessWidget {
final TextEditingController controller;
const NameField({
super.key,
required this.controller,
});
@override
Widget build(BuildContext context) => TextFormField(
controller: controller,
decoration: getInputDecoration(context, AppLocalizations.of(context)!.accountName, true),
style: getTextFieldStyle(context, true),
validator: (value) => (value == null || value.trim().isEmpty) ? AppLocalizations.of(context)!.errorNameMissing : null,
);
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:pweb/utils/text_field_styles.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class OwnerField extends StatelessWidget {
final String value;
final List<DropdownMenuItem<String>> items;
final ValueChanged<String?>? onChanged;
const OwnerField({
super.key,
required this.value,
required this.items,
required this.onChanged,
});
@override
Widget build(BuildContext context) => DropdownButtonFormField<String>(
initialValue: value,
decoration: getInputDecoration(context, AppLocalizations.of(context)!.colDataOwner, true),
items: items,
onChanged: onChanged,
);
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
class DialogSubmitButton extends StatelessWidget {
final String label;
final bool isSaving;
final VoidCallback onSubmit;
const DialogSubmitButton({
super.key,
required this.label,
required this.isSaving,
required this.onSubmit,
});
@override
Widget build(BuildContext context) => ElevatedButton(
onPressed: isSaving ? null : onSubmit,
child: isSaving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(label),
);
}

View File

@@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/wallets.dart';
import 'package:pshared/provider/ledger.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/dashboard/buttons/balance/carousel.dart';
import 'package:pweb/pages/dashboard/buttons/balance/controller.dart';
import 'package:pweb/pages/dashboard/buttons/balance/balance_item.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -22,38 +24,69 @@ class BalanceWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final walletsController = context.watch<WalletsController>();
final ledgerProvider = context.watch<LedgerAccountsProvider>();
final carousel = context.watch<CarouselIndexController>();
final loc = AppLocalizations.of(context)!;
if (walletsController.isLoading) {
final wallets = walletsController.wallets;
final accounts = ledgerProvider.accounts;
final isLoading = walletsController.isLoading &&
ledgerProvider.isLoading &&
wallets.isEmpty &&
accounts.isEmpty;
if (isLoading) {
return const Center(child: CircularProgressIndicator());
}
final wallets = walletsController.wallets;
final items = [
...wallets.map(BalanceItem.wallet),
...accounts.map(BalanceItem.ledger),
const BalanceItem.addAction(),
];
if (wallets.isEmpty) {
return Center(child: Text(loc.noWalletsAvailable));
if (items.isEmpty) {
return const SizedBox.shrink();
}
// Ensure index is always valid when wallets list changes
carousel.setIndex(carousel.index, wallets.length);
// Ensure index is always valid when list changes
carousel.setIndex(carousel.index, items.length);
final index = carousel.index;
final wallet = wallets[index];
final current = items[index];
// Single source of truth: controller
if (walletsController.selectedWallet?.id != wallet.id) {
walletsController.selectWallet(wallet);
if (current.isWallet) {
final wallet = current.wallet!;
if (walletsController.selectedWallet?.id != wallet.id) {
walletsController.selectWallet(wallet);
}
}
return WalletCarousel(
wallets: wallets,
final carouselWidget = BalanceCarousel(
items: items,
currentIndex: index,
onIndexChanged: (i) {
carousel.setIndex(i, wallets.length);
walletsController.selectWallet(wallets[i]);
carousel.setIndex(i, items.length);
final next = items[carousel.index];
if (next.isWallet) {
walletsController.selectWallet(next.wallet!);
}
},
onTopUp: onTopUp,
);
if (wallets.isEmpty && accounts.isEmpty) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Center(child: Text(loc.noWalletsAvailable)),
const SizedBox(height: 12),
carouselWidget,
],
);
}
return carouselWidget;
}
}

View File

@@ -0,0 +1,21 @@
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/wallet.dart';
enum BalanceItemType { wallet, ledger, addAction }
class BalanceItem {
final BalanceItemType type;
final Wallet? wallet;
final LedgerAccount? account;
const BalanceItem.wallet(this.wallet) : type = BalanceItemType.wallet, account = null;
const BalanceItem.ledger(this.account) : type = BalanceItemType.ledger, wallet = null;
const BalanceItem.addAction() : type = BalanceItemType.addAction, wallet = null, account = null;
bool get isWallet => type == BalanceItemType.wallet;
bool get isLedger => type == BalanceItemType.ledger;
bool get isAdd => type == BalanceItemType.addAction;
}

View File

@@ -4,12 +4,16 @@ import 'package:provider/provider.dart';
import 'package:pshared/controllers/wallets.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/utils/l10n/chain.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add_funds.dart';
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
import 'package:pweb/widgets/wallet_balance_refresh_button.dart';
import 'package:pweb/widgets/refresh_balance/wallet.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class WalletCard extends StatelessWidget {
@@ -24,42 +28,49 @@ class WalletCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final networkLabel = (wallet.network == null || wallet.network == ChainNetwork.unspecified)
? null
: wallet.network!.localizedName(context);
final symbol = wallet.tokenSymbol?.trim();
return Card(
color: Theme.of(context).colorScheme.onSecondary,
elevation: WalletCardConfig.elevation,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius),
),
child: Padding(
padding: WalletCardConfig.contentPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BalanceHeader(
walletNetwork: wallet.network,
tokenSymbol: wallet.tokenSymbol,
),
Row(
children: [
BalanceAmount(
wallet: wallet,
onToggleVisibility: () {
context.read<WalletsController>().toggleVisibility(wallet.id);
},
),
WalletBalanceRefreshButton(
walletId: wallet.id,
),
],
),
BalanceAddFunds(
onTopUp: () {
onTopUp();
},
),
],
child: SizedBox.expand(
child: Padding(
padding: WalletCardConfig.contentPadding,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BalanceHeader(
title: loc.paymentTypeCryptoWallet,
subtitle: networkLabel,
badge: (symbol == null || symbol.isEmpty) ? null : symbol,
),
Row(
children: [
BalanceAmount(
wallet: wallet,
onToggleVisibility: () {
context.read<WalletsController>().toggleVisibility(wallet.id);
},
),
WalletBalanceRefreshButton(
walletRef: wallet.id,
),
],
),
BalanceAddFunds(onTopUp: onTopUp),
],
),
),
),
);
}
}

View File

@@ -1,43 +1,117 @@
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/card.dart';
import 'package:pweb/pages/dashboard/buttons/balance/balance_item.dart';
import 'package:pweb/pages/dashboard/buttons/balance/card.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
import 'package:pweb/pages/dashboard/buttons/balance/indicator.dart';
import 'package:pweb/pages/dashboard/buttons/balance/ledger.dart';
class WalletCarousel extends StatelessWidget {
final List<Wallet> wallets;
class BalanceCarousel extends StatefulWidget {
final List<BalanceItem> items;
final int currentIndex;
final ValueChanged<int> onIndexChanged;
final ValueChanged<Wallet> onTopUp;
const WalletCarousel({
const BalanceCarousel({
super.key,
required this.wallets,
required this.items,
required this.currentIndex,
required this.onIndexChanged,
required this.onTopUp,
});
@override
State<BalanceCarousel> createState() => _BalanceCarouselState();
}
class _BalanceCarouselState extends State<BalanceCarousel> {
late final PageController _controller;
@override
void initState() {
super.initState();
_controller = PageController(
initialPage: widget.currentIndex,
viewportFraction: WalletCardConfig.viewportFraction,
);
}
@override
void didUpdateWidget(covariant BalanceCarousel oldWidget) {
super.didUpdateWidget(oldWidget);
if (!mounted) return;
if (_controller.hasClients) {
final currentPage = _controller.page?.round();
if (currentPage != widget.currentIndex) {
_controller.jumpToPage(widget.currentIndex);
}
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _goToPage(int index) {
if (!_controller.hasClients) return;
_controller.animateToPage(
index,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
);
}
@override
Widget build(BuildContext context) {
if (wallets.isEmpty) {
if (widget.items.isEmpty) {
return const SizedBox.shrink();
}
final safeIndex = currentIndex.clamp(0, wallets.length - 1);
final wallet = wallets[safeIndex];
final safeIndex = widget.currentIndex.clamp(0, widget.items.length - 1);
final scrollBehavior = ScrollConfiguration.of(context).copyWith(
dragDevices: const {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
},
);
return Column(
children: [
SizedBox(
height: WalletCardConfig.cardHeight,
child: Padding(
padding: WalletCardConfig.cardPadding,
child: WalletCard(
wallet: wallet,
onTopUp: () => onTopUp(wallet),
child: MouseRegion(
cursor: SystemMouseCursors.grab,
child: ScrollConfiguration(
behavior: scrollBehavior,
child: PageView.builder(
controller: _controller,
onPageChanged: widget.onIndexChanged,
itemCount: widget.items.length,
itemBuilder: (context, index) {
final item = widget.items[index];
final Widget card = switch (item.type) {
BalanceItemType.wallet => WalletCard(
wallet: item.wallet!,
onTopUp: () => widget.onTopUp(item.wallet!),
),
BalanceItemType.ledger => LedgerAccountCard(account: item.account!),
BalanceItemType.addAction => const AddBalanceCard(),
};
return Padding(
padding: WalletCardConfig.cardPadding,
child: card,
);
},
),
),
),
),
@@ -47,20 +121,18 @@ class WalletCarousel extends StatelessWidget {
children: [
IconButton(
onPressed: safeIndex > 0
? () => onIndexChanged(safeIndex - 1)
? () => _goToPage(safeIndex - 1)
: null,
icon: const Icon(Icons.arrow_back),
),
const SizedBox(width: 16),
CarouselIndicator(
itemCount: wallets.length,
itemCount: widget.items.length,
index: safeIndex,
),
const SizedBox(width: 16),
IconButton(
onPressed: safeIndex < wallets.length - 1
? () => onIndexChanged(safeIndex + 1)
: null,
onPressed: safeIndex < widget.items.length - 1 ? () => _goToPage(safeIndex + 1) : null,
icon: const Icon(Icons.arrow_forward),
),
],

View File

@@ -1,29 +1,24 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/utils/l10n/chain.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class BalanceHeader extends StatelessWidget {
final ChainNetwork? walletNetwork;
final String? tokenSymbol;
final String title;
final String? subtitle;
final String? badge;
const BalanceHeader({
super.key,
this.walletNetwork,
this.tokenSymbol,
required this.title,
this.subtitle,
this.badge,
});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final loc = AppLocalizations.of(context)!;
final symbol = tokenSymbol?.trim();
final networkLabel = (walletNetwork == null || walletNetwork == ChainNetwork.unspecified)
? null
: walletNetwork!.localizedName(context);
final subtitleText = subtitle?.trim();
final badgeText = badge?.trim();
return Row(
children: [
@@ -32,14 +27,14 @@ class BalanceHeader extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.paymentTypeCryptoWallet,
title,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
),
),
if (networkLabel != null)
if (subtitleText != null && subtitleText.isNotEmpty)
Text(
networkLabel,
subtitleText,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
@@ -48,7 +43,7 @@ class BalanceHeader extends StatelessWidget {
],
),
),
if (symbol != null && symbol.isNotEmpty) ...[
if (badgeText != null && badgeText.isNotEmpty) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
@@ -57,7 +52,7 @@ class BalanceHeader extends StatelessWidget {
borderRadius: BorderRadius.circular(999),
),
child: Text(
symbol,
badgeText,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
import 'package:pweb/widgets/refresh_balance/ledger.dart';
class LedgerAccountCard extends StatelessWidget {
final LedgerAccount account;
const LedgerAccountCard({
super.key,
required this.account,
});
String _formatBalance() {
final money = account.balance?.balance;
if (money == null) return '--';
final amount = double.tryParse(money.amount);
if (amount == null) {
return '${money.amount} ${money.currency}';
}
try {
final currency = currencyStringToCode(money.currency);
final symbol = currencyCodeToSymbol(currency);
if (symbol.trim().isEmpty) {
return '${amountToString(amount)} ${money.currency}';
}
return '${amountToString(amount)} $symbol';
} catch (_) {
return '${amountToString(amount)} ${money.currency}';
}
}
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final loc = AppLocalizations.of(context)!;
final subtitle = account.name.isNotEmpty ? account.name : account.accountCode;
final badge = account.currency.trim().isEmpty ? null : account.currency.toUpperCase();
return Card(
color: colorScheme.onSecondary,
elevation: WalletCardConfig.elevation,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius),
),
child: Padding(
padding: WalletCardConfig.contentPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BalanceHeader(
title: loc.paymentTypeLedger,
subtitle: subtitle.isNotEmpty ? subtitle : null,
badge: badge,
),
Row(
children: [
Text(
_formatBalance(),
style: textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(width: 12),
LedgerBalanceRefreshButton(
ledgerAccountRef: account.ledgerAccountRef,
),
],
),
],
),
),
);
}
}

View File

@@ -17,7 +17,7 @@ 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/wallet_balance_refresh_button.dart';
import 'package:pweb/widgets/refresh_balance/wallet.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -87,7 +87,7 @@ class PaymentPageContent extends StatelessWidget {
if (selectedWalletId == null) {
return const SizedBox.shrink();
}
return WalletBalanceRefreshButton(walletId: selectedWalletId);
return WalletBalanceRefreshButton(walletRef: selectedWalletId);
},
),
],

View File

@@ -8,7 +8,7 @@ import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/models/visibility.dart';
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
import 'package:pweb/widgets/wallet_balance_refresh_button.dart';
import 'package:pweb/widgets/refresh_balance/wallet.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -55,7 +55,7 @@ class WalletCard extends StatelessWidget {
},
),
WalletBalanceRefreshButton(
walletId: wallet.id,
walletRef: wallet.id,
iconOnly: VisibilityState.hidden,
),
],

View File

@@ -6,7 +6,7 @@ import 'package:provider/provider.dart';
import 'package:pshared/controllers/wallets.dart';
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
import 'package:pweb/widgets/wallet_balance_refresh_button.dart';
import 'package:pweb/widgets/refresh_balance/wallet.dart';
class WalletEditFields extends StatelessWidget {
@@ -33,7 +33,7 @@ class WalletEditFields extends StatelessWidget {
onToggleVisibility: () => controller.toggleVisibility(wallet.id),
),
),
WalletBalanceRefreshButton(walletId: wallet.id),
WalletBalanceRefreshButton(walletRef: wallet.id),
],
),
const SizedBox(height: 8),

View File

@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:pweb/models/visibility.dart';
class BalanceRefreshButton extends StatelessWidget {
final bool isBusy;
final bool enabled;
final VoidCallback onPressed;
final VisibilityState iconOnly;
final String label;
final String tooltip;
final double iconSize;
const BalanceRefreshButton({
super.key,
required this.isBusy,
required this.enabled,
required this.onPressed,
required this.iconOnly,
required this.label,
required this.tooltip,
this.iconSize = 18,
});
@override
Widget build(BuildContext context) {
final canPress = enabled && !isBusy;
if (iconOnly == VisibilityState.hidden) {
return IconButton(
tooltip: tooltip,
onPressed: canPress ? onPressed : null,
icon: isBusy
? SizedBox(
width: iconSize,
height: iconSize,
child: const CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
);
}
return TextButton.icon(
onPressed: canPress ? onPressed : null,
icon: isBusy
? SizedBox(
width: iconSize,
height: iconSize,
child: const CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
label: Text(label),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/ledger.dart';
import 'package:pweb/models/visibility.dart';
import 'package:pweb/widgets/refresh_balance/button.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class LedgerBalanceRefreshButton extends StatelessWidget {
final String ledgerAccountRef;
final VisibilityState iconOnly;
final double iconSize = 18;
const LedgerBalanceRefreshButton({
super.key,
required this.ledgerAccountRef,
this.iconOnly = VisibilityState.visible,
});
@override
Widget build(BuildContext context) {
final ledgerProvider = context.watch<LedgerAccountsProvider>();
final loc = AppLocalizations.of(context)!;
final isBusy = ledgerProvider.isWalletRefreshing(ledgerAccountRef) || ledgerProvider.isLoading;
final hasTarget = ledgerProvider.accounts.any((a) => a.ledgerAccountRef == ledgerAccountRef);
void refresh() {
final provider = context.read<LedgerAccountsProvider>();
provider.refreshBalance(ledgerAccountRef);
}
return BalanceRefreshButton(
isBusy: isBusy,
enabled: hasTarget,
onPressed: refresh,
iconOnly: iconOnly,
label: loc.refreshBalance,
tooltip: loc.refreshBalance,
iconSize: iconSize,
);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/models/visibility.dart';
import 'package:pweb/widgets/refresh_balance/button.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class WalletBalanceRefreshButton extends StatelessWidget {
final String walletRef;
final VisibilityState iconOnly;
final double iconSize = 18;
const WalletBalanceRefreshButton({
super.key,
required this.walletRef,
this.iconOnly = VisibilityState.visible,
});
@override
Widget build(BuildContext context) {
final walletsProvider = context.watch<WalletsProvider>();
final loc = AppLocalizations.of(context)!;
final isBusy = walletsProvider.isWalletRefreshing(walletRef) || walletsProvider.isLoading;
final hasTarget = walletsProvider.wallets.any((w) => w.id == walletRef);
void refresh() {
final provider = context.read<WalletsProvider>();
provider.refreshBalance(walletRef);
}
return BalanceRefreshButton(
isBusy: isBusy,
enabled: hasTarget,
onPressed: refresh,
iconOnly: iconOnly,
label: loc.refreshBalance,
tooltip: loc.refreshBalance,
iconSize: iconSize,
);
}
}

View File

@@ -1,62 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/models/visibility.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class WalletBalanceRefreshButton extends StatelessWidget {
final String walletId;
final VisibilityState iconOnly;
final double iconSize = 18;
const WalletBalanceRefreshButton({
super.key,
required this.walletId,
this.iconOnly = VisibilityState.visible,
});
@override
Widget build(BuildContext context) {
final walletsProvider = context.watch<WalletsProvider>();
final loc = AppLocalizations.of(context)!;
final isBusy = walletsProvider.isWalletRefreshing(walletId) || walletsProvider.isLoading;
final hasTarget = walletsProvider.wallets.any((w) => w.id == walletId);
void refresh() {
final provider = context.read<WalletsProvider>();
provider.refreshBalance(walletId);
}
if (iconOnly == VisibilityState.hidden) {
return IconButton(
tooltip: loc.refreshBalance,
onPressed: hasTarget && !isBusy ? refresh : null,
icon: isBusy
? SizedBox(
width: iconSize,
height: iconSize,
child: const CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
);
}
return TextButton.icon(
onPressed: hasTarget && !isBusy ? refresh : null,
icon: isBusy
? SizedBox(
width: iconSize,
height: iconSize,
child: const CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
label: Text(loc.refreshBalance),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
);
}
}