✨ Translate infra & post translation
This commit is contained in:
parent
e5212419ae
commit
aecd04e0b9
@ -812,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 {}",
|
||||||
@ -839,5 +841,7 @@
|
|||||||
"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",
|
||||||
|
"translated": "Translated"
|
||||||
}
|
}
|
||||||
|
@ -839,5 +839,7 @@
|
|||||||
"accountContactMethodsPublicHint": "这个联系方式公开地显示在个人资料中。",
|
"accountContactMethodsPublicHint": "这个联系方式公开地显示在个人资料中。",
|
||||||
"accountContactMethodsDelete": "删除联系方式",
|
"accountContactMethodsDelete": "删除联系方式",
|
||||||
"accountContactMethodsDeleteDescription": "你确定要删除联系方式 {} 吗?这个操作不可撤销。",
|
"accountContactMethodsDeleteDescription": "你确定要删除联系方式 {} 吗?这个操作不可撤销。",
|
||||||
"postCommentAdd": "撰写一条评论"
|
"postCommentAdd": "撰写一条评论",
|
||||||
|
"translate": "翻译",
|
||||||
|
"translated": "已翻译"
|
||||||
}
|
}
|
||||||
|
@ -839,5 +839,7 @@
|
|||||||
"accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
|
"accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
|
||||||
"accountContactMethodsDelete": "刪除聯繫方式",
|
"accountContactMethodsDelete": "刪除聯繫方式",
|
||||||
"accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
|
"accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
|
||||||
"postCommentAdd": "撰寫一條評論"
|
"postCommentAdd": "撰寫一條評論",
|
||||||
|
"translate": "翻譯",
|
||||||
|
"translated": "已翻譯"
|
||||||
}
|
}
|
||||||
|
@ -839,5 +839,7 @@
|
|||||||
"accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
|
"accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
|
||||||
"accountContactMethodsDelete": "刪除聯繫方式",
|
"accountContactMethodsDelete": "刪除聯繫方式",
|
||||||
"accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
|
"accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
|
||||||
"postCommentAdd": "撰寫一條評論"
|
"postCommentAdd": "撰寫一條評論",
|
||||||
|
"translate": "翻譯",
|
||||||
|
"translated": "已翻譯"
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
@ -22,6 +22,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';
|
||||||
@ -112,10 +113,11 @@ 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 showAvatar;
|
||||||
@ -130,6 +132,7 @@ 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.showAvatar = true,
|
||||||
@ -140,13 +143,23 @@ class PostItem extends StatelessWidget {
|
|||||||
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;
|
||||||
|
|
||||||
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);
|
||||||
@ -174,7 +187,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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -199,7 +212,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();
|
||||||
@ -210,18 +223,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,19 +244,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 (showAvatar)
|
if (widget.showAvatar)
|
||||||
_PostAvatar(
|
_PostAvatar(
|
||||||
data: data,
|
data: widget.data,
|
||||||
isCompact: false,
|
isCompact: false,
|
||||||
),
|
),
|
||||||
if (showAvatar) const Gap(12),
|
if (widget.showAvatar) const Gap(12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@ -250,25 +266,33 @@ 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: (text) {
|
||||||
|
setState(() {
|
||||||
|
_displayText = text['content']?.trim() ?? '';
|
||||||
|
_displayTitle = text['title']?.trim() ?? '';
|
||||||
|
_displayDescription =
|
||||||
|
text['description']?.trim() ?? '';
|
||||||
|
_isTranslated = true;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
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(
|
||||||
@ -288,60 +312,103 @@ 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)
|
||||||
Row(
|
.padding(top: 4, bottom: 6),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
spacing: 4,
|
||||||
children: [
|
children: [
|
||||||
Icon(Symbols.play_circle, size: 20),
|
if (widget.showViews)
|
||||||
const Gap(4),
|
Row(
|
||||||
Text('postViews').plural(data.totalViews),
|
children: [
|
||||||
|
Icon(Symbols.play_circle, size: 20),
|
||||||
|
const Gap(4),
|
||||||
|
Text('postViews')
|
||||||
|
.plural(widget.data.totalViews),
|
||||||
|
],
|
||||||
|
).opacity(0.75),
|
||||||
|
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 ? 8 : 0,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -354,40 +421,43 @@ 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: EdgeInsets.only(left: showAvatar ? 60 : 12, 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!).padding(
|
PostPoll(poll: widget.data.preload!.poll!).padding(
|
||||||
left: showAvatar ? 60 : 12,
|
left: widget.showAvatar ? 60 : 12,
|
||||||
right: 12,
|
right: 12,
|
||||||
top: 12,
|
top: 12,
|
||||||
bottom: 4,
|
bottom: 4,
|
||||||
),
|
),
|
||||||
if (data.body['content'] != null &&
|
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: showAvatar ? 60 : 12, right: 4),
|
).padding(left: widget.showAvatar ? 60 : 12, right: 4),
|
||||||
if (showExpandableComments)
|
if (widget.showExpandableComments)
|
||||||
_PostCommentIntent(
|
_PostCommentIntent(
|
||||||
data: data,
|
data: widget.data,
|
||||||
showAvatar: showAvatar,
|
showAvatar: widget.showAvatar,
|
||||||
).padding(left: showAvatar ? 60 : 12, right: 12)
|
).padding(left: widget.showAvatar ? 60 : 12, right: 12)
|
||||||
else
|
else
|
||||||
_PostFeaturedComment(data: data, maxWidth: maxWidth)
|
_PostFeaturedComment(data: widget.data, maxWidth: widget.maxWidth)
|
||||||
.padding(left: showAvatar ? 60 : 12, right: 12),
|
.padding(left: widget.showAvatar ? 60 : 12, right: 12),
|
||||||
Padding(
|
if (widget.showReactions)
|
||||||
padding: const EdgeInsets.only(top: 4),
|
Padding(
|
||||||
child: _PostReactionList(
|
padding: const EdgeInsets.only(top: 4),
|
||||||
data: data,
|
child: _PostReactionList(
|
||||||
padding: EdgeInsets.only(left: showAvatar ? 60 : 12, right: 12),
|
data: widget.data,
|
||||||
onChanged: _onChanged,
|
padding:
|
||||||
|
EdgeInsets.only(left: widget.showAvatar ? 60 : 12, right: 12),
|
||||||
|
onChanged: _onChanged,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -448,6 +518,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),
|
||||||
@ -733,10 +804,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,
|
||||||
});
|
});
|
||||||
@ -769,19 +844,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(
|
||||||
@ -814,14 +894,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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -908,12 +989,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(Map<String, dynamic>)? 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -958,6 +1041,28 @@ class _PostActionPopup extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _translatePost(BuildContext context) async {
|
||||||
|
final ta = context.read<SnTranslator>();
|
||||||
|
try {
|
||||||
|
final to = EasyLocalization.of(context)!.locale.languageCode;
|
||||||
|
final body = {
|
||||||
|
'title': (data.body['title']?.isNotEmpty ?? false)
|
||||||
|
? await ta.translate(data.body['title'], to: to)
|
||||||
|
: null,
|
||||||
|
'description': (data.body['description']?.isNotEmpty ?? false)
|
||||||
|
? await ta.translate(data.body['description'], to: to)
|
||||||
|
: null,
|
||||||
|
'content': (data.body['content']?.isNotEmpty ?? false)
|
||||||
|
? await ta.translate(data.body['content'], to: to)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
onTranslate?.call(body);
|
||||||
|
} catch (err) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
@ -969,6 +1074,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: () {
|
||||||
|
_translatePost(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (onTranslate != null) PopupMenuDivider(),
|
||||||
if (isAuthor && onSelectAnswer != null)
|
if (isAuthor && onSelectAnswer != null)
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
child: Row(
|
child: Row(
|
||||||
@ -1192,11 +1311,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,
|
||||||
@ -1204,13 +1325,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,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1251,7 +1371,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),
|
||||||
],
|
],
|
||||||
@ -1486,6 +1609,8 @@ class _PostCommentIntentState extends State<_PostCommentIntent> {
|
|||||||
data: ele,
|
data: ele,
|
||||||
showAvatar: false,
|
showAvatar: false,
|
||||||
showExpandableComments: true,
|
showExpandableComments: true,
|
||||||
|
showReactions: false,
|
||||||
|
showViews: false,
|
||||||
maxWidth: double.infinity,
|
maxWidth: double.infinity,
|
||||||
).padding(vertical: 8, left: 6),
|
).padding(vertical: 8, left: 6),
|
||||||
],
|
],
|
||||||
|
@ -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"
|
||||||
|
@ -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:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user