Claim fund

This commit is contained in:
2025-11-17 01:20:49 +08:00
parent 943e4b7b5c
commit d4f95bbbf4
10 changed files with 876 additions and 45 deletions

View File

@@ -0,0 +1,596 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/wallet.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:easy_localization/easy_localization.dart';
part 'fund_envelope.g.dart';
@riverpod
Future<SnWalletFund> walletFund(Ref ref, String fundId) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/pass/wallets/funds/$fundId');
return SnWalletFund.fromJson(resp.data);
}
class FundEnvelopeWidget extends HookConsumerWidget {
const FundEnvelopeWidget({
super.key,
required this.fundId,
this.maxWidth,
this.margin,
});
final String fundId;
final double? maxWidth;
final EdgeInsetsGeometry? margin;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fundAsync = ref.watch(walletFundProvider(fundId));
return Container(
width: maxWidth,
margin: margin ?? const EdgeInsets.symmetric(vertical: 8),
child: fundAsync.when(
loading:
() => Card(
margin: EdgeInsets.zero,
child: const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
),
error:
(error, stack) => Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 8),
Text(
'Failed to load fund envelope',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
],
),
),
),
data:
(fund) => Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => _showClaimDialog(context, ref, fund),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Fund title and status
Row(
children: [
Icon(
Icons.account_balance_wallet,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Fund Envelope',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
),
_buildStatusChips(context, fund),
],
),
const SizedBox(height: 12),
// Amount information
Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${fund.totalAmount.toStringAsFixed(2)} ${fund.currency}',
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
if (fund.remainingAmount != fund.totalAmount)
Text(
'Remaining: ${fund.remainingAmount.toStringAsFixed(2)} ${fund.currency}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.w500,
),
),
Text(
'Split: ${fund.splitType == 0 ? 'Evenly' : 'Randomly'}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.textTheme
.bodySmall
?.color
?.withOpacity(0.7),
),
),
],
),
],
),
// Recipients overview
if (fund.recipients.isNotEmpty) ...[
const SizedBox(height: 12),
_buildRecipientsOverview(context, fund),
],
// Message
if (fund.message != null && fund.message!.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
'"${fund.message}"',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(
fontStyle: FontStyle.italic,
color:
Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
// Creator info
if (fund.creatorAccount != null) ...[
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.person,
size: 16,
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
fund.creatorAccount!.nick,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
],
// Expiry info
const SizedBox(height: 6),
Row(
children: [
Icon(
Icons.schedule,
size: 16,
color:
Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
_formatDate(fund.expiredAt),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
],
),
),
),
),
),
);
}
void _showClaimDialog(
BuildContext context,
WidgetRef ref,
SnWalletFund fund,
) {
showDialog(
context: context,
builder:
(dialogContext) => FundClaimDialog(
fund: fund,
onClaim: () async {
try {
final apiClient = ref.read(apiClientProvider);
await apiClient.post('/pass/wallets/funds/${fund.id}/receive');
// Refresh the fund data after claiming
ref.invalidate(walletFundProvider(fund.id));
if (dialogContext.mounted) {
Navigator.of(dialogContext).pop();
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(content: Text('Fund claimed successfully!'.tr())),
);
}
} catch (e) {
if (dialogContext.mounted) {
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(
content: Text('Failed to claim fund: $e'),
backgroundColor:
Theme.of(dialogContext).colorScheme.error,
),
);
}
}
},
),
);
}
Widget _buildStatusChip(BuildContext context, int status) {
String text;
Color color;
switch (status) {
case 0:
text = 'Created';
color = Colors.blue;
break;
case 1:
text = 'Partially Claimed';
color = Colors.orange;
break;
case 2:
text = 'Fully Claimed';
color = Colors.green;
break;
case 3:
text = 'Expired';
color = Colors.red;
break;
default:
text = 'Unknown';
color = Colors.grey;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Text(
text,
style: TextStyle(
fontSize: 10,
color: color,
fontWeight: FontWeight.w500,
),
),
);
}
Widget _buildStatusChips(BuildContext context, SnWalletFund fund) {
return Row(
children: [
if (fund.isOpen) ...[
_buildOpenFundBadge(context),
const SizedBox(width: 6),
],
_buildStatusChip(context, fund.status),
],
);
}
Widget _buildOpenFundBadge(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.green.withOpacity(0.3)),
),
child: Text(
'Open Fund'.tr(),
style: const TextStyle(
fontSize: 10,
color: Colors.green,
fontWeight: FontWeight.w500,
),
),
);
}
Widget _buildRecipientsOverview(BuildContext context, SnWalletFund fund) {
final claimedCount = fund.recipients.where((r) => r.isReceived).length;
final totalCount =
fund.isOpen ? fund.amountOfSplits : fund.recipients.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Recipients ($claimedCount/$totalCount claimed)',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: totalCount > 0 ? claimedCount / totalCount : 0,
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
),
],
);
}
String _formatDate(DateTime date) {
try {
final now = DateTime.now();
final difference = date.difference(now);
if (difference.isNegative) {
return 'Expired ${difference.inDays.abs()} days ago';
} else if (difference.inDays == 0) {
final hours = difference.inHours;
if (hours == 0) {
return 'Expires soon';
}
return 'Expires in $hours hour${hours == 1 ? '' : 's'}';
} else if (difference.inDays < 7) {
return 'Expires in ${difference.inDays} day${difference.inDays == 1 ? '' : 's'}';
} else {
return '${date.day}/${date.month}/${date.year}';
}
} catch (e) {
return date.toString();
}
}
}
class FundClaimDialog extends HookConsumerWidget {
const FundClaimDialog({super.key, required this.fund, required this.onClaim});
final SnWalletFund fund;
final VoidCallback onClaim;
@override
Widget build(BuildContext context, WidgetRef ref) {
final userInfo = ref.watch(userInfoProvider);
// Check if user can claim
final now = DateTime.now();
final isExpired = fund.expiredAt.isBefore(now);
final hasRemainingAmount = fund.remainingAmount > 0;
final hasUserClaimed =
userInfo.value != null &&
fund.recipients.any(
(recipient) =>
recipient.recipientAccountId == userInfo.value!.id &&
recipient.isReceived,
);
final userAbleToClaim =
userInfo.value != null &&
(fund.isOpen ||
fund.recipients.any(
(recipient) => recipient.recipientAccountId == userInfo.value!.id,
));
final canClaim =
!isExpired && hasRemainingAmount && !hasUserClaimed && userAbleToClaim;
// Get claimed recipients for display
final claimedRecipients =
fund.recipients.where((r) => r.isReceived).toList();
final unclaimedRecipients =
fund.recipients.where((r) => !r.isReceived).toList();
final remainingSplits =
fund.isOpen
? fund.amountOfSplits - claimedRecipients.length
: unclaimedRecipients.length;
return AlertDialog(
title: Row(
children: [
Icon(
Icons.account_balance_wallet,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text('Claim Fund'.tr()),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Fund info
Text(
'${fund.totalAmount.toStringAsFixed(2)} ${fund.currency}',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
// Remaining amount
Text(
'${fund.remainingAmount.toStringAsFixed(2)} ${fund.currency} / ${remainingSplits} splits',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
// Status indicator
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color:
fund.isOpen
? Colors.green.withOpacity(0.1)
: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color:
fund.isOpen
? Colors.green.withOpacity(0.3)
: Colors.blue.withOpacity(0.3),
),
),
child: Text(
fund.isOpen ? 'Open Fund'.tr() : 'Invite Only'.tr(),
style: TextStyle(
fontSize: 12,
color: fund.isOpen ? Colors.green : Colors.blue,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 16),
// Claimed recipients section
if (claimedRecipients.isNotEmpty) ...[
Text(
'Already Claimed'.tr(),
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
...claimedRecipients.map(
(recipient) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(Icons.check_circle, size: 16, color: Colors.green),
const SizedBox(width: 8),
Expanded(
child: Text(
recipient.recipientAccount?.nick ?? 'Unknown User',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
decoration: TextDecoration.lineThrough,
color:
Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
Text(
'${recipient.amount.toStringAsFixed(2)} ${fund.currency}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
),
const SizedBox(height: 12),
],
// Unclaimed recipients section
if (unclaimedRecipients.isNotEmpty) ...[
Text(
'Available to Claim'.tr(),
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
...unclaimedRecipients.map(
(recipient) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(
Icons.radio_button_unchecked,
size: 16,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withOpacity(0.5),
),
const SizedBox(width: 8),
Expanded(
child: Text(
recipient.recipientAccount?.nick ?? 'Unknown User',
style: Theme.of(context).textTheme.bodySmall,
),
),
Text(
'${recipient.amount.toStringAsFixed(2)} ${fund.currency}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancel'.tr()),
),
if (canClaim)
FilledButton.icon(
icon: const Icon(Icons.account_balance_wallet),
label: Text('Claim'.tr()),
onPressed: onClaim,
),
],
);
}
}

View File

@@ -0,0 +1,151 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'fund_envelope.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$walletFundHash() => r'521fa280708e71266f8164268ba11f135f4ba810';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// See also [walletFund].
@ProviderFor(walletFund)
const walletFundProvider = WalletFundFamily();
/// See also [walletFund].
class WalletFundFamily extends Family<AsyncValue<SnWalletFund>> {
/// See also [walletFund].
const WalletFundFamily();
/// See also [walletFund].
WalletFundProvider call(String fundId) {
return WalletFundProvider(fundId);
}
@override
WalletFundProvider getProviderOverride(
covariant WalletFundProvider provider,
) {
return call(provider.fundId);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'walletFundProvider';
}
/// See also [walletFund].
class WalletFundProvider extends AutoDisposeFutureProvider<SnWalletFund> {
/// See also [walletFund].
WalletFundProvider(String fundId)
: this._internal(
(ref) => walletFund(ref as WalletFundRef, fundId),
from: walletFundProvider,
name: r'walletFundProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$walletFundHash,
dependencies: WalletFundFamily._dependencies,
allTransitiveDependencies: WalletFundFamily._allTransitiveDependencies,
fundId: fundId,
);
WalletFundProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.fundId,
}) : super.internal();
final String fundId;
@override
Override overrideWith(
FutureOr<SnWalletFund> Function(WalletFundRef provider) create,
) {
return ProviderOverride(
origin: this,
override: WalletFundProvider._internal(
(ref) => create(ref as WalletFundRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
fundId: fundId,
),
);
}
@override
AutoDisposeFutureProviderElement<SnWalletFund> createElement() {
return _WalletFundProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is WalletFundProvider && other.fundId == fundId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, fundId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin WalletFundRef on AutoDisposeFutureProviderRef<SnWalletFund> {
/// The parameter `fundId` of this provider.
String get fundId;
}
class _WalletFundProviderElement
extends AutoDisposeFutureProviderElement<SnWalletFund>
with WalletFundRef {
_WalletFundProviderElement(super.provider);
@override
String get fundId => (origin as WalletFundProvider).fundId;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package