✨ News in feed
This commit is contained in:
		| @@ -7,5 +7,5 @@ meta { | ||||
| get { | ||||
|   url: {{endpoint}}/cgi/re/well-known/sources | ||||
|   body: none | ||||
|   auth: none | ||||
|   auth: inherit | ||||
| } | ||||
|   | ||||
| @@ -12,7 +12,7 @@ post { | ||||
|  | ||||
| body:json { | ||||
|   { | ||||
|     "sources": ["taiwan-ltn"], | ||||
|     "sources": ["taiwan-pts"], | ||||
|     "eager": true | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -794,5 +794,6 @@ | ||||
|   "accountStatusPositive": "Positive", | ||||
|   "mixedFeed": "Mixed Feed", | ||||
|   "mixedFeedDescription": "The Explore screen may not only display the user's posts, but may also contain other content. However, this mode does not apply to classification and filtering.", | ||||
|   "filterFeed": "Exploring Adjust" | ||||
|   "filterFeed": "Exploring Adjust", | ||||
|   "feedUnknownItem": "Unable to display this content, the current version of the client does not support the type of content, please try to update the application afterwards." | ||||
| } | ||||
|   | ||||
| @@ -792,5 +792,6 @@ | ||||
|   "accountStatusPositive": "正面", | ||||
|   "mixedFeed": "混合推荐流", | ||||
|   "mixedFeedDescription": "探索页面可能不只会展示用户的帖子,更可能包含其他的内容。但该模式不适用分类和过滤。", | ||||
|   "filterFeed": "探索队列调整" | ||||
|   "filterFeed": "探索队列调整", | ||||
|   "feedUnknownItem": "无法显示该内容,当前版本客户端不支持该类型的内容,请尝试更新应用程序后再试。" | ||||
| } | ||||
|   | ||||
| @@ -17,6 +17,8 @@ import 'package:surface/types/realm.dart'; | ||||
| import 'package:surface/widgets/account/account_image.dart'; | ||||
| import 'package:surface/widgets/app_bar_leading.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:surface/widgets/feed/feed_news.dart'; | ||||
| import 'package:surface/widgets/feed/feed_unknown.dart'; | ||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||
| import 'package:surface/widgets/post/fediverse_post_item.dart'; | ||||
| import 'package:surface/widgets/post/post_item.dart'; | ||||
| @@ -459,7 +461,12 @@ class _PostListWidgetState extends State<_PostListWidget> { | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     final pt = context.read<SnPostContentProvider>(); | ||||
|     final result = await pt.getFeed(cursor: _feed.lastOrNull?.createdAt); | ||||
|     final result = await pt.getFeed( | ||||
|       cursor: _feed | ||||
|           .where((ele) => !['reader.news'].contains(ele.type)) | ||||
|           .lastOrNull | ||||
|           ?.createdAt, | ||||
|     ); | ||||
|  | ||||
|     if (!mounted) return; | ||||
|  | ||||
| @@ -498,7 +505,12 @@ class _PostListWidgetState extends State<_PostListWidget> { | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _fetchPosts(); | ||||
|     final cfg = context.read<ConfigProvider>(); | ||||
|     if (cfg.mixedFeed) { | ||||
|       _fetchFeed(); | ||||
|     } else { | ||||
|       _fetchPosts(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -538,8 +550,10 @@ class _PostListWidgetState extends State<_PostListWidget> { | ||||
|                   data: SnFediversePost.fromJson(ele.data), | ||||
|                   maxWidth: 640, | ||||
|                 ); | ||||
|               case 'reader.news': | ||||
|                 return NewsFeedEntry(data: ele); | ||||
|               default: | ||||
|                 return Placeholder(); | ||||
|                 return FeedUnknownEntry(data: ele); | ||||
|             } | ||||
|           }, | ||||
|           separatorBuilder: (_, __) => const Gap(8), | ||||
|   | ||||
| @@ -171,7 +171,7 @@ abstract class SnSubscription with _$SnSubscription { | ||||
| abstract class SnFeedEntry with _$SnFeedEntry { | ||||
|   const factory SnFeedEntry({ | ||||
|     required String type, | ||||
|     required Map<String, dynamic> data, | ||||
|     required dynamic data, | ||||
|     required DateTime createdAt, | ||||
|   }) = _SnFeedEntry; | ||||
|  | ||||
|   | ||||
| @@ -3123,7 +3123,7 @@ class __$SnSubscriptionCopyWithImpl<$Res> | ||||
| /// @nodoc | ||||
| mixin _$SnFeedEntry { | ||||
|   String get type; | ||||
|   Map<String, dynamic> get data; | ||||
|   dynamic get data; | ||||
|   DateTime get createdAt; | ||||
|  | ||||
|   /// Create a copy of SnFeedEntry | ||||
| @@ -3164,7 +3164,7 @@ abstract mixin class $SnFeedEntryCopyWith<$Res> { | ||||
|           SnFeedEntry value, $Res Function(SnFeedEntry) _then) = | ||||
|       _$SnFeedEntryCopyWithImpl; | ||||
|   @useResult | ||||
|   $Res call({String type, Map<String, dynamic> data, DateTime createdAt}); | ||||
|   $Res call({String type, dynamic data, DateTime createdAt}); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @@ -3180,7 +3180,7 @@ class _$SnFeedEntryCopyWithImpl<$Res> implements $SnFeedEntryCopyWith<$Res> { | ||||
|   @override | ||||
|   $Res call({ | ||||
|     Object? type = null, | ||||
|     Object? data = null, | ||||
|     Object? data = freezed, | ||||
|     Object? createdAt = null, | ||||
|   }) { | ||||
|     return _then(_self.copyWith( | ||||
| @@ -3188,10 +3188,10 @@ class _$SnFeedEntryCopyWithImpl<$Res> implements $SnFeedEntryCopyWith<$Res> { | ||||
|           ? _self.type | ||||
|           : type // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       data: null == data | ||||
|       data: freezed == data | ||||
|           ? _self.data | ||||
|           : data // ignore: cast_nullable_to_non_nullable | ||||
|               as Map<String, dynamic>, | ||||
|               as dynamic, | ||||
|       createdAt: null == createdAt | ||||
|           ? _self.createdAt | ||||
|           : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| @@ -3204,23 +3204,14 @@ class _$SnFeedEntryCopyWithImpl<$Res> implements $SnFeedEntryCopyWith<$Res> { | ||||
| @JsonSerializable() | ||||
| class _SnFeedEntry implements SnFeedEntry { | ||||
|   const _SnFeedEntry( | ||||
|       {required this.type, | ||||
|       required final Map<String, dynamic> data, | ||||
|       required this.createdAt}) | ||||
|       : _data = data; | ||||
|       {required this.type, required this.data, required this.createdAt}); | ||||
|   factory _SnFeedEntry.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnFeedEntryFromJson(json); | ||||
|  | ||||
|   @override | ||||
|   final String type; | ||||
|   final Map<String, dynamic> _data; | ||||
|   @override | ||||
|   Map<String, dynamic> get data { | ||||
|     if (_data is EqualUnmodifiableMapView) return _data; | ||||
|     // ignore: implicit_dynamic_type | ||||
|     return EqualUnmodifiableMapView(_data); | ||||
|   } | ||||
|  | ||||
|   final dynamic data; | ||||
|   @override | ||||
|   final DateTime createdAt; | ||||
|  | ||||
| @@ -3245,7 +3236,7 @@ class _SnFeedEntry implements SnFeedEntry { | ||||
|         (other.runtimeType == runtimeType && | ||||
|             other is _SnFeedEntry && | ||||
|             (identical(other.type, type) || other.type == type) && | ||||
|             const DeepCollectionEquality().equals(other._data, _data) && | ||||
|             const DeepCollectionEquality().equals(other.data, data) && | ||||
|             (identical(other.createdAt, createdAt) || | ||||
|                 other.createdAt == createdAt)); | ||||
|   } | ||||
| @@ -3253,7 +3244,7 @@ class _SnFeedEntry implements SnFeedEntry { | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   @override | ||||
|   int get hashCode => Object.hash( | ||||
|       runtimeType, type, const DeepCollectionEquality().hash(_data), createdAt); | ||||
|       runtimeType, type, const DeepCollectionEquality().hash(data), createdAt); | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
| @@ -3269,7 +3260,7 @@ abstract mixin class _$SnFeedEntryCopyWith<$Res> | ||||
|       __$SnFeedEntryCopyWithImpl; | ||||
|   @override | ||||
|   @useResult | ||||
|   $Res call({String type, Map<String, dynamic> data, DateTime createdAt}); | ||||
|   $Res call({String type, dynamic data, DateTime createdAt}); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @@ -3285,7 +3276,7 @@ class __$SnFeedEntryCopyWithImpl<$Res> implements _$SnFeedEntryCopyWith<$Res> { | ||||
|   @pragma('vm:prefer-inline') | ||||
|   $Res call({ | ||||
|     Object? type = null, | ||||
|     Object? data = null, | ||||
|     Object? data = freezed, | ||||
|     Object? createdAt = null, | ||||
|   }) { | ||||
|     return _then(_SnFeedEntry( | ||||
| @@ -3293,10 +3284,10 @@ class __$SnFeedEntryCopyWithImpl<$Res> implements _$SnFeedEntryCopyWith<$Res> { | ||||
|           ? _self.type | ||||
|           : type // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       data: null == data | ||||
|           ? _self._data | ||||
|       data: freezed == data | ||||
|           ? _self.data | ||||
|           : data // ignore: cast_nullable_to_non_nullable | ||||
|               as Map<String, dynamic>, | ||||
|               as dynamic, | ||||
|       createdAt: null == createdAt | ||||
|           ? _self.createdAt | ||||
|           : createdAt // ignore: cast_nullable_to_non_nullable | ||||
|   | ||||
| @@ -285,7 +285,7 @@ Map<String, dynamic> _$SnSubscriptionToJson(_SnSubscription instance) => | ||||
|  | ||||
| _SnFeedEntry _$SnFeedEntryFromJson(Map<String, dynamic> json) => _SnFeedEntry( | ||||
|       type: json['type'] as String, | ||||
|       data: json['data'] as Map<String, dynamic>, | ||||
|       data: json['data'], | ||||
|       createdAt: DateTime.parse(json['created_at'] as String), | ||||
|     ); | ||||
|  | ||||
|   | ||||
							
								
								
									
										107
									
								
								lib/widgets/feed/feed_news.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								lib/widgets/feed/feed_news.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,107 @@ | ||||
| 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:relative_time/relative_time.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/types/news.dart'; | ||||
| import 'package:surface/types/post.dart'; | ||||
|  | ||||
| class NewsFeedEntry extends StatelessWidget { | ||||
|   final SnFeedEntry data; | ||||
|   const NewsFeedEntry({super.key, required this.data}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final List<SnNewsArticle> news = data.data | ||||
|         .map((ele) => SnNewsArticle.fromJson(ele)) | ||||
|         .cast<SnNewsArticle>() | ||||
|         .toList(); | ||||
|  | ||||
|     return Card( | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           Row( | ||||
|             children: [ | ||||
|               const Icon(Symbols.newspaper), | ||||
|               const Gap(8), | ||||
|               Text( | ||||
|                 'newsToday', | ||||
|                 style: Theme.of(context).textTheme.titleLarge, | ||||
|               ).tr() | ||||
|             ], | ||||
|           ).padding(horizontal: 18, top: 12, bottom: 8), | ||||
|           Container( | ||||
|             margin: const EdgeInsets.only(bottom: 12), | ||||
|             height: 150, | ||||
|             child: ListView.separated( | ||||
|               scrollDirection: Axis.horizontal, | ||||
|               itemCount: news.length, | ||||
|               padding: const EdgeInsets.symmetric(horizontal: 12), | ||||
|               itemBuilder: (context, idx) { | ||||
|                 return Container( | ||||
|                   width: 360, | ||||
|                   decoration: BoxDecoration( | ||||
|                     border: Border.all( | ||||
|                       color: Theme.of(context).dividerColor, | ||||
|                       width: 1, | ||||
|                     ), | ||||
|                     borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|                   ), | ||||
|                   child: Material( | ||||
|                     elevation: 0, | ||||
|                     color: Theme.of(context).colorScheme.surface, | ||||
|                     borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|                     child: InkWell( | ||||
|                       borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           Text( | ||||
|                             news[idx].title, | ||||
|                             maxLines: 2, | ||||
|                             style: Theme.of(context).textTheme.titleMedium, | ||||
|                           ).padding(horizontal: 16, top: 12, bottom: 4), | ||||
|                           Text( | ||||
|                             news[idx].description, | ||||
|                             maxLines: 2, | ||||
|                             style: Theme.of(context).textTheme.bodyMedium, | ||||
|                           ).padding(horizontal: 16, vertical: 4), | ||||
|                           const Gap(4), | ||||
|                           Row( | ||||
|                             children: [ | ||||
|                               Text( | ||||
|                                 DateFormat('y/M/d HH:mm') | ||||
|                                     .format(news[idx].createdAt.toLocal()), | ||||
|                                 style: Theme.of(context).textTheme.bodySmall, | ||||
|                               ), | ||||
|                               const Gap(4), | ||||
|                               Text( | ||||
|                                 RelativeTime(context) | ||||
|                                     .format(news[idx].createdAt.toLocal()), | ||||
|                                 style: Theme.of(context).textTheme.bodySmall, | ||||
|                               ), | ||||
|                             ], | ||||
|                           ).opacity(0.8).padding(horizontal: 16), | ||||
|                         ], | ||||
|                       ), | ||||
|                       onTap: () { | ||||
|                         GoRouter.of(context).pushNamed( | ||||
|                           'newsDetail', | ||||
|                           pathParameters: {'hash': news[idx].hash}, | ||||
|                         ); | ||||
|                       }, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ); | ||||
|               }, | ||||
|               separatorBuilder: (_, __) => const Gap(12), | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										27
									
								
								lib/widgets/feed/feed_unknown.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								lib/widgets/feed/feed_unknown.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:google_fonts/google_fonts.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/types/post.dart'; | ||||
|  | ||||
| class FeedUnknownEntry extends StatelessWidget { | ||||
|   final SnFeedEntry data; | ||||
|   const FeedUnknownEntry({super.key, required this.data}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Card( | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           const Icon(Symbols.help, size: 36), | ||||
|           const Gap(4), | ||||
|           Text('feedUnknownItem').tr(), | ||||
|           Text(data.type, style: GoogleFonts.robotoMono()), | ||||
|         ], | ||||
|       ).padding(horizontal: 12, vertical: 8), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user