♻️ 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;
}
},
),
),
],

View File

@@ -13,12 +13,11 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
final cloudFileListNotifierProvider =
AsyncNotifierProvider.autoDispose<CloudFileListNotifier, List<SnCloudFile>>(
CloudFileListNotifier.new,
);
final cloudFileListNotifierProvider = AsyncNotifierProvider.autoDispose(
CloudFileListNotifier.new,
);
class CloudFileListNotifier extends AsyncNotifier<List<SnCloudFile>>
class CloudFileListNotifier extends AsyncNotifier<PaginationState<SnCloudFile>>
with AsyncPaginationController<SnCloudFile> {
@override
Future<List<SnCloudFile>> fetch() async {
@@ -83,28 +82,24 @@ class ComposeLinkAttachment extends HookConsumerWidget {
width: 48,
child: switch (itemType) {
'image' => CloudImageWidget(file: item),
'audio' =>
const Icon(
Symbols.audio_file,
fill: 1,
).center(),
'video' =>
const Icon(
Symbols.video_file,
fill: 1,
).center(),
_ =>
const Icon(
Symbols.body_system,
fill: 1,
).center(),
'audio' => const Icon(
Symbols.audio_file,
fill: 1,
).center(),
'video' => const Icon(
Symbols.video_file,
fill: 1,
).center(),
_ => const Icon(
Symbols.body_system,
fill: 1,
).center(),
},
),
),
title:
item.name.isEmpty
? Text('untitled').tr().italic()
: Text(item.name),
title: item.name.isEmpty
? Text('untitled').tr().italic()
: Text(item.name),
onTap: () {
Navigator.pop(context, item);
},
@@ -128,9 +123,8 @@ class ComposeLinkAttachment extends HookConsumerWidget {
),
),
),
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(16),
InkWell(

View File

@@ -291,7 +291,9 @@ class ComposeSettingsSheet extends HookConsumerWidget {
),
),
hint: Text('categories'.tr(), style: TextStyle(fontSize: 15)),
items: (postCategories.value ?? <SnPostCategory>[]).map((item) {
items: (postCategories.value?.items ?? <SnPostCategory>[]).map((
item,
) {
return DropdownMenuItem(
value: item,
enabled: false,
@@ -337,7 +339,7 @@ class ComposeSettingsSheet extends HookConsumerWidget {
value: currentCategories.isEmpty ? null : currentCategories.last,
onChanged: (_) {},
selectedItemBuilder: (context) {
return (postCategories.value ?? []).map((item) {
return (postCategories.value?.items ?? []).map((item) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(

View File

@@ -6,12 +6,11 @@ import 'package:island/pods/paging.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/paging/pagination_list.dart';
final postAwardListNotifierProvider = AsyncNotifierProvider.autoDispose
.family<PostAwardListNotifier, List<SnPostAward>, String>(
PostAwardListNotifier.new,
);
final postAwardListNotifierProvider = AsyncNotifierProvider.autoDispose.family(
PostAwardListNotifier.new,
);
class PostAwardListNotifier extends AsyncNotifier<List<SnPostAward>>
class PostAwardListNotifier extends AsyncNotifier<PaginationState<SnPostAward>>
with AsyncPaginationController<SnPostAward> {
static const int pageSize = 20;
@@ -52,7 +51,7 @@ class PostAwardHistorySheet extends HookConsumerWidget {
return Column(
children: [
PostAwardItem(award: award),
if (index < (ref.read(provider).value?.length ?? 0) - 1)
if (index < (ref.read(provider).value?.items.length ?? 0) - 1)
const Divider(height: 1),
],
);

View File

@@ -31,12 +31,12 @@ sealed class ReactionListQuery with _$ReactionListQuery {
}) = _ReactionListQuery;
}
final reactionListNotifierProvider = AsyncNotifierProvider.autoDispose
.family<ReactionListNotifier, List<SnPostReaction>, ReactionListQuery>(
ReactionListNotifier.new,
);
final reactionListNotifierProvider = AsyncNotifierProvider.autoDispose.family(
ReactionListNotifier.new,
);
class ReactionListNotifier extends AsyncNotifier<List<SnPostReaction>>
class ReactionListNotifier
extends AsyncNotifier<PaginationState<SnPostReaction>>
with AsyncPaginationController<SnPostReaction> {
static const int pageSize = 20;

View File

@@ -11,7 +11,7 @@ final postRepliesProvider = AsyncNotifierProvider.autoDispose.family(
PostRepliesNotifier.new,
);
class PostRepliesNotifier extends AsyncNotifier<List<SnPost>>
class PostRepliesNotifier extends AsyncNotifier<PaginationState<SnPost>>
with AsyncPaginationController<SnPost> {
static const int pageSize = 20;

View File

@@ -42,9 +42,9 @@ class PostShuffleScreen extends HookConsumerWidget {
kBottomControlHeight + MediaQuery.of(context).padding.bottom,
),
child: Builder(
key: ValueKey(postListState.value?.length ?? 0),
key: ValueKey(postListState.value?.items.length ?? 0),
builder: (context) {
final items = postListState.value ?? [];
final items = postListState.value?.items ?? [];
if (items.isNotEmpty) {
return CardSwiper(
controller: cardSwiperController,