diff --git a/ios/Podfile.lock b/ios/Podfile.lock index e4fd06a..85d1837 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -76,6 +76,9 @@ PODS: - flutter_webrtc (0.11.3): - Flutter - WebRTC-SDK (= 125.6422.04) + - gal (1.0.0): + - Flutter + - FlutterMacOS - GoogleDataTransport (9.4.1): - GoogleUtilities/Environment (~> 7.7) - nanopb (< 2.30911.0, >= 2.30908.0) @@ -169,6 +172,7 @@ DEPENDENCIES: - Flutter (from `Flutter`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) + - gal (from `.symlinks/plugins/gal/darwin`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - livekit_client (from `.symlinks/plugins/livekit_client/ios`) - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) @@ -223,6 +227,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_secure_storage/ios" flutter_webrtc: :path: ".symlinks/plugins/flutter_webrtc/ios" + gal: + :path: ".symlinks/plugins/gal/darwin" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" livekit_client: @@ -276,6 +282,7 @@ SPEC CHECKSUMS: Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 flutter_webrtc: 75b868e4f9e817c7a9a42ca4b6169063de4eec9f + gal: 61e868295d28fe67ffa297fae6dacebf56fd53e1 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 diff --git a/lib/exts.dart b/lib/exts.dart index 2c51b02..763c08b 100644 --- a/lib/exts.dart +++ b/lib/exts.dart @@ -2,9 +2,10 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; extension SolianExtenions on BuildContext { - void showSnackbar(String content) { + void showSnackbar(String content, {SnackBarAction? action}) { ScaffoldMessenger.of(this).showSnackBar(SnackBar( content: Text(content), + action: action, )); } diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index e833fb0..30aa5a7 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; diff --git a/lib/translations/en_us.dart b/lib/translations/en_us.dart index 5aa744a..2a8775c 100644 --- a/lib/translations/en_us.dart +++ b/lib/translations/en_us.dart @@ -33,6 +33,7 @@ const i18nEnglish = { 'article': 'Article', 'reply': 'Reply', 'repost': 'Repost', + 'openInAlbum': 'Open in album', 'openInBrowser': 'Open in browser', 'notification': 'Notification', 'errorHappened': 'An error occurred', @@ -313,4 +314,5 @@ const i18nEnglish = { 'themeColorKagamine': 'Kagamine Yellow', 'themeColorLuka': 'Luka Pink', 'themeColorApplied': 'Global theme color has been applied.', + 'attachmentSaved': 'Attachment saved to your system album.', }; diff --git a/lib/translations/zh_cn.dart b/lib/translations/zh_cn.dart index 2c0eb95..1b3c085 100644 --- a/lib/translations/zh_cn.dart +++ b/lib/translations/zh_cn.dart @@ -33,6 +33,7 @@ const i18nSimplifiedChinese = { 'article': '文章', 'reply': '回复', 'repost': '转帖', + 'openInAlbum': '在相簿中打开', 'openInBrowser': '在浏览器中打开', 'notification': '通知', 'errorHappened': '发生错误了', @@ -290,4 +291,5 @@ const i18nSimplifiedChinese = { 'themeColorKagamine': '镜音黄', 'themeColorLuka': '流音粉', 'themeColorApplied': '全局主题颜色已应用', + 'attachmentSaved': '附件已保存到系统相册', }; diff --git a/lib/widgets/attachments/attachment_list.dart b/lib/widgets/attachments/attachment_list.dart index beef923..609a291 100644 --- a/lib/widgets/attachments/attachment_list.dart +++ b/lib/widgets/attachments/attachment_list.dart @@ -322,7 +322,7 @@ class AttachmentListEntry extends StatelessWidget { context.pushTransparentRoute( AttachmentListFullScreen( parentId: parentId, - attachment: item!, + item: item!, ), rootNavigator: true, ); diff --git a/lib/widgets/attachments/attachment_list_fullscreen.dart b/lib/widgets/attachments/attachment_list_fullscreen.dart index 496f017..910db36 100644 --- a/lib/widgets/attachments/attachment_list_fullscreen.dart +++ b/lib/widgets/attachments/attachment_list_fullscreen.dart @@ -1,19 +1,27 @@ +import 'dart:io'; import 'dart:math' as math; +import 'package:dio/dio.dart'; import 'package:dismissible_page/dismissible_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; +import 'package:gal/gal.dart'; import 'package:get/get.dart'; +import 'package:solian/exts.dart'; import 'package:solian/models/attachment.dart'; +import 'package:solian/platform.dart'; +import 'package:solian/services.dart'; import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/attachments/attachment_item.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:path/path.dart' show extension; class AttachmentListFullScreen extends StatefulWidget { final String parentId; - final Attachment attachment; + final Attachment item; const AttachmentListFullScreen( - {super.key, required this.parentId, required this.attachment}); + {super.key, required this.parentId, required this.item}); @override State createState() => @@ -23,6 +31,10 @@ class AttachmentListFullScreen extends StatefulWidget { class _AttachmentListFullScreenState extends State { bool _showDetails = true; + bool _isDownloading = false; + bool _isCompletedDownload = false; + double? _progressOfDownload = 0; + Color get _unFocusColor => Theme.of(context).colorScheme.onSurface.withOpacity(0.75); @@ -46,13 +58,63 @@ class _AttachmentListFullScreenState extends State { } double _getRatio() { - final value = widget.attachment.metadata?['ratio']; + final value = widget.item.metadata?['ratio']; if (value == null) return 1; if (value is int) return value.toDouble(); if (value is double) return value; return 1; } + Future _saveToAlbum() async { + final url = ServiceFinder.buildUrl( + 'files', + '/attachments/${widget.item.id}', + ); + + if (PlatformInfo.isWeb) { + await launchUrlString(url); + return; + } + + if (!await Gal.hasAccess(toAlbum: true)) { + if (!await Gal.requestAccess(toAlbum: true)) return; + } + + setState(() => _isDownloading = true); + + var extName = extension(widget.item.name); + if(extName.isEmpty) extName = '.png'; + final imagePath = + '${Directory.systemTemp.path}/${widget.item.uuid}$extName'; + await Dio().download( + url, + imagePath, + onReceiveProgress: (count, total) { + setState(() => _progressOfDownload = count / total); + }, + ); + + bool isSuccess = false; + try { + await Gal.putImage(imagePath); + isSuccess = true; + } on GalException catch (e) { + context.showErrorDialog(e.type.message); + } + + context.showSnackbar( + 'attachmentSaved'.tr, + action: SnackBarAction( + label: 'openInAlbum'.tr, + onPressed: () async => Gal.open(), + ), + ); + setState(() { + _isDownloading = false; + _isCompletedDownload = isSuccess; + }); + } + @override void initState() { super.initState(); @@ -60,8 +122,13 @@ class _AttachmentListFullScreenState extends State { @override Widget build(BuildContext context) { + final metaTextStyle = TextStyle( + fontSize: 12, + color: _unFocusColor, + ); + return DismissiblePage( - key: Key('attachment-dismissible${widget.attachment.id}'), + key: Key('attachment-dismissible${widget.item.id}'), direction: DismissiblePageDismissDirection.vertical, onDismissed: () => Navigator.pop(context), dismissThresholds: const { @@ -89,7 +156,7 @@ class _AttachmentListFullScreenState extends State { child: AttachmentItem( parentId: widget.parentId, showHideButton: false, - item: widget.attachment, + item: widget.item, fit: BoxFit.contain, ), ), @@ -118,38 +185,66 @@ class _AttachmentListFullScreenState extends State { bottom: math.max(MediaQuery.of(context).padding.bottom, 16), left: 16, right: 16, - child: IgnorePointer( - child: Material( - color: Colors.transparent, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.attachment.account != null) - Row( - children: [ - AccountAvatar( - content: widget.attachment.account!.avatar, + child: Material( + color: Colors.transparent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.item.account != null) + Row( + children: [ + IgnorePointer( + child: AccountAvatar( + content: widget.item.account!.avatar, radius: 19, ), - const SizedBox(width: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'attachmentUploadBy'.tr, - style: Theme.of(context).textTheme.bodySmall, - ), - Text( - widget.attachment.account!.nick, - style: Theme.of(context).textTheme.bodyMedium, - ), - ], + ), + const IgnorePointer(child: SizedBox(width: 8)), + Expanded( + child: IgnorePointer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'attachmentUploadBy'.tr, + style: + Theme.of(context).textTheme.bodySmall, + ), + Text( + widget.item.account!.nick, + style: + Theme.of(context).textTheme.bodyMedium, + ), + ], + ), ), - ], - ), - const SizedBox(height: 4), - Text( - widget.attachment.alt, + ), + IconButton( + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -2, + ), + icon: !_isDownloading + ? !_isCompletedDownload + ? const Icon(Icons.save_alt) + : const Icon(Icons.download_done) + : SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + value: _progressOfDownload, + strokeWidth: 3, + ), + ), + onPressed: + _isDownloading ? null : () => _saveToAlbum(), + ), + ], + ), + const IgnorePointer(child: SizedBox(height: 4)), + IgnorePointer( + child: Text( + widget.item.alt, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( @@ -157,38 +252,35 @@ class _AttachmentListFullScreenState extends State { fontWeight: FontWeight.w500, ), ), - const SizedBox(height: 2), - Wrap( + ), + const IgnorePointer(child: SizedBox(height: 2)), + IgnorePointer( + child: Wrap( spacing: 6, children: [ - if (widget.attachment.metadata?['width'] != null && - widget.attachment.metadata?['height'] != null) + if (widget.item.metadata?['width'] != null && + widget.item.metadata?['height'] != null) Text( - '${widget.attachment.metadata?['width']}x${widget.attachment.metadata?['height']}', - style: TextStyle( - fontSize: 12, - color: _unFocusColor, - ), + '${widget.item.metadata?['width']}x${widget.item.metadata?['height']}', + style: metaTextStyle, ), - if (widget.attachment.metadata?['ratio'] != null) + if (widget.item.metadata?['ratio'] != null) Text( '${_getRatio().toPrecision(2)}', - style: TextStyle( - fontSize: 12, - color: _unFocusColor, - ), + style: metaTextStyle, ), Text( - _formatBytes(widget.attachment.size), - style: TextStyle( - fontSize: 12, - color: _unFocusColor, - ), - ) + _formatBytes(widget.item.size), + style: metaTextStyle, + ), + Text( + widget.item.mimetype, + style: metaTextStyle, + ), ], ), - ], - ), + ), + ], ), ), ) diff --git a/lib/widgets/posts/post_item.dart b/lib/widgets/posts/post_item.dart index 7837a67..3f18c9e 100644 --- a/lib/widgets/posts/post_item.dart +++ b/lib/widgets/posts/post_item.dart @@ -8,7 +8,7 @@ import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_profile_popup.dart'; import 'package:solian/widgets/attachments/attachment_list.dart'; import 'package:solian/widgets/markdown_text_content.dart'; -import 'package:solian/widgets/feed/feed_tags.dart'; +import 'package:solian/widgets/posts/post_tags.dart'; import 'package:solian/widgets/posts/post_quick_action.dart'; import 'package:solian/widgets/sized_container.dart'; import 'package:timeago/timeago.dart' show format; @@ -129,7 +129,7 @@ class _PostItemState extends State { List widgets = List.empty(growable: true); if (widget.item.tags?.isNotEmpty ?? false) { - widgets.add(FeedTagsList(tags: widget.item.tags!)); + widgets.add(PostTagsList(tags: widget.item.tags!)); } if (labels.isNotEmpty) { widgets.add(Text( diff --git a/lib/widgets/feed/feed_tags.dart b/lib/widgets/posts/post_tags.dart similarity index 92% rename from lib/widgets/feed/feed_tags.dart rename to lib/widgets/posts/post_tags.dart index 304c584..787fbe1 100644 --- a/lib/widgets/feed/feed_tags.dart +++ b/lib/widgets/posts/post_tags.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:solian/models/feed.dart'; import 'package:solian/router.dart'; -class FeedTagsList extends StatelessWidget { +class PostTagsList extends StatelessWidget { final List tags; - const FeedTagsList({ + const PostTagsList({ super.key, required this.tags, }); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 20f3330..8cfdef3 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -13,6 +13,7 @@ import firebase_core import firebase_messaging import flutter_secure_storage_macos import flutter_webrtc +import gal import livekit_client import macos_window_utils import media_kit_libs_macos_video @@ -38,6 +39,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) + GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin")) LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin")) MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) diff --git a/pubspec.lock b/pubspec.lock index a4086ef..6f8e88b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -321,6 +321,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + dio: + dependency: "direct main" + description: + name: dio + sha256: e17f6b3097b8c51b72c74c9f071a605c47bcc8893839bd66732457a5ebe73714 + url: "https://pub.dev" + source: hosted + version: "5.5.0+1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "36c5b2d79eb17cdae41e974b7a8284fec631651d2a6f39a8a2ff22327e90aeac" + url: "https://pub.dev" + source: hosted + version: "1.0.1" dismissible_page: dependency: "direct main" description: @@ -672,6 +688,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + gal: + dependency: "direct main" + description: + name: gal + sha256: "54c9b72528efce7c66234f3b6dd01cb0304fd8af8196de15571d7bdddb940977" + url: "https://pub.dev" + source: hosted + version: "2.3.0" get: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index f04607b..e9f3cf3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,6 +62,8 @@ dependencies: shared_preferences: ^2.2.3 easy_debounce: ^2.0.3 provider: ^6.1.2 + gal: ^2.3.0 + dio: ^5.5.0+1 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 944c2e7..f307a7b 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -39,6 +40,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); FlutterWebRTCPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterWebRTCPlugin")); + GalPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GalPluginCApi")); LiveKitPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("LiveKitPlugin")); MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 449dcf5..de2568f 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_acrylic flutter_secure_storage_windows flutter_webrtc + gal livekit_client media_kit_libs_windows_video media_kit_video