import 'package:auto_route/auto_route.dart'; import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/sticker.dart'; import 'package:island/pods/network.dart'; import 'package:island/route.gr.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; part 'stickers.g.dart'; @RoutePage() class StickersScreen extends HookConsumerWidget { final String pubName; const StickersScreen({super.key, @PathParam("name") required this.pubName}); @override Widget build(BuildContext context, WidgetRef ref) { final stickersState = ref.watch(stickerPacksProvider); final stickersNotifier = ref.watch(stickerPacksProvider.notifier); return AppScaffold( appBar: AppBar( title: const Text('stickers').tr(), actions: [ IconButton( onPressed: () { context.router.push(NewStickerPacksRoute(pubName: pubName)).then(( value, ) { if (value != null) { stickersNotifier.refresh(); } }); }, icon: const Icon(Symbols.add_circle), ), const Gap(8), ], ), body: stickersState.when( data: (stickers) => RefreshIndicator( onRefresh: stickersNotifier.refresh, child: InfiniteList( padding: EdgeInsets.zero, itemCount: stickers.length, hasReachedMax: stickersNotifier.isReachedMax, isLoading: stickersNotifier.isLoading, onFetchData: stickersNotifier.fetchMore, itemBuilder: (context, index) { return ListTile( title: Text(stickers[index].name), subtitle: Text(stickers[index].description), trailing: const Icon(Symbols.chevron_right), onTap: () { context.router.push( StickerPackDetailRoute( pubName: pubName, id: stickers[index].id, ), ); }, ); }, ), ), loading: () => const CircularProgressIndicator(), error: (error, stack) => Text('Error: $error'), ), ); } } final stickerPacksProvider = StateNotifierProvider< StickerPacksNotifier, AsyncValue> >((ref) { return StickerPacksNotifier(ref.watch(apiClientProvider)); }); class StickerPacksNotifier extends StateNotifier>> { final Dio _apiClient; StickerPacksNotifier(this._apiClient) : super(const AsyncValue.loading()) { fetchStickers(); } int offset = 0; int take = 20; int total = 0; bool isLoading = false; bool get isReachedMax => state.valueOrNull != null && state.valueOrNull!.length >= total; Future fetchStickers() async { if (isLoading) return; isLoading = true; try { final response = await _apiClient.get( '/stickers?offset=$offset&take=$take', ); if (response.statusCode == 200) { total = int.parse(response.headers.value('X-Total') ?? '0'); final newStickers = response.data .map((e) => SnStickerPack.fromJson(e)) .cast() .toList(); state = AsyncValue.data( state.valueOrNull != null ? [...state.value!, ...newStickers] : newStickers, ); offset += take; } else { state = AsyncValue.error('Failed to load stickers', StackTrace.current); } } catch (err, stackTrace) { state = AsyncValue.error(err, stackTrace); } finally { isLoading = false; } } Future fetchMore() async { if (state.valueOrNull == null || state.valueOrNull!.length >= total) return; await fetchStickers(); } Future refresh() async { offset = 0; state = const AsyncValue.loading(); await fetchStickers(); } } @riverpod Future stickerPack(Ref ref, String? packId) async { if (packId == null) return null; final apiClient = ref.watch(apiClientProvider); final resp = await apiClient.get('/stickers/$packId'); return SnStickerPack.fromJson(resp.data); } @RoutePage() class NewStickerPacksScreen extends HookConsumerWidget { final String pubName; const NewStickerPacksScreen({ super.key, @PathParam("name") required this.pubName, }); @override Widget build(BuildContext context, WidgetRef ref) { return EditStickerPacksScreen(pubName: pubName); } } @RoutePage() class EditStickerPacksScreen extends HookConsumerWidget { final String pubName; final String? packId; const EditStickerPacksScreen({ super.key, @PathParam("name") required this.pubName, @PathParam("packId") this.packId, }); @override Widget build(BuildContext context, WidgetRef ref) { final formKey = useMemoized(() => GlobalKey(), []); final initialPack = ref.watch(stickerPackProvider(packId)); final nameController = useTextEditingController(); final descriptionController = useTextEditingController(); final prefixController = useTextEditingController(); useEffect(() { if (initialPack.value != null) { nameController.text = initialPack.value!.name; descriptionController.text = initialPack.value!.description; prefixController.text = initialPack.value!.prefix; } return null; }, [initialPack]); final submitting = useState(false); Future submit() async { if (!(formKey.currentState?.validate() ?? false)) return; try { submitting.value = true; final apiClient = ref.watch(apiClientProvider); final resp = await apiClient.request( '/stickers', data: { 'name': nameController.text, 'description': descriptionController.text, 'prefix': prefixController.text, }, options: Options( method: packId == null ? 'POST' : 'PATCH', headers: {'X-Pub': pubName}, ), ); if (!context.mounted) return; context.router.maybePop(SnStickerPack.fromJson(resp.data)); } catch (err) { showErrorAlert(err); } finally { submitting.value = false; } } return AppScaffold( appBar: AppBar( title: Text(packId == null ? 'createStickerPack' : 'editStickerPack').tr(), ), body: Column( children: [ Form( key: formKey, child: Column( spacing: 8, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextFormField( controller: nameController, decoration: InputDecoration( labelText: 'name'.tr(), border: const UnderlineInputBorder(), ), validator: (value) { if (value == null || value.isEmpty) { return 'fieldCannotBeEmpty'.tr(); } return null; }, onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ), TextFormField( controller: descriptionController, decoration: InputDecoration( labelText: 'description'.tr(), border: const UnderlineInputBorder(), ), minLines: 3, maxLines: null, onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ), TextFormField( controller: prefixController, decoration: InputDecoration( labelText: 'stickerPackPrefix'.tr(), border: const UnderlineInputBorder(), helperText: 'deleteStickerHint'.tr(), ), validator: (value) { if (value == null || value.isEmpty) { return 'fieldCannotBeEmpty'.tr(); } return null; }, onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ), ], ), ), const Gap(12), Align( alignment: Alignment.centerRight, child: TextButton.icon( onPressed: submitting.value ? null : submit, icon: const Icon(Symbols.save), label: Text(packId == null ? 'create'.tr() : 'saveChanges'.tr()), ), ), ], ).padding(horizontal: 24, vertical: 16), ); } }