Compare commits

...

8 Commits

Author SHA1 Message Date
b492db90ca 🚀 Launch Feature Drop 2.4.2+80 2025-03-16 23:27:52 +08:00
c9f69fed2c Complete translation 2025-03-16 23:24:36 +08:00
d2f4e7a969 Message translation 2025-03-16 23:10:59 +08:00
aecd04e0b9 Translate infra & post translation 2025-03-16 23:05:07 +08:00
e5212419ae 🐛 Fix poll percentage background 2025-03-16 22:13:19 +08:00
ec7650a920 💄 Optimize nesting 2025-03-16 22:11:40 +08:00
7b96013406 Better(?) comment nesting 2025-03-16 21:41:38 +08:00
fc5a79b29b Blurry attachment background 2025-03-16 19:34:42 +08:00
18 changed files with 677 additions and 172 deletions

View File

@@ -207,6 +207,7 @@
"one": "{} comment", "one": "{} comment",
"other": "{} comments" "other": "{} comments"
}, },
"postCommentExpand": "Show comments",
"settingsAppearance": "Appearance", "settingsAppearance": "Appearance",
"settingsCustomFonts": "Custom Fonts", "settingsCustomFonts": "Custom Fonts",
"settingsCustomFontsDescription": "Set custom fonts for the application.", "settingsCustomFontsDescription": "Set custom fonts for the application.",
@@ -811,6 +812,8 @@
"accountActionEvent": "Action Events", "accountActionEvent": "Action Events",
"accountActionEventDescription": "View your action event logs.", "accountActionEventDescription": "View your action event logs.",
"eventMetadata": "Metadata", "eventMetadata": "Metadata",
"accountAuthTickets": "Auth Sessions",
"accountAuthTicketsDescription": "View and manage your auth sessions.",
"authTicketCreatedAt": "Issued at {}", "authTicketCreatedAt": "Issued at {}",
"authTicketExpiredAt": "Expired at {}", "authTicketExpiredAt": "Expired at {}",
"authTicketLastGrantAt": "Last granted at {}", "authTicketLastGrantAt": "Last granted at {}",
@@ -838,5 +841,10 @@
"accountContactMethodsPublicHint": "This contact method will be displayed publicly on your profile.", "accountContactMethodsPublicHint": "This contact method will be displayed publicly on your profile.",
"accountContactMethodsDelete": "Delete Contact Method", "accountContactMethodsDelete": "Delete Contact Method",
"accountContactMethodsDeleteDescription": "Are you sure you want to delete contact method {}? This operation is irreversible.", "accountContactMethodsDeleteDescription": "Are you sure you want to delete contact method {}? This operation is irreversible.",
"postCommentAdd": "Write a comment" "postCommentAdd": "Write a comment",
"translate": "Translate",
"translating": "Translating…",
"translated": "Translated",
"settingsAutoTranslate": "Auto Translate",
"settingsAutoTranslateDescription": "Automatically translate text when viewing posts and messages."
} }

View File

@@ -205,6 +205,7 @@
"one": "{} 条评论", "one": "{} 条评论",
"other": "{} 条评论" "other": "{} 条评论"
}, },
"postCommentExpand": "展开评论",
"settingsAppearance": "外观", "settingsAppearance": "外观",
"settingsCustomFonts": "自定义字体", "settingsCustomFonts": "自定义字体",
"settingsCustomFontsDescription": "设置应用程序使用的字体。", "settingsCustomFontsDescription": "设置应用程序使用的字体。",
@@ -838,5 +839,10 @@
"accountContactMethodsPublicHint": "这个联系方式公开地显示在个人资料中。", "accountContactMethodsPublicHint": "这个联系方式公开地显示在个人资料中。",
"accountContactMethodsDelete": "删除联系方式", "accountContactMethodsDelete": "删除联系方式",
"accountContactMethodsDeleteDescription": "你确定要删除联系方式 {} 吗?这个操作不可撤销。", "accountContactMethodsDeleteDescription": "你确定要删除联系方式 {} 吗?这个操作不可撤销。",
"postCommentAdd": "撰写一条评论" "postCommentAdd": "撰写一条评论",
"translate": "翻译",
"translating": "正在翻译……",
"translated": "已翻译",
"settingsAutoTranslate": "自动翻译",
"settingsAutoTranslateDescription": "在查看帖子、消息时自动翻译文本。"
} }

View File

@@ -205,6 +205,7 @@
"one": "{} 條評論", "one": "{} 條評論",
"other": "{} 條評論" "other": "{} 條評論"
}, },
"postCommentExpand": "展開評論",
"settingsAppearance": "外觀", "settingsAppearance": "外觀",
"settingsCustomFonts": "自定義字體", "settingsCustomFonts": "自定義字體",
"settingsCustomFontsDescription": "設置應用程序使用的字體。", "settingsCustomFontsDescription": "設置應用程序使用的字體。",
@@ -838,5 +839,10 @@
"accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。", "accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
"accountContactMethodsDelete": "刪除聯繫方式", "accountContactMethodsDelete": "刪除聯繫方式",
"accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。", "accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
"postCommentAdd": "撰寫一條評論" "postCommentAdd": "撰寫一條評論",
"translate": "翻譯",
"translating": "正在翻譯……",
"translated": "已翻譯",
"settingsAutoTranslate": "自動翻譯",
"settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。"
} }

View File

@@ -205,6 +205,7 @@
"one": "{} 條評論", "one": "{} 條評論",
"other": "{} 條評論" "other": "{} 條評論"
}, },
"postCommentExpand": "展開評論",
"settingsAppearance": "外觀", "settingsAppearance": "外觀",
"settingsCustomFonts": "自定義字體", "settingsCustomFonts": "自定義字體",
"settingsCustomFontsDescription": "設置應用程序使用的字體。", "settingsCustomFontsDescription": "設置應用程序使用的字體。",
@@ -838,5 +839,10 @@
"accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。", "accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
"accountContactMethodsDelete": "刪除聯繫方式", "accountContactMethodsDelete": "刪除聯繫方式",
"accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。", "accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
"postCommentAdd": "撰寫一條評論" "postCommentAdd": "撰寫一條評論",
"translate": "翻譯",
"translating": "正在翻譯……",
"translated": "已翻譯",
"settingsAutoTranslate": "自動翻譯",
"settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。"
} }

View File

@@ -37,6 +37,7 @@ import 'package:surface/providers/sn_realm.dart';
import 'package:surface/providers/sn_sticker.dart'; import 'package:surface/providers/sn_sticker.dart';
import 'package:surface/providers/special_day.dart'; import 'package:surface/providers/special_day.dart';
import 'package:surface/providers/theme.dart'; import 'package:surface/providers/theme.dart';
import 'package:surface/providers/translation.dart';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.dart'; import 'package:surface/providers/websocket.dart';
@@ -167,6 +168,7 @@ class SolianApp extends StatelessWidget {
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)), ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)), ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)), ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
Provider(create: (ctx) => SnTranslator()),
// Additional helper layer // Additional helper layer
Provider(create: (ctx) => SpecialDayProvider(ctx)), Provider(create: (ctx) => SpecialDayProvider(ctx)),
@@ -274,7 +276,9 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
mounted) { mounted) {
final config = context.read<ConfigProvider>(); final config = context.read<ConfigProvider>();
config.setUpdate( config.setUpdate(
remoteVersionString, resp.data?['body'] ?? 'No changelog'); remoteVersionString,
resp.data?['body'] ?? 'No changelog',
);
logging.info("[Update] Update available: $remoteVersionString"); logging.info("[Update] Update available: $remoteVersionString");
} }
} catch (e) { } catch (e) {

View File

@@ -20,6 +20,7 @@ const kAppExpandChatLink = 'app_expand_chat_link';
const kAppRealmCompactView = 'app_realm_compact_view'; const kAppRealmCompactView = 'app_realm_compact_view';
const kAppCustomFonts = 'app_custom_fonts'; const kAppCustomFonts = 'app_custom_fonts';
const kAppMixedFeed = 'app_mixed_feed'; const kAppMixedFeed = 'app_mixed_feed';
const kAppAutoTranslate = 'app_auto_translate';
const Map<String, FilterQuality> kImageQualityLevel = { const Map<String, FilterQuality> kImageQualityLevel = {
'settingsImageQualityLowest': FilterQuality.none, 'settingsImageQualityLowest': FilterQuality.none,
@@ -86,6 +87,15 @@ class ConfigProvider extends ChangeNotifier {
return prefs.getBool(kAppMixedFeed) ?? true; return prefs.getBool(kAppMixedFeed) ?? true;
} }
bool get autoTranslate {
return prefs.getBool(kAppAutoTranslate) ?? false;
}
set autoTranslate(bool value) {
prefs.setBool(kAppAutoTranslate, value);
notifyListeners();
}
set mixedFeed(bool value) { set mixedFeed(bool value) {
prefs.setBool(kAppMixedFeed, value); prefs.setBool(kAppMixedFeed, value);
notifyListeners(); notifyListeners();

View File

@@ -0,0 +1,56 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:surface/logger.dart';
// TODO self host translate api
const kTranslateApiBaseUrl = 'https://translate.disroot.org';
class SnTranslator {
final Dio client = Dio(
BaseOptions(
baseUrl: kTranslateApiBaseUrl,
connectTimeout: Duration(seconds: 3),
sendTimeout: Duration(seconds: 3),
receiveTimeout: Duration(seconds: 3),
),
);
final Map<String, String> _cache = {};
Future<String> translate(
String text, {
required String to,
String from = 'auto',
bool skipCache = false,
}) async {
if (text.isEmpty) return text;
final cacheKey = md5.convert(utf8.encode('$text$from$to')).toString();
if (!skipCache && _cache.containsKey(cacheKey)) {
return _cache[cacheKey]!;
}
logging.info('[Translator] Translate $text from $from to $to');
final resp = await client.post(
'/translate',
data: {
'q': text,
'source': from,
'target': to,
'format': 'text',
},
);
if (resp.statusCode == 200) {
final out = resp.data['translatedText'];
if (out.isNotEmpty) {
logging.info('[Translator] Translated $text from $from to $to');
_cache[cacheKey] = out;
return out;
}
}
throw Exception('translate failed: $resp');
}
}

View File

@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class AccountNotifyPrefsScreen extends StatelessWidget {
const AccountNotifyPrefsScreen({super.key});
@override
Widget build(BuildContext context) {
return AppScaffold();
}
}

View File

@@ -387,6 +387,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
.fontSize(17) .fontSize(17)
.tr() .tr()
.padding(horizontal: 20, bottom: 4), .padding(horizontal: 20, bottom: 4),
CheckboxListTile(
secondary: const Icon(Symbols.translate),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
title: Text('settingsAutoTranslate').tr(),
subtitle: Text('settingsAutoTranslateDescription').tr(),
value: _prefs.getBool(kAppAutoTranslate) ?? false,
onChanged: (value) {
setState(() {
_prefs.setBool(kAppAutoTranslate, value ?? false);
});
},
),
CheckboxListTile( CheckboxListTile(
secondary: const Icon(Symbols.vibration), secondary: const Icon(Symbols.vibration),
contentPadding: const EdgeInsets.only(left: 24, right: 17), contentPadding: const EdgeInsets.only(left: 24, right: 17),

View File

@@ -14,6 +14,7 @@ class AccountImage extends StatelessWidget {
final Widget? fallbackWidget; final Widget? fallbackWidget;
final Widget? badge; final Widget? badge;
final Offset? badgeOffset; final Offset? badgeOffset;
final FilterQuality? filterQuality;
const AccountImage({ const AccountImage({
super.key, super.key,
@@ -25,6 +26,7 @@ class AccountImage extends StatelessWidget {
this.fallbackWidget, this.fallbackWidget,
this.badge, this.badge,
this.badgeOffset, this.badgeOffset,
this.filterQuality,
}); });
@override @override
@@ -54,6 +56,7 @@ class AccountImage extends StatelessWidget {
) )
: AutoResizeUniversalImage( : AutoResizeUniversalImage(
sn.getAttachmentUrl(url), sn.getAttachmentUrl(url),
filterQuality: filterQuality,
key: Key('attachment-${content.hashCode}'), key: Key('attachment-${content.hashCode}'),
fit: BoxFit.cover, fit: BoxFit.cover,
), ),

View File

@@ -45,12 +45,26 @@ class AttachmentItem extends StatelessWidget {
case 'image': case 'image':
return Hero( return Hero(
tag: 'attachment-${data!.rid}-$tag', tag: 'attachment-${data!.rid}-$tag',
child: Stack(
fit: StackFit.expand,
children: [
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: AutoResizeUniversalImage( child: AutoResizeUniversalImage(
sn.getAttachmentUrl(data!.rid),
key: Key('attachment-${data!.rid}-$tag-blur-background'),
fit: BoxFit.cover,
filterQuality: filterQuality,
),
),
AutoResizeUniversalImage(
sn.getAttachmentUrl(data!.rid), sn.getAttachmentUrl(data!.rid),
key: Key('attachment-${data!.rid}-$tag'), key: Key('attachment-${data!.rid}-$tag'),
fit: fit, fit: fit,
filterQuality: filterQuality, filterQuality: filterQuality,
), ),
],
),
); );
case 'video': case 'video':
return _AttachmentItemContentVideo( return _AttachmentItemContentVideo(

View File

@@ -10,6 +10,7 @@ import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/config.dart'; import 'package:surface/providers/config.dart';
import 'package:surface/providers/keypair.dart'; import 'package:surface/providers/keypair.dart';
import 'package:surface/providers/translation.dart';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/chat.dart'; import 'package:surface/types/chat.dart';
@@ -18,6 +19,7 @@ import 'package:surface/widgets/account/account_popover.dart';
import 'package:surface/widgets/account/badge.dart'; import 'package:surface/widgets/account/badge.dart';
import 'package:surface/widgets/attachment/attachment_list.dart'; import 'package:surface/widgets/attachment/attachment_list.dart';
import 'package:surface/widgets/context_menu.dart'; import 'package:surface/widgets/context_menu.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/link_preview.dart'; import 'package:surface/widgets/link_preview.dart';
import 'package:surface/widgets/markdown_content.dart'; import 'package:surface/widgets/markdown_content.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
@@ -228,7 +230,7 @@ class ChatMessage extends StatelessWidget {
} }
} }
class _ChatMessageText extends StatelessWidget { class _ChatMessageText extends StatefulWidget {
final SnChatMessage data; final SnChatMessage data;
final Function(SnChatMessage)? onReply; final Function(SnChatMessage)? onReply;
final Function(SnChatMessage)? onEdit; final Function(SnChatMessage)? onEdit;
@@ -237,13 +239,56 @@ class _ChatMessageText extends StatelessWidget {
const _ChatMessageText( const _ChatMessageText(
{required this.data, this.onReply, this.onEdit, this.onDelete}); {required this.data, this.onReply, this.onEdit, this.onDelete});
@override
State<_ChatMessageText> createState() => _ChatMessageTextState();
}
class _ChatMessageTextState extends State<_ChatMessageText> {
late String _displayText = widget.data.body['text'] ?? '';
bool _isTranslated = false;
bool _isTranslating = false;
Future<void> _translateText() async {
if (widget.data.body['text'] == null || widget.data.body['text']!.isEmpty) {
return;
}
final ta = context.read<SnTranslator>();
setState(() => _isTranslating = true);
try {
final to = EasyLocalization.of(context)!.locale.languageCode;
_displayText = await ta.translate(
widget.data.body['text'],
to: to,
);
_isTranslated = true;
if (mounted) setState(() {});
} catch (err) {
if (mounted) context.showErrorDialog(err);
} finally {
setState(() => _isTranslating = false);
}
}
@override
void initState() {
super.initState();
final cfg = context.read<ConfigProvider>();
if (cfg.autoTranslate) {
Future.delayed(const Duration(milliseconds: 100), () {
_translateText();
});
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
final isOwner = ua.isAuthorized && data.sender.accountId == ua.user?.id; final isOwner =
ua.isAuthorized && widget.data.sender.accountId == ua.user?.id;
if (data.body['text'] != null && data.body['text'].isNotEmpty) { if (widget.data.body['text'] != null &&
widget.data.body['text'].isNotEmpty) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -252,38 +297,50 @@ class _ChatMessageText extends StatelessWidget {
final List<ContextMenuButtonItem> items = final List<ContextMenuButtonItem> items =
editableTextState.contextMenuButtonItems; editableTextState.contextMenuButtonItems;
if (onReply != null) { if (widget.onReply != null) {
items.insert( items.insert(
0, 0,
ContextMenuButtonItem( ContextMenuButtonItem(
label: 'reply'.tr(), label: 'reply'.tr(),
onPressed: () { onPressed: () {
ContextMenuController.removeAny(); ContextMenuController.removeAny();
onReply?.call(data); widget.onReply?.call(widget.data);
}, },
), ),
); );
} }
if (isOwner && onEdit != null) { if (isOwner && widget.onEdit != null) {
items.insert( items.insert(
1, 1,
ContextMenuButtonItem( ContextMenuButtonItem(
label: 'edit'.tr(), label: 'edit'.tr(),
onPressed: () { onPressed: () {
ContextMenuController.removeAny(); ContextMenuController.removeAny();
onEdit?.call(data); widget.onEdit?.call(widget.data);
}, },
), ),
); );
} }
if (isOwner && onDelete != null) { if (isOwner && widget.onDelete != null) {
items.insert( items.insert(
2, 2,
ContextMenuButtonItem( ContextMenuButtonItem(
label: 'delete'.tr(), label: 'delete'.tr(),
onPressed: () { onPressed: () {
ContextMenuController.removeAny(); ContextMenuController.removeAny();
onDelete?.call(data); widget.onDelete?.call(widget.data);
},
),
);
}
if (widget.data.body['algorithm'] == 'plain') {
items.insert(
3,
ContextMenuButtonItem(
label: 'translate'.tr(),
onPressed: () {
ContextMenuController.removeAny();
_translateText();
}, },
), ),
); );
@@ -294,26 +351,47 @@ class _ChatMessageText extends StatelessWidget {
buttonItems: items, buttonItems: items,
); );
}, },
child: switch (data.body['algorithm']) { child: switch (widget.data.body['algorithm']) {
'rsa' => _ChatDecryptMessage(message: data), 'rsa' => _ChatDecryptMessage(message: widget.data),
_ => MarkdownTextContent( _ => MarkdownTextContent(
content: data.body['text'], content: _displayText,
isAutoWarp: true, isAutoWarp: true,
isEnlargeSticker: isEnlargeSticker: RegExp(r"^:([-\w]+):$")
RegExp(r"^:([-\w]+):$").hasMatch(data.body['text'] ?? ''), .hasMatch(widget.data.body['text'] ?? ''),
), ),
}, },
), ),
if (data.updatedAt != data.createdAt) if (widget.data.updatedAt != widget.data.createdAt)
Text('messageEditedHint'.tr()).fontSize(13).opacity(0.75), Text('messageEditedHint'.tr()).fontSize(13).opacity(0.75),
if (_isTranslating)
AnimateWidgetExtensions(Text('translating').tr())
.animate(onPlay: (e) => e.repeat())
.fadeIn(duration: 500.ms, curve: Curves.easeOut)
.then()
.fadeOut(
duration: 500.ms,
delay: 1000.ms,
curve: Curves.easeIn,
),
if (_isTranslated)
InkWell(
child: Text('translated').tr().opacity(0.75),
onTap: () {
setState(() {
_displayText = widget.data.body['text'] ?? '';
_isTranslated = false;
});
},
),
], ],
); );
} else if (data.body['attachments']?.isNotEmpty) { } else if (widget.data.body['attachments']?.isNotEmpty) {
return Row( return Row(
children: [ children: [
const Icon(Symbols.file_present, size: 20), const Icon(Symbols.file_present, size: 20),
const Gap(4), const Gap(4),
Text('messageFileHint'.plural(data.body['attachments']!.length)), Text('messageFileHint'
.plural(widget.data.body['attachments']!.length)),
], ],
).opacity(0.8); ).opacity(0.8);
} }

View File

@@ -138,6 +138,7 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
child: PostItem( child: PostItem(
data: _posts[idx], data: _posts[idx],
maxWidth: widget.maxWidth, maxWidth: widget.maxWidth,
showExpandableComments: true,
onSelectAnswer: widget.parentPost.type == 'question' onSelectAnswer: widget.parentPost.type == 'question'
? () => _selectAnswer(_posts[idx]) ? () => _selectAnswer(_posts[idx])
: null, : null,
@@ -209,6 +210,7 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> {
if (ua.isAuthorized) if (ua.isAuthorized)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Container( child: Container(
margin: const EdgeInsets.only(bottom: 8),
height: 240, height: 240,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.symmetric( border: Border.symmetric(

View File

@@ -8,6 +8,7 @@ import 'package:file_saver/file_saver.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@@ -22,6 +23,7 @@ import 'package:share_plus/share_plus.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/config.dart'; import 'package:surface/providers/config.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/translation.dart';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/screens/post/post_detail.dart'; import 'package:surface/screens/post/post_detail.dart';
@@ -50,6 +52,7 @@ class OpenablePostItem extends StatelessWidget {
final bool showComments; final bool showComments;
final bool showMenu; final bool showMenu;
final bool showFullPost; final bool showFullPost;
final bool showExpandableComments;
final double? maxWidth; final double? maxWidth;
final Function(SnPost data)? onChanged; final Function(SnPost data)? onChanged;
final Function()? onDeleted; final Function()? onDeleted;
@@ -62,6 +65,7 @@ class OpenablePostItem extends StatelessWidget {
this.showComments = true, this.showComments = true,
this.showMenu = true, this.showMenu = true,
this.showFullPost = false, this.showFullPost = false,
this.showExpandableComments = false,
this.maxWidth, this.maxWidth,
this.onChanged, this.onChanged,
this.onDeleted, this.onDeleted,
@@ -83,6 +87,7 @@ class OpenablePostItem extends StatelessWidget {
maxWidth: maxWidth, maxWidth: maxWidth,
showComments: showComments, showComments: showComments,
showFullPost: showFullPost, showFullPost: showFullPost,
showExpandableComments: showExpandableComments,
onChanged: onChanged, onChanged: onChanged,
onDeleted: onDeleted, onDeleted: onDeleted,
onSelectAnswer: onSelectAnswer, onSelectAnswer: onSelectAnswer,
@@ -109,12 +114,15 @@ class OpenablePostItem extends StatelessWidget {
} }
} }
class PostItem extends StatelessWidget { class PostItem extends StatefulWidget {
final SnPost data; final SnPost data;
final bool showReactions; final bool showReactions;
final bool showComments; final bool showComments;
final bool showViews;
final bool showMenu; final bool showMenu;
final bool showFullPost; final bool showFullPost;
final bool showAvatar;
final bool showExpandableComments;
final double? maxWidth; final double? maxWidth;
final Function(SnPost data)? onChanged; final Function(SnPost data)? onChanged;
final Function()? onDeleted; final Function()? onDeleted;
@@ -125,21 +133,77 @@ class PostItem extends StatelessWidget {
required this.data, required this.data,
this.showReactions = true, this.showReactions = true,
this.showComments = true, this.showComments = true,
this.showViews = true,
this.showMenu = true, this.showMenu = true,
this.showFullPost = false, this.showFullPost = false,
this.showAvatar = true,
this.showExpandableComments = false,
this.maxWidth, this.maxWidth,
this.onChanged, this.onChanged,
this.onDeleted, this.onDeleted,
this.onSelectAnswer, this.onSelectAnswer,
}); });
@override
State<PostItem> createState() => _PostItemState();
}
class _PostItemState extends State<PostItem> {
late String _displayText = widget.data.body['content'] ?? '';
late String _displayTitle = widget.data.body['title'] ?? '';
late String _displayDescription = widget.data.body['description'] ?? '';
bool _isTranslated = false;
bool _isTranslating = false;
@override
void initState() {
super.initState();
final cfg = context.read<ConfigProvider>();
if (cfg.autoTranslate) {
Future.delayed(const Duration(milliseconds: 100), () {
_translateText();
});
}
}
Future<void> _translateText() async {
final ta = context.read<SnTranslator>();
setState(() => _isTranslating = true);
try {
final to = EasyLocalization.of(context)!.locale.languageCode;
final futures = List<Future<void>>.empty(growable: true);
if (_displayTitle.isNotEmpty) {
futures.add(ta.translate(_displayTitle, to: to).then((value) {
_displayTitle = value;
}));
}
if (_displayDescription.isNotEmpty) {
futures.add(ta.translate(_displayDescription, to: to).then((value) {
_displayDescription = value;
}));
}
if (_displayText.isNotEmpty) {
futures.add(ta.translate(_displayText, to: to).then((value) {
_displayText = value;
}));
}
await Future.wait(futures);
_isTranslated = true;
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isTranslating = false);
}
}
void _onChanged(SnPost data) { void _onChanged(SnPost data) {
if (onChanged != null) onChanged!(data); if (widget.onChanged != null) widget.onChanged!(data);
} }
void _doShare(BuildContext context) { void _doShare(BuildContext context) {
final box = context.findRenderObject() as RenderBox?; final box = context.findRenderObject() as RenderBox?;
final url = 'https://solsynth.dev/posts/${data.id}'; final url = 'https://solsynth.dev/posts/${widget.data.id}';
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
Share.shareUri(Uri.parse(url), Share.shareUri(Uri.parse(url),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size); sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size);
@@ -167,7 +231,7 @@ class PostItem extends StatelessWidget {
], ],
child: ResponsiveBreakpoints.builder( child: ResponsiveBreakpoints.builder(
breakpoints: ResponsiveBreakpoints.of(context).breakpoints, breakpoints: ResponsiveBreakpoints.of(context).breakpoints,
child: PostShareImageWidget(data: data), child: PostShareImageWidget(data: widget.data),
), ),
), ),
), ),
@@ -192,7 +256,7 @@ class PostItem extends StatelessWidget {
); );
} else { } else {
await FileSaver.instance.saveFile( await FileSaver.instance.saveFile(
name: 'Solar Network Post #${data.id}.png', file: imageFile); name: 'Solar Network Post #${widget.data.id}.png', file: imageFile);
} }
await imageFile.delete(); await imageFile.delete();
@@ -203,18 +267,20 @@ class PostItem extends StatelessWidget {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
final isAuthor = ua.isAuthorized && data.publisher.accountId == ua.user?.id; final isAuthor =
ua.isAuthorized && widget.data.publisher.accountId == ua.user?.id;
final displayableAttachments = data.preload?.attachments final displayableAttachments = widget.data.preload?.attachments
?.where((ele) => ?.where((ele) =>
ele?.mediaType != SnMediaType.image || data.type != 'article') ele?.mediaType != SnMediaType.image ||
widget.data.type != 'article')
.toList(); .toList();
final cfg = context.read<ConfigProvider>(); final cfg = context.read<ConfigProvider>();
var attachmentSize = math.min( var attachmentSize = math.min(
MediaQuery.of(context).size.width, maxWidth ?? double.infinity); MediaQuery.of(context).size.width, widget.maxWidth ?? double.infinity);
if ((data.preload?.attachments?.length ?? 0) > 1) { if ((widget.data.preload?.attachments?.length ?? 0) > 1) {
attachmentSize -= 80; attachmentSize -= 80;
} }
@@ -222,18 +288,20 @@ class PostItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Container( Container(
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), constraints:
BoxConstraints(maxWidth: widget.maxWidth ?? double.infinity),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (widget.showAvatar)
_PostAvatar( _PostAvatar(
data: data, data: widget.data,
isCompact: false, isCompact: false,
), ),
const Gap(12), if (widget.showAvatar) const Gap(12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -242,25 +310,27 @@ class PostItem extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: _PostContentHeader( child: _PostContentHeader(
isRelativeDate: !showFullPost, isRelativeDate: !widget.showFullPost,
isCompact: true, isCompact: true,
data: data, data: widget.data,
), ),
), ),
_PostActionPopup( _PostActionPopup(
data: data, data: widget.data,
isAuthor: isAuthor, isAuthor: isAuthor,
onShare: () => _doShare(context), onShare: () => _doShare(context),
onShareImage: () => _doShareViaPicture(context), onShareImage: () => _doShareViaPicture(context),
onSelectAnswer: onSelectAnswer, onSelectAnswer: widget.onSelectAnswer,
onDeleted: () { onDeleted: () {
onDeleted?.call(); widget.onDeleted?.call();
},
onTranslate: () {
_translateText();
}, },
), ),
], ],
), ),
const Gap(8), if (widget.data.preload?.thumbnail != null)
if (data.preload?.thumbnail != null)
Container( Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -280,60 +350,124 @@ class PostItem extends StatelessWidget {
), ),
child: AutoResizeUniversalImage( child: AutoResizeUniversalImage(
sn.getAttachmentUrl( sn.getAttachmentUrl(
data.preload!.thumbnail!.rid, widget.data.preload!.thumbnail!.rid,
), ),
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
), ),
), ),
), ),
if (data.preload?.video != null) if (widget.data.preload?.video != null)
_PostVideoPlayer(data: data).padding(bottom: 8), _PostVideoPlayer(data: widget.data)
if (data.type == 'question') .padding(bottom: 8),
_PostQuestionHint(data: data).padding(bottom: 8), if (widget.data.type == 'question')
if (data.body['title'] != null || _PostQuestionHint(data: widget.data)
data.body['description'] != null) .padding(bottom: 8),
if (_displayDescription.isNotEmpty ||
_displayTitle.isNotEmpty)
_PostHeadline( _PostHeadline(
data: data, title: _displayTitle,
isEnlarge: data.type == 'article' && showFullPost, description: _displayDescription,
data: widget.data,
isEnlarge: widget.data.type == 'article' &&
widget.showFullPost,
).padding(bottom: 8), ).padding(bottom: 8),
if (data.type == 'article' && !showFullPost) if (widget.data.type == 'article' &&
!widget.showFullPost)
Text('postArticle') Text('postArticle')
.tr() .tr()
.fontSize(13) .fontSize(13)
.opacity(0.75) .opacity(0.75)
.padding(bottom: 8), .padding(bottom: 8),
if ((data.body['content']?.isNotEmpty ?? false) && if ((_displayText.isNotEmpty) &&
(showFullPost || data.type != 'article')) (widget.showFullPost ||
widget.data.type != 'article'))
_PostContentBody( _PostContentBody(
data: data, text: _displayText,
isSelectable: showFullPost, data: widget.data,
isEnlarge: data.type == 'article' && showFullPost, isSelectable: widget.showFullPost,
isEnlarge: widget.data.type == 'article' &&
widget.showFullPost,
).padding(bottom: 6), ).padding(bottom: 6),
if (data.repostTo != null) if (widget.data.repostTo != null)
_PostQuoteContent(child: data.repostTo!).padding( _PostQuoteContent(child: widget.data.repostTo!)
.padding(
bottom: bottom:
data.preload?.attachments?.isNotEmpty ?? false widget.data.preload?.attachments?.isNotEmpty ??
false
? 12 ? 12
: 0, : 0,
), ),
if (data.visibility > 0) if (widget.data.visibility > 0)
_PostVisibilityHint(data: data).padding( _PostVisibilityHint(data: widget.data).padding(
vertical: 4, vertical: 4,
), ),
if (data.body['content_truncated'] == true) if (widget.data.body['content_truncated'] == true)
_PostTruncatedHint(data: data).padding( _PostTruncatedHint(data: widget.data).padding(
vertical: 4, vertical: 4,
), ),
if (data.tags.isNotEmpty) if (widget.data.tags.isNotEmpty)
_PostTagsList(data: data).padding(top: 4, bottom: 6), _PostTagsList(data: widget.data)
.padding(top: 4, bottom: 6),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
if (widget.showViews)
Row( Row(
children: [ children: [
Icon(Symbols.play_circle, size: 20), Icon(Symbols.play_circle, size: 20),
const Gap(4), const Gap(4),
Text('postViews').plural(data.totalViews), Text('postViews')
.plural(widget.data.totalViews),
], ],
).opacity(0.75).padding(vertical: 4), ).opacity(0.75),
if (_isTranslating)
AnimateWidgetExtensions(Row(
children: [
Icon(Symbols.translate, size: 20),
const Gap(4),
Text('translating').tr(),
],
))
.animate(onPlay: (e) => e.repeat())
.fadeIn(
duration: 500.ms, curve: Curves.easeOut)
.then()
.fadeOut(
duration: 500.ms,
delay: 1000.ms,
curve: Curves.easeIn,
),
if (_isTranslated)
InkWell(
child: Row(
children: [
Icon(Symbols.translate, size: 20),
const Gap(4),
Text('translated').tr(),
],
).opacity(0.75),
onTap: () {
setState(() {
_displayText =
widget.data.body['content'] ?? '';
_displayTitle =
widget.data.body['title'] ?? '';
_displayDescription =
widget.data.body['description'] ?? '';
_isTranslated = false;
});
},
),
],
).padding(
bottom: widget.showViews ||
_isTranslated ||
_isTranslating
? 8
: 0,
),
], ],
), ),
) )
@@ -346,27 +480,40 @@ class PostItem extends StatelessWidget {
AttachmentList( AttachmentList(
data: displayableAttachments!, data: displayableAttachments!,
bordered: true, bordered: true,
maxHeight: showFullPost ? null : 480, maxHeight: widget.showFullPost ? null : 480,
minWidth: attachmentSize, minWidth: attachmentSize,
maxWidth: attachmentSize, maxWidth: attachmentSize,
fit: showFullPost ? BoxFit.cover : BoxFit.contain, fit: widget.showFullPost ? BoxFit.cover : BoxFit.contain,
padding: const EdgeInsets.only(left: 60, right: 12), padding:
EdgeInsets.only(left: widget.showAvatar ? 60 : 12, right: 12),
), ),
if (data.preload?.poll != null) if (widget.data.preload?.poll != null)
PostPoll(poll: data.preload!.poll!) PostPoll(poll: widget.data.preload!.poll!).padding(
.padding(horizontal: 12, vertical: 4), left: widget.showAvatar ? 60 : 12,
if (data.body['content'] != null && right: 12,
top: 12,
bottom: 4,
),
if (widget.data.body['content'] != null &&
(cfg.prefs.getBool(kAppExpandPostLink) ?? true)) (cfg.prefs.getBool(kAppExpandPostLink) ?? true))
LinkPreviewWidget( LinkPreviewWidget(
text: data.body['content'], text: widget.data.body['content'],
).padding(left: 60, right: 4), ).padding(left: widget.showAvatar ? 60 : 12, right: 4),
_PostFeaturedComment(data: data, maxWidth: maxWidth) if (widget.showExpandableComments)
.padding(left: 60, right: 12), _PostCommentIntent(
data: widget.data,
showAvatar: widget.showAvatar,
).padding(left: widget.showAvatar ? 60 : 12, right: 12)
else
_PostFeaturedComment(data: widget.data, maxWidth: widget.maxWidth)
.padding(left: widget.showAvatar ? 60 : 12, right: 12),
if (widget.showReactions)
Padding( Padding(
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: _PostReactionList( child: _PostReactionList(
data: data, data: widget.data,
padding: const EdgeInsets.only(left: 60, right: 12), padding:
EdgeInsets.only(left: widget.showAvatar ? 60 : 12, right: 12),
onChanged: _onChanged, onChanged: _onChanged,
), ),
), ),
@@ -404,13 +551,24 @@ class PostShareImageWidget extends StatelessWidget {
child: AutoResizeUniversalImage( child: AutoResizeUniversalImage(
sn.getAttachmentUrl(data.preload!.thumbnail!.rid), sn.getAttachmentUrl(data.preload!.thumbnail!.rid),
fit: BoxFit.cover, fit: BoxFit.cover,
filterQuality: FilterQuality.high,
), ),
), ),
).padding(bottom: 8), ).padding(bottom: 8),
Row(
children: [
_PostAvatar(
data: data,
isCompact: false,
filterQuality: FilterQuality.high,
),
const Gap(12),
_PostContentHeader( _PostContentHeader(
data: data, data: data,
isRelativeDate: false, isRelativeDate: false,
).padding(horizontal: 16, bottom: 8), ).padding(horizontal: 16, bottom: 8),
],
),
if (data.type == 'question') if (data.type == 'question')
_PostQuestionHint(data: data).padding(horizontal: 16, bottom: 8), _PostQuestionHint(data: data).padding(horizontal: 16, bottom: 8),
_PostHeadline( _PostHeadline(
@@ -419,6 +577,7 @@ class PostShareImageWidget extends StatelessWidget {
).padding(horizontal: 16, bottom: 8), ).padding(horizontal: 16, bottom: 8),
if (data.body['content']?.isNotEmpty ?? false) if (data.body['content']?.isNotEmpty ?? false)
_PostContentBody( _PostContentBody(
text: data.body['content'] ?? '',
data: data, data: data,
isEnlarge: data.type == 'article', isEnlarge: data.type == 'article',
).padding(horizontal: 16, bottom: 8), ).padding(horizontal: 16, bottom: 8),
@@ -704,10 +863,14 @@ class _PostReactionListState extends State<_PostReactionList> {
} }
class _PostHeadline extends StatelessWidget { class _PostHeadline extends StatelessWidget {
final String? title;
final String? description;
final SnPost data; final SnPost data;
final bool isEnlarge; final bool isEnlarge;
const _PostHeadline({ const _PostHeadline({
this.title,
this.description,
required this.data, required this.data,
this.isEnlarge = false, this.isEnlarge = false,
}); });
@@ -740,19 +903,24 @@ class _PostHeadline extends StatelessWidget {
), ),
), ),
), ),
if (data.body['title'] != null) if (data.body['title'] != null || (title?.isNotEmpty ?? false))
Text( Text(
data.body['title'], title ?? data.body['title'],
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
textScaler: TextScaler.linear(1.4), textScaler: TextScaler.linear(1.4),
), ),
if (data.body['description'] != null) if (data.body['description'] != null ||
(description?.isNotEmpty ?? false))
Text( Text(
data.body['description'], description ?? data.body['description'],
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
textScaler: TextScaler.linear(1.1), textScaler: TextScaler.linear(1.1),
), ),
if (data.body['description'] != null) const Gap(8) else const Gap(4), if (data.body['description'] != null ||
(description?.isNotEmpty ?? false))
const Gap(8)
else
const Gap(4),
Row( Row(
children: [ children: [
Text( Text(
@@ -785,14 +953,15 @@ class _PostHeadline extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (data.body['title'] != null) if (data.body['title'] != null || (title?.isNotEmpty ?? false))
Text( Text(
data.body['title'], title ?? data.body['title'],
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
if (data.body['description'] != null) if (data.body['description'] != null ||
(description?.isNotEmpty ?? false))
Text( Text(
data.body['description'], description ?? data.body['description'],
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
), ),
], ],
@@ -803,7 +972,12 @@ class _PostHeadline extends StatelessWidget {
class _PostAvatar extends StatelessWidget { class _PostAvatar extends StatelessWidget {
final SnPost data; final SnPost data;
final bool isCompact; final bool isCompact;
const _PostAvatar({required this.data, required this.isCompact}); final FilterQuality? filterQuality;
const _PostAvatar({
required this.data,
required this.isCompact,
this.filterQuality,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -815,6 +989,7 @@ class _PostAvatar extends StatelessWidget {
return GestureDetector( return GestureDetector(
child: data.preload?.realm == null child: data.preload?.realm == null
? AccountImage( ? AccountImage(
filterQuality: filterQuality,
content: data.publisher.avatar, content: data.publisher.avatar,
radius: isCompact ? 12 : 20, radius: isCompact ? 12 : 20,
borderRadius: data.publisher.type == 1 ? (isCompact ? 4 : 8) : 20, borderRadius: data.publisher.type == 1 ? (isCompact ? 4 : 8) : 20,
@@ -827,6 +1002,7 @@ class _PostAvatar extends StatelessWidget {
: null, : null,
) )
: AccountImage( : AccountImage(
filterQuality: filterQuality,
content: data.preload!.realm!.avatar, content: data.preload!.realm!.avatar,
radius: isCompact ? 12 : 20, radius: isCompact ? 12 : 20,
borderRadius: isCompact ? 4 : 8, borderRadius: isCompact ? 4 : 8,
@@ -872,12 +1048,14 @@ class _PostActionPopup extends StatelessWidget {
final Function onDeleted; final Function onDeleted;
final Function() onShare, onShareImage; final Function() onShare, onShareImage;
final Function()? onSelectAnswer; final Function()? onSelectAnswer;
final Function()? onTranslate;
const _PostActionPopup({ const _PostActionPopup({
required this.data, required this.data,
required this.isAuthor, required this.isAuthor,
required this.onDeleted, required this.onDeleted,
required this.onShare, required this.onShare,
required this.onShareImage, required this.onShareImage,
this.onTranslate,
this.onSelectAnswer, this.onSelectAnswer,
}); });
@@ -933,6 +1111,20 @@ class _PostActionPopup extends StatelessWidget {
), ),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
itemBuilder: (BuildContext context) => <PopupMenuEntry>[ itemBuilder: (BuildContext context) => <PopupMenuEntry>[
if (onTranslate != null)
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.translate),
const Gap(16),
Text('translate').tr(),
],
),
onTap: () {
onTranslate?.call();
},
),
if (onTranslate != null) PopupMenuDivider(),
if (isAuthor && onSelectAnswer != null) if (isAuthor && onSelectAnswer != null)
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
@@ -1156,11 +1348,13 @@ class _PostContentHeader extends StatelessWidget {
} }
class _PostContentBody extends StatelessWidget { class _PostContentBody extends StatelessWidget {
final String text;
final SnPost data; final SnPost data;
final bool isEnlarge; final bool isEnlarge;
final bool isSelectable; final bool isSelectable;
const _PostContentBody({ const _PostContentBody({
required this.text,
required this.data, required this.data,
this.isEnlarge = false, this.isEnlarge = false,
this.isSelectable = false, this.isSelectable = false,
@@ -1168,13 +1362,12 @@ class _PostContentBody extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (data.body['content'] == null) return const SizedBox.shrink();
final content = MarkdownTextContent( final content = MarkdownTextContent(
isAutoWarp: data.type == 'story', isAutoWarp: data.type == 'story',
isEnlargeSticker: isEnlargeSticker:
RegExp(r"^:([-\w]+):$").hasMatch(data.body['content'] ?? ''), RegExp(r"^:([-\w]+):$").hasMatch(data.body['content'] ?? ''),
textScaler: isEnlarge ? TextScaler.linear(1.1) : null, textScaler: isEnlarge ? TextScaler.linear(1.1) : null,
content: data.body['content'], content: text,
attachments: data.preload?.attachments, attachments: data.preload?.attachments,
); );
@@ -1215,7 +1408,10 @@ class _PostQuoteContent extends StatelessWidget {
isCompact: true, isCompact: true,
isRelativeDate: isRelativeDate, isRelativeDate: isRelativeDate,
).padding(bottom: 4), ).padding(bottom: 4),
_PostContentBody(data: child), _PostContentBody(
data: child,
text: child.body['content'] ?? '',
),
if (child.visibility > 0) if (child.visibility > 0)
_PostVisibilityHint(data: child).padding(top: 4), _PostVisibilityHint(data: child).padding(top: 4),
], ],
@@ -1395,6 +1591,99 @@ class _PostTruncatedHint extends StatelessWidget {
} }
} }
class _PostCommentIntent extends StatefulWidget {
final SnPost data;
final bool showAvatar;
const _PostCommentIntent({required this.data, this.showAvatar = false});
@override
State<_PostCommentIntent> createState() => _PostCommentIntentState();
}
class _PostCommentIntentState extends State<_PostCommentIntent> {
bool _isBusy = false;
int? _totalCount;
final List<SnPost> _comments = List.empty(growable: true);
bool get _isAllLoaded =>
_totalCount != null && _comments.length >= _totalCount!;
Future<void> _fetchComments() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get(
'/cgi/co/posts/${widget.data.id}/replies',
queryParameters: {
'take': 10,
'offset': _comments.length,
},
);
_totalCount = resp.data['count'];
_comments.addAll(
resp.data['data'].map((ele) => SnPost.fromJson(ele)).cast<SnPost>(),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
if (_comments.isNotEmpty)
Card(
margin: EdgeInsets.zero,
child: Column(
spacing: 8,
children: [
for (final ele in _comments)
PostItem(
data: ele,
showAvatar: false,
showExpandableComments: true,
showReactions: false,
showViews: false,
maxWidth: double.infinity,
).padding(vertical: 8, left: 6),
],
),
).padding(bottom: 8),
Row(
children: [
Transform.flip(
flipX: true,
child: const Icon(Symbols.comment, size: 20),
),
const Gap(4),
Text('postCommentsDetailed'.plural(widget.data.metric.replyCount)),
if (widget.data.metric.replyCount > 0 && !_isAllLoaded)
SizedBox(
width: 20,
height: 20,
child: IconButton(
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
icon: const Icon(Symbols.expand_more, size: 18),
onPressed: _isBusy
? null
: () {
_fetchComments();
},
),
).padding(left: 8),
],
).opacity(0.75).padding(horizontal: widget.showAvatar ? 4 : 0),
],
);
}
}
class _PostFeaturedComment extends StatefulWidget { class _PostFeaturedComment extends StatefulWidget {
final SnPost data; final SnPost data;
final double? maxWidth; final double? maxWidth;

View File

@@ -80,6 +80,8 @@ class _PostPollState extends State<PostPoll> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return Card( return Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
child: Column( child: Column(
@@ -91,10 +93,11 @@ class _PostPollState extends State<PostPoll> {
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Container( child: Container(
height: 60, height: 60,
width: MediaQuery.of(context).size.width * width: constraints.maxWidth *
(_poll.metric.byOptionsPercentage[option.id] ?? 0) (_poll.metric.byOptionsPercentage[option.id] ?? 0)
.toDouble(), .toDouble(),
color: Theme.of(context).colorScheme.surfaceContainerHigh, color:
Theme.of(context).colorScheme.surfaceContainerHigh,
), ),
), ),
ListTile( ListTile(
@@ -114,8 +117,8 @@ class _PostPollState extends State<PostPoll> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
'pollVotes' 'pollVotes'.plural(
.plural(_poll.metric.byOptions[option.id] ?? 0), _poll.metric.byOptions[option.id] ?? 0),
), ),
Text(' · ').padding(horizontal: 4), Text(' · ').padding(horizontal: 4),
Text( Text(
@@ -134,5 +137,7 @@ class _PostPollState extends State<PostPoll> {
], ],
), ),
); );
},
);
} }
} }

View File

@@ -90,8 +90,6 @@ PODS:
- gal (1.0.0): - gal (1.0.0):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- geolocator_apple (1.2.0):
- FlutterMacOS
- GoogleAppMeasurement (11.8.0): - GoogleAppMeasurement (11.8.0):
- GoogleAppMeasurement/AdIdSupport (= 11.8.0) - GoogleAppMeasurement/AdIdSupport (= 11.8.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
@@ -148,7 +146,7 @@ PODS:
- HotKey - HotKey
- in_app_review (2.0.0): - in_app_review (2.0.0):
- FlutterMacOS - FlutterMacOS
- livekit_client (2.4.0): - livekit_client (2.4.1):
- flutter_webrtc - flutter_webrtc
- FlutterMacOS - FlutterMacOS
- WebRTC-SDK (= 125.6422.06) - WebRTC-SDK (= 125.6422.06)
@@ -232,7 +230,6 @@ DEPENDENCIES:
- flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`) - flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`)
- FlutterMacOS (from `Flutter/ephemeral`) - FlutterMacOS (from `Flutter/ephemeral`)
- gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`) - gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`)
- geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/macos`)
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`) - hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
- in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`) - in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`)
- livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`) - livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`)
@@ -307,8 +304,6 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral :path: Flutter/ephemeral
gal: gal:
:path: Flutter/ephemeral/.symlinks/plugins/gal/darwin :path: Flutter/ephemeral/.symlinks/plugins/gal/darwin
geolocator_apple:
:path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/macos
hotkey_manager_macos: hotkey_manager_macos:
:path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos :path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos
in_app_review: in_app_review:
@@ -372,14 +367,13 @@ SPEC CHECKSUMS:
flutter_webrtc: d55fd3f5c75b42940b6b4b2cf376a5797398d1f8 flutter_webrtc: d55fd3f5c75b42940b6b4b2cf376a5797398d1f8
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5 gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
geolocator_apple: 72a78ae3f3e4ec0db62117bd93e34523f5011d58
GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896 GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
hotkey_manager_macos: 1e2edb0c7ae4fe67108af44a9d3445de41404160 hotkey_manager_macos: 1e2edb0c7ae4fe67108af44a9d3445de41404160
in_app_review: a6a031b9acd03c7d103e341aa334adf2c493fb93 in_app_review: a6a031b9acd03c7d103e341aa334adf2c493fb93
livekit_client: 2e766be2c3ee6274a8e2633b356b98b5eb842987 livekit_client: d03409f83df069a1bb00a4c8dc78c54fb2287262
local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff
media_kit_libs_macos_video: b3e2bbec2eef97c285f2b1baa7963c67c753fb82 media_kit_libs_macos_video: b3e2bbec2eef97c285f2b1baa7963c67c753fb82
media_kit_native_event_loop: 81fd5b45192b72f8b5b69eaf5b540f45777eb8d5 media_kit_native_event_loop: 81fd5b45192b72f8b5b69eaf5b540f45777eb8d5

View File

@@ -314,7 +314,7 @@ packages:
source: hosted source: hosted
version: "0.3.4+2" version: "0.3.4+2"
crypto: crypto:
dependency: transitive dependency: "direct main"
description: description:
name: crypto name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 2.4.2+79 version: 2.4.2+80
environment: environment:
sdk: ^3.5.4 sdk: ^3.5.4
@@ -142,6 +142,7 @@ dependencies:
flutter_blurhash: ^0.8.2 flutter_blurhash: ^0.8.2
timelines_plus: ^1.0.6 timelines_plus: ^1.0.6
latlong2: ^0.9.1 latlong2: ^0.9.1
crypto: ^3.0.6
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: