Compare commits

...

2 Commits

Author SHA1 Message Date
ddd6ff7eee Service status on home
🗑️ Remove news from home
2025-03-15 15:38:50 +08:00
b8f379796f News in feed 2025-03-15 14:53:42 +08:00
14 changed files with 380 additions and 108 deletions

View File

@ -7,5 +7,5 @@ meta {
get {
url: {{endpoint}}/cgi/re/well-known/sources
body: none
auth: none
auth: inherit
}

View File

@ -12,7 +12,7 @@ post {
body:json {
{
"sources": ["taiwan-ltn"],
"sources": ["taiwan-pts"],
"eager": true
}
}

View File

@ -794,5 +794,18 @@
"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.",
"serviceStatusOperational": "All services operational",
"serviceStatusDowngraded": "Some services downgraded",
"serviceStatusFailed": "All services unavailable",
"serviceStatusFailedDescription": "The server is down or the maintenance is just finished.",
"serviceNameInsights": "Summarize and Insights",
"serviceNameInteractive": "Posts, Reactions and Explore",
"serviceNameReader": "News and Link Previews",
"serviceNameMessaging": "Chat",
"serviceNameMatrix": "Matrix Software and Game Marketplace",
"serviceNamePaperclip": "Attachments, Images and Files",
"serviceNameWallet": "Source Points Wallet",
"serviceNamePassport": "Authorization and Authentication"
}

View File

@ -792,5 +792,18 @@
"accountStatusPositive": "正面",
"mixedFeed": "混合推荐流",
"mixedFeedDescription": "探索页面可能不只会展示用户的帖子,更可能包含其他的内容。但该模式不适用分类和过滤。",
"filterFeed": "探索队列调整"
"filterFeed": "探索队列调整",
"feedUnknownItem": "无法显示该内容,当前版本客户端不支持该类型的内容,请尝试更新应用程序后再试。",
"serviceStatusOperational": "所有服务正常",
"serviceStatusDowngraded": "部分服务异常",
"serviceStatusFailed": "服务状态异常",
"serviceStatusFailedDescription": "服务器炸了或者刚刚执行完维护程序。",
"serviceNameInsights": "总结、见解与洞察",
"serviceNameInteractive": "帖子与互动",
"serviceNameReader": "新闻与链接展开",
"serviceNameMessaging": "即使聊天",
"serviceNameMatrix": "矩阵市场",
"serviceNamePaperclip": "附件",
"serviceNameWallet": "源点钱包",
"serviceNamePassport": "身份验证与授权"
}

View File

@ -789,5 +789,21 @@
"fieldAccountStatusClearAt": "清除時間",
"accountStatusNegative": "負面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面"
"accountStatusPositive": "正面",
"mixedFeed": "混合推薦流",
"mixedFeedDescription": "探索頁面可能不只會展示用户的帖子,更可能包含其他的內容。但該模式不適用分類和過濾。",
"filterFeed": "探索隊列調整",
"feedUnknownItem": "無法顯示該內容,當前版本客户端不支持該類型的內容,請嘗試更新應用程序後再試。",
"serviceStatusOperational": "所有服務正常",
"serviceStatusDowngraded": "部分服務異常",
"serviceStatusFailed": "服務狀態異常",
"serviceStatusFailedDescription": "服務器炸了或者剛剛執行完維護程序。",
"serviceNameInsights": "總結、見解與洞察",
"serviceNameInteractive": "帖子與互動",
"serviceNameReader": "新聞與鏈接展開",
"serviceNameMessaging": "即使聊天",
"serviceNameMatrix": "矩陣市場",
"serviceNamePaperclip": "附件",
"serviceNameWallet": "源點錢包",
"serviceNamePassport": "身份驗證與授權"
}

View File

@ -789,5 +789,21 @@
"fieldAccountStatusClearAt": "清除時間",
"accountStatusNegative": "負面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面"
"accountStatusPositive": "正面",
"mixedFeed": "混合推薦流",
"mixedFeedDescription": "探索頁面可能不只會展示用戶的帖子,更可能包含其他的內容。但該模式不適用分類和過濾。",
"filterFeed": "探索隊列調整",
"feedUnknownItem": "無法顯示該內容,當前版本客戶端不支持該類型的內容,請嘗試更新應用程序後再試。",
"serviceStatusOperational": "所有服務正常",
"serviceStatusDowngraded": "部分服務異常",
"serviceStatusFailed": "服務狀態異常",
"serviceStatusFailedDescription": "服務器炸了或者剛剛執行完維護程序。",
"serviceNameInsights": "總結、見解與洞察",
"serviceNameInteractive": "帖子與互動",
"serviceNameReader": "新聞與鏈接展開",
"serviceNameMessaging": "即使聊天",
"serviceNameMatrix": "矩陣市場",
"serviceNamePaperclip": "附件",
"serviceNameWallet": "源點錢包",
"serviceNamePassport": "身份驗證與授權"
}

View File

@ -17,6 +17,20 @@ import 'package:synchronized/synchronized.dart';
import 'package:talker_dio_logger/talker_dio_logger_interceptor.dart';
import 'package:talker_dio_logger/talker_dio_logger_settings.dart';
enum ServiceStatus { operational, downgraded, failed }
const Map<String, String> kServicesName = {
'ai': 'Insights',
'co': 'Interactive',
're': 'Reader',
'im': 'Messaging',
'ma': 'Matrix',
'uc': 'Paperclip',
'wa': 'Wallet',
'id': 'Passport',
'pusher': 'Pusher',
};
const kNetworkServerDirectory = [
('Solar Network', 'https://api.sn.solsynth.dev'),
('Local', 'http://localhost:8001'),

View File

@ -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),

View File

@ -6,7 +6,6 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:html/parser.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart';
@ -20,13 +19,14 @@ import 'package:surface/providers/special_day.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/widget.dart';
import 'package:surface/types/check_in.dart';
import 'package:surface/types/news.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/updater.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:url_launcher/url_launcher_string.dart';
class HomeScreenDashEntry {
final String name;
@ -66,7 +66,7 @@ class _HomeScreenState extends State<HomeScreen> {
),
HomeScreenDashEntry(
name: 'dashEntryTodayNews',
child: _HomeDashTodayNews(),
child: _HomeDashServiceStatus(),
cols: MediaQuery.of(context).size.width >= 640 ? 3 : 2,
),
];
@ -245,21 +245,31 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
}
}
class _HomeDashTodayNews extends StatefulWidget {
const _HomeDashTodayNews();
class _HomeDashServiceStatus extends StatefulWidget {
const _HomeDashServiceStatus();
@override
State<_HomeDashTodayNews> createState() => _HomeDashTodayNewsState();
State<_HomeDashServiceStatus> createState() => _HomeDashServiceStatusState();
}
class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
SnNewsArticle? _article;
class _HomeDashServiceStatusState extends State<_HomeDashServiceStatus> {
Map<String, dynamic>? _statuses;
ServiceStatus? _serviceStatus;
Future<void> _fetchArticle() async {
Future<void> _fetchStatuses() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/re/news/today');
_article = SnNewsArticle.fromJson(resp.data['data']);
final resp = await sn.client.get('/directory/status');
_statuses = resp.data;
if (_statuses!.values.contains(false)) {
if (_statuses!.values.contains(true)) {
_serviceStatus = ServiceStatus.downgraded;
} else {
_serviceStatus = ServiceStatus.failed;
}
} else {
_serviceStatus = ServiceStatus.operational;
}
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
@ -272,7 +282,7 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
@override
initState() {
super.initState();
_fetchArticle();
_fetchStatuses();
}
@override
@ -284,73 +294,124 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
children: [
Row(
children: [
const Icon(Symbols.newspaper),
const Icon(Symbols.flare),
const Gap(8),
Text(
'newsToday',
style: Theme.of(context).textTheme.titleLarge,
).tr()
],
).padding(horizontal: 18, top: 12, bottom: 8),
if (_article != null)
Expanded(
child: InkWell(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
Text(
_article!.title,
style: Theme.of(context)
.textTheme
.titleMedium!
.copyWith(fontSize: 18),
maxLines:
MediaQuery.of(context).size.width >= 640 ? 2 : 1,
overflow: TextOverflow.ellipsis,
),
Text(
parse(_article!.description)
.children
.map((e) => e.text.trim())
.join(),
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
Builder(builder: (context) {
final date = _article!.publishedAt ?? _article!.createdAt;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 2,
children: [
Text(DateFormat().format(date)).textStyle(
Theme.of(context).textTheme.bodySmall!),
Text(' · ')
.textStyle(Theme.of(context).textTheme.bodySmall!)
.bold(),
Text(RelativeTime(context).format(date)).textStyle(
Theme.of(context).textTheme.bodySmall!),
],
).opacity(0.75);
}),
],
).padding(horizontal: 16),
onTap: () {
GoRouter.of(context).pushNamed(
'newsDetail',
pathParameters: {'hash': _article!.hash},
);
Expanded(
child: Text(
'serviceStatus',
style: Theme.of(context).textTheme.titleLarge,
).tr(),
),
IconButton(
icon: const Icon(Symbols.launch, size: 20),
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
onPressed: () {
launchUrlString('https://status.solsynth.dev');
},
),
)
else
],
).padding(horizontal: 18, top: 12, bottom: 8),
Container(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 6),
width: double.infinity,
color: _serviceStatus == null
? Theme.of(context).colorScheme.surfaceContainerHigh
: switch (_serviceStatus) {
ServiceStatus.operational => Colors.green[300],
ServiceStatus.failed => Colors.red[300],
_ => Colors.orange[300],
},
child: _serviceStatus == null
? Row(
children: [
const Icon(
Symbols.more_horiz,
size: 20,
),
const Gap(10),
Text('serviceStatusOperational').tr(),
],
)
: switch (_serviceStatus) {
ServiceStatus.operational => Row(
children: [
const Icon(
Symbols.check,
size: 20,
),
const Gap(10),
Text('serviceStatusOperational').tr(),
],
),
ServiceStatus.failed => Tooltip(
message: 'serviceStatusFailedDescription'.tr(),
child: Row(
children: [
const Icon(
Symbols.dangerous,
size: 20,
),
const Gap(10),
Text('serviceStatusFailed').tr(),
],
),
),
_ => Row(
children: [
const Icon(
Symbols.error,
size: 20,
),
const Gap(10),
Text('serviceStatusDowngraded').tr(),
],
),
},
),
if (_statuses != null)
Expanded(
child: Center(
child: CircularProgressIndicator(),
child: SingleChildScrollView(
padding: EdgeInsets.only(top: 6),
child: Wrap(
spacing: 8,
children: [
for (final entry in _statuses!.entries)
Tooltip(
message: kServicesName[entry.key] != null
? 'serviceName${kServicesName[entry.key]}'.tr()
: 'unknown'.tr(),
child: Chip(
avatar: entry.value
? const Icon(
Symbols.circle,
color: Colors.green,
fill: 1,
size: 16,
)
: AnimateWidgetExtensions(const Icon(
Symbols.error,
color: Colors.red,
fill: 1,
size: 16,
))
.animate(onPlay: (e) => e.repeat())
.fadeIn(
duration: 500.ms, curve: Curves.easeOut)
.then()
.fadeOut(
duration: 500.ms,
delay: 1000.ms,
curve: Curves.easeIn,
),
label: Text(kServicesName[entry.key] ?? entry.key),
),
),
],
).padding(horizontal: 12),
),
)
),
],
),
);

View File

@ -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;

View File

@ -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

View File

@ -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),
);

View 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),
),
),
],
),
);
}
}

View 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),
);
}
}