diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..eed8719 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,83 @@ +name: Build Release + +on: + push: + tags: + - '*' + workflow_dispatch: + +jobs: + build-web: + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@v4 + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - run: flutter pub get + - run: flutter build web --release + - name: Archive production artifacts + uses: actions/upload-artifact@v4 + with: + name: build-output-web + path: build/web + build-exe: + runs-on: windows-latest + steps: + - name: Clone repository + uses: actions/checkout@v4 + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - run: flutter pub get + - run: flutter build windows + - name: Archive production artifacts + uses: actions/upload-artifact@v4 + with: + name: build-output-windows + path: build/windows/x64/runner/Release + build-linux: + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@v4 + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + - run: | + sudo apt-get update -y + sudo apt-get install -y ninja-build libgtk-3-dev + sudo apt-get install -y libmpv-dev mpv + sudo apt-get install -y libayatana-appindicator3-dev + sudo apt-get install -y keybinder-3.0 + sudo apt-get install -y libnotify-dev + sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev + sudo apt-get install -y gstreamer-1.0 + - run: flutter pub get + - run: flutter build linux + - name: Archive production artifacts + uses: actions/upload-artifact@v4 + with: + name: build-output-linux + path: build/linux/x64/release/bundle + - name: Build AppImage + run: | + rm -r Solian.AppDir | true + mkdir Solian.AppDir + cp -r build/linux/x64/release/bundle/* Solian.AppDir + cp -r buildtools/appimage_config/* Solian.AppDir + cp assets/icon/icon-light-radius.png Solian.AppDir + sudo chmod +x buildtools/appimagetool-x86_64.AppImage + sudo chmod +x Solian.AppDir/AppRun + ./buildtools/appimagetool-x86_64.AppImage Solian.AppDir + - name: Archive production artifacts + uses: actions/upload-artifact@v4 + with: + name: build-output-linux-appimage + path: './*.AppImage*' \ No newline at end of file diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index e19fbfc..aba0727 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -325,5 +325,10 @@ "accountSettingsHelpContent": "This page allows you to manage your account security, privacy, and other settings. If you need assistance, please contact support.", "unauthorized": "Unauthorized", "unauthorizedHint": "You're not signed in or session expired, please sign in again.", - "publisherVisitAccountPage": "Visit the profile of {}" + "publisherVisitAccountPage": "Visit the profile of {}", + "postVisibility": "Visibility", + "postVisibilityPublic": "Public", + "postVisibilityFriends": "Friends Only", + "postVisibilityUnlisted": "Unlisted", + "postVisibilityPrivate": "Private" } diff --git a/assets/i18n/zh-CN.json b/assets/i18n/zh-CN.json index 8477495..d1cd541 100644 --- a/assets/i18n/zh-CN.json +++ b/assets/i18n/zh-CN.json @@ -286,5 +286,10 @@ "settingsHideBottomNav": "隐藏底部导航", "settingsSoundEffects": "音效", "settingsAprilFoolFeatures": "愚人节功能", - "settingsEnterToSend": "按下 Enter 发送" + "settingsEnterToSend": "按下 Enter 发送", + "postVisibility": "可见性", + "postVisibilityPublic": "公开", + "postVisibilityFriends": "仅好友可见", + "postVisibilityUnlisted": "不公开", + "postVisibilityPrivate": "私密" } \ No newline at end of file diff --git a/assets/i18n/zh-TW.json b/assets/i18n/zh-TW.json index 831c19b..9c59d57 100644 --- a/assets/i18n/zh-TW.json +++ b/assets/i18n/zh-TW.json @@ -286,5 +286,10 @@ "settingsHideBottomNav": "隱藏底部導航", "settingsSoundEffects": "音效", "settingsAprilFoolFeatures": "愚人節功能", - "settingsEnterToSend": "按下 Enter 傳送" + "settingsEnterToSend": "按下 Enter 傳送", + "postVisibility": "可見性", + "postVisibilityPublic": "公開", + "postVisibilityFriends": "僅好友可見", + "postVisibilityUnlisted": "不公開", + "postVisibilityPrivate": "私密" } \ No newline at end of file diff --git a/lib/route.gr.dart b/lib/route.gr.dart index ec5ca83..eaf74e5 100644 --- a/lib/route.gr.dart +++ b/lib/route.gr.dart @@ -1071,10 +1071,17 @@ class PostComposeRoute extends _i27.PageRouteInfo { PostComposeRoute({ _i28.Key? key, _i30.SnPost? originalPost, + _i30.SnPost? repliedPost, + _i30.SnPost? forwardedPost, List<_i27.PageRouteInfo>? children, }) : super( PostComposeRoute.name, - args: PostComposeRouteArgs(key: key, originalPost: originalPost), + args: PostComposeRouteArgs( + key: key, + originalPost: originalPost, + repliedPost: repliedPost, + forwardedPost: forwardedPost, + ), initialChildren: children, ); @@ -1089,32 +1096,50 @@ class PostComposeRoute extends _i27.PageRouteInfo { return _i18.PostComposeScreen( key: args.key, originalPost: args.originalPost, + repliedPost: args.repliedPost, + forwardedPost: args.forwardedPost, ); }, ); } class PostComposeRouteArgs { - const PostComposeRouteArgs({this.key, this.originalPost}); + const PostComposeRouteArgs({ + this.key, + this.originalPost, + this.repliedPost, + this.forwardedPost, + }); final _i28.Key? key; final _i30.SnPost? originalPost; + final _i30.SnPost? repliedPost; + + final _i30.SnPost? forwardedPost; + @override String toString() { - return 'PostComposeRouteArgs{key: $key, originalPost: $originalPost}'; + return 'PostComposeRouteArgs{key: $key, originalPost: $originalPost, repliedPost: $repliedPost, forwardedPost: $forwardedPost}'; } @override bool operator ==(Object other) { if (identical(this, other)) return true; if (other is! PostComposeRouteArgs) return false; - return key == other.key && originalPost == other.originalPost; + return key == other.key && + originalPost == other.originalPost && + repliedPost == other.repliedPost && + forwardedPost == other.forwardedPost; } @override - int get hashCode => key.hashCode ^ originalPost.hashCode; + int get hashCode => + key.hashCode ^ + originalPost.hashCode ^ + repliedPost.hashCode ^ + forwardedPost.hashCode; } /// generated route for diff --git a/lib/screens/account/me/update.dart b/lib/screens/account/me/update.dart index 2ebeeda..d7c268b 100644 --- a/lib/screens/account/me/update.dart +++ b/lib/screens/account/me/update.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:island/models/file.dart'; import 'package:island/pods/config.dart'; import 'package:island/pods/network.dart'; import 'package:island/pods/userinfo.dart'; @@ -63,7 +64,10 @@ class UpdateProfileScreen extends HookConsumerWidget { if (token == null) throw ArgumentError('Token is null'); final cloudFile = await putMediaToCloud( - fileData: result, + fileData: UniversalFile( + data: result, + type: UniversalFileType.image, + ), atk: token, baseUrl: baseUrl, filename: result.name, diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart index 29394de..0002927 100644 --- a/lib/screens/chat/chat.dart +++ b/lib/screens/chat/chat.dart @@ -527,7 +527,10 @@ class EditChatScreen extends HookConsumerWidget { if (token == null) throw ArgumentError('Token is null'); final cloudFile = await putMediaToCloud( - fileData: result, + fileData: UniversalFile( + data: result, + type: UniversalFileType.image, + ), atk: token, baseUrl: baseUrl, filename: result.name, diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index e3ea7b5..aa157fa 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -17,12 +17,12 @@ import 'package:island/pods/database.dart'; import 'package:island/pods/network.dart'; import 'package:island/pods/websocket.dart'; import 'package:island/route.gr.dart'; -import 'package:island/screens/posts/compose.dart'; import 'package:island/services/responsive.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/chat/call_overlay.dart'; import 'package:island/widgets/chat/message_item.dart'; +import 'package:island/widgets/content/attachment_preview.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/response.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; @@ -514,7 +514,7 @@ class ChatRoomScreen extends HookConsumerWidget { ), loading: () => const Text('Loading...'), error: - (err, __) => ResponseErrorWidget( + (err, _) => ResponseErrorWidget( error: err, onRetry: () => messagesNotifier.loadInitial(), ), @@ -615,7 +615,7 @@ class ChatRoomScreen extends HookConsumerWidget { progress: null, showAvatar: false, ), - error: (_, __) => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), ); }, ), @@ -680,7 +680,7 @@ class ChatRoomScreen extends HookConsumerWidget { attachments.value = newAttachments; }, ), - error: (_, __) => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), loading: () => const SizedBox.shrink(), ), ], @@ -788,7 +788,7 @@ class _ChatInput extends ConsumerWidget { onMove: (delta) => onMoveAttachment(idx, delta), ); }, - separatorBuilder: (_, __) => const Gap(8), + separatorBuilder: (_, _) => const Gap(8), ), ).padding(top: 12), if (messageReplyingTo != null || diff --git a/lib/screens/creators/publishers.dart b/lib/screens/creators/publishers.dart index c68ca64..01b6957 100644 --- a/lib/screens/creators/publishers.dart +++ b/lib/screens/creators/publishers.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:island/models/file.dart'; import 'package:island/models/post.dart'; import 'package:island/models/realm.dart'; import 'package:island/pods/config.dart'; @@ -99,7 +100,10 @@ class EditPublisherScreen extends HookConsumerWidget { if (token == null) throw ArgumentError('Token is null'); final cloudFile = await putMediaToCloud( - fileData: result, + fileData: UniversalFile( + data: result, + type: UniversalFileType.image, + ), atk: token, baseUrl: baseUrl, filename: result.name, diff --git a/lib/screens/posts/compose.dart b/lib/screens/posts/compose.dart index 9bd743c..d0a1f26 100644 --- a/lib/screens/posts/compose.dart +++ b/lib/screens/posts/compose.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; @@ -21,6 +19,7 @@ import 'package:island/services/responsive.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/content/cloud_files.dart'; +import 'package:island/widgets/content/attachment_preview.dart'; import 'package:island/widgets/post/publishers_modal.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:pasteboard/pasteboard.dart'; @@ -53,7 +52,14 @@ class PostEditScreen extends HookConsumerWidget { @RoutePage() class PostComposeScreen extends HookConsumerWidget { final SnPost? originalPost; - const PostComposeScreen({super.key, this.originalPost}); + final SnPost? repliedPost; + final SnPost? forwardedPost; + const PostComposeScreen({ + super.key, + this.originalPost, + this.repliedPost, + this.forwardedPost, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -90,9 +96,14 @@ class PostComposeScreen extends HookConsumerWidget { text: originalPost?.description, ); final contentController = useTextEditingController( - text: originalPost?.content, + text: + originalPost?.content ?? + (forwardedPost != null ? '> ${forwardedPost!.content}\n\n' : null), ); + // Add visibility state with default value from original post or 0 (public) + final visibility = useState(originalPost?.visibility ?? 0); + final submitting = useState(false); Future pickPhotoMedia() async { @@ -188,12 +199,18 @@ class PostComposeScreen extends HookConsumerWidget { await client.request( originalPost == null ? '/posts' : '/posts/${originalPost!.id}', data: { + 'title': titleController.text, + 'description': descriptionController.text, 'content': contentController.text, + 'visibility': + visibility.value, // Add visibility field to API request 'attachments': attachments.value .where((e) => e.isOnCloud) .map((e) => e.data.id) .toList(), + if (repliedPost != null) 'replied_post_id': repliedPost!.id, + if (forwardedPost != null) 'forwarded_post_id': forwardedPost!.id, }, options: Options( headers: {'X-Pub': currentPublisher.value?.name}, @@ -210,7 +227,7 @@ class PostComposeScreen extends HookConsumerWidget { } } - Future _handlePaste() async { + Future handlePaste() async { final clipboard = await Pasteboard.image; if (clipboard == null) return; @@ -223,14 +240,93 @@ class PostComposeScreen extends HookConsumerWidget { ]; } - void _handleKeyPress(RawKeyEvent event) { + void handleKeyPress(RawKeyEvent event) { if (event is! RawKeyDownEvent) return; final isPaste = event.logicalKey == LogicalKeyboardKey.keyV; final isModifierPressed = event.isMetaPressed || event.isControlPressed; if (isPaste && isModifierPressed) { - _handlePaste(); + handlePaste(); + } + } + + void showVisibilityModal() { + showDialog( + context: context, + builder: + (context) => AlertDialog( + title: Text('postVisibility'.tr()), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon(Symbols.public), + title: Text('postVisibilityPublic'.tr()), + onTap: () { + visibility.value = 0; + Navigator.pop(context); + }, + selected: visibility.value == 0, + ), + ListTile( + leading: Icon(Symbols.group), + title: Text('postVisibilityFriends'.tr()), + onTap: () { + visibility.value = 1; + Navigator.pop(context); + }, + selected: visibility.value == 1, + ), + ListTile( + leading: Icon(Symbols.link_off), + title: Text('postVisibilityUnlisted'.tr()), + onTap: () { + visibility.value = 2; + Navigator.pop(context); + }, + selected: visibility.value == 2, + ), + ListTile( + leading: Icon(Symbols.lock), + title: Text('postVisibilityPrivate'.tr()), + onTap: () { + visibility.value = 3; + Navigator.pop(context); + }, + selected: visibility.value == 3, + ), + ], + ), + ), + ); + } + + // Helper method to get the appropriate icon for each visibility status + IconData getVisibilityIcon(int visibilityValue) { + switch (visibilityValue) { + case 1: // Friends + return Symbols.group; + case 2: // Unlisted + return Symbols.link_off; + case 3: // Private + return Symbols.lock; + default: // Public (0) or unknown + return Symbols.public; + } + } + + // Helper method to get the translation key for each visibility status + String getVisibilityText(int visibilityValue) { + switch (visibilityValue) { + case 1: // Friends + return 'postVisibilityFriends'; + case 2: // Unlisted + return 'postVisibilityUnlisted'; + case 3: // Private + return 'postVisibilityPrivate'; + default: // Public (0) or unknown + return 'postVisibilityPublic'; } } @@ -296,6 +392,48 @@ class PostComposeScreen extends HookConsumerWidget { body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (repliedPost != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: Theme.of( + context, + ).colorScheme.surfaceVariant.withOpacity(0.5), + child: Row( + children: [ + const Icon(Symbols.reply, size: 16), + const Gap(8), + Expanded( + child: Text( + '${'reply'.tr()}: ${repliedPost!.publisher.nick}', + style: Theme.of(context).textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + if (forwardedPost != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: Theme.of( + context, + ).colorScheme.surfaceVariant.withOpacity(0.5), + child: Row( + children: [ + const Icon(Symbols.forward, size: 16), + const Gap(8), + Expanded( + child: Text( + '${'forward'.tr()}: ${forwardedPost!.publisher.nick}', + style: Theme.of(context).textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), Expanded( child: Row( spacing: 12, @@ -324,7 +462,52 @@ class PostComposeScreen extends HookConsumerWidget { child: SingleChildScrollView( padding: EdgeInsets.symmetric(vertical: 16), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row( + children: [ + OutlinedButton( + onPressed: () { + showVisibilityModal(); + }, + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + side: BorderSide( + color: Theme.of( + context, + ).colorScheme.primary.withOpacity(0.5), + ), + padding: EdgeInsets.symmetric(horizontal: 16), + visualDensity: const VisualDensity( + vertical: -2, + horizontal: -4, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + getVisibilityIcon(visibility.value), + size: 16, + color: + Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + getVisibilityText(visibility.value).tr(), + style: TextStyle( + fontSize: 14, + color: + Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), + ], + ).padding(bottom: 6), TextField( controller: titleController, decoration: InputDecoration.collapsed( @@ -348,7 +531,7 @@ class PostComposeScreen extends HookConsumerWidget { const Gap(8), RawKeyboardListener( focusNode: FocusNode(), - onKey: _handleKeyPress, + onKey: handleKeyPress, child: TextField( controller: contentController, style: TextStyle(fontSize: 14), @@ -474,204 +657,3 @@ class PostComposeScreen extends HookConsumerWidget { ); } } - -class AttachmentPreview extends StatelessWidget { - final UniversalFile item; - final double? progress; - final Function(int)? onMove; - final Function? onDelete; - final Function? onRequestUpload; - const AttachmentPreview({ - super.key, - required this.item, - this.progress, - this.onRequestUpload, - this.onMove, - this.onDelete, - }); - - @override - Widget build(BuildContext context) { - return AspectRatio( - aspectRatio: - (item.isOnCloud ? (item.data.fileMeta?['ratio'] ?? 1) : 1).toDouble(), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Stack( - fit: StackFit.expand, - children: [ - Container( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - child: Builder( - builder: (context) { - if (item.isOnCloud) { - return CloudFileWidget(item: item.data); - } else if (item.data is XFile) { - if (item.type == UniversalFileType.image) { - return Image.file(File(item.data.path)); - } else { - return Center( - child: Text( - 'Preview is not supported for ${item.type}', - textAlign: TextAlign.center, - ), - ); - } - } else if (item is List || item is Uint8List) { - if (item.type == UniversalFileType.image) { - return Image.memory(item.data); - } else { - return Center( - child: Text( - 'Preview is not supported for ${item.type}', - textAlign: TextAlign.center, - ), - ); - } - } - return Placeholder(); - }, - ), - ), - if (progress != null) - Positioned.fill( - child: Container( - color: Colors.black.withOpacity(0.3), - padding: EdgeInsets.symmetric(horizontal: 40, vertical: 16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (progress != null) - Text( - '${progress!.toStringAsFixed(2)}%', - style: TextStyle(color: Colors.white), - ) - else - Text( - 'uploading'.tr(), - style: TextStyle(color: Colors.white), - ), - Gap(6), - Center(child: LinearProgressIndicator(value: progress)), - ], - ), - ), - ), - Positioned( - left: 8, - top: 8, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Container( - color: Colors.black.withOpacity(0.5), - child: Material( - color: Colors.transparent, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (onDelete != null) - InkWell( - borderRadius: BorderRadius.circular(8), - child: const Icon( - Symbols.delete, - size: 14, - color: Colors.white, - ).padding(horizontal: 8, vertical: 6), - onTap: () { - onDelete?.call(); - }, - ), - if (onDelete != null && onMove != null) - SizedBox( - height: 26, - child: const VerticalDivider( - width: 0.3, - color: Colors.white, - thickness: 0.3, - ), - ).padding(horizontal: 2), - if (onMove != null) - InkWell( - borderRadius: BorderRadius.circular(8), - child: const Icon( - Symbols.keyboard_arrow_up, - size: 14, - color: Colors.white, - ).padding(horizontal: 8, vertical: 6), - onTap: () { - onMove?.call(-1); - }, - ), - if (onMove != null) - InkWell( - borderRadius: BorderRadius.circular(8), - child: const Icon( - Symbols.keyboard_arrow_down, - size: 14, - color: Colors.white, - ).padding(horizontal: 8, vertical: 6), - onTap: () { - onMove?.call(1); - }, - ), - ], - ), - ), - ), - ), - ), - if (onRequestUpload != null) - Positioned( - top: 8, - right: 8, - child: InkWell( - borderRadius: BorderRadius.circular(8), - onTap: () => onRequestUpload?.call(), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Container( - color: Colors.black.withOpacity(0.5), - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: - (item.isOnCloud) - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Symbols.cloud, - size: 16, - color: Colors.white, - ), - const Gap(8), - Text( - 'On-cloud', - style: TextStyle(color: Colors.white), - ), - ], - ) - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Symbols.cloud_off, - size: 16, - color: Colors.white, - ), - const Gap(8), - Text( - 'On-device', - style: TextStyle(color: Colors.white), - ), - ], - ), - ), - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/screens/posts/pub_profile.dart b/lib/screens/posts/pub_profile.dart index 8c54496..7e77dee 100644 --- a/lib/screens/posts/pub_profile.dart +++ b/lib/screens/posts/pub_profile.dart @@ -1,4 +1,3 @@ -import 'package:auto_route/annotations.dart'; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; diff --git a/lib/screens/realm/realms.dart b/lib/screens/realm/realms.dart index debeec8..0fd069b 100644 --- a/lib/screens/realm/realms.dart +++ b/lib/screens/realm/realms.dart @@ -215,7 +215,10 @@ class EditRealmScreen extends HookConsumerWidget { if (token == null) throw ArgumentError('Access token is null'); final cloudFile = await putMediaToCloud( - fileData: result, + fileData: UniversalFile( + data: result, + type: UniversalFileType.image, + ), atk: token, baseUrl: baseUrl, filename: result.name, diff --git a/lib/services/file.dart b/lib/services/file.dart index 114c1a2..de8f831 100644 --- a/lib/services/file.dart +++ b/lib/services/file.dart @@ -39,7 +39,7 @@ Future cropImage( } Completer putMediaToCloud({ - required dynamic fileData, // Can be XFile or List (Uint8List) + required UniversalFile fileData, required String atk, required String baseUrl, String? filename, @@ -51,21 +51,27 @@ Completer putMediaToCloud({ String actualMimetype = mimetype ?? ''; Uint8List? byteData; - if (fileData is XFile) { - file = fileData; - actualFilename = filename ?? fileData.name; - actualMimetype = mimetype ?? fileData.mimeType ?? ''; - } else if (fileData is List || fileData is Uint8List) { - byteData = fileData is List ? Uint8List.fromList(fileData) : fileData; + // Handle the data based on what's in the UniversalFile + final data = fileData.data; + + if (data is XFile) { + file = data; + actualFilename = filename ?? data.name; + actualMimetype = mimetype ?? data.mimeType ?? ''; + } else if (data is List || data is Uint8List) { + byteData = data is List ? Uint8List.fromList(data) : data; actualFilename = filename ?? 'uploaded_file'; actualMimetype = mimetype ?? 'application/octet-stream'; if (mimetype == null) { throw ArgumentError('Mimetype is required when providing raw bytes.'); } file = XFile.fromData(byteData!, mimeType: actualMimetype); + } else if (data is SnCloudFile) { + // If the file is already on the cloud, just return it + return Completer()..complete(data); } else { throw ArgumentError( - 'Invalid fileData type. Expected XFile or List (Uint8List).', + 'Invalid fileData type. Expected data to be XFile, List, Uint8List, or SnCloudFile.', ); } diff --git a/lib/widgets/chat/message_item.dart b/lib/widgets/chat/message_item.dart index e505362..384f28d 100644 --- a/lib/widgets/chat/message_item.dart +++ b/lib/widgets/chat/message_item.dart @@ -414,7 +414,7 @@ class _MessageItemContent extends StatelessWidget { ); case 'text': default: - return MarkdownTextContent(content: item.content!); + return MarkdownTextContent(content: item.content!, isSelectable: true); } } diff --git a/lib/widgets/content/attachment_preview.dart b/lib/widgets/content/attachment_preview.dart new file mode 100644 index 0000000..25c8be7 --- /dev/null +++ b/lib/widgets/content/attachment_preview.dart @@ -0,0 +1,212 @@ +import 'dart:io'; + +import 'package:cross_file/cross_file.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:island/models/file.dart'; +import 'package:island/widgets/content/cloud_files.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class AttachmentPreview extends StatelessWidget { + final UniversalFile item; + final double? progress; + final Function(int)? onMove; + final Function? onDelete; + final Function? onRequestUpload; + const AttachmentPreview({ + super.key, + required this.item, + this.progress, + this.onRequestUpload, + this.onMove, + this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: + (item.isOnCloud ? (item.data.fileMeta?['ratio'] ?? 1) : 1).toDouble(), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Stack( + fit: StackFit.expand, + children: [ + Container( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: Builder( + builder: (context) { + if (item.isOnCloud) { + return CloudFileWidget(item: item.data); + } else if (item.data is XFile) { + if (item.type == UniversalFileType.image) { + return Image.file(File(item.data.path)); + } else { + return Center( + child: Text( + 'Preview is not supported for ${item.type}', + textAlign: TextAlign.center, + ), + ); + } + } else if (item is List || item is Uint8List) { + if (item.type == UniversalFileType.image) { + return Image.memory(item.data); + } else { + return Center( + child: Text( + 'Preview is not supported for ${item.type}', + textAlign: TextAlign.center, + ), + ); + } + } + return Placeholder(); + }, + ), + ), + if (progress != null) + Positioned.fill( + child: Container( + color: Colors.black.withOpacity(0.3), + padding: EdgeInsets.symmetric(horizontal: 40, vertical: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (progress != null) + Text( + '${progress!.toStringAsFixed(2)}%', + style: TextStyle(color: Colors.white), + ) + else + Text( + 'uploading'.tr(), + style: TextStyle(color: Colors.white), + ), + Gap(6), + Center(child: LinearProgressIndicator(value: progress)), + ], + ), + ), + ), + Positioned( + left: 8, + top: 8, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + color: Colors.black.withOpacity(0.5), + child: Material( + color: Colors.transparent, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (onDelete != null) + InkWell( + borderRadius: BorderRadius.circular(8), + child: const Icon( + Symbols.delete, + size: 14, + color: Colors.white, + ).padding(horizontal: 8, vertical: 6), + onTap: () { + onDelete?.call(); + }, + ), + if (onDelete != null && onMove != null) + SizedBox( + height: 26, + child: const VerticalDivider( + width: 0.3, + color: Colors.white, + thickness: 0.3, + ), + ).padding(horizontal: 2), + if (onMove != null) + InkWell( + borderRadius: BorderRadius.circular(8), + child: const Icon( + Symbols.keyboard_arrow_up, + size: 14, + color: Colors.white, + ).padding(horizontal: 8, vertical: 6), + onTap: () { + onMove?.call(-1); + }, + ), + if (onMove != null) + InkWell( + borderRadius: BorderRadius.circular(8), + child: const Icon( + Symbols.keyboard_arrow_down, + size: 14, + color: Colors.white, + ).padding(horizontal: 8, vertical: 6), + onTap: () { + onMove?.call(1); + }, + ), + ], + ), + ), + ), + ), + ), + if (onRequestUpload != null) + Positioned( + top: 8, + right: 8, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => onRequestUpload?.call(), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + color: Colors.black.withOpacity(0.5), + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: + (item.isOnCloud) + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Symbols.cloud, + size: 16, + color: Colors.white, + ), + const Gap(8), + Text( + 'On-cloud', + style: TextStyle(color: Colors.white), + ), + ], + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Symbols.cloud_off, + size: 16, + color: Colors.white, + ), + const Gap(8), + Text( + 'On-device', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/content/cloud_file_picker.dart b/lib/widgets/content/cloud_file_picker.dart index e9d8a70..3ade712 100644 --- a/lib/widgets/content/cloud_file_picker.dart +++ b/lib/widgets/content/cloud_file_picker.dart @@ -8,9 +8,9 @@ import 'package:image_picker/image_picker.dart'; import 'package:island/models/file.dart'; import 'package:island/pods/config.dart'; import 'package:island/pods/network.dart'; -import 'package:island/screens/posts/compose.dart'; import 'package:island/services/file.dart'; import 'package:island/widgets/alert.dart'; +import 'package:island/widgets/content/attachment_preview.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 4d3a03a..f074876 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -103,6 +103,20 @@ class PostItem extends HookConsumerWidget { ); }, ), + MenuAction( + title: 'reply'.tr(), + image: MenuImage.icon(Symbols.reply), + callback: () { + context.router.push(PostComposeRoute(repliedPost: item)); + }, + ), + MenuAction( + title: 'forward'.tr(), + image: MenuImage.icon(Symbols.forward), + callback: () { + context.router.push(PostComposeRoute(forwardedPost: item)); + }, + ), ], ); }, @@ -134,6 +148,46 @@ class PostItem extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(item.publisher.nick).bold(), + // Add visibility indicator if not public (visibility != 0) + if (item.visibility != 0) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getVisibilityIcon(item.visibility), + size: 14, + color: + Theme.of(context).colorScheme.secondary, + ), + const SizedBox(width: 4), + Text( + _getVisibilityText(item.visibility).tr(), + style: TextStyle( + fontSize: 12, + color: + Theme.of(context).colorScheme.secondary, + ), + ), + ], + ).padding(top: 2, bottom: 2), + if (item.title?.isNotEmpty ?? false) + Text( + item.title!, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + if (item.description?.isNotEmpty ?? false) + Text( + item.description!, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith( + color: + Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ).padding(bottom: 8), if (item.content?.isNotEmpty ?? false) MarkdownTextContent(content: item.content!), if ((item.repliedPost != null || @@ -241,6 +295,45 @@ Widget _buildReferencePost(BuildContext context, SnPost item) { fontSize: 14, ), ), + // Add visibility indicator for referenced post if not public + if (referencePost.visibility != 0) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getVisibilityIcon(referencePost.visibility), + size: 12, + color: Theme.of(context).colorScheme.secondary, + ), + const SizedBox(width: 4), + Text( + _getVisibilityText(referencePost.visibility).tr(), + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.secondary, + ), + ), + ], + ).padding(top: 2, bottom: 2), + if (referencePost.title?.isNotEmpty ?? false) + Text( + referencePost.title!, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface, + ), + ).padding(top: 2, bottom: 2), + if (referencePost.description?.isNotEmpty ?? false) + Text( + referencePost.description!, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ).padding(bottom: 2), if (referencePost.content?.isNotEmpty ?? false) MarkdownTextContent( content: referencePost.content!, @@ -490,3 +583,31 @@ class _PostReactionSheet extends StatelessWidget { ); } } + +// Helper method to get the appropriate icon for each visibility status +IconData _getVisibilityIcon(int visibility) { + switch (visibility) { + case 1: // Friends + return Symbols.group; + case 2: // Unlisted + return Symbols.link_off; + case 3: // Private + return Symbols.lock; + default: // Public (0) or unknown + return Symbols.public; + } +} + +// Helper method to get the translation key for each visibility status +String _getVisibilityText(int visibility) { + switch (visibility) { + case 1: // Friends + return 'postVisibilityFriends'; + case 2: // Unlisted + return 'postVisibilityUnlisted'; + case 3: // Private + return 'postVisibilityPrivate'; + default: // Public (0) or unknown + return 'postVisibilityPublic'; + } +} diff --git a/lib/widgets/post/post_list.dart b/lib/widgets/post/post_list.dart index 6fa0745..0f90e95 100644 --- a/lib/widgets/post/post_list.dart +++ b/lib/widgets/post/post_list.dart @@ -14,8 +14,6 @@ class PostListNotifier extends _$PostListNotifier with CursorPagingNotifierMixin { static const int _pageSize = 20; - String? pubName; - @override Future> build(String? pubName) { this.pubName = pubName; diff --git a/lib/widgets/post/post_list.g.dart b/lib/widgets/post/post_list.g.dart index 0422d7d..28fc928 100644 --- a/lib/widgets/post/post_list.g.dart +++ b/lib/widgets/post/post_list.g.dart @@ -6,7 +6,7 @@ part of 'post_list.dart'; // RiverpodGenerator // ************************************************************************** -String _$postListNotifierHash() => r'6568b7a5afad71551009d9bc7af26afb4b07c9e5'; +String _$postListNotifierHash() => r'58a2d5d9a8f742f0a3a3e224a51a811d43903e0d'; /// Copied from Dart SDK class _SystemHash { diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index f7a7fb1..43372a2 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -48,6 +49,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) pasteboard_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin"); pasteboard_plugin_register_with_registrar(pasteboard_registrar); + g_autoptr(FlPluginRegistrar) record_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); + record_linux_plugin_register_with_registrar(record_linux_registrar); g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index bd96754..9bf6e18 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -12,6 +12,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_libs_linux media_kit_video pasteboard + record_linux sqlite3_flutter_libs super_native_extensions url_launcher_linux diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index b534a7f..39dfde7 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -23,6 +23,7 @@ import media_kit_video import package_info_plus import pasteboard import path_provider_foundation +import record_macos import shared_preferences_foundation import sqflite_darwin import sqlite3_flutter_libs @@ -50,6 +51,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 1c25c41..5a52011 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1550,6 +1550,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + record: + dependency: "direct main" + description: + name: record + sha256: daeb3f9b3fea9797094433fe6e49a879d8e4ca4207740bc6dc7e4a58764f0817 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + record_android: + dependency: transitive + description: + name: record_android + sha256: "97d7122455f30de89a01c6c244c839085be6b12abca251fc0e78f67fed73628b" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: "73706ebbece6150654c9d6f57897cf9b622c581148304132ba85dba15df0fdfb" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "29e7735b05c1944bb6c9b72a36c08d4a1b24117e712d6a9523c003bde12bf484" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: "02240833fde16c33fcf2c589f3e08d4394b704761b4a3bb609d872ff3043fbbd" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8a575828733d4c3cb5983c914696f40db8667eab3538d4c41c50cbb79e722ef4" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: f8e536a9c927e52f95326d7540898457eaeefbe0b21a84d3cb3d2d7d4645e8cb + url: "https://pub.dev" + source: hosted + version: "1.1.7" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "85a22fc97f6d73ecd67c8ba5f2f472b74ef1d906f795b7970f771a0914167e99" + url: "https://pub.dev" + source: hosted + version: "1.0.6" relative_time: dependency: "direct main" description: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0b9121b..c9251b8 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -51,6 +52,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); PasteboardPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PasteboardPlugin")); + RecordWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); SuperNativeExtensionsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 6652b07..9fbdf14 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -16,6 +16,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_libs_windows_video media_kit_video pasteboard + record_windows sqlite3_flutter_libs super_native_extensions url_launcher_windows