diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 47b71aa..058c4d4 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -676,5 +676,6 @@ "publisherFeatureDevelopDescription": "Unlock development abilities for your publisher, including custom apps, API keys, and more.", "publisherFeatureDevelopHint": "Currently, this feature is under active development, you need send a request to unlock this feature.", "learnMore": "Learn More", - "discoverWebArticles": "Articles from external sites" + "discoverWebArticles": "Articles from external sites", + "webArticlesStand": "Article Stand" } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 690dfe5..92f5fbe 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -40,31 +40,31 @@ PODS: - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter - - Firebase/CoreOnly (11.13.0): - - FirebaseCore (~> 11.13.0) - - Firebase/Messaging (11.13.0): + - Firebase/CoreOnly (11.15.0): + - FirebaseCore (~> 11.15.0) + - Firebase/Messaging (11.15.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 11.13.0) - - firebase_core (3.14.0): - - Firebase/CoreOnly (= 11.13.0) + - FirebaseMessaging (~> 11.15.0) + - firebase_core (3.15.0): + - Firebase/CoreOnly (= 11.15.0) - Flutter - - firebase_messaging (15.2.7): - - Firebase/Messaging (= 11.13.0) + - firebase_messaging (15.2.8): + - Firebase/Messaging (= 11.15.0) - firebase_core - Flutter - - FirebaseCore (11.13.0): - - FirebaseCoreInternal (~> 11.13.0) + - FirebaseCore (11.15.0): + - FirebaseCoreInternal (~> 11.15.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Logger (~> 8.1) - - FirebaseCoreInternal (11.13.0): + - FirebaseCoreInternal (11.15.0): - "GoogleUtilities/NSData+zlib (~> 8.1)" - - FirebaseInstallations (11.13.0): - - FirebaseCore (~> 11.13.0) + - FirebaseInstallations (11.15.0): + - FirebaseCore (~> 11.15.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - - FirebaseMessaging (11.13.0): - - FirebaseCore (~> 11.13.0) + - FirebaseMessaging (11.15.0): + - FirebaseCore (~> 11.15.0) - FirebaseInstallations (~> 11.0) - GoogleDataTransport (~> 10.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) @@ -128,8 +128,8 @@ PODS: - Flutter - irondash_engine_context (0.0.1): - Flutter - - Kingfisher (8.3.2) - - livekit_client (2.4.8): + - Kingfisher (8.3.3) + - livekit_client (2.4.9): - Flutter - flutter_webrtc - WebRTC-SDK (= 125.6422.07) @@ -351,13 +351,13 @@ SPEC CHECKSUMS: DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be - Firebase: 3435bc66b4d494c2f22c79fd3aae4c1db6662327 - firebase_core: 700bac7ed92bb754fd70fbf01d72b36ecdd6d450 - firebase_messaging: 860c017fcfbb5e27c163062d1d3135388f3ef954 - FirebaseCore: c692c7f1c75305ab6aff2b367f25e11d73aa8bd0 - FirebaseCoreInternal: 29d7b3af4aaf0b8f3ed20b568c13df399b06f68c - FirebaseInstallations: 0ee9074f2c1e86561ace168ee1470dc67aabaf02 - FirebaseMessaging: 195bbdb73e6ca1dbc76cd46e73f3552c084ef6e4 + Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e + firebase_core: c727a02c560a53f1f1e56e18f16515eb5753c492 + firebase_messaging: 4158969b04b667f5435731ec9d6e453bb58b0c4c + FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e + FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4 + FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843 + FirebaseMessaging: 3b26e2cee503815e01c3701236b020aa9b576f09 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf @@ -371,8 +371,8 @@ SPEC CHECKSUMS: GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 - Kingfisher: 0621d0ac0c78fecb19f6dc5303bde2b52abaf2f5 - livekit_client: 9e901890552514206e5ff828903ed271531da264 + Kingfisher: ff82cb91d9266ddb56cbb2f72d32c26f00d3e5be + livekit_client: 3f79d79233a5bd13d5b541732624ef959d7c538e local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391 media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 diff --git a/lib/route.dart b/lib/route.dart index 94ae021..de8b954 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -5,6 +5,7 @@ import 'package:island/screens/developers/apps.dart'; import 'package:island/screens/developers/edit_app.dart'; import 'package:island/screens/developers/new_app.dart'; import 'package:island/screens/developers/hub.dart'; +import 'package:island/screens/posts/post_search.dart'; import 'package:island/widgets/app_wrapper.dart'; import 'package:island/screens/tabs.dart'; import 'package:island/screens/explore.dart'; @@ -263,6 +264,10 @@ final routerProvider = Provider((ref) { path: '/', builder: (context, state) => const ExploreScreen(), ), + GoRoute( + path: '/posts/search', + builder: (context, state) => const PostSearchScreen(), + ), GoRoute( path: '/posts/:id', builder: (context, state) { diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index 43a3430..9a86811 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -93,37 +93,67 @@ class ExploreScreen extends HookConsumerWidget { extendBody: false, // Prevent conflicts with tabs navigation appBar: AppBar( toolbarHeight: 0, - bottom: TabBar( - controller: tabController, - tabs: [ - Tab( - child: Text( - 'explore'.tr(), - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of(context).appBarTheme.foregroundColor!, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(48), + child: Row( + children: [ + Expanded( + child: TabBar( + controller: tabController, + tabAlignment: TabAlignment.start, + isScrollable: true, + tabs: [ + Tab( + icon: Tooltip( + message: 'explore'.tr(), + child: Icon( + Symbols.explore, + color: Theme.of(context).appBarTheme.foregroundColor!, + ), + ), + ), + Tab( + icon: Tooltip( + message: 'exploreFilterSubscriptions'.tr(), + child: Icon( + Symbols.subscriptions, + color: Theme.of(context).appBarTheme.foregroundColor!, + ), + ), + ), + Tab( + icon: Tooltip( + message: 'exploreFilterFriends'.tr(), + child: Icon( + Symbols.people, + color: Theme.of(context).appBarTheme.foregroundColor!, + ), + ), + ), + ], ), ), - ), - Tab( - child: Text( - 'exploreFilterSubscriptions'.tr(), - textAlign: TextAlign.center, - style: TextStyle( + Spacer(), + IconButton( + onPressed: () {}, + icon: Icon( + Symbols.auto_stories, color: Theme.of(context).appBarTheme.foregroundColor!, ), + tooltip: 'webArticlesStand'.tr(), ), - ), - Tab( - child: Text( - 'exploreFilterFriends'.tr(), - textAlign: TextAlign.center, - style: TextStyle( + IconButton( + onPressed: () { + context.push('/posts/search'); + }, + icon: Icon( + Symbols.search, color: Theme.of(context).appBarTheme.foregroundColor!, ), + tooltip: 'search'.tr(), ), - ), - ], + ], + ).padding(horizontal: 8), ), ), floatingActionButton: FloatingActionButton( diff --git a/lib/screens/posts/post_search.dart b/lib/screens/posts/post_search.dart new file mode 100644 index 0000000..d501df3 --- /dev/null +++ b/lib/screens/posts/post_search.dart @@ -0,0 +1,165 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/post.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/widgets/post/post_item.dart'; +import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; + +final postSearchNotifierProvider = StateNotifierProvider.autoDispose< + PostSearchNotifier, + AsyncValue> +>((ref) => PostSearchNotifier(ref)); + +class PostSearchNotifier + extends StateNotifier>> { + final AutoDisposeRef ref; + static const int _pageSize = 20; + String _currentQuery = ''; + bool _isLoading = false; + + PostSearchNotifier(this.ref) : super(const AsyncValue.loading()) { + state = const AsyncValue.data( + CursorPagingData(items: [], hasMore: false, nextCursor: null), + ); + } + + Future search(String query) async { + if (_isLoading) return; + + _currentQuery = query.trim(); + if (_currentQuery.isEmpty) { + state = AsyncValue.data( + CursorPagingData(items: [], hasMore: false, nextCursor: null), + ); + return; + } + + await fetch(cursor: null); + } + + Future fetch({String? cursor}) async { + if (_isLoading) return; + + _isLoading = true; + state = const AsyncValue.loading(); + + try { + final client = ref.read(apiClientProvider); + final offset = cursor == null ? 0 : int.parse(cursor); + + final response = await client.get( + '/posts/search', + queryParameters: { + 'query': _currentQuery, + 'offset': offset, + 'take': _pageSize, + 'useVector': true, + }, + ); + + final data = response.data as List; + final posts = data.map((json) => SnPost.fromJson(json)).toList(); + final hasMore = posts.length == _pageSize; + final nextCursor = hasMore ? (offset + posts.length).toString() : null; + + state = AsyncValue.data( + CursorPagingData( + items: posts, + hasMore: hasMore, + nextCursor: nextCursor, + ), + ); + } catch (e, stack) { + state = AsyncValue.error(e, stack); + } finally { + _isLoading = false; + } + } +} + +class PostSearchScreen extends ConsumerStatefulWidget { + const PostSearchScreen({super.key}); + + @override + ConsumerState createState() => _PostSearchScreenState(); +} + +class _PostSearchScreenState extends ConsumerState { + final _searchController = TextEditingController(); + final _debounce = Duration(milliseconds: 500); + Timer? _debounceTimer; + + @override + void dispose() { + _searchController.dispose(); + _debounceTimer?.cancel(); + super.dispose(); + } + + void _onSearchChanged(String query) { + if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); + + _debounceTimer = Timer(_debounce, () { + ref.read(postSearchNotifierProvider.notifier).search(query); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search posts...', + border: InputBorder.none, + hintStyle: TextStyle( + color: Theme.of(context).appBarTheme.foregroundColor, + ), + ), + style: TextStyle( + color: Theme.of(context).appBarTheme.foregroundColor, + ), + onChanged: _onSearchChanged, + onSubmitted: (value) { + ref.read(postSearchNotifierProvider.notifier).search(value); + }, + autofocus: true, + ), + ), + body: Consumer( + builder: (context, ref, child) { + final searchState = ref.watch(postSearchNotifierProvider); + + return searchState.when( + data: (data) { + if (data.items.isEmpty && _searchController.text.isNotEmpty) { + return const Center(child: Text('No results found')); + } + + return ListView.builder( + itemCount: data.items.length + (data.hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= data.items.length) { + ref + .read(postSearchNotifierProvider.notifier) + .fetch(cursor: data.nextCursor); + return const Center(child: CircularProgressIndicator()); + } + + final post = data.items[index]; + return Column( + children: [PostItem(item: post), const Divider(height: 1)], + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center(child: Text('Error: $error')), + ); + }, + ), + ); + } +}