From aecd04e0b950717ad8bc1b7f782408468dbc8bc7 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 16 Mar 2025 23:05:07 +0800 Subject: [PATCH] :sparkles: Translate infra & post translation --- assets/translations/en-US.json | 6 +- assets/translations/zh-CN.json | 4 +- assets/translations/zh-HK.json | 4 +- assets/translations/zh-TW.json | 4 +- lib/main.dart | 6 +- lib/providers/translation.dart | 56 +++++ lib/screens/account/prefs/notify.dart | 11 + lib/widgets/post/post_item.dart | 293 ++++++++++++++++++-------- pubspec.lock | 2 +- pubspec.yaml | 1 + 10 files changed, 297 insertions(+), 90 deletions(-) create mode 100644 lib/providers/translation.dart create mode 100644 lib/screens/account/prefs/notify.dart diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index a6d339d..960c537 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -812,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 {}", @@ -839,5 +841,7 @@ "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", + "translated": "Translated" } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index aabea29..87859a5 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -839,5 +839,7 @@ "accountContactMethodsPublicHint": "这个联系方式公开地显示在个人资料中。", "accountContactMethodsDelete": "删除联系方式", "accountContactMethodsDeleteDescription": "你确定要删除联系方式 {} 吗?这个操作不可撤销。", - "postCommentAdd": "撰写一条评论" + "postCommentAdd": "撰写一条评论", + "translate": "翻译", + "translated": "已翻译" } diff --git a/assets/translations/zh-HK.json b/assets/translations/zh-HK.json index 41b403e..28dbdba 100644 --- a/assets/translations/zh-HK.json +++ b/assets/translations/zh-HK.json @@ -839,5 +839,7 @@ "accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。", "accountContactMethodsDelete": "刪除聯繫方式", "accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。", - "postCommentAdd": "撰寫一條評論" + "postCommentAdd": "撰寫一條評論", + "translate": "翻譯", + "translated": "已翻譯" } diff --git a/assets/translations/zh-TW.json b/assets/translations/zh-TW.json index e2f1daf..115b828 100644 --- a/assets/translations/zh-TW.json +++ b/assets/translations/zh-TW.json @@ -839,5 +839,7 @@ "accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。", "accountContactMethodsDelete": "刪除聯繫方式", "accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。", - "postCommentAdd": "撰寫一條評論" + "postCommentAdd": "撰寫一條評論", + "translate": "翻譯", + "translated": "已翻譯" } diff --git a/lib/main.dart b/lib/main.dart index 8de4e11..f1043e3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(); config.setUpdate( - remoteVersionString, resp.data?['body'] ?? 'No changelog'); + remoteVersionString, + resp.data?['body'] ?? 'No changelog', + ); logging.info("[Update] Update available: $remoteVersionString"); } } catch (e) { diff --git a/lib/providers/translation.dart b/lib/providers/translation.dart new file mode 100644 index 0000000..f3e7669 --- /dev/null +++ b/lib/providers/translation.dart @@ -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 _cache = {}; + + Future 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'); + } +} diff --git a/lib/screens/account/prefs/notify.dart b/lib/screens/account/prefs/notify.dart new file mode 100644 index 0000000..189ffd0 --- /dev/null +++ b/lib/screens/account/prefs/notify.dart @@ -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(); + } +} diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index d868323..91aadfe 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -22,6 +22,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'; @@ -112,10 +113,11 @@ 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; @@ -130,6 +132,7 @@ 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, @@ -140,13 +143,23 @@ class PostItem extends StatelessWidget { this.onSelectAnswer, }); + @override + State createState() => _PostItemState(); +} + +class _PostItemState extends State { + 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) { - 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); @@ -174,7 +187,7 @@ class PostItem extends StatelessWidget { ], child: ResponsiveBreakpoints.builder( breakpoints: ResponsiveBreakpoints.of(context).breakpoints, - child: PostShareImageWidget(data: data), + child: PostShareImageWidget(data: widget.data), ), ), ), @@ -199,7 +212,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(); @@ -210,18 +223,20 @@ class PostItem extends StatelessWidget { final sn = context.read(); final ua = context.read(); - 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(); 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; } @@ -229,19 +244,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: [ - if (showAvatar) + if (widget.showAvatar) _PostAvatar( - data: data, + data: widget.data, isCompact: false, ), - if (showAvatar) const Gap(12), + if (widget.showAvatar) const Gap(12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -250,25 +266,33 @@ 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: (text) { + setState(() { + _displayText = text['content']?.trim() ?? ''; + _displayTitle = text['title']?.trim() ?? ''; + _displayDescription = + text['description']?.trim() ?? ''; + _isTranslated = true; + }); }, ), ], ), - const Gap(8), - if (data.preload?.thumbnail != null) + if (widget.data.preload?.thumbnail != null) Container( margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( @@ -288,60 +312,103 @@ 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 (_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( data: displayableAttachments!, bordered: true, - maxHeight: showFullPost ? null : 480, + maxHeight: widget.showFullPost ? null : 480, minWidth: attachmentSize, maxWidth: attachmentSize, - fit: showFullPost ? BoxFit.cover : BoxFit.contain, - padding: EdgeInsets.only(left: showAvatar ? 60 : 12, 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( - left: showAvatar ? 60 : 12, + 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 (data.body['content'] != null && + if (widget.data.body['content'] != null && (cfg.prefs.getBool(kAppExpandPostLink) ?? true)) LinkPreviewWidget( - text: data.body['content'], - ).padding(left: showAvatar ? 60 : 12, right: 4), - if (showExpandableComments) + text: widget.data.body['content'], + ).padding(left: widget.showAvatar ? 60 : 12, right: 4), + if (widget.showExpandableComments) _PostCommentIntent( - data: data, - showAvatar: showAvatar, - ).padding(left: showAvatar ? 60 : 12, right: 12) + data: widget.data, + showAvatar: widget.showAvatar, + ).padding(left: widget.showAvatar ? 60 : 12, right: 12) else - _PostFeaturedComment(data: data, maxWidth: maxWidth) - .padding(left: showAvatar ? 60 : 12, right: 12), - Padding( - padding: const EdgeInsets.only(top: 4), - child: _PostReactionList( - data: data, - padding: EdgeInsets.only(left: showAvatar ? 60 : 12, right: 12), - onChanged: _onChanged, + _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, + ), ), - ), ], ); } @@ -448,6 +518,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), @@ -733,10 +804,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, }); @@ -769,19 +844,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( @@ -814,14 +894,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, ), ], @@ -908,12 +989,14 @@ class _PostActionPopup extends StatelessWidget { final Function onDeleted; final Function() onShare, onShareImage; final Function()? onSelectAnswer; + final Function(Map)? onTranslate; const _PostActionPopup({ required this.data, required this.isAuthor, required this.onDeleted, required this.onShare, required this.onShareImage, + this.onTranslate, this.onSelectAnswer, }); @@ -958,6 +1041,28 @@ class _PostActionPopup extends StatelessWidget { } } + Future _translatePost(BuildContext context) async { + final ta = context.read(); + 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 Widget build(BuildContext context) { return SizedBox( @@ -969,6 +1074,20 @@ class _PostActionPopup extends StatelessWidget { ), padding: EdgeInsets.zero, itemBuilder: (BuildContext context) => [ + 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) PopupMenuItem( child: Row( @@ -1192,11 +1311,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, @@ -1204,13 +1325,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, ); @@ -1251,7 +1371,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), ], @@ -1486,6 +1609,8 @@ class _PostCommentIntentState extends State<_PostCommentIntent> { data: ele, showAvatar: false, showExpandableComments: true, + showReactions: false, + showViews: false, maxWidth: double.infinity, ).padding(vertical: 8, left: 6), ], diff --git a/pubspec.lock b/pubspec.lock index 59f8ba8..ffe9330 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -314,7 +314,7 @@ packages: source: hosted version: "0.3.4+2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" diff --git a/pubspec.yaml b/pubspec.yaml index 27976d9..6705b68 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: