From 899d5f3e5e23e07a53c87d3c7872d9925530d51d Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 23 Feb 2025 13:14:16 +0800 Subject: [PATCH] :sparkles: Sticker page & add sticker --- assets/translations/en-US.json | 10 +- assets/translations/zh-CN.json | 10 +- assets/translations/zh-HK.json | 12 +- assets/translations/zh-TW.json | 12 +- lib/providers/navigation.dart | 16 +- lib/providers/sn_sticker.dart | 20 ++- lib/router.dart | 136 ++++++++------- lib/screens/settings.dart | 7 +- lib/screens/stickers.dart | 304 +++++++++++++++++++++++++++++++++ 9 files changed, 456 insertions(+), 71 deletions(-) create mode 100644 lib/screens/stickers.dart diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index bcd23a0..36e4250 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -689,5 +689,13 @@ "databaseDeleteDescription": "Remove the database on your local disk, the content will be fetched from server again.", "databaseDeleted": "The local database has been deleted.", "settingsEnablePushNotifications": "Enable Push Notifications", - "settingsEnablePushNotificationsDescription": "Re-enable and request permission to receive push notifications. Just in case it didn't run automatically." + "settingsEnablePushNotificationsDescription": "Re-enable and request permission to receive push notifications. Just in case it didn't run automatically.", + "screenStickers": "Stickers", + "stickersDiscovery": "Discovery", + "stickersOwned": "Owned", + "stickersCreated": "Created", + "stickersAdd": "Add Sticker Pack", + "stickersAdded": "Sticker pack has been added.", + "add": "Add", + "stickersRemoved": "Sticker pack has been removed, you can add it again anytime." } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index f4bc550..577773e 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -687,5 +687,13 @@ "databaseDeleteDescription": "删除本地数据库,内容将从服务器重新获取。", "databaseDeleted": "本地数据库已被删除。", "settingsEnablePushNotifications": "启用推送数据", - "settingsEnablePushNotificationsDescription": "重新启用并请求推送权限,以防自动激活失败。" + "settingsEnablePushNotificationsDescription": "重新启用并请求推送权限,以防自动激活失败。", + "screenStickers": "贴图", + "stickersDiscovery": "发现", + "stickersOwned": "由我拥有", + "stickersCreated": "由我发布", + "stickersAdd": "添加贴图包", + "stickersAdded": "贴图包已添加。", + "add": "添加", + "stickersRemoved": "贴图包已被移除,你可以随时再次添加回来。" } diff --git a/assets/translations/zh-HK.json b/assets/translations/zh-HK.json index 4819293..62b0ef0 100644 --- a/assets/translations/zh-HK.json +++ b/assets/translations/zh-HK.json @@ -685,5 +685,15 @@ "databaseSize": "數據庫大小", "databaseDelete": "刪除數據庫", "databaseDeleteDescription": "刪除本地數據庫,內容將從服務器重新獲取。", - "databaseDeleted": "本地數據庫已被刪除。" + "databaseDeleted": "本地數據庫已被刪除。", + "settingsEnablePushNotifications": "啓用推送數據", + "settingsEnablePushNotificationsDescription": "重新啓用並請求推送權限,以防自動激活失敗。", + "screenStickers": "貼圖", + "stickersDiscovery": "發現", + "stickersOwned": "由我擁有", + "stickersCreated": "由我發佈", + "stickersAdd": "添加貼圖包", + "stickersAdded": "貼圖包已添加。", + "add": "添加", + "stickersRemoved": "貼圖包已被移除,你可以隨時再次添加回來。" } diff --git a/assets/translations/zh-TW.json b/assets/translations/zh-TW.json index 314a56a..e8da9be 100644 --- a/assets/translations/zh-TW.json +++ b/assets/translations/zh-TW.json @@ -685,5 +685,15 @@ "databaseSize": "數據庫大小", "databaseDelete": "刪除數據庫", "databaseDeleteDescription": "刪除本地數據庫,內容將從服務器重新獲取。", - "databaseDeleted": "本地數據庫已被刪除。" + "databaseDeleted": "本地數據庫已被刪除。", + "settingsEnablePushNotifications": "啟用推送數據", + "settingsEnablePushNotificationsDescription": "重新啟用並請求推送權限,以防自動激活失敗。", + "screenStickers": "貼圖", + "stickersDiscovery": "發現", + "stickersOwned": "由我擁有", + "stickersCreated": "由我發佈", + "stickersAdd": "添加貼圖包", + "stickersAdded": "貼圖包已添加。", + "add": "添加", + "stickersRemoved": "貼圖包已被移除,你可以隨時再次添加回來。" } diff --git a/lib/providers/navigation.dart b/lib/providers/navigation.dart index e9f82a1..1baf241 100644 --- a/lib/providers/navigation.dart +++ b/lib/providers/navigation.dart @@ -63,6 +63,11 @@ class NavigationProvider extends ChangeNotifier { screen: 'news', label: 'screenNews', ), + AppNavDestination( + icon: Icon(Symbols.emoji_emotions, weight: 400, opticalSize: 20), + screen: 'stickers', + label: 'screenStickers', + ), AppNavDestination( icon: Icon(Symbols.photo_library, weight: 400, opticalSize: 20), screen: 'album', @@ -88,7 +93,8 @@ class NavigationProvider extends ChangeNotifier { List destinations = []; - int get pinnedDestinationCount => destinations.where((ele) => ele.isPinned).length; + int get pinnedDestinationCount => + destinations.where((ele) => ele.isPinned).length; NavigationProvider() { buildDestinations(kDefaultPinnedDestination); @@ -117,13 +123,17 @@ class NavigationProvider extends ChangeNotifier { } bool isIndexInRange(int min, int max) { - return _currentIndex != null && _currentIndex! >= min && _currentIndex! < max; + return _currentIndex != null && + _currentIndex! >= min && + _currentIndex! < max; } void autoDetectIndex(GoRouter? state) { if (state == null) return; final idx = destinations.indexWhere( - (ele) => ele.screen == state.routerDelegate.currentConfiguration.last.route.name, + (ele) => + ele.screen == + state.routerDelegate.currentConfiguration.last.route.name, ); _currentIndex = idx == -1 ? null : idx; notifyListeners(); diff --git a/lib/providers/sn_sticker.dart b/lib/providers/sn_sticker.dart index 8557a27..a72c747 100644 --- a/lib/providers/sn_sticker.dart +++ b/lib/providers/sn_sticker.dart @@ -11,7 +11,8 @@ class SnStickerProvider { final Map> stickersByPack = {}; - List get stickers => _cache.values.where((ele) => ele != null).cast().toList(); + List get stickers => + _cache.values.where((ele) => ele != null).cast().toList(); SnStickerProvider(BuildContext context) { _sn = context.read(); @@ -23,8 +24,18 @@ class SnStickerProvider { void _cacheSticker(SnSticker sticker) { _cache['${sticker.pack.prefix}:${sticker.alias}'] = sticker; - if (stickersByPack[sticker.pack.id] == null) stickersByPack[sticker.pack.id] = List.empty(growable: true); - if (!stickersByPack[sticker.pack.id]!.contains(sticker)) stickersByPack[sticker.pack.id]!.add(sticker); + if (stickersByPack[sticker.pack.id] == null) { + stickersByPack[sticker.pack.id] = List.empty(growable: true); + } + if (!stickersByPack[sticker.pack.id]!.contains(sticker)) { + stickersByPack[sticker.pack.id]!.add(sticker); + } + } + + void putSticker(Iterable sticker) { + for (final ele in sticker) { + _cacheSticker(ele); + } } Future lookupSticker(String alias) async { @@ -61,7 +72,8 @@ class SnStickerProvider { 'offset': page * 10, }); final data = resp.data; - final stickers = List.from(data['data']).map((ele) => SnSticker.fromJson(ele)); + final stickers = + List.from(data['data']).map((ele) => SnSticker.fromJson(ele)); for (final sticker in stickers) { _cacheSticker(sticker); } diff --git a/lib/router.dart b/lib/router.dart index 276b4c4..57f15ac 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -34,13 +34,14 @@ import 'package:surface/screens/realm/realm_detail.dart'; import 'package:surface/screens/realm/realm_discovery.dart'; import 'package:surface/screens/settings.dart'; import 'package:surface/screens/sharing.dart'; +import 'package:surface/screens/stickers.dart'; import 'package:surface/screens/wallet.dart'; import 'package:surface/types/post.dart'; import 'package:surface/widgets/about.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart'; -Widget _fadeThroughTransition( - BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { +Widget _fadeThroughTransition(BuildContext context, Animation animation, + Animation secondaryAnimation, Widget child) { return FadeThroughTransition( animation: animation, secondaryAnimation: secondaryAnimation, @@ -82,13 +83,15 @@ final _appRoutes = [ name: 'postSearch', builder: (context, state) => PostSearchScreen( initialTags: state.uri.queryParameters['tags']?.split(','), - initialCategories: state.uri.queryParameters['categories']?.split(','), + initialCategories: + state.uri.queryParameters['categories']?.split(','), ), ), GoRoute( path: '/publishers/:name', name: 'postPublisher', - builder: (context, state) => PostPublisherScreen(name: state.pathParameters['name']!), + builder: (context, state) => + PostPublisherScreen(name: state.pathParameters['name']!), ), GoRoute( path: '/:slug', @@ -100,52 +103,56 @@ final _appRoutes = [ ), ], ), - GoRoute(path: '/account', name: 'account', builder: (context, state) => const AccountScreen(), routes: [ - GoRoute( - path: '/wallet', - name: 'accountWallet', - builder: (context, state) => const WalletScreen(), - ), - GoRoute( - path: '/settings', - name: 'accountSettings', - builder: (context, state) => AccountSettingsScreen(), - ), - GoRoute( - path: '/settings/factors', - name: 'factorSettings', - builder: (context, state) => FactorSettingsScreen(), - ), - GoRoute( - path: '/profile/edit', - name: 'accountProfileEdit', - builder: (context, state) => ProfileEditScreen(), - ), - GoRoute( - path: '/publishers', - name: 'accountPublishers', - builder: (context, state) => PublisherScreen(), - ), - GoRoute( - path: '/publishers/new', - name: 'accountPublisherNew', - builder: (context, state) => AccountPublisherNewScreen(), - ), - GoRoute( - path: '/publishers/edit/:name', - name: 'accountPublisherEdit', - builder: (context, state) => AccountPublisherEditScreen( - name: state.pathParameters['name']!, - ), - ), - GoRoute( - path: '/:name', - name: 'accountProfilePage', - pageBuilder: (context, state) => NoTransitionPage( - child: UserScreen(name: state.pathParameters['name']!), - ), - ), - ]), + GoRoute( + path: '/account', + name: 'account', + builder: (context, state) => const AccountScreen(), + routes: [ + GoRoute( + path: '/wallet', + name: 'accountWallet', + builder: (context, state) => const WalletScreen(), + ), + GoRoute( + path: '/settings', + name: 'accountSettings', + builder: (context, state) => AccountSettingsScreen(), + ), + GoRoute( + path: '/settings/factors', + name: 'factorSettings', + builder: (context, state) => FactorSettingsScreen(), + ), + GoRoute( + path: '/profile/edit', + name: 'accountProfileEdit', + builder: (context, state) => ProfileEditScreen(), + ), + GoRoute( + path: '/publishers', + name: 'accountPublishers', + builder: (context, state) => PublisherScreen(), + ), + GoRoute( + path: '/publishers/new', + name: 'accountPublisherNew', + builder: (context, state) => AccountPublisherNewScreen(), + ), + GoRoute( + path: '/publishers/edit/:name', + name: 'accountPublisherEdit', + builder: (context, state) => AccountPublisherEditScreen( + name: state.pathParameters['name']!, + ), + ), + GoRoute( + path: '/:name', + name: 'accountProfilePage', + pageBuilder: (context, state) => NoTransitionPage( + child: UserScreen(name: state.pathParameters['name']!), + ), + ), + ]), GoRoute( path: '/chat', name: 'chat', @@ -208,19 +215,30 @@ final _appRoutes = [ GoRoute( path: '/:alias', name: 'realmDetail', - builder: (context, state) => RealmDetailScreen(alias: state.pathParameters['alias']!), + builder: (context, state) => + RealmDetailScreen(alias: state.pathParameters['alias']!), ), ], ), - GoRoute(path: '/news', name: 'news', builder: (context, state) => const NewsScreen(), routes: [ - GoRoute( - path: '/:hash', - name: 'newsDetail', - builder: (context, state) => NewsDetailScreen( - hash: state.pathParameters['hash']!, + GoRoute( + path: '/news', + name: 'news', + builder: (context, state) => const NewsScreen(), + routes: [ + GoRoute( + path: '/:hash', + name: 'newsDetail', + builder: (context, state) => NewsDetailScreen( + hash: state.pathParameters['hash']!, + ), ), - ), - ]), + ], + ), + GoRoute( + path: '/stickers', + name: 'stickers', + builder: (context, state) => const StickerScreen(), + ), GoRoute( path: '/album', name: 'album', diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 184661b..99d6b28 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -572,7 +572,12 @@ class _SettingsScreenState extends State { trailing: const Icon(Symbols.chevron_right), onTap: () { final nty = context.read(); - nty.registerPushNotifications(); + try { + nty.registerPushNotifications(); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } }, ), ListTile( diff --git a/lib/screens/stickers.dart b/lib/screens/stickers.dart new file mode 100644 index 0000000..b0a8328 --- /dev/null +++ b/lib/screens/stickers.dart @@ -0,0 +1,304 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:surface/providers/sn_network.dart'; +import 'package:surface/providers/sn_sticker.dart'; +import 'package:surface/providers/userinfo.dart'; +import 'package:surface/types/attachment.dart'; +import 'package:surface/widgets/attachment/attachment_item.dart'; +import 'package:surface/widgets/dialog.dart'; +import 'package:surface/widgets/loading_indicator.dart'; +import 'package:surface/widgets/navigation/app_scaffold.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; + +class StickerScreen extends StatefulWidget { + const StickerScreen({super.key}); + + @override + State createState() => _StickerScreenState(); +} + +class _StickerScreenState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController = + TabController(length: 3, vsync: this); + + bool _isBusy = false; + int? _totalCount; + final List _packs = List.empty(growable: true); + + Future _fetchPacks() async { + try { + setState(() => _isBusy = true); + final sn = context.read(); + final ua = context.read(); + final resp = await sn.client.get( + _tabController.index == 1 + ? '/cgi/uc/stickers/packs/own' + : '/cgi/uc/stickers/packs', + queryParameters: { + 'take': 10, + 'offset': _packs.length, + if (_tabController.index == 2) 'author': ua.user?.id, + }, + ); + if (resp.data is Map) { + _totalCount = resp.data['count'] as int?; + final out = List.from( + resp.data['data'].map((ele) => SnStickerPack.fromJson(ele)), + ); + _packs.addAll(out); + } else { + _totalCount = 0; + final out = List.from( + resp.data.map((ele) => SnStickerPack.fromJson(ele)), + ); + _packs.addAll(out); + } + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } finally { + setState(() => _isBusy = false); + } + } + + Future _removePack(SnStickerPack pack) async { + try { + setState(() => _isBusy = true); + final sn = context.read(); + await sn.client.delete('/cgi/uc/stickers/packs/${pack.id}/own'); + if (!mounted) return; + context.showSnackbar('stickersRemoved'.tr()); + _refreshPacks(); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } finally { + setState(() => _isBusy = false); + } + } + + Future _refreshPacks() async { + _packs.clear(); + _totalCount = null; + await _fetchPacks(); + } + + @override + void initState() { + super.initState(); + _fetchPacks(); + _tabController.addListener(() { + if (_tabController.indexIsChanging) { + _refreshPacks(); + } + }); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AppScaffold( + appBar: AppBar( + title: Text('screenStickers').tr(), + bottom: TabBar( + controller: _tabController, + tabs: [ + Tab( + child: Text('stickersDiscovery'.tr()).textColor( + Theme.of(context).appBarTheme.foregroundColor, + ), + ), + Tab( + child: Text('stickersOwned'.tr()).textColor( + Theme.of(context).appBarTheme.foregroundColor, + ), + ), + Tab( + child: Text('stickersCreated'.tr()).textColor( + Theme.of(context).appBarTheme.foregroundColor, + ), + ), + ], + ), + ), + body: MediaQuery.removePadding( + context: context, + removeTop: true, + child: RefreshIndicator( + onRefresh: _refreshPacks, + child: InfiniteList( + itemCount: _packs.length, + onFetchData: _fetchPacks, + hasReachedMax: _totalCount != null && _packs.length >= _totalCount!, + isLoading: _isBusy, + itemBuilder: (context, idx) { + final pack = _packs[idx]; + return ListTile( + title: Text(pack.name), + subtitle: Text( + pack.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + trailing: _tabController.index == 1 + ? IconButton( + onPressed: () { + _removePack(pack); + }, + icon: const Icon(Symbols.remove), + ) + : null, + onTap: () { + showModalBottomSheet( + context: context, + builder: (context) => _StickerPackAddPopup(pack: pack), + ).then((value) { + if (value == true && _tabController.index == 1) { + _refreshPacks(); + } + }); + }, + ); + }, + ), + ), + ), + ); + } +} + +class _StickerPackAddPopup extends StatefulWidget { + final SnStickerPack pack; + const _StickerPackAddPopup({required this.pack}); + + @override + State<_StickerPackAddPopup> createState() => _StickerPackAddPopupState(); +} + +class _StickerPackAddPopupState extends State<_StickerPackAddPopup> { + SnStickerPack? _pack; + + bool _isBusy = false; + + Future _fetchPack() async { + try { + setState(() => _isBusy = true); + final sn = context.read(); + final resp = + await sn.client.get('/cgi/uc/stickers/packs/${widget.pack.id}'); + _pack = SnStickerPack.fromJson(resp.data); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } finally { + setState(() => _isBusy = false); + } + } + + @override + void initState() { + super.initState(); + _fetchPack(); + } + + bool _isAdding = false; + + Future _addPack() async { + if (_pack == null) return; + + try { + setState(() => _isAdding = true); + final sn = context.read(); + final stickers = context.read(); + await sn.client.post( + '/cgi/uc/stickers/packs/${widget.pack.id}/own', + ); + if (!mounted) return; + context.showSnackbar('stickersAdded'.tr()); + if (_pack?.stickers != null) stickers.putSticker(_pack!.stickers!); + Navigator.pop(context, true); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } finally { + setState(() => _isAdding = false); + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Symbols.add, size: 24), + const Gap(16), + Text('stickersAdd', style: Theme.of(context).textTheme.titleLarge) + .tr(), + ], + ).padding(horizontal: 20, top: 16, bottom: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.pack.name).bold(), + Text( + widget.pack.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ElevatedButton( + onPressed: _isAdding ? null : _addPack, + child: Text('add').tr(), + ), + ], + ).padding(horizontal: 24), + LoadingIndicator(isActive: _isBusy), + if (_pack?.stickers != null) + Expanded( + child: GridView.extent( + padding: EdgeInsets.only(left: 20, right: 20, top: 8), + maxCrossAxisExtent: 48, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + children: _pack!.stickers! + .map( + (ele) => ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + color: + Theme.of(context).colorScheme.surfaceContainerHigh, + child: AttachmentItem( + data: ele.attachment, + heroTag: 'sticker-pack-${ele.attachment.rid}', + fit: BoxFit.contain, + ), + ), + ), + ) + .toList(), + ), + ), + ], + ); + } +}