♻️ Rebuilt fetching state machine

This commit is contained in:
2026-01-01 11:40:28 +08:00
parent eea56a742e
commit 38dffa414f
34 changed files with 665 additions and 430 deletions

View File

@@ -44,144 +44,125 @@ class ComposeFundSheet extends HookConsumerWidget {
children: [
// Link/Select existing fund list
fundsData.when(
data:
(funds) =>
funds.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Symbols.money_bag,
size: 48,
color:
Theme.of(
context,
).colorScheme.outline,
),
const Gap(16),
Text(
'noFundsCreated'.tr(),
style:
Theme.of(
context,
).textTheme.titleMedium,
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: funds.length,
itemBuilder: (context, index) {
final fund = funds[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: InkWell(
onTap:
() =>
Navigator.of(context).pop(fund),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.money_bag,
color:
Theme.of(
context,
).colorScheme.primary,
fill: 1,
),
const Gap(8),
Expanded(
child: Text(
'${fund.totalAmount.toStringAsFixed(2)} ${fund.currency}',
style: TextStyle(
fontSize: 18,
fontWeight:
FontWeight.bold,
color:
Theme.of(context)
.colorScheme
.primary,
),
),
),
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,
),
),
),
],
),
if (fund.message != null &&
fund.message!.isNotEmpty) ...[
const Gap(8),
Text(
fund.message!,
style:
Theme.of(
context,
).textTheme.bodyMedium,
),
],
const Gap(8),
Text(
'${'recipients'.tr()}: ${fund.recipients.where((r) => r.isReceived).length}/${fund.recipients.length}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
],
),
),
),
);
},
data: (funds) => funds.items.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Symbols.money_bag,
size: 48,
color: Theme.of(context).colorScheme.outline,
),
loading:
() => const Center(child: CircularProgressIndicator()),
error:
(error, stack) => Center(child: Text('Error: $error')),
const Gap(16),
Text(
'noFundsCreated'.tr(),
style: Theme.of(
context,
).textTheme.titleMedium,
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: funds.items.length,
itemBuilder: (context, index) {
final fund = funds.items[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: InkWell(
onTap: () => Navigator.of(context).pop(fund),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.money_bag,
color: Theme.of(
context,
).colorScheme.primary,
fill: 1,
),
const Gap(8),
Expanded(
child: Text(
'${fund.totalAmount.toStringAsFixed(2)} ${fund.currency}',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Theme.of(
context,
).colorScheme.primary,
),
),
),
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,
),
),
),
],
),
if (fund.message != null &&
fund.message!.isNotEmpty) ...[
const Gap(8),
Text(
fund.message!,
style: Theme.of(
context,
).textTheme.bodyMedium,
),
],
const Gap(8),
Text(
'${'recipients'.tr()}: ${fund.recipients.where((r) => r.isReceived).length}/${fund.recipients.length}',
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')),
),
// Create new fund and return it
@@ -208,127 +189,125 @@ class ComposeFundSheet extends HookConsumerWidget {
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
icon:
isPushing.value
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Symbols.add_circle),
icon: isPushing.value
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Symbols.add_circle),
label: Text('create'.tr()),
onPressed:
isPushing.value
? null
: () async {
errorText.value = null;
onPressed: isPushing.value
? null
: () async {
errorText.value = null;
isPushing.value = true;
// Show modal bottom sheet with fund creation form and await result
final result = await showModalBottomSheet<
Map<String, dynamic>
>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder:
(context) =>
const CreateFundSheet(),
isPushing.value = true;
// Show modal bottom sheet with fund creation form and await result
final result =
await showModalBottomSheet<
Map<String, dynamic>
>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) =>
const CreateFundSheet(),
);
if (result == null) {
isPushing.value = false;
return;
}
try {
if (!context.mounted) return;
final client = ref.read(
apiClientProvider,
);
showLoadingModal(context);
final resp = await client.post(
'/pass/wallets/funds',
data: result,
options: Options(
headers: {'X-Noop': true},
),
);
if (result == null) {
isPushing.value = false;
final fund = SnWalletFund.fromJson(
resp.data,
);
if (fund.status == 0) {
// Return the fund that was just created (but not yet paid)
if (context.mounted) {
hideLoadingModal(context);
Navigator.of(context).pop(fund);
}
return;
}
try {
if (!context.mounted) return;
final orderResp = await client.post(
'/pass/wallets/funds/${fund.id}/order',
);
final order = SnWalletOrder.fromJson(
orderResp.data,
);
final client = ref.read(
apiClientProvider,
);
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 (paidOrder != null &&
context.mounted) {
showLoadingModal(context);
final resp = await client.post(
'/pass/wallets/funds',
data: result,
options: Options(
headers: {'X-Noop': true},
),
// Wait for server to handle order
await Future.delayed(
const Duration(seconds: 1),
);
ref.invalidate(walletFundsProvider);
final fund = SnWalletFund.fromJson(
resp.data,
// Return the created fund
final updatedResp = await client.get(
'/pass/wallets/funds/${fund.id}',
);
if (fund.status == 0) {
// Return the fund that was just created (but not yet paid)
if (context.mounted) {
hideLoadingModal(context);
Navigator.of(context).pop(fund);
}
return;
}
final orderResp = await client.post(
'/pass/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,
final updatedFund =
SnWalletFund.fromJson(
updatedResp.data,
);
if (paidOrder != null &&
context.mounted) {
showLoadingModal(context);
// Wait for server to handle order
await Future.delayed(
const Duration(seconds: 1),
);
ref.invalidate(walletFundsProvider);
// Return the created fund
final updatedResp = await client.get(
'/pass/wallets/funds/${fund.id}',
);
final updatedFund =
SnWalletFund.fromJson(
updatedResp.data,
);
if (context.mounted) {
hideLoadingModal(context);
Navigator.of(
context,
).pop(updatedFund);
}
} else {
isPushing.value = false;
}
} catch (err) {
if (context.mounted) {
hideLoadingModal(context);
Navigator.of(
context,
).pop(updatedFund);
}
errorText.value = err.toString();
} else {
isPushing.value = false;
}
},
} catch (err) {
if (context.mounted) {
hideLoadingModal(context);
}
errorText.value = err.toString();
isPushing.value = false;
}
},
),
),
],