From b55e56c3c457075fbb2b80abd8eda5e96981859a Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 2 Jul 2025 01:11:25 +0800 Subject: [PATCH] :sparkles: Web articles list --- lib/route.dart | 26 ++-- lib/screens/discovery/articles.dart | 142 ++++++++++++++++++ lib/screens/discovery/articles.g.dart | 206 ++++++++++++++++++++++++++ lib/screens/explore.dart | 4 +- lib/widgets/web_article_card.dart | 38 ++++- 5 files changed, 399 insertions(+), 17 deletions(-) create mode 100644 lib/screens/discovery/articles.dart create mode 100644 lib/screens/discovery/articles.g.dart diff --git a/lib/route.dart b/lib/route.dart index de8b954..203e478 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/discovery/articles.dart'; import 'package:island/screens/posts/post_search.dart'; import 'package:island/widgets/app_wrapper.dart'; import 'package:island/screens/tabs.dart'; @@ -220,6 +221,19 @@ final routerProvider = Provider((ref) { ], ), + // Web articles + GoRoute( + path: '/feeds/articles', + builder: (context, state) => const ArticlesScreen(), + ), + GoRoute( + path: '/feeds/articles/:id', + builder: (context, state) { + final id = state.pathParameters['id']!; + return ArticleDetailScreen(articleId: id); + }, + ), + // Auth routes GoRoute( path: '/auth/login', @@ -243,18 +257,6 @@ final routerProvider = Provider((ref) { return TabsScreen(child: child); }, routes: [ - // Article detail route - GoRoute( - path: '/articles/:id', - pageBuilder: (context, state) { - final id = state.pathParameters['id']!; - return MaterialPage( - key: state.pageKey, - child: ArticleDetailScreen(articleId: id), - ); - }, - ), - // Explore tab ShellRoute( builder: diff --git a/lib/screens/discovery/articles.dart b/lib/screens/discovery/articles.dart new file mode 100644 index 0000000..5571a85 --- /dev/null +++ b/lib/screens/discovery/articles.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/webfeed.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/widgets/web_article_card.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; + +part 'articles.g.dart'; + +@riverpod +class ArticlesListNotifier extends _$ArticlesListNotifier + with CursorPagingNotifierMixin { + static const int _pageSize = 20; + + Map _params = {}; + + @override + Future> build({ + String? feedId, + String? publisherId, + }) async { + _params = { + if (feedId != null) 'feedId': feedId, + if (publisherId != null) 'publisherId': publisherId, + }; + return fetch(cursor: null); + } + + @override + Future> fetch({ + required String? cursor, + }) async { + final client = ref.read(apiClientProvider); + final offset = cursor == null ? 0 : int.parse(cursor); + + final queryParams = {'limit': _pageSize, 'offset': offset, ..._params}; + + try { + final response = await client.get( + '/feeds/articles', + queryParameters: queryParams, + ); + + final List data = response.data; + final articles = + data + .map( + (json) => SnWebArticle.fromJson(json as Map), + ) + .toList(); + + final total = int.tryParse(response.headers.value('X-Total') ?? '0') ?? 0; + final hasMore = offset + articles.length < total; + final nextCursor = hasMore ? (offset + articles.length).toString() : null; + + return CursorPagingData( + items: articles, + hasMore: hasMore, + nextCursor: nextCursor, + ); + } catch (e) { + debugPrint('Error fetching articles: $e'); + rethrow; + } + } +} + +class SliverArticlesList extends ConsumerWidget { + final String? feedId; + final String? publisherId; + final Color? backgroundColor; + final EdgeInsets? padding; + final Function? onRefresh; + + const SliverArticlesList({ + super.key, + this.feedId, + this.publisherId, + this.backgroundColor, + this.padding, + this.onRefresh, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return PagingHelperSliverView( + provider: articlesListNotifierProvider( + feedId: feedId, + publisherId: publisherId, + ), + futureRefreshable: + articlesListNotifierProvider( + feedId: feedId, + publisherId: publisherId, + ).future, + notifierRefreshable: + articlesListNotifierProvider( + feedId: feedId, + publisherId: publisherId, + ).notifier, + contentBuilder: + (data, widgetCount, endItemView) => SliverList.builder( + itemCount: widgetCount, + itemBuilder: (context, index) { + if (index == widgetCount - 1) { + return endItemView; + } + + final article = data.items[index]; + return WebArticleCard(article: article, showDetails: true); + }, + ), + ); + } +} + +class ArticlesScreen extends ConsumerWidget { + final String? feedId; + final String? publisherId; + final String? title; + + const ArticlesScreen({super.key, this.feedId, this.publisherId, this.title}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar(title: Text(title ?? 'Articles')), + body: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.only(top: 8, left: 8, right: 8), + sliver: SliverArticlesList( + feedId: feedId, + publisherId: publisherId, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/discovery/articles.g.dart b/lib/screens/discovery/articles.g.dart new file mode 100644 index 0000000..2a80280 --- /dev/null +++ b/lib/screens/discovery/articles.g.dart @@ -0,0 +1,206 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'articles.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$articlesListNotifierHash() => + r'924f2344c3bbf0ff7b92fe69e88d3b64a534b538'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$ArticlesListNotifier + extends BuildlessAutoDisposeAsyncNotifier> { + late final String? feedId; + late final String? publisherId; + + FutureOr> build({ + String? feedId, + String? publisherId, + }); +} + +/// See also [ArticlesListNotifier]. +@ProviderFor(ArticlesListNotifier) +const articlesListNotifierProvider = ArticlesListNotifierFamily(); + +/// See also [ArticlesListNotifier]. +class ArticlesListNotifierFamily + extends Family>> { + /// See also [ArticlesListNotifier]. + const ArticlesListNotifierFamily(); + + /// See also [ArticlesListNotifier]. + ArticlesListNotifierProvider call({String? feedId, String? publisherId}) { + return ArticlesListNotifierProvider( + feedId: feedId, + publisherId: publisherId, + ); + } + + @override + ArticlesListNotifierProvider getProviderOverride( + covariant ArticlesListNotifierProvider provider, + ) { + return call(feedId: provider.feedId, publisherId: provider.publisherId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'articlesListNotifierProvider'; +} + +/// See also [ArticlesListNotifier]. +class ArticlesListNotifierProvider + extends + AutoDisposeAsyncNotifierProviderImpl< + ArticlesListNotifier, + CursorPagingData + > { + /// See also [ArticlesListNotifier]. + ArticlesListNotifierProvider({String? feedId, String? publisherId}) + : this._internal( + () => + ArticlesListNotifier() + ..feedId = feedId + ..publisherId = publisherId, + from: articlesListNotifierProvider, + name: r'articlesListNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$articlesListNotifierHash, + dependencies: ArticlesListNotifierFamily._dependencies, + allTransitiveDependencies: + ArticlesListNotifierFamily._allTransitiveDependencies, + feedId: feedId, + publisherId: publisherId, + ); + + ArticlesListNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.feedId, + required this.publisherId, + }) : super.internal(); + + final String? feedId; + final String? publisherId; + + @override + FutureOr> runNotifierBuild( + covariant ArticlesListNotifier notifier, + ) { + return notifier.build(feedId: feedId, publisherId: publisherId); + } + + @override + Override overrideWith(ArticlesListNotifier Function() create) { + return ProviderOverride( + origin: this, + override: ArticlesListNotifierProvider._internal( + () => + create() + ..feedId = feedId + ..publisherId = publisherId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + feedId: feedId, + publisherId: publisherId, + ), + ); + } + + @override + AutoDisposeAsyncNotifierProviderElement< + ArticlesListNotifier, + CursorPagingData + > + createElement() { + return _ArticlesListNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ArticlesListNotifierProvider && + other.feedId == feedId && + other.publisherId == publisherId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, feedId.hashCode); + hash = _SystemHash.combine(hash, publisherId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin ArticlesListNotifierRef + on AutoDisposeAsyncNotifierProviderRef> { + /// The parameter `feedId` of this provider. + String? get feedId; + + /// The parameter `publisherId` of this provider. + String? get publisherId; +} + +class _ArticlesListNotifierProviderElement + extends + AutoDisposeAsyncNotifierProviderElement< + ArticlesListNotifier, + CursorPagingData + > + with ArticlesListNotifierRef { + _ArticlesListNotifierProviderElement(super.provider); + + @override + String? get feedId => (origin as ArticlesListNotifierProvider).feedId; + @override + String? get publisherId => + (origin as ArticlesListNotifierProvider).publisherId; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index 9a86811..6ba8cde 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -135,7 +135,9 @@ class ExploreScreen extends HookConsumerWidget { ), Spacer(), IconButton( - onPressed: () {}, + onPressed: () { + context.push('/feeds/articles'); + }, icon: Icon( Symbols.auto_stories, color: Theme.of(context).appBarTheme.foregroundColor!, diff --git a/lib/widgets/web_article_card.dart b/lib/widgets/web_article_card.dart index 9aa09d5..427045a 100644 --- a/lib/widgets/web_article_card.dart +++ b/lib/widgets/web_article_card.dart @@ -2,15 +2,22 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:island/models/webfeed.dart'; +import 'package:island/services/time.dart'; class WebArticleCard extends StatelessWidget { final SnWebArticle article; final double? maxWidth; + final bool showDetails; - const WebArticleCard({super.key, required this.article, this.maxWidth}); + const WebArticleCard({ + super.key, + required this.article, + this.maxWidth, + this.showDetails = false, + }); void _onTap(BuildContext context) { - context.push('/articles/${article.id}'); + context.push('/feeds/articles/${article.id}'); } @override @@ -74,6 +81,7 @@ class WebArticleCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ + if (showDetails) const SizedBox(height: 8), Text( article.title, style: theme.textTheme.titleSmall?.copyWith( @@ -81,10 +89,32 @@ class WebArticleCard extends StatelessWidget { fontWeight: FontWeight.bold, height: 1.3, ), - maxLines: 2, + maxLines: showDetails ? 3 : 2, overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 2), + if (showDetails && + article.author?.isNotEmpty == true) ...[ + const SizedBox(height: 4), + Text( + article.author!, + style: TextStyle( + fontSize: 10, + color: Colors.white.withOpacity(0.9), + fontWeight: FontWeight.w500, + ), + ), + ], + const Spacer(), + if (showDetails && article.publishedAt != null) ...[ + Text( + '${article.publishedAt!.formatSystem()} ยท ${article.publishedAt!.formatRelative(context)}', + style: const TextStyle( + fontSize: 9, + color: Colors.white70, + ), + ), + const SizedBox(height: 2), + ], Text( article.feed?.title ?? 'Unknown Source', style: const TextStyle(