Compare commits
8 Commits
4146820be5
...
2.4.2+80
| Author | SHA1 | Date | |
|---|---|---|---|
| b492db90ca | |||
| c9f69fed2c | |||
| d2f4e7a969 | |||
| aecd04e0b9 | |||
| e5212419ae | |||
| ec7650a920 | |||
| 7b96013406 | |||
| fc5a79b29b |
@@ -207,6 +207,7 @@
|
||||
"one": "{} comment",
|
||||
"other": "{} comments"
|
||||
},
|
||||
"postCommentExpand": "Show comments",
|
||||
"settingsAppearance": "Appearance",
|
||||
"settingsCustomFonts": "Custom Fonts",
|
||||
"settingsCustomFontsDescription": "Set custom fonts for the application.",
|
||||
@@ -811,6 +812,8 @@
|
||||
"accountActionEvent": "Action Events",
|
||||
"accountActionEventDescription": "View your action event logs.",
|
||||
"eventMetadata": "Metadata",
|
||||
"accountAuthTickets": "Auth Sessions",
|
||||
"accountAuthTicketsDescription": "View and manage your auth sessions.",
|
||||
"authTicketCreatedAt": "Issued at {}",
|
||||
"authTicketExpiredAt": "Expired at {}",
|
||||
"authTicketLastGrantAt": "Last granted at {}",
|
||||
@@ -838,5 +841,10 @@
|
||||
"accountContactMethodsPublicHint": "This contact method will be displayed publicly on your profile.",
|
||||
"accountContactMethodsDelete": "Delete Contact Method",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
"one": "{} 条评论",
|
||||
"other": "{} 条评论"
|
||||
},
|
||||
"postCommentExpand": "展开评论",
|
||||
"settingsAppearance": "外观",
|
||||
"settingsCustomFonts": "自定义字体",
|
||||
"settingsCustomFontsDescription": "设置应用程序使用的字体。",
|
||||
@@ -838,5 +839,10 @@
|
||||
"accountContactMethodsPublicHint": "这个联系方式公开地显示在个人资料中。",
|
||||
"accountContactMethodsDelete": "删除联系方式",
|
||||
"accountContactMethodsDeleteDescription": "你确定要删除联系方式 {} 吗?这个操作不可撤销。",
|
||||
"postCommentAdd": "撰写一条评论"
|
||||
"postCommentAdd": "撰写一条评论",
|
||||
"translate": "翻译",
|
||||
"translating": "正在翻译……",
|
||||
"translated": "已翻译",
|
||||
"settingsAutoTranslate": "自动翻译",
|
||||
"settingsAutoTranslateDescription": "在查看帖子、消息时自动翻译文本。"
|
||||
}
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
"one": "{} 條評論",
|
||||
"other": "{} 條評論"
|
||||
},
|
||||
"postCommentExpand": "展開評論",
|
||||
"settingsAppearance": "外觀",
|
||||
"settingsCustomFonts": "自定義字體",
|
||||
"settingsCustomFontsDescription": "設置應用程序使用的字體。",
|
||||
@@ -838,5 +839,10 @@
|
||||
"accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
|
||||
"accountContactMethodsDelete": "刪除聯繫方式",
|
||||
"accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
|
||||
"postCommentAdd": "撰寫一條評論"
|
||||
"postCommentAdd": "撰寫一條評論",
|
||||
"translate": "翻譯",
|
||||
"translating": "正在翻譯……",
|
||||
"translated": "已翻譯",
|
||||
"settingsAutoTranslate": "自動翻譯",
|
||||
"settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。"
|
||||
}
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
"one": "{} 條評論",
|
||||
"other": "{} 條評論"
|
||||
},
|
||||
"postCommentExpand": "展開評論",
|
||||
"settingsAppearance": "外觀",
|
||||
"settingsCustomFonts": "自定義字體",
|
||||
"settingsCustomFontsDescription": "設置應用程序使用的字體。",
|
||||
@@ -838,5 +839,10 @@
|
||||
"accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
|
||||
"accountContactMethodsDelete": "刪除聯繫方式",
|
||||
"accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
|
||||
"postCommentAdd": "撰寫一條評論"
|
||||
"postCommentAdd": "撰寫一條評論",
|
||||
"translate": "翻譯",
|
||||
"translating": "正在翻譯……",
|
||||
"translated": "已翻譯",
|
||||
"settingsAutoTranslate": "自動翻譯",
|
||||
"settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。"
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import 'package:surface/providers/sn_realm.dart';
|
||||
import 'package:surface/providers/sn_sticker.dart';
|
||||
import 'package:surface/providers/special_day.dart';
|
||||
import 'package:surface/providers/theme.dart';
|
||||
import 'package:surface/providers/translation.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/providers/websocket.dart';
|
||||
@@ -167,6 +168,7 @@ class SolianApp extends StatelessWidget {
|
||||
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
|
||||
Provider(create: (ctx) => SnTranslator()),
|
||||
|
||||
// Additional helper layer
|
||||
Provider(create: (ctx) => SpecialDayProvider(ctx)),
|
||||
@@ -274,7 +276,9 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
||||
mounted) {
|
||||
final config = context.read<ConfigProvider>();
|
||||
config.setUpdate(
|
||||
remoteVersionString, resp.data?['body'] ?? 'No changelog');
|
||||
remoteVersionString,
|
||||
resp.data?['body'] ?? 'No changelog',
|
||||
);
|
||||
logging.info("[Update] Update available: $remoteVersionString");
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -20,6 +20,7 @@ const kAppExpandChatLink = 'app_expand_chat_link';
|
||||
const kAppRealmCompactView = 'app_realm_compact_view';
|
||||
const kAppCustomFonts = 'app_custom_fonts';
|
||||
const kAppMixedFeed = 'app_mixed_feed';
|
||||
const kAppAutoTranslate = 'app_auto_translate';
|
||||
|
||||
const Map<String, FilterQuality> kImageQualityLevel = {
|
||||
'settingsImageQualityLowest': FilterQuality.none,
|
||||
@@ -86,6 +87,15 @@ class ConfigProvider extends ChangeNotifier {
|
||||
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) {
|
||||
prefs.setBool(kAppMixedFeed, value);
|
||||
notifyListeners();
|
||||
|
||||
56
lib/providers/translation.dart
Normal file
56
lib/providers/translation.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
11
lib/screens/account/prefs/notify.dart
Normal file
11
lib/screens/account/prefs/notify.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -387,6 +387,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
.fontSize(17)
|
||||
.tr()
|
||||
.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(
|
||||
secondary: const Icon(Symbols.vibration),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
|
||||
@@ -14,6 +14,7 @@ class AccountImage extends StatelessWidget {
|
||||
final Widget? fallbackWidget;
|
||||
final Widget? badge;
|
||||
final Offset? badgeOffset;
|
||||
final FilterQuality? filterQuality;
|
||||
|
||||
const AccountImage({
|
||||
super.key,
|
||||
@@ -25,6 +26,7 @@ class AccountImage extends StatelessWidget {
|
||||
this.fallbackWidget,
|
||||
this.badge,
|
||||
this.badgeOffset,
|
||||
this.filterQuality,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -54,6 +56,7 @@ class AccountImage extends StatelessWidget {
|
||||
)
|
||||
: AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(url),
|
||||
filterQuality: filterQuality,
|
||||
key: Key('attachment-${content.hashCode}'),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
|
||||
@@ -45,11 +45,25 @@ class AttachmentItem extends StatelessWidget {
|
||||
case 'image':
|
||||
return Hero(
|
||||
tag: 'attachment-${data!.rid}-$tag',
|
||||
child: AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(data!.rid),
|
||||
key: Key('attachment-${data!.rid}-$tag'),
|
||||
fit: fit,
|
||||
filterQuality: filterQuality,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(data!.rid),
|
||||
key: Key('attachment-${data!.rid}-$tag-blur-background'),
|
||||
fit: BoxFit.cover,
|
||||
filterQuality: filterQuality,
|
||||
),
|
||||
),
|
||||
AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(data!.rid),
|
||||
key: Key('attachment-${data!.rid}-$tag'),
|
||||
fit: fit,
|
||||
filterQuality: filterQuality,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
case 'video':
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/config.dart';
|
||||
import 'package:surface/providers/keypair.dart';
|
||||
import 'package:surface/providers/translation.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/providers/userinfo.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/attachment/attachment_list.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/markdown_content.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 Function(SnChatMessage)? onReply;
|
||||
final Function(SnChatMessage)? onEdit;
|
||||
@@ -237,13 +239,56 @@ class _ChatMessageText extends StatelessWidget {
|
||||
const _ChatMessageText(
|
||||
{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
|
||||
Widget build(BuildContext context) {
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -252,38 +297,50 @@ class _ChatMessageText extends StatelessWidget {
|
||||
final List<ContextMenuButtonItem> items =
|
||||
editableTextState.contextMenuButtonItems;
|
||||
|
||||
if (onReply != null) {
|
||||
if (widget.onReply != null) {
|
||||
items.insert(
|
||||
0,
|
||||
ContextMenuButtonItem(
|
||||
label: 'reply'.tr(),
|
||||
onPressed: () {
|
||||
ContextMenuController.removeAny();
|
||||
onReply?.call(data);
|
||||
widget.onReply?.call(widget.data);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isOwner && onEdit != null) {
|
||||
if (isOwner && widget.onEdit != null) {
|
||||
items.insert(
|
||||
1,
|
||||
ContextMenuButtonItem(
|
||||
label: 'edit'.tr(),
|
||||
onPressed: () {
|
||||
ContextMenuController.removeAny();
|
||||
onEdit?.call(data);
|
||||
widget.onEdit?.call(widget.data);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isOwner && onDelete != null) {
|
||||
if (isOwner && widget.onDelete != null) {
|
||||
items.insert(
|
||||
2,
|
||||
ContextMenuButtonItem(
|
||||
label: 'delete'.tr(),
|
||||
onPressed: () {
|
||||
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,
|
||||
);
|
||||
},
|
||||
child: switch (data.body['algorithm']) {
|
||||
'rsa' => _ChatDecryptMessage(message: data),
|
||||
child: switch (widget.data.body['algorithm']) {
|
||||
'rsa' => _ChatDecryptMessage(message: widget.data),
|
||||
_ => MarkdownTextContent(
|
||||
content: data.body['text'],
|
||||
content: _displayText,
|
||||
isAutoWarp: true,
|
||||
isEnlargeSticker:
|
||||
RegExp(r"^:([-\w]+):$").hasMatch(data.body['text'] ?? ''),
|
||||
isEnlargeSticker: RegExp(r"^:([-\w]+):$")
|
||||
.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),
|
||||
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(
|
||||
children: [
|
||||
const Icon(Symbols.file_present, size: 20),
|
||||
const Gap(4),
|
||||
Text('messageFileHint'.plural(data.body['attachments']!.length)),
|
||||
Text('messageFileHint'
|
||||
.plural(widget.data.body['attachments']!.length)),
|
||||
],
|
||||
).opacity(0.8);
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
|
||||
child: PostItem(
|
||||
data: _posts[idx],
|
||||
maxWidth: widget.maxWidth,
|
||||
showExpandableComments: true,
|
||||
onSelectAnswer: widget.parentPost.type == 'question'
|
||||
? () => _selectAnswer(_posts[idx])
|
||||
: null,
|
||||
@@ -209,6 +210,7 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> {
|
||||
if (ua.isAuthorized)
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
height: 240,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.symmetric(
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:file_saver/file_saver.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.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:surface/providers/config.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/userinfo.dart';
|
||||
import 'package:surface/screens/post/post_detail.dart';
|
||||
@@ -50,6 +52,7 @@ class OpenablePostItem extends StatelessWidget {
|
||||
final bool showComments;
|
||||
final bool showMenu;
|
||||
final bool showFullPost;
|
||||
final bool showExpandableComments;
|
||||
final double? maxWidth;
|
||||
final Function(SnPost data)? onChanged;
|
||||
final Function()? onDeleted;
|
||||
@@ -62,6 +65,7 @@ class OpenablePostItem extends StatelessWidget {
|
||||
this.showComments = true,
|
||||
this.showMenu = true,
|
||||
this.showFullPost = false,
|
||||
this.showExpandableComments = false,
|
||||
this.maxWidth,
|
||||
this.onChanged,
|
||||
this.onDeleted,
|
||||
@@ -83,6 +87,7 @@ class OpenablePostItem extends StatelessWidget {
|
||||
maxWidth: maxWidth,
|
||||
showComments: showComments,
|
||||
showFullPost: showFullPost,
|
||||
showExpandableComments: showExpandableComments,
|
||||
onChanged: onChanged,
|
||||
onDeleted: onDeleted,
|
||||
onSelectAnswer: onSelectAnswer,
|
||||
@@ -109,12 +114,15 @@ class OpenablePostItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class PostItem extends StatelessWidget {
|
||||
class PostItem extends StatefulWidget {
|
||||
final SnPost data;
|
||||
final bool showReactions;
|
||||
final bool showComments;
|
||||
final bool showViews;
|
||||
final bool showMenu;
|
||||
final bool showFullPost;
|
||||
final bool showAvatar;
|
||||
final bool showExpandableComments;
|
||||
final double? maxWidth;
|
||||
final Function(SnPost data)? onChanged;
|
||||
final Function()? onDeleted;
|
||||
@@ -125,21 +133,77 @@ class PostItem extends StatelessWidget {
|
||||
required this.data,
|
||||
this.showReactions = true,
|
||||
this.showComments = true,
|
||||
this.showViews = true,
|
||||
this.showMenu = true,
|
||||
this.showFullPost = false,
|
||||
this.showAvatar = true,
|
||||
this.showExpandableComments = false,
|
||||
this.maxWidth,
|
||||
this.onChanged,
|
||||
this.onDeleted,
|
||||
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) {
|
||||
if (onChanged != null) onChanged!(data);
|
||||
if (widget.onChanged != null) widget.onChanged!(data);
|
||||
}
|
||||
|
||||
void _doShare(BuildContext context) {
|
||||
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)) {
|
||||
Share.shareUri(Uri.parse(url),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size);
|
||||
@@ -167,7 +231,7 @@ class PostItem extends StatelessWidget {
|
||||
],
|
||||
child: ResponsiveBreakpoints.builder(
|
||||
breakpoints: ResponsiveBreakpoints.of(context).breakpoints,
|
||||
child: PostShareImageWidget(data: data),
|
||||
child: PostShareImageWidget(data: widget.data),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -192,7 +256,7 @@ class PostItem extends StatelessWidget {
|
||||
);
|
||||
} else {
|
||||
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();
|
||||
@@ -203,18 +267,20 @@ class PostItem extends StatelessWidget {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
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) =>
|
||||
ele?.mediaType != SnMediaType.image || data.type != 'article')
|
||||
ele?.mediaType != SnMediaType.image ||
|
||||
widget.data.type != 'article')
|
||||
.toList();
|
||||
|
||||
final cfg = context.read<ConfigProvider>();
|
||||
|
||||
var attachmentSize = math.min(
|
||||
MediaQuery.of(context).size.width, maxWidth ?? double.infinity);
|
||||
if ((data.preload?.attachments?.length ?? 0) > 1) {
|
||||
MediaQuery.of(context).size.width, widget.maxWidth ?? double.infinity);
|
||||
if ((widget.data.preload?.attachments?.length ?? 0) > 1) {
|
||||
attachmentSize -= 80;
|
||||
}
|
||||
|
||||
@@ -222,18 +288,20 @@ class PostItem extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
|
||||
constraints:
|
||||
BoxConstraints(maxWidth: widget.maxWidth ?? double.infinity),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_PostAvatar(
|
||||
data: data,
|
||||
isCompact: false,
|
||||
),
|
||||
const Gap(12),
|
||||
if (widget.showAvatar)
|
||||
_PostAvatar(
|
||||
data: widget.data,
|
||||
isCompact: false,
|
||||
),
|
||||
if (widget.showAvatar) const Gap(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -242,25 +310,27 @@ class PostItem extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: _PostContentHeader(
|
||||
isRelativeDate: !showFullPost,
|
||||
isRelativeDate: !widget.showFullPost,
|
||||
isCompact: true,
|
||||
data: data,
|
||||
data: widget.data,
|
||||
),
|
||||
),
|
||||
_PostActionPopup(
|
||||
data: data,
|
||||
data: widget.data,
|
||||
isAuthor: isAuthor,
|
||||
onShare: () => _doShare(context),
|
||||
onShareImage: () => _doShareViaPicture(context),
|
||||
onSelectAnswer: onSelectAnswer,
|
||||
onSelectAnswer: widget.onSelectAnswer,
|
||||
onDeleted: () {
|
||||
onDeleted?.call();
|
||||
widget.onDeleted?.call();
|
||||
},
|
||||
onTranslate: () {
|
||||
_translateText();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
if (data.preload?.thumbnail != null)
|
||||
if (widget.data.preload?.thumbnail != null)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
@@ -280,60 +350,124 @@ class PostItem extends StatelessWidget {
|
||||
),
|
||||
child: AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(
|
||||
data.preload!.thumbnail!.rid,
|
||||
widget.data.preload!.thumbnail!.rid,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (data.preload?.video != null)
|
||||
_PostVideoPlayer(data: data).padding(bottom: 8),
|
||||
if (data.type == 'question')
|
||||
_PostQuestionHint(data: data).padding(bottom: 8),
|
||||
if (data.body['title'] != null ||
|
||||
data.body['description'] != null)
|
||||
if (widget.data.preload?.video != null)
|
||||
_PostVideoPlayer(data: widget.data)
|
||||
.padding(bottom: 8),
|
||||
if (widget.data.type == 'question')
|
||||
_PostQuestionHint(data: widget.data)
|
||||
.padding(bottom: 8),
|
||||
if (_displayDescription.isNotEmpty ||
|
||||
_displayTitle.isNotEmpty)
|
||||
_PostHeadline(
|
||||
data: data,
|
||||
isEnlarge: data.type == 'article' && showFullPost,
|
||||
title: _displayTitle,
|
||||
description: _displayDescription,
|
||||
data: widget.data,
|
||||
isEnlarge: widget.data.type == 'article' &&
|
||||
widget.showFullPost,
|
||||
).padding(bottom: 8),
|
||||
if (data.type == 'article' && !showFullPost)
|
||||
if (widget.data.type == 'article' &&
|
||||
!widget.showFullPost)
|
||||
Text('postArticle')
|
||||
.tr()
|
||||
.fontSize(13)
|
||||
.opacity(0.75)
|
||||
.padding(bottom: 8),
|
||||
if ((data.body['content']?.isNotEmpty ?? false) &&
|
||||
(showFullPost || data.type != 'article'))
|
||||
if ((_displayText.isNotEmpty) &&
|
||||
(widget.showFullPost ||
|
||||
widget.data.type != 'article'))
|
||||
_PostContentBody(
|
||||
data: data,
|
||||
isSelectable: showFullPost,
|
||||
isEnlarge: data.type == 'article' && showFullPost,
|
||||
text: _displayText,
|
||||
data: widget.data,
|
||||
isSelectable: widget.showFullPost,
|
||||
isEnlarge: widget.data.type == 'article' &&
|
||||
widget.showFullPost,
|
||||
).padding(bottom: 6),
|
||||
if (data.repostTo != null)
|
||||
_PostQuoteContent(child: data.repostTo!).padding(
|
||||
if (widget.data.repostTo != null)
|
||||
_PostQuoteContent(child: widget.data.repostTo!)
|
||||
.padding(
|
||||
bottom:
|
||||
data.preload?.attachments?.isNotEmpty ?? false
|
||||
widget.data.preload?.attachments?.isNotEmpty ??
|
||||
false
|
||||
? 12
|
||||
: 0,
|
||||
),
|
||||
if (data.visibility > 0)
|
||||
_PostVisibilityHint(data: data).padding(
|
||||
if (widget.data.visibility > 0)
|
||||
_PostVisibilityHint(data: widget.data).padding(
|
||||
vertical: 4,
|
||||
),
|
||||
if (data.body['content_truncated'] == true)
|
||||
_PostTruncatedHint(data: data).padding(
|
||||
if (widget.data.body['content_truncated'] == true)
|
||||
_PostTruncatedHint(data: widget.data).padding(
|
||||
vertical: 4,
|
||||
),
|
||||
if (data.tags.isNotEmpty)
|
||||
_PostTagsList(data: data).padding(top: 4, bottom: 6),
|
||||
Row(
|
||||
if (widget.data.tags.isNotEmpty)
|
||||
_PostTagsList(data: widget.data)
|
||||
.padding(top: 4, bottom: 6),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 4,
|
||||
children: [
|
||||
Icon(Symbols.play_circle, size: 20),
|
||||
const Gap(4),
|
||||
Text('postViews').plural(data.totalViews),
|
||||
if (widget.showViews)
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.play_circle, size: 20),
|
||||
const Gap(4),
|
||||
Text('postViews')
|
||||
.plural(widget.data.totalViews),
|
||||
],
|
||||
).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;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
).opacity(0.75).padding(vertical: 4),
|
||||
).padding(
|
||||
bottom: widget.showViews ||
|
||||
_isTranslated ||
|
||||
_isTranslating
|
||||
? 8
|
||||
: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -346,30 +480,43 @@ class PostItem extends StatelessWidget {
|
||||
AttachmentList(
|
||||
data: displayableAttachments!,
|
||||
bordered: true,
|
||||
maxHeight: showFullPost ? null : 480,
|
||||
maxHeight: widget.showFullPost ? null : 480,
|
||||
minWidth: attachmentSize,
|
||||
maxWidth: attachmentSize,
|
||||
fit: showFullPost ? BoxFit.cover : BoxFit.contain,
|
||||
padding: const EdgeInsets.only(left: 60, right: 12),
|
||||
fit: widget.showFullPost ? BoxFit.cover : BoxFit.contain,
|
||||
padding:
|
||||
EdgeInsets.only(left: widget.showAvatar ? 60 : 12, right: 12),
|
||||
),
|
||||
if (data.preload?.poll != null)
|
||||
PostPoll(poll: data.preload!.poll!)
|
||||
.padding(horizontal: 12, vertical: 4),
|
||||
if (data.body['content'] != null &&
|
||||
if (widget.data.preload?.poll != null)
|
||||
PostPoll(poll: widget.data.preload!.poll!).padding(
|
||||
left: widget.showAvatar ? 60 : 12,
|
||||
right: 12,
|
||||
top: 12,
|
||||
bottom: 4,
|
||||
),
|
||||
if (widget.data.body['content'] != null &&
|
||||
(cfg.prefs.getBool(kAppExpandPostLink) ?? true))
|
||||
LinkPreviewWidget(
|
||||
text: data.body['content'],
|
||||
).padding(left: 60, right: 4),
|
||||
_PostFeaturedComment(data: data, maxWidth: maxWidth)
|
||||
.padding(left: 60, right: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: _PostReactionList(
|
||||
data: data,
|
||||
padding: const EdgeInsets.only(left: 60, right: 12),
|
||||
onChanged: _onChanged,
|
||||
text: widget.data.body['content'],
|
||||
).padding(left: widget.showAvatar ? 60 : 12, right: 4),
|
||||
if (widget.showExpandableComments)
|
||||
_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: const EdgeInsets.only(top: 4),
|
||||
child: _PostReactionList(
|
||||
data: widget.data,
|
||||
padding:
|
||||
EdgeInsets.only(left: widget.showAvatar ? 60 : 12, right: 12),
|
||||
onChanged: _onChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -404,13 +551,24 @@ class PostShareImageWidget extends StatelessWidget {
|
||||
child: AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(data.preload!.thumbnail!.rid),
|
||||
fit: BoxFit.cover,
|
||||
filterQuality: FilterQuality.high,
|
||||
),
|
||||
),
|
||||
).padding(bottom: 8),
|
||||
_PostContentHeader(
|
||||
data: data,
|
||||
isRelativeDate: false,
|
||||
).padding(horizontal: 16, bottom: 8),
|
||||
Row(
|
||||
children: [
|
||||
_PostAvatar(
|
||||
data: data,
|
||||
isCompact: false,
|
||||
filterQuality: FilterQuality.high,
|
||||
),
|
||||
const Gap(12),
|
||||
_PostContentHeader(
|
||||
data: data,
|
||||
isRelativeDate: false,
|
||||
).padding(horizontal: 16, bottom: 8),
|
||||
],
|
||||
),
|
||||
if (data.type == 'question')
|
||||
_PostQuestionHint(data: data).padding(horizontal: 16, bottom: 8),
|
||||
_PostHeadline(
|
||||
@@ -419,6 +577,7 @@ class PostShareImageWidget extends StatelessWidget {
|
||||
).padding(horizontal: 16, bottom: 8),
|
||||
if (data.body['content']?.isNotEmpty ?? false)
|
||||
_PostContentBody(
|
||||
text: data.body['content'] ?? '',
|
||||
data: data,
|
||||
isEnlarge: data.type == 'article',
|
||||
).padding(horizontal: 16, bottom: 8),
|
||||
@@ -704,10 +863,14 @@ class _PostReactionListState extends State<_PostReactionList> {
|
||||
}
|
||||
|
||||
class _PostHeadline extends StatelessWidget {
|
||||
final String? title;
|
||||
final String? description;
|
||||
final SnPost data;
|
||||
final bool isEnlarge;
|
||||
|
||||
const _PostHeadline({
|
||||
this.title,
|
||||
this.description,
|
||||
required this.data,
|
||||
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(
|
||||
data.body['title'],
|
||||
title ?? data.body['title'],
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
textScaler: TextScaler.linear(1.4),
|
||||
),
|
||||
if (data.body['description'] != null)
|
||||
if (data.body['description'] != null ||
|
||||
(description?.isNotEmpty ?? false))
|
||||
Text(
|
||||
data.body['description'],
|
||||
description ?? data.body['description'],
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
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(
|
||||
children: [
|
||||
Text(
|
||||
@@ -785,14 +953,15 @@ class _PostHeadline extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (data.body['title'] != null)
|
||||
if (data.body['title'] != null || (title?.isNotEmpty ?? false))
|
||||
Text(
|
||||
data.body['title'],
|
||||
title ?? data.body['title'],
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
if (data.body['description'] != null)
|
||||
if (data.body['description'] != null ||
|
||||
(description?.isNotEmpty ?? false))
|
||||
Text(
|
||||
data.body['description'],
|
||||
description ?? data.body['description'],
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
@@ -803,7 +972,12 @@ class _PostHeadline extends StatelessWidget {
|
||||
class _PostAvatar extends StatelessWidget {
|
||||
final SnPost data;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
@@ -815,6 +989,7 @@ class _PostAvatar extends StatelessWidget {
|
||||
return GestureDetector(
|
||||
child: data.preload?.realm == null
|
||||
? AccountImage(
|
||||
filterQuality: filterQuality,
|
||||
content: data.publisher.avatar,
|
||||
radius: isCompact ? 12 : 20,
|
||||
borderRadius: data.publisher.type == 1 ? (isCompact ? 4 : 8) : 20,
|
||||
@@ -827,6 +1002,7 @@ class _PostAvatar extends StatelessWidget {
|
||||
: null,
|
||||
)
|
||||
: AccountImage(
|
||||
filterQuality: filterQuality,
|
||||
content: data.preload!.realm!.avatar,
|
||||
radius: isCompact ? 12 : 20,
|
||||
borderRadius: isCompact ? 4 : 8,
|
||||
@@ -872,12 +1048,14 @@ class _PostActionPopup extends StatelessWidget {
|
||||
final Function onDeleted;
|
||||
final Function() onShare, onShareImage;
|
||||
final Function()? onSelectAnswer;
|
||||
final Function()? onTranslate;
|
||||
const _PostActionPopup({
|
||||
required this.data,
|
||||
required this.isAuthor,
|
||||
required this.onDeleted,
|
||||
required this.onShare,
|
||||
required this.onShareImage,
|
||||
this.onTranslate,
|
||||
this.onSelectAnswer,
|
||||
});
|
||||
|
||||
@@ -933,6 +1111,20 @@ class _PostActionPopup extends StatelessWidget {
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
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)
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
@@ -1156,11 +1348,13 @@ class _PostContentHeader extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _PostContentBody extends StatelessWidget {
|
||||
final String text;
|
||||
final SnPost data;
|
||||
final bool isEnlarge;
|
||||
final bool isSelectable;
|
||||
|
||||
const _PostContentBody({
|
||||
required this.text,
|
||||
required this.data,
|
||||
this.isEnlarge = false,
|
||||
this.isSelectable = false,
|
||||
@@ -1168,13 +1362,12 @@ class _PostContentBody extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (data.body['content'] == null) return const SizedBox.shrink();
|
||||
final content = MarkdownTextContent(
|
||||
isAutoWarp: data.type == 'story',
|
||||
isEnlargeSticker:
|
||||
RegExp(r"^:([-\w]+):$").hasMatch(data.body['content'] ?? ''),
|
||||
textScaler: isEnlarge ? TextScaler.linear(1.1) : null,
|
||||
content: data.body['content'],
|
||||
content: text,
|
||||
attachments: data.preload?.attachments,
|
||||
);
|
||||
|
||||
@@ -1215,7 +1408,10 @@ class _PostQuoteContent extends StatelessWidget {
|
||||
isCompact: true,
|
||||
isRelativeDate: isRelativeDate,
|
||||
).padding(bottom: 4),
|
||||
_PostContentBody(data: child),
|
||||
_PostContentBody(
|
||||
data: child,
|
||||
text: child.body['content'] ?? '',
|
||||
),
|
||||
if (child.visibility > 0)
|
||||
_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 {
|
||||
final SnPost data;
|
||||
final double? maxWidth;
|
||||
|
||||
@@ -80,59 +80,64 @@ class _PostPollState extends State<PostPoll> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (final option in _poll.options)
|
||||
Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Container(
|
||||
height: 60,
|
||||
width: MediaQuery.of(context).size.width *
|
||||
(_poll.metric.byOptionsPercentage[option.id] ?? 0)
|
||||
.toDouble(),
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
minTileHeight: 60,
|
||||
leading: _answeredChoice == option.id
|
||||
? const Icon(Symbols.circle, fill: 1)
|
||||
: const Icon(Symbols.circle),
|
||||
title: Text(option.name),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (final option in _poll.options)
|
||||
Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Container(
|
||||
height: 60,
|
||||
width: constraints.maxWidth *
|
||||
(_poll.metric.byOptionsPercentage[option.id] ?? 0)
|
||||
.toDouble(),
|
||||
color:
|
||||
Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
minTileHeight: 60,
|
||||
leading: _answeredChoice == option.id
|
||||
? const Icon(Symbols.circle, fill: 1)
|
||||
: const Icon(Symbols.circle),
|
||||
title: Text(option.name),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'pollVotes'
|
||||
.plural(_poll.metric.byOptions[option.id] ?? 0),
|
||||
),
|
||||
Text(' · ').padding(horizontal: 4),
|
||||
Text(
|
||||
'${((_poll.metric.byOptionsPercentage[option.id] ?? 0).toDouble() * 100).toStringAsFixed(2)}%',
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'pollVotes'.plural(
|
||||
_poll.metric.byOptions[option.id] ?? 0),
|
||||
),
|
||||
Text(' · ').padding(horizontal: 4),
|
||||
Text(
|
||||
'${((_poll.metric.byOptionsPercentage[option.id] ?? 0).toDouble() * 100).toStringAsFixed(2)}%',
|
||||
),
|
||||
],
|
||||
),
|
||||
if (option.description.isNotEmpty)
|
||||
Text(option.description),
|
||||
],
|
||||
),
|
||||
if (option.description.isNotEmpty)
|
||||
Text(option.description),
|
||||
],
|
||||
),
|
||||
onTap: _isBusy ? null : () => _voteForOption(option),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
onTap: _isBusy ? null : () => _voteForOption(option),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,8 +90,6 @@ PODS:
|
||||
- gal (1.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- geolocator_apple (1.2.0):
|
||||
- FlutterMacOS
|
||||
- GoogleAppMeasurement (11.8.0):
|
||||
- GoogleAppMeasurement/AdIdSupport (= 11.8.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
|
||||
@@ -148,7 +146,7 @@ PODS:
|
||||
- HotKey
|
||||
- in_app_review (2.0.0):
|
||||
- FlutterMacOS
|
||||
- livekit_client (2.4.0):
|
||||
- livekit_client (2.4.1):
|
||||
- flutter_webrtc
|
||||
- FlutterMacOS
|
||||
- WebRTC-SDK (= 125.6422.06)
|
||||
@@ -232,7 +230,6 @@ DEPENDENCIES:
|
||||
- flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- 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`)
|
||||
- in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`)
|
||||
- livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`)
|
||||
@@ -307,8 +304,6 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral
|
||||
gal:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/gal/darwin
|
||||
geolocator_apple:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/macos
|
||||
hotkey_manager_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos
|
||||
in_app_review:
|
||||
@@ -372,14 +367,13 @@ SPEC CHECKSUMS:
|
||||
flutter_webrtc: d55fd3f5c75b42940b6b4b2cf376a5797398d1f8
|
||||
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
|
||||
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
|
||||
geolocator_apple: 72a78ae3f3e4ec0db62117bd93e34523f5011d58
|
||||
GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
|
||||
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
|
||||
hotkey_manager_macos: 1e2edb0c7ae4fe67108af44a9d3445de41404160
|
||||
in_app_review: a6a031b9acd03c7d103e341aa334adf2c493fb93
|
||||
livekit_client: 2e766be2c3ee6274a8e2633b356b98b5eb842987
|
||||
livekit_client: d03409f83df069a1bb00a4c8dc78c54fb2287262
|
||||
local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff
|
||||
media_kit_libs_macos_video: b3e2bbec2eef97c285f2b1baa7963c67c753fb82
|
||||
media_kit_native_event_loop: 81fd5b45192b72f8b5b69eaf5b540f45777eb8d5
|
||||
|
||||
@@ -314,7 +314,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.3.4+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
|
||||
|
||||
@@ -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
|
||||
# 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.
|
||||
version: 2.4.2+79
|
||||
version: 2.4.2+80
|
||||
|
||||
environment:
|
||||
sdk: ^3.5.4
|
||||
@@ -142,6 +142,7 @@ dependencies:
|
||||
flutter_blurhash: ^0.8.2
|
||||
timelines_plus: ^1.0.6
|
||||
latlong2: ^0.9.1
|
||||
crypto: ^3.0.6
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user