diff --git a/assets/translations/en.json b/assets/translations/en.json index c067a83..0ca73c5 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -23,6 +23,7 @@ "screenRealmManage": "Edit Realm", "screenRealmNew": "New Realm", "screenNotification": "Notification", + "screenPostSearch": "Search Posts", "dialogOkay": "Okay", "dialogCancel": "Cancel", "dialogConfirm": "Confirm", @@ -195,5 +196,11 @@ "one": "Marked {} notification as read.", "other": "Marked {} notifications as read." }, - "notificationMarkOneReadPrompt": "Marked notification {} as read." + "notificationMarkOneReadPrompt": "Marked notification {} as read.", + "postSearchResult": { + "zero": "No results", + "one": "{} result", + "other": "{} results" + }, + "postSearchTook": "Took {}" } diff --git a/assets/translations/zh.json b/assets/translations/zh.json index 28bef5d..8a557bd 100644 --- a/assets/translations/zh.json +++ b/assets/translations/zh.json @@ -23,6 +23,7 @@ "screenRealmManage": "编辑领域", "screenRealmNew": "新建领域", "screenNotification": "通知", + "screenPostSearch": "搜索帖子", "dialogOkay": "好的", "dialogCancel": "取消", "dialogConfirm": "确认", @@ -195,5 +196,11 @@ "one": "已将 {} 个通知标记为已读。", "other": "已将 {} 个通知标记为已读。" }, - "notificationMarkOneReadPrompt": "已将通知 {} 标记为已读。" + "notificationMarkOneReadPrompt": "已将通知 {} 标记为已读。", + "postSearchResult": { + "zero": "没有搜索到结果", + "one": "搜索到 {} 个结果", + "other": "搜索到 {} 个结果" + }, + "postSearchTook": "耗时 {}" } diff --git a/lib/router.dart b/lib/router.dart index e85c2aa..3e5eaef 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -17,6 +17,7 @@ import 'package:surface/screens/home.dart'; import 'package:surface/screens/notification.dart'; import 'package:surface/screens/post/post_detail.dart'; import 'package:surface/screens/post/post_editor.dart'; +import 'package:surface/screens/post/post_search.dart'; import 'package:surface/screens/realm.dart'; import 'package:surface/screens/realm/manage.dart'; import 'package:surface/screens/settings.dart'; @@ -64,11 +65,17 @@ final _appRoutes = [ ), ), ), + GoRoute( + path: '/post/search', + name: 'postSearch', + builder: (context, state) => const AppBackground( + child: PostSearchScreen(), + ), + ), GoRoute( path: '/post/:slug', name: 'postDetail', builder: (context, state) => AppBackground( - isLessOptimization: true, child: PostDetailScreen( slug: state.pathParameters['slug']!, preload: state.extra as SnPost?, diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index 1c4d87e..2e7dcfb 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -164,6 +164,14 @@ class _ExploreScreenState extends State { title: Text('screenExplore').tr(), floating: true, snap: true, + actions: [ + IconButton( + icon: const Icon(Symbols.search), + onPressed: () { + GoRouter.of(context).pushNamed('postSearch'); + }, + ), + ], ), SliverInfiniteList( itemCount: _posts.length, diff --git a/lib/screens/post/post_detail.dart b/lib/screens/post/post_detail.dart index 6c6dca6..2b4876e 100644 --- a/lib/screens/post/post_detail.dart +++ b/lib/screens/post/post_detail.dart @@ -80,7 +80,8 @@ class _PostDetailScreenState extends State { leading: BackButton( onPressed: () { if (GoRouter.of(context).canPop()) { - Navigator.pop(context); + GoRouter.of(context).pop(context); + return; } GoRouter.of(context).replaceNamed('explore'); }, diff --git a/lib/screens/post/post_search.dart b/lib/screens/post/post_search.dart index 4298f3d..e773da9 100644 --- a/lib/screens/post/post_search.dart +++ b/lib/screens/post/post_search.dart @@ -1,10 +1,189 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.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_attachment.dart'; +import 'package:surface/providers/sn_network.dart'; +import 'package:surface/types/post.dart'; +import 'package:surface/widgets/post/post_item.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; -class PostSearchScreen extends StatelessWidget { +class PostSearchScreen extends StatefulWidget { const PostSearchScreen({super.key}); + @override + State createState() => _PostSearchScreenState(); +} + +class _PostSearchScreenState extends State { + bool _isBusy = false; + + final List _posts = List.empty(growable: true); + int? _postCount; + + String _searchTerm = ''; + Duration? _lastTook; + + Future _fetchPosts() async { + if (_searchTerm.isEmpty) return; + if (_postCount != null && _posts.length >= _postCount!) return; + + setState(() => _isBusy = true); + + final stopwatch = Stopwatch()..start(); + + final sn = context.read(); + final resp = await sn.client.get('/cgi/co/posts/search', queryParameters: { + 'take': 10, + 'offset': _posts.length, + 'probe': _searchTerm, + }); + final List out = + List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []); + + Set rids = {}; + for (var i = 0; i < out.length; i++) { + rids.addAll(out[i].body['attachments']?.cast() ?? []); + } + + if (!mounted) return; + final attach = context.read(); + final attachments = await attach.getMultiple(rids.toList()); + for (var i = 0; i < out.length; i++) { + out[i] = out[i].copyWith( + preload: SnPostPreload( + attachments: attachments + .where( + (ele) => + out[i].body['attachments']?.contains(ele?.rid) ?? false, + ) + .toList(), + ), + ); + } + + stopwatch.stop(); + + _lastTook = stopwatch.elapsed; + _postCount = resp.data['count']; + _posts.addAll(out); + + if (mounted) setState(() => _isBusy = false); + } + @override Widget build(BuildContext context) { - return const Placeholder(); + const labelShadows = [ + Shadow( + offset: Offset(1, 1), + blurRadius: 8.0, + color: Color.fromARGB(255, 0, 0, 0), + ), + ]; + + return Scaffold( + appBar: AppBar( + title: Text('screenPostSearch').tr(), + ), + body: Stack( + children: [ + InfiniteList( + padding: const EdgeInsets.only(top: 96), + itemCount: _posts.length, + isLoading: _isBusy, + hasReachedMax: _postCount != null && _posts.length >= _postCount!, + onFetchData: () { + _fetchPosts(); + }, + itemBuilder: (context, idx) { + return GestureDetector( + child: PostItem( + data: _posts[idx], + maxWidth: 640, + onChanged: (data) { + setState(() => _posts[idx] = data); + }, + ), + onTap: () { + GoRouter.of(context).pushNamed( + 'postDetail', + pathParameters: {'slug': _posts[idx].id.toString()}, + extra: _posts[idx], + ); + }, + ); + }, + separatorBuilder: (context, index) => const Divider(height: 1), + ), + Positioned( + top: 16, + left: 16, + right: 16, + child: Column( + children: [ + SearchBar( + elevation: const WidgetStatePropertyAll(1), + leading: const Icon(Symbols.search), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 24), + ), + onChanged: (value) { + _searchTerm = value; + }, + onSubmitted: (value) { + setState(() => _posts.clear()); + + _searchTerm = value; + _fetchPosts(); + }, + ), + if (_lastTook != null) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Symbols.summarize, + color: Colors.white, + shadows: labelShadows, + size: 16, + ), + const Gap(4), + Text( + 'postSearchResult'.plural(_postCount ?? 0), + style: TextStyle( + color: Colors.white, + shadows: labelShadows, + fontSize: 13, + ), + ), + const Gap(8), + Icon( + Symbols.pace, + color: Colors.white, + shadows: labelShadows, + size: 16, + ), + const Gap(4), + Text( + 'postSearchTook'.tr(args: [ + '${(_lastTook!.inMilliseconds / 1000).toStringAsFixed(3)}s', + ]), + style: TextStyle( + color: Colors.white, + shadows: labelShadows, + fontSize: 13, + ), + ), + ], + ).padding(vertical: 8), + ], + ), + ), + ], + ), + ); } }