import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/account.dart'; import 'package:island/models/wallet.dart'; import 'package:island/pods/network.dart'; import 'package:island/widgets/account/account_picker.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/payment/payment_overlay.dart'; import 'package:island/widgets/response.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; import 'package:styled_widget/styled_widget.dart'; part 'wallet.g.dart'; @riverpod Future walletCurrent(Ref ref) async { try { final apiClient = ref.watch(apiClientProvider); final resp = await apiClient.get('/id/wallets'); return SnWallet.fromJson(resp.data); } catch (err) { if (err is DioException && err.response?.statusCode == 404) { return null; } rethrow; } } class CreateFundSheet extends StatefulWidget { const CreateFundSheet({super.key}); @override State createState() => _CreateFundSheetState(); } class _CreateFundSheetState extends State { final amountController = TextEditingController(); final messageController = TextEditingController(); String selectedCurrency = 'golds'; int selectedSplitType = 0; // 0: even, 1: random List selectedRecipients = []; @override void dispose() { amountController.dispose(); messageController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SheetScaffold( titleText: 'createFund'.tr(), child: Column( children: [ Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Amount Section Text( 'fundAmount'.tr(), style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.primary, ), ), const Gap(8), TextField( controller: amountController, keyboardType: TextInputType.numberWithOptions( decimal: true, ), inputFormatters: [ FilteringTextInputFormatter.allow( RegExp(r'^\d+\.?\d{0,2}'), ), ], decoration: InputDecoration( labelText: 'enterAmount'.tr(), hintText: '0.00', prefixIcon: Icon(kCurrencyIconData[selectedCurrency]), border: OutlineInputBorder(), enabledBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of( context, ).colorScheme.outline.withOpacity(0.2), ), ), focusedBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.primary, ), ), ), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ), const Gap(16), // Currency Selection Text( 'selectCurrency'.tr(), style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.primary, ), ), const Gap(8), DropdownButtonFormField( value: selectedCurrency, decoration: InputDecoration( border: OutlineInputBorder(), enabledBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of( context, ).colorScheme.outline.withOpacity(0.2), ), ), focusedBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.primary, ), ), ), items: kCurrencyIconData.keys.map((currency) { return DropdownMenuItem( value: currency, child: Row( children: [ Icon(kCurrencyIconData[currency]), const Gap(8), Text( 'walletCurrency${currency[0].toUpperCase()}${currency.substring(1).toLowerCase()}' .tr(), ), ], ), ); }).toList(), onChanged: (value) { if (value != null) { setState(() => selectedCurrency = value); } }, ), // Split Type Section (only show when there are 2+ recipients) if (selectedRecipients.length >= 2) ...[ const Gap(16), Text( 'splitType'.tr(), style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.primary, ), ), const Gap(8), Row( children: [ Expanded( child: RadioListTile( title: Text('evenSplit'.tr()), subtitle: Text('equalAmountEach'.tr()), value: 0, groupValue: selectedSplitType, onChanged: (value) { if (value != null) { setState(() => selectedSplitType = value); } }, ), ), Expanded( child: RadioListTile( title: Text('randomSplit'.tr()), subtitle: Text('randomAmountEach'.tr()), value: 1, groupValue: selectedSplitType, onChanged: (value) { if (value != null) { setState(() => selectedSplitType = value); } }, ), ), ], ), ], const Gap(16), // Recipient Selection Section Text( 'selectRecipients'.tr(), style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.primary, ), ), const Gap(8), Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(8), border: Border.all( color: Theme.of( context, ).colorScheme.outline.withOpacity(0.2), ), ), child: selectedRecipients.isNotEmpty ? Column( children: [ ...selectedRecipients.map((recipient) { return ListTile( contentPadding: const EdgeInsets.only( left: 20, right: 12, ), leading: ProfilePictureWidget( file: recipient.profile.picture, ), title: Text( recipient.nick, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), ), subtitle: Text( 'selectedRecipient'.tr(), style: Theme.of( context, ).textTheme.bodySmall?.copyWith( color: Theme.of( context, ).colorScheme.onSurfaceVariant, ), ), trailing: IconButton( onPressed: () => setState( () => selectedRecipients.remove( recipient, ), ), icon: Icon( Icons.clear, color: Theme.of(context).colorScheme.error, ), tooltip: 'Remove recipient', ), ); }), if (selectedRecipients.length < 10) OutlinedButton.icon( onPressed: () async { final recipient = await showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, builder: (context) => const AccountPickerSheet(), ); if (recipient != null && !selectedRecipients.contains( recipient, )) { setState( () => selectedRecipients.add(recipient), ); } }, icon: const Icon(Icons.person_add), label: Text('addRecipient'.tr()), style: OutlinedButton.styleFrom( minimumSize: const Size( double.infinity, 48, ), ), ).padding(all: 16), ], ) : Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.person_add_outlined, size: 48, color: Theme.of( context, ).colorScheme.onSurfaceVariant, ), const Gap(8), Text( 'noRecipientsSelected'.tr(), style: Theme.of( context, ).textTheme.bodyMedium?.copyWith( color: Theme.of( context, ).colorScheme.onSurfaceVariant, ), ), const Gap(4), Text( 'selectRecipientsToSendFund'.tr(), style: Theme.of( context, ).textTheme.bodySmall?.copyWith( color: Theme.of( context, ).colorScheme.onSurfaceVariant, ), textAlign: TextAlign.center, ), ], ).padding(vertical: 32), ), const Gap(12), OutlinedButton.icon( onPressed: () async { final recipient = await showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, builder: (context) => const AccountPickerSheet(), ); if (recipient != null && !selectedRecipients.contains(recipient)) { setState(() => selectedRecipients.add(recipient)); } }, icon: const Icon(Icons.person_search), label: Text( selectedRecipients.isNotEmpty ? 'addMoreRecipients'.tr() : 'selectRecipients'.tr(), ), style: OutlinedButton.styleFrom( minimumSize: const Size(double.infinity, 48), ), ), const Gap(16), // Message Section Text( 'addMessage'.tr(), style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.primary, ), ), const Gap(8), TextField( controller: messageController, decoration: InputDecoration( labelText: 'personalMessage'.tr(), hintText: 'addPersonalMessageForRecipients'.tr(), alignLabelWithHint: true, border: OutlineInputBorder(), enabledBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of( context, ).colorScheme.outline.withOpacity(0.2), ), ), focusedBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.primary, ), ), ), maxLines: 3, onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ), ], ), ), ), // Action Buttons Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Expanded( child: OutlinedButton( onPressed: () => Navigator.of(context).pop(), child: Text('cancel'.tr()), ), ), const Gap(8), Expanded( child: FilledButton( onPressed: _createFund, child: Text('createFund'.tr()), ), ), ], ), ), ], ), ); } Future _createFund() async { final amount = double.tryParse(amountController.text); if (amount == null || amount <= 0) { showErrorAlert('invalidAmount'.tr()); return; } if (selectedRecipients.isEmpty) { showErrorAlert('noRecipientsSelected'.tr()); return; } final data = { 'currency': selectedCurrency, 'total_amount': amount, 'split_type': selectedSplitType, 'recipient_account_ids': selectedRecipients.map((r) => r.id).toList(), 'message': messageController.text.trim().isEmpty ? null : messageController.text.trim(), }; Navigator.of(context).pop(data); } } const Map kCurrencyIconData = { 'points': Symbols.toll, 'golds': Symbols.attach_money, }; @riverpod class TransactionListNotifier extends _$TransactionListNotifier with CursorPagingNotifierMixin { static const int _pageSize = 20; @override Future> build() => fetch(cursor: null); @override Future> fetch({ required String? cursor, }) async { final client = ref.read(apiClientProvider); final offset = cursor == null ? 0 : int.parse(cursor); final queryParams = {'offset': offset, 'take': _pageSize}; final response = await client.get( '/id/wallets/transactions', queryParameters: queryParams, ); final total = int.parse(response.headers.value('X-Total') ?? '0'); final List data = response.data; final transactions = data.map((json) => SnTransaction.fromJson(json)).toList(); final hasMore = offset + transactions.length < total; final nextCursor = hasMore ? (offset + transactions.length).toString() : null; return CursorPagingData( items: transactions, hasMore: hasMore, nextCursor: nextCursor, ); } } @riverpod Future> walletFunds( Ref ref, { int offset = 0, int take = 20, }) async { final client = ref.watch(apiClientProvider); final resp = await client.get('/id/wallets/funds?offset=$offset&take=$take'); return (resp.data as List).map((e) => SnWalletFund.fromJson(e)).toList(); } @riverpod Future> walletFundRecipients( Ref ref, { int offset = 0, int take = 20, }) async { final client = ref.watch(apiClientProvider); final resp = await client.get( '/id/wallets/funds/recipients?offset=$offset&take=$take', ); return (resp.data as List) .map((e) => SnWalletFundRecipient.fromJson(e)) .toList(); } @riverpod Future walletFund(Ref ref, String fundId) async { final client = ref.watch(apiClientProvider); final resp = await client.get('/id/wallets/funds/$fundId'); return SnWalletFund.fromJson(resp.data); } @riverpod Future> walletFundStats(Ref ref) async { final client = ref.watch(apiClientProvider); final resp = await client.get('/id/wallets/funds/stats'); return resp.data as Map; } class WalletScreen extends HookConsumerWidget { const WalletScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final wallet = ref.watch(walletCurrentProvider); final tabController = useTabController(initialLength: 3); final fundStats = ref.watch(walletFundStatsProvider); Future createWallet() async { final client = ref.read(apiClientProvider); try { await client.post('/id/wallets'); ref.invalidate(walletCurrentProvider); } catch (err) { showErrorAlert(err); } } Future createFund() async { final result = await showModalBottomSheet>( context: context, useRootNavigator: true, isScrollControlled: true, builder: (context) => const CreateFundSheet(), ); if (result != null && context.mounted) { await _handleFundCreation(context, ref, result); } } String getCurrencyTranslationKey(String currency, {bool isShort = false}) { return 'walletCurrency${isShort ? 'Short' : ''}${currency[0].toUpperCase()}${currency.substring(1).toLowerCase()}'; } return AppScaffold( appBar: AppBar( title: Text('wallet').tr(), actions: [ IconButton( icon: const Icon(Symbols.add), onPressed: createFund, tooltip: 'createFund'.tr(), ), const Gap(8), ], ), body: wallet.when( data: (data) { if (data == null) { return ConstrainedBox( constraints: BoxConstraints(maxWidth: 280), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('walletNotFound').tr().fontSize(16).bold(), Text('walletCreateHint', textAlign: TextAlign.center).tr(), TextButton( onPressed: createWallet, child: Text('walletCreate').tr(), ), ], ), ).center(); } return Column( children: [ // Wallet Overview Column( spacing: 8, children: [ // Pockets ...data.pockets.map( (pocket) => Card( margin: EdgeInsets.zero, child: ListTile( leading: Icon( kCurrencyIconData[pocket.currency] ?? Symbols.universal_currency_alt, ), title: Text( getCurrencyTranslationKey(pocket.currency), ).tr(), subtitle: Text( '${pocket.amount.toStringAsFixed(2)} ${getCurrencyTranslationKey(pocket.currency, isShort: true).tr()}', ), ), ), ), // Fund Stats fundStats.when( data: (stats) => Card( margin: EdgeInsets.zero, child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Symbols.celebration, color: Theme.of(context).colorScheme.primary, ), const Gap(8), Text( 'fundOverview'.tr(), style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), ], ), const Gap(8), Row( children: [ Expanded( child: _buildStatItem( context, 'totalFundsSent'.tr(), '${stats['total_sent'] ?? 0}', Icons.send, ), ), Expanded( child: _buildStatItem( context, 'totalFundsReceived'.tr(), '${stats['total_received'] ?? 0}', Icons.call_received, ), ), ], ), ], ), ), ), loading: () => const Card( margin: EdgeInsets.zero, child: Padding( padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()), ), ), error: (error, stack) => const SizedBox.shrink(), ), ], ).padding(horizontal: 16, vertical: 16), // Tab Bar TabBar( controller: tabController, tabs: [ Tab(text: 'transactions'.tr()), Tab(text: 'myFunds'.tr()), ], ), // Tab Content Expanded( child: TabBarView( controller: tabController, children: [ // Transactions Tab PagingHelperView( provider: transactionListNotifierProvider, futureRefreshable: transactionListNotifierProvider.future, notifierRefreshable: transactionListNotifierProvider.notifier, contentBuilder: (data, widgetCount, endItemView) => ListView.builder( padding: EdgeInsets.zero, itemCount: widgetCount, itemBuilder: (context, index) { if (index == widgetCount - 1) { return endItemView; } final transaction = data.items[index]; final isIncome = transaction.payeeWalletId == wallet.value?.id; return ListTile( key: ValueKey(transaction.id), leading: Icon( isIncome ? Symbols.arrow_upward : Symbols.arrow_downward, ), title: Text(transaction.remarks ?? ''), subtitle: Text( DateFormat.yMd().add_Hm().format( transaction.createdAt, ), ), trailing: Text( '${isIncome ? '+' : '-'}${transaction.amount.toStringAsFixed(2)} ${transaction.currency}', style: TextStyle( color: isIncome ? Colors.green : Colors.red, ), ), ); }, ), ), // My Funds Tab _buildFundsList(context, ref), ], ), ), ], ); }, error: (error, stackTrace) => ResponseErrorWidget( error: error, onRetry: () => ref.invalidate(walletCurrentProvider), ), loading: () => const Center(child: CircularProgressIndicator()), ), ); } Widget _buildStatItem( BuildContext context, String label, String value, IconData icon, ) { return Column( children: [ Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), const Gap(4), Text( value, style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary, ), ), Text( label, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center, ), ], ); } Widget _buildFundsList(BuildContext context, WidgetRef ref) { final funds = ref.watch(walletFundsProvider()); return funds.when( data: (fundList) { if (fundList.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Symbols.celebration, size: 48, color: Theme.of(context).colorScheme.outline, ), const Gap(16), Text( 'noFundsCreated'.tr(), style: Theme.of(context).textTheme.titleMedium, ), const Gap(8), Text( 'createYourFirstFund'.tr(), style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), textAlign: TextAlign.center, ), ], ), ); } return ListView.builder( padding: const EdgeInsets.all(16), itemCount: fundList.length, itemBuilder: (context, index) { final fund = fundList[index]; final claimedCount = fund.recipients.where((r) => r.status == 1).length; final totalRecipients = fund.recipients.length; return Card( margin: const EdgeInsets.only(bottom: 8), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Symbols.celebration, color: Theme.of(context).colorScheme.primary, ), const Gap(8), Expanded( child: Text( '${fund.totalAmount.toStringAsFixed(2)} ${fund.currency}', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), ), Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), decoration: BoxDecoration( color: _getFundStatusColor( context, fund.status, ).withOpacity(0.1), borderRadius: BorderRadius.circular(12), ), child: Text( _getFundStatusText(fund.status), style: TextStyle( color: _getFundStatusColor(context, fund.status), fontSize: 12, fontWeight: FontWeight.w600, ), ), ), ], ), const Gap(8), Text( '${'recipients'.tr()}: $claimedCount/$totalRecipients', style: Theme.of(context).textTheme.bodyMedium, ), if (fund.message != null && fund.message!.isNotEmpty) ...[ const Gap(4), Text( fund.message!, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], const Gap(8), Text( DateFormat.yMd().add_Hm().format(fund.createdAt), style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ), ), ); }, ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (error, stack) => Center(child: Text('Error: $error')), ); } Future _handleFundCreation( BuildContext context, WidgetRef ref, Map fundData, ) async { final client = ref.read(apiClientProvider); try { showLoadingModal(context); final resp = await client.post( '/id/wallets/funds', data: fundData, options: Options(headers: {'X-Noop': true}), ); final fund = SnWalletFund.fromJson(resp.data); if (fund.status == 0) return; // Already created final orderResp = await client.post('/id/wallets/funds/${fund.id}/order'); final order = SnWalletOrder.fromJson(orderResp.data); if (context.mounted) hideLoadingModal(context); // Show payment overlay to complete the payment if (!context.mounted) return; final paidOrder = await PaymentOverlay.show( context: context, order: order, enableBiometric: true, ); if (context.mounted) showLoadingModal(context); if (paidOrder != null) { // Wait for server to handle order await Future.delayed(const Duration(seconds: 1)); ref.invalidate(walletFundsProvider); ref.invalidate(walletFundStatsProvider); ref.invalidate(walletCurrentProvider); if (context.mounted) { showSnackBar('fundCreatedSuccessfully'.tr()); } } } catch (err) { showErrorAlert(err); } finally { if (context.mounted) hideLoadingModal(context); } } String _getFundStatusText(int status) { switch (status) { case 0: return 'fundStatusCreated'.tr(); case 1: return 'fundStatusPartial'.tr(); case 2: return 'fundStatusCompleted'.tr(); case 3: return 'fundStatusExpired'.tr(); default: return 'fundStatusUnknown'.tr(); } } Color _getFundStatusColor(BuildContext context, int status) { switch (status) { case 0: return Colors.blue; case 1: return Colors.orange; case 2: return Colors.green; case 3: return Colors.red; default: return Theme.of(context).colorScheme.primary; } } }