diff --git a/ios/SolianShareExtension/Info.plist b/ios/SolianShareExtension/Info.plist index 0ff1f8e..0908143 100644 --- a/ios/SolianShareExtension/Info.plist +++ b/ios/SolianShareExtension/Info.plist @@ -23,6 +23,8 @@ NSExtensionActivationSupportsWebURLWithMaxCount 1 + NSExtensionActivationSupportsWebPageWithMaxCount + 1 NSExtensionActivationSupportsImageWithMaxCount 100 NSExtensionActivationSupportsMovieWithMaxCount diff --git a/lib/main.dart b/lib/main.dart index c506283..7adc46c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,7 +19,6 @@ import 'package:island/pods/userinfo.dart'; import 'package:island/pods/websocket.dart'; import 'package:island/route.dart'; import 'package:island/services/notify.dart'; -import 'package:island/widgets/app_wrapper.dart'; import 'package:island/services/timezone.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; @@ -127,6 +126,8 @@ void main() async { final appRouter = AppRouter(); +final globalOverlay = GlobalKey(); + class IslandApp extends HookConsumerWidget { const IslandApp({super.key}); @@ -195,6 +196,7 @@ class IslandApp extends HookConsumerWidget { locale: context.locale, builder: (context, child) { return Overlay( + key: globalOverlay, initialEntries: [ OverlayEntry( builder: diff --git a/lib/route.gr.dart b/lib/route.gr.dart index c18b484..f0392f5 100644 --- a/lib/route.gr.dart +++ b/lib/route.gr.dart @@ -1266,6 +1266,7 @@ class PostComposeRoute extends _i31.PageRouteInfo { _i34.SnPost? repliedPost, _i34.SnPost? forwardedPost, int? type, + _i22.PostComposeInitialState? initialState, List<_i31.PageRouteInfo>? children, }) : super( PostComposeRoute.name, @@ -1275,6 +1276,7 @@ class PostComposeRoute extends _i31.PageRouteInfo { repliedPost: repliedPost, forwardedPost: forwardedPost, type: type, + initialState: initialState, ), rawQueryParams: {'type': type}, initialChildren: children, @@ -1295,6 +1297,7 @@ class PostComposeRoute extends _i31.PageRouteInfo { repliedPost: args.repliedPost, forwardedPost: args.forwardedPost, type: args.type, + initialState: args.initialState, ); }, ); @@ -1307,6 +1310,7 @@ class PostComposeRouteArgs { this.repliedPost, this.forwardedPost, this.type, + this.initialState, }); final _i32.Key? key; @@ -1319,9 +1323,11 @@ class PostComposeRouteArgs { final int? type; + final _i22.PostComposeInitialState? initialState; + @override String toString() { - return 'PostComposeRouteArgs{key: $key, originalPost: $originalPost, repliedPost: $repliedPost, forwardedPost: $forwardedPost, type: $type}'; + return 'PostComposeRouteArgs{key: $key, originalPost: $originalPost, repliedPost: $repliedPost, forwardedPost: $forwardedPost, type: $type, initialState: $initialState}'; } @override @@ -1332,7 +1338,8 @@ class PostComposeRouteArgs { originalPost == other.originalPost && repliedPost == other.repliedPost && forwardedPost == other.forwardedPost && - type == other.type; + type == other.type && + initialState == other.initialState; } @override @@ -1341,7 +1348,8 @@ class PostComposeRouteArgs { originalPost.hashCode ^ repliedPost.hashCode ^ forwardedPost.hashCode ^ - type.hashCode; + type.hashCode ^ + initialState.hashCode; } /// generated route for diff --git a/lib/screens/account/leveling.dart b/lib/screens/account/leveling.dart index 1c8d7a6..ef3b285 100644 --- a/lib/screens/account/leveling.dart +++ b/lib/screens/account/leveling.dart @@ -641,7 +641,7 @@ class LevelingScreen extends HookConsumerWidget { ref.invalidate(accountStellarSubscriptionProvider); ref.read(userInfoProvider.notifier).fetchUser(); if (context.mounted) { - showSnackBar(context, 'membershipPurchaseSuccess'.tr()); + showSnackBar('membershipPurchaseSuccess'.tr()); } } } catch (err) { diff --git a/lib/screens/account/me/settings.dart b/lib/screens/account/me/settings.dart index ed00ddf..4d1250a 100644 --- a/lib/screens/account/me/settings.dart +++ b/lib/screens/account/me/settings.dart @@ -72,7 +72,7 @@ class AccountSettingsScreen extends HookConsumerWidget { final client = ref.read(apiClientProvider); await client.delete('/accounts/me'); if (context.mounted) { - showSnackBar(context, 'accountDeletionSent'.tr()); + showSnackBar('accountDeletionSent'.tr()); } } catch (err) { showErrorAlert(err); @@ -100,7 +100,7 @@ class AccountSettingsScreen extends HookConsumerWidget { data: {'account': userInfo.value!.name, 'captcha_token': captchaTk}, ); if (context.mounted) { - showSnackBar(context, 'accountPasswordChangeSent'.tr()); + showSnackBar('accountPasswordChangeSent'.tr()); } } catch (err) { showErrorAlert(err); diff --git a/lib/screens/account/me/settings_auth_factors.dart b/lib/screens/account/me/settings_auth_factors.dart index 026078b..423696d 100644 --- a/lib/screens/account/me/settings_auth_factors.dart +++ b/lib/screens/account/me/settings_auth_factors.dart @@ -205,7 +205,7 @@ class AuthFactorNewSheet extends HookConsumerWidget { builder: (context) => AuthFactorNewAdditonalSheet(factor: factor), ).then((_) { if (context.mounted) { - showSnackBar(context, 'contactMethodVerificationNeeded'.tr()); + showSnackBar('contactMethodVerificationNeeded'.tr()); } if (context.mounted) Navigator.pop(context, true); }); diff --git a/lib/screens/account/me/settings_connections.dart b/lib/screens/account/me/settings_connections.dart index 5c7b4bc..0264d20 100644 --- a/lib/screens/account/me/settings_connections.dart +++ b/lib/screens/account/me/settings_connections.dart @@ -181,7 +181,7 @@ class AccountConnectionNewSheet extends HookConsumerWidget { }, ); if (context.mounted) { - showSnackBar(context, 'accountConnectionAddSuccess'.tr()); + showSnackBar('accountConnectionAddSuccess'.tr()); Navigator.pop(context, true); } } catch (err) { @@ -208,7 +208,7 @@ class AccountConnectionNewSheet extends HookConsumerWidget { if (context.mounted) Navigator.pop(context, true); break; default: - showSnackBar(context, 'accountConnectionAddError'.tr()); + showSnackBar('accountConnectionAddError'.tr()); return; } } diff --git a/lib/screens/account/me/settings_contacts.dart b/lib/screens/account/me/settings_contacts.dart index 9bea06f..db2550f 100644 --- a/lib/screens/account/me/settings_contacts.dart +++ b/lib/screens/account/me/settings_contacts.dart @@ -40,7 +40,7 @@ class ContactMethodSheet extends HookConsumerWidget { final client = ref.read(apiClientProvider); await client.post('/accounts/me/contacts/${contact.id}/verify'); if (context.mounted) { - showSnackBar(context, 'contactMethodVerificationSent'.tr()); + showSnackBar('contactMethodVerificationSent'.tr()); } } catch (err) { showErrorAlert(err); @@ -152,7 +152,7 @@ class ContactMethodNewSheet extends HookConsumerWidget { Future addContactMethod() async { if (contentController.text.isEmpty) { - showSnackBar(context, 'contactMethodContentEmpty'.tr()); + showSnackBar('contactMethodContentEmpty'.tr()); return; } @@ -164,7 +164,7 @@ class ContactMethodNewSheet extends HookConsumerWidget { data: {'type': contactType.value, 'content': contentController.text}, ); if (context.mounted) { - showSnackBar(context, 'contactMethodVerificationNeeded'.tr()); + showSnackBar('contactMethodVerificationNeeded'.tr()); Navigator.pop(context, true); } } catch (err) { diff --git a/lib/screens/account/relationship.dart b/lib/screens/account/relationship.dart index 4926480..b3064b1 100644 --- a/lib/screens/account/relationship.dart +++ b/lib/screens/account/relationship.dart @@ -242,12 +242,10 @@ class RelationshipScreen extends HookConsumerWidget { if (!context.mounted) return; if (isAccept) { showSnackBar( - context, 'friendRequestAccepted'.tr(args: ['@${relationship.account.name}']), ); } else { showSnackBar( - context, 'friendRequestDeclined'.tr(args: ['@${relationship.account.name}']), ); } diff --git a/lib/screens/auth/login.dart b/lib/screens/auth/login.dart index 21d23d9..39d902c 100644 --- a/lib/screens/auth/login.dart +++ b/lib/screens/auth/login.dart @@ -427,7 +427,7 @@ class _LoginPickerScreen extends HookConsumerWidget { onPickFactor(factors!.where((x) => x == factorPicked.value).first); onNext(); if (context.mounted) { - showSnackBar(context, err.response!.data.toString()); + showSnackBar(err.response!.data.toString()); } return; } diff --git a/lib/screens/chat/room_detail.dart b/lib/screens/chat/room_detail.dart index 8d8d2be..393e516 100644 --- a/lib/screens/chat/room_detail.dart +++ b/lib/screens/chat/room_detail.dart @@ -49,7 +49,6 @@ class ChatDetailScreen extends HookConsumerWidget { ref.invalidate(chatroomIdentityProvider(id)); if (context.mounted) { showSnackBar( - context, 'chatNotifyLevelUpdated'.tr(args: [kNotifyLevelText[level].tr()]), ); } @@ -140,7 +139,7 @@ class ChatDetailScreen extends HookConsumerWidget { setChatBreak(now); Navigator.pop(context); if (context.mounted) { - showSnackBar(context, 'chatBreakCleared'.tr()); + showSnackBar('chatBreakCleared'.tr()); } }, ), @@ -152,7 +151,7 @@ class ChatDetailScreen extends HookConsumerWidget { setChatBreak(now.add(const Duration(minutes: 5))); Navigator.pop(context); if (context.mounted) { - showSnackBar(context, 'chatBreakSet'.tr(args: ['5m'])); + showSnackBar('chatBreakSet'.tr(args: ['5m'])); } }, ), @@ -164,7 +163,7 @@ class ChatDetailScreen extends HookConsumerWidget { setChatBreak(now.add(const Duration(minutes: 10))); Navigator.pop(context); if (context.mounted) { - showSnackBar(context, 'chatBreakSet'.tr(args: ['10m'])); + showSnackBar('chatBreakSet'.tr(args: ['10m'])); } }, ), @@ -176,7 +175,7 @@ class ChatDetailScreen extends HookConsumerWidget { setChatBreak(now.add(const Duration(minutes: 15))); Navigator.pop(context); if (context.mounted) { - showSnackBar(context, 'chatBreakSet'.tr(args: ['15m'])); + showSnackBar('chatBreakSet'.tr(args: ['15m'])); } }, ), @@ -188,7 +187,7 @@ class ChatDetailScreen extends HookConsumerWidget { setChatBreak(now.add(const Duration(minutes: 30))); Navigator.pop(context); if (context.mounted) { - showSnackBar(context, 'chatBreakSet'.tr(args: ['30m'])); + showSnackBar('chatBreakSet'.tr(args: ['30m'])); } }, ), @@ -208,7 +207,6 @@ class ChatDetailScreen extends HookConsumerWidget { Navigator.pop(context); if (context.mounted) { showSnackBar( - context, 'chatBreakSet'.tr(args: ['${minutes}m']), ); } diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index e251681..58ecaa6 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -8,6 +8,7 @@ import 'package:island/models/activity.dart'; import 'package:island/pods/userinfo.dart'; import 'package:island/route.gr.dart'; import 'package:island/services/responsive.dart'; +import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/models/post.dart'; import 'package:island/widgets/check_in.dart'; @@ -75,6 +76,7 @@ class ExploreScreen extends HookConsumerWidget { currentFilter.value = 'friends'; break; } + showSnackBar('Browsing ${currentFilter.value}'); } tabController.addListener(listener); diff --git a/lib/screens/notification.dart b/lib/screens/notification.dart index 9dbae09..512584b 100644 --- a/lib/screens/notification.dart +++ b/lib/screens/notification.dart @@ -186,7 +186,6 @@ class NotificationScreen extends HookConsumerWidget { final uri = Uri.tryParse(href); if (uri == null) { showSnackBar( - context, 'brokenLink'.tr(args: []), action: SnackBarAction( label: 'copyToClipboard'.tr(), diff --git a/lib/screens/posts/compose.dart b/lib/screens/posts/compose.dart index c9d738e..ebbd926 100644 --- a/lib/screens/posts/compose.dart +++ b/lib/screens/posts/compose.dart @@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/file.dart'; @@ -22,6 +23,23 @@ import 'package:island/widgets/post/draft_manager.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; +part 'compose.freezed.dart'; +part 'compose.g.dart'; + +@freezed +sealed class PostComposeInitialState with _$PostComposeInitialState { + const factory PostComposeInitialState({ + String? title, + String? description, + String? content, + @Default([]) List attachments, + int? visibility, + }) = _PostComposeInitialState; + + factory PostComposeInitialState.fromJson(Map json) => + _$PostComposeInitialStateFromJson(json); +} + @RoutePage() class PostEditScreen extends HookConsumerWidget { final String id; @@ -54,12 +72,14 @@ class PostComposeScreen extends HookConsumerWidget { final SnPost? repliedPost; final SnPost? forwardedPost; final int? type; + final PostComposeInitialState? initialState; const PostComposeScreen({ super.key, this.originalPost, this.repliedPost, this.forwardedPost, @QueryParam('type') this.type, + this.initialState, }); @override @@ -107,11 +127,28 @@ class PostComposeScreen extends HookConsumerWidget { return null; }, [publishers]); - // Load draft if available (only for new posts) + // Load initial state if provided (for sharing functionality) + useEffect(() { + if (initialState != null) { + state.titleController.text = initialState!.title ?? ''; + state.descriptionController.text = initialState!.description ?? ''; + state.contentController.text = initialState!.content ?? ''; + if (initialState!.visibility != null) { + state.visibility.value = initialState!.visibility!; + } + if (initialState!.attachments.isNotEmpty) { + state.attachments.value = List.from(initialState!.attachments); + } + } + return null; + }, [initialState]); + + // Load draft if available (only for new posts without initial state) useEffect(() { if (originalPost == null && effectiveForwardedPost == null && - effectiveRepliedPost == null) { + effectiveRepliedPost == null && + initialState == null) { // Try to load the most recent draft final drafts = ref.read(composeStorageNotifierProvider); if (drafts.isNotEmpty) { diff --git a/lib/screens/posts/compose.freezed.dart b/lib/screens/posts/compose.freezed.dart new file mode 100644 index 0000000..3987fb7 --- /dev/null +++ b/lib/screens/posts/compose.freezed.dart @@ -0,0 +1,166 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'compose.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$PostComposeInitialState { + + String? get title; String? get description; String? get content; List get attachments; int? get visibility; +/// Create a copy of PostComposeInitialState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$PostComposeInitialStateCopyWith get copyWith => _$PostComposeInitialStateCopyWithImpl(this as PostComposeInitialState, _$identity); + + /// Serializes this PostComposeInitialState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is PostComposeInitialState&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.content, content) || other.content == content)&&const DeepCollectionEquality().equals(other.attachments, attachments)&&(identical(other.visibility, visibility) || other.visibility == visibility)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,title,description,content,const DeepCollectionEquality().hash(attachments),visibility); + +@override +String toString() { + return 'PostComposeInitialState(title: $title, description: $description, content: $content, attachments: $attachments, visibility: $visibility)'; +} + + +} + +/// @nodoc +abstract mixin class $PostComposeInitialStateCopyWith<$Res> { + factory $PostComposeInitialStateCopyWith(PostComposeInitialState value, $Res Function(PostComposeInitialState) _then) = _$PostComposeInitialStateCopyWithImpl; +@useResult +$Res call({ + String? title, String? description, String? content, List attachments, int? visibility +}); + + + + +} +/// @nodoc +class _$PostComposeInitialStateCopyWithImpl<$Res> + implements $PostComposeInitialStateCopyWith<$Res> { + _$PostComposeInitialStateCopyWithImpl(this._self, this._then); + + final PostComposeInitialState _self; + final $Res Function(PostComposeInitialState) _then; + +/// Create a copy of PostComposeInitialState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? title = freezed,Object? description = freezed,Object? content = freezed,Object? attachments = null,Object? visibility = freezed,}) { + return _then(_self.copyWith( +title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String?,content: freezed == content ? _self.content : content // ignore: cast_nullable_to_non_nullable +as String?,attachments: null == attachments ? _self.attachments : attachments // ignore: cast_nullable_to_non_nullable +as List,visibility: freezed == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + +} + + +/// @nodoc +@JsonSerializable() + +class _PostComposeInitialState implements PostComposeInitialState { + const _PostComposeInitialState({this.title, this.description, this.content, final List attachments = const [], this.visibility}): _attachments = attachments; + factory _PostComposeInitialState.fromJson(Map json) => _$PostComposeInitialStateFromJson(json); + +@override final String? title; +@override final String? description; +@override final String? content; + final List _attachments; +@override@JsonKey() List get attachments { + if (_attachments is EqualUnmodifiableListView) return _attachments; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_attachments); +} + +@override final int? visibility; + +/// Create a copy of PostComposeInitialState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$PostComposeInitialStateCopyWith<_PostComposeInitialState> get copyWith => __$PostComposeInitialStateCopyWithImpl<_PostComposeInitialState>(this, _$identity); + +@override +Map toJson() { + return _$PostComposeInitialStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _PostComposeInitialState&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.content, content) || other.content == content)&&const DeepCollectionEquality().equals(other._attachments, _attachments)&&(identical(other.visibility, visibility) || other.visibility == visibility)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,title,description,content,const DeepCollectionEquality().hash(_attachments),visibility); + +@override +String toString() { + return 'PostComposeInitialState(title: $title, description: $description, content: $content, attachments: $attachments, visibility: $visibility)'; +} + + +} + +/// @nodoc +abstract mixin class _$PostComposeInitialStateCopyWith<$Res> implements $PostComposeInitialStateCopyWith<$Res> { + factory _$PostComposeInitialStateCopyWith(_PostComposeInitialState value, $Res Function(_PostComposeInitialState) _then) = __$PostComposeInitialStateCopyWithImpl; +@override @useResult +$Res call({ + String? title, String? description, String? content, List attachments, int? visibility +}); + + + + +} +/// @nodoc +class __$PostComposeInitialStateCopyWithImpl<$Res> + implements _$PostComposeInitialStateCopyWith<$Res> { + __$PostComposeInitialStateCopyWithImpl(this._self, this._then); + + final _PostComposeInitialState _self; + final $Res Function(_PostComposeInitialState) _then; + +/// Create a copy of PostComposeInitialState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? title = freezed,Object? description = freezed,Object? content = freezed,Object? attachments = null,Object? visibility = freezed,}) { + return _then(_PostComposeInitialState( +title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String?,content: freezed == content ? _self.content : content // ignore: cast_nullable_to_non_nullable +as String?,attachments: null == attachments ? _self._attachments : attachments // ignore: cast_nullable_to_non_nullable +as List,visibility: freezed == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + + +} + +// dart format on diff --git a/lib/screens/posts/compose.g.dart b/lib/screens/posts/compose.g.dart new file mode 100644 index 0000000..1faba5c --- /dev/null +++ b/lib/screens/posts/compose.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'compose.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_PostComposeInitialState _$PostComposeInitialStateFromJson( + Map json, +) => _PostComposeInitialState( + title: json['title'] as String?, + description: json['description'] as String?, + content: json['content'] as String?, + attachments: + (json['attachments'] as List?) + ?.map((e) => UniversalFile.fromJson(e as Map)) + .toList() ?? + const [], + visibility: (json['visibility'] as num?)?.toInt(), +); + +Map _$PostComposeInitialStateToJson( + _PostComposeInitialState instance, +) => { + 'title': instance.title, + 'description': instance.description, + 'content': instance.content, + 'attachments': instance.attachments.map((e) => e.toJson()).toList(), + 'visibility': instance.visibility, +}; diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 56fa276..ea76965 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -110,7 +110,7 @@ class SettingsScreen extends HookConsumerWidget { ref .read(appSettingsNotifierProvider.notifier) .setCustomFonts(null); - showSnackBar(context, 'settingsApplied'.tr()); + showSnackBar('settingsApplied'.tr()); }, ), border: OutlineInputBorder( @@ -122,7 +122,7 @@ class SettingsScreen extends HookConsumerWidget { ref .read(appSettingsNotifierProvider.notifier) .setCustomFonts(value.isEmpty ? null : value); - showSnackBar(context, 'settingsApplied'.tr()); + showSnackBar('settingsApplied'.tr()); }, ), ), @@ -215,7 +215,7 @@ class SettingsScreen extends HookConsumerWidget { prefs.setBool(kAppBackgroundStoreKey, true); ref.invalidate(backgroundImageFileProvider); if (context.mounted) { - showSnackBar(context, 'settingsApplied'.tr()); + showSnackBar('settingsApplied'.tr()); } }, ), @@ -243,7 +243,7 @@ class SettingsScreen extends HookConsumerWidget { prefs.remove(kAppBackgroundStoreKey); ref.invalidate(backgroundImageFileProvider); if (context.mounted) { - showSnackBar(context, 'settingsApplied'.tr()); + showSnackBar('settingsApplied'.tr()); } }, ); @@ -290,7 +290,7 @@ class SettingsScreen extends HookConsumerWidget { .setAppColorScheme(color.value); if (context.mounted) { hideLoadingModal(context); - showSnackBar(context, 'settingsApplied'.tr()); + showSnackBar('settingsApplied'.tr()); } }, ); @@ -321,7 +321,7 @@ class SettingsScreen extends HookConsumerWidget { kNetworkServerDefault, ); ref.invalidate(serverUrlProvider); - showSnackBar(context, 'settingsApplied'.tr()); + showSnackBar('settingsApplied'.tr()); }, ), border: OutlineInputBorder( @@ -333,7 +333,7 @@ class SettingsScreen extends HookConsumerWidget { if (value.isNotEmpty) { prefs.setString(kNetworkServerStoreKey, value); ref.invalidate(serverUrlProvider); - showSnackBar(context, 'settingsApplied'.tr()); + showSnackBar('settingsApplied'.tr()); } }, ), diff --git a/lib/services/sharing_intent.dart b/lib/services/sharing_intent.dart index e1cb0ce..6f1dee2 100644 --- a/lib/services/sharing_intent.dart +++ b/lib/services/sharing_intent.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:island/widgets/share/share_sheet.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:easy_localization/easy_localization.dart'; class SharingIntentService { static final SharingIntentService _instance = @@ -73,14 +72,44 @@ class SharingIntentService { ); } - // Convert SharedMediaFile to XFile + // Convert SharedMediaFile to XFile for files final List files = sharedFiles + .where( + (file) => + file.type == SharedMediaType.file || + file.type == SharedMediaType.video || + file.type == SharedMediaType.image, + ) .map((file) => XFile(file.path, name: file.path.split('/').last)) .toList(); + // Extract links from shared content + final List links = + sharedFiles + .where((file) => file.type == SharedMediaType.text) + .map((file) => file.path) + .toList(); + // Show ShareSheet with the shared files - showShareSheet(context: _context!, content: ShareContent.files(files)); + if (files.isNotEmpty) { + showShareSheet(context: _context!, content: ShareContent.files(files)); + } else if (links.isNotEmpty) { + showShareSheet( + context: _context!, + content: ShareContent.link(links.first), + ); + } else { + showShareSheet( + context: _context!, + content: ShareContent.text( + sharedFiles + .where((file) => file.type == SharedMediaType.text) + .map((text) => text.message) + .join('\n'), + ), + ); + } } /// Dispose of resources diff --git a/lib/widgets/account/restore_purchase_sheet.dart b/lib/widgets/account/restore_purchase_sheet.dart index 24ebdbb..7891fed 100644 --- a/lib/widgets/account/restore_purchase_sheet.dart +++ b/lib/widgets/account/restore_purchase_sheet.dart @@ -38,7 +38,7 @@ class RestorePurchaseSheet extends HookConsumerWidget { if (context.mounted) { Navigator.pop(context); - showSnackBar(context, 'Purchase restored successfully!'); + showSnackBar('Purchase restored successfully!'); } } catch (err) { showErrorAlert(err); diff --git a/lib/widgets/alert.dart b/lib/widgets/alert.dart index c6232f4..c59e018 100644 --- a/lib/widgets/alert.dart +++ b/lib/widgets/alert.dart @@ -1,31 +1,18 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; -import 'package:island/services/responsive.dart'; +import 'package:island/main.dart'; import 'package:styled_widget/styled_widget.dart'; +import 'package:top_snackbar_flutter/top_snack_bar.dart'; export 'content/alert.native.dart' if (dart.library.html) 'content/alert.web.dart'; -void showSnackBar( - BuildContext context, - String message, { - SnackBarAction? action, -}) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - action: action, - margin: - isWideScreen(context) - ? null - : EdgeInsets.fromLTRB( - 15.0, - 5.0, - 15.0, - MediaQuery.of(context).padding.bottom + 28, - ), - ), +void showSnackBar(String message, {SnackBarAction? action}) { + showTopSnackBar( + globalOverlay.currentState!, + Card(child: Text(message).padding(horizontal: 24, vertical: 16)), + snackBarPosition: SnackBarPosition.bottom, ); } diff --git a/lib/widgets/content/markdown.dart b/lib/widgets/content/markdown.dart index fd050fb..dee1b63 100644 --- a/lib/widgets/content/markdown.dart +++ b/lib/widgets/content/markdown.dart @@ -94,7 +94,6 @@ class MarkdownTextContent extends HookConsumerWidget { }); } else { showSnackBar( - context, 'brokenLink'.tr(args: [href]), action: SnackBarAction( label: 'copyToClipboard'.tr(), diff --git a/lib/widgets/payment/payment_overlay.dart b/lib/widgets/payment/payment_overlay.dart index 0958907..1aa4b54 100644 --- a/lib/widgets/payment/payment_overlay.dart +++ b/lib/widgets/payment/payment_overlay.dart @@ -279,7 +279,7 @@ class _PaymentContentState extends ConsumerState<_PaymentContent> { _isPinMode = true; }); if (message != null && message.isNotEmpty) { - showSnackBar(context, message); + showSnackBar(message); } } diff --git a/lib/widgets/post/compose_shared.dart b/lib/widgets/post/compose_shared.dart index 8d25b3c..eb9bb09 100644 --- a/lib/widgets/post/compose_shared.dart +++ b/lib/widgets/post/compose_shared.dart @@ -112,7 +112,11 @@ class ComposeLogic { ); } - static Future saveDraft(WidgetRef ref, ComposeState state, {int postType = 0}) async { + static Future saveDraft( + WidgetRef ref, + ComposeState state, { + int postType = 0, + }) async { final hasContent = state.titleController.text.trim().isNotEmpty || state.descriptionController.text.trim().isNotEmpty || @@ -142,7 +146,9 @@ class ComposeLogic { fileData: attachment, atk: token, baseUrl: baseUrl, - filename: attachment.data.name ?? (postType == 1 ? 'Article media' : 'Post media'), + filename: + attachment.data.name ?? + (postType == 1 ? 'Article media' : 'Post media'), mimetype: attachment.data.mimeType ?? ComposeLogic.getMimeTypeFromFileType(attachment.type), @@ -217,7 +223,11 @@ class ComposeLogic { } } - static Future saveDraftWithoutUpload(WidgetRef ref, ComposeState state, {int postType = 0}) async { + static Future saveDraftWithoutUpload( + WidgetRef ref, + ComposeState state, { + int postType = 0, + }) async { final hasContent = state.titleController.text.trim().isNotEmpty || state.descriptionController.text.trim().isNotEmpty || @@ -346,12 +356,12 @@ class ComposeLogic { await ref.read(composeStorageNotifierProvider.notifier).saveDraft(draft); if (context.mounted) { - showSnackBar(context, 'draftSaved'.tr()); + showSnackBar('draftSaved'.tr()); } } catch (e) { log('[ComposeLogic] Failed to save draft manually, error: $e'); if (context.mounted) { - showSnackBar(context, 'draftSaveFailed'.tr()); + showSnackBar('draftSaveFailed'.tr()); } } } @@ -511,7 +521,7 @@ class ComposeLogic { if (!hasContent && !hasAttachments) { if (context.mounted) { - showSnackBar(context, 'postContentEmpty'.tr()); + showSnackBar('postContentEmpty'.tr()); } return; // Don't submit empty posts } diff --git a/lib/widgets/share/share_sheet.dart b/lib/widgets/share/share_sheet.dart index 036965e..b215405 100644 --- a/lib/widgets/share/share_sheet.dart +++ b/lib/widgets/share/share_sheet.dart @@ -1,9 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/content/sheet.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:island/route.gr.dart'; +import 'package:island/screens/posts/compose.dart'; +import 'package:island/models/file.dart'; +import 'package:island/models/embed.dart'; +import 'package:island/pods/network.dart'; import 'dart:io'; import 'package:path/path.dart' as path; @@ -125,9 +132,55 @@ class _ShareSheetState extends ConsumerState { Future _shareToPost() async { setState(() => _isLoading = true); try { - // TODO: Implement share to post functionality - // This would typically navigate to the post composer with pre-filled content - showSnackBar(context, 'Share to post functionality coming soon'); + // Convert ShareContent to PostComposeInitialState + String content = ''; + List attachments = []; + + switch (widget.content.type) { + case ShareContentType.text: + content = widget.content.text ?? ''; + break; + case ShareContentType.link: + content = widget.content.link ?? ''; + break; + case ShareContentType.file: + if (widget.content.files != null) { + // Convert XFiles to UniversalFiles + for (final xFile in widget.content.files!) { + final file = File(xFile.path); + final mimeType = xFile.mimeType; + + UniversalFileType fileType; + if (mimeType?.startsWith('image/') == true) { + fileType = UniversalFileType.image; + } else if (mimeType?.startsWith('video/') == true) { + fileType = UniversalFileType.video; + } else if (mimeType?.startsWith('audio/') == true) { + fileType = UniversalFileType.audio; + } else { + fileType = UniversalFileType.file; + } + + attachments.add(UniversalFile( + data: file, + type: fileType, + )); + } + } + break; + } + + final initialState = PostComposeInitialState( + title: widget.title, + content: content, + attachments: attachments, + ); + + // Navigate to compose screen + if (mounted) { + context.router.push(PostComposeRoute(initialState: initialState)); + Navigator.of(context).pop(); // Close the share sheet + } } catch (e) { showErrorAlert(e); } finally { @@ -213,7 +266,7 @@ class _ShareSheetState extends ConsumerState { } await Clipboard.setData(ClipboardData(text: textToCopy)); - if (mounted) showSnackBar(context, 'copyToClipboard'.tr()); + if (mounted) showSnackBar('copyToClipboard'.tr()); } catch (e) { showErrorAlert(e); } @@ -664,48 +717,212 @@ class _TextPreview extends StatelessWidget { } } -class _LinkPreview extends StatelessWidget { +class _LinkPreview extends HookConsumerWidget { final String link; const _LinkPreview({required this.link}); @override - Widget build(BuildContext context) { - return Container( - constraints: const BoxConstraints(maxHeight: kPreviewMaxHeight), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Symbols.link, - size: 16, - color: Theme.of(context).colorScheme.primary, + Widget build(BuildContext context, WidgetRef ref) { + final linkData = useState(null); + final isLoading = useState(false); + final hasError = useState(false); + + useEffect(() { + Future fetchLinkData() async { + if (link.isEmpty) return; + + isLoading.value = true; + hasError.value = false; + + try { + final client = ref.read(apiClientProvider); + final response = await client.get('/scrap/link', queryParameters: { + 'url': link, + }); + + if (response.data != null) { + linkData.value = SnEmbedLink.fromJson(response.data); + } + } catch (e) { + hasError.value = true; + } finally { + isLoading.value = false; + } + } + + fetchLinkData(); + return null; + }, [link]); + + if (isLoading.value) { + return Container( + constraints: const BoxConstraints(maxHeight: kPreviewMaxHeight), + child: Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 8), + Text( + 'Loading link preview...', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - const SizedBox(width: 8), - Text( - 'Link', - style: Theme.of(context).textTheme.labelSmall?.copyWith( + ), + ], + ), + ); + } + + if (hasError.value || linkData.value == null) { + return Container( + constraints: const BoxConstraints(maxHeight: kPreviewMaxHeight), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Symbols.link, + size: 16, color: Theme.of(context).colorScheme.primary, ), + const SizedBox(width: 8), + Text( + 'Link', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: SingleChildScrollView( + child: SelectableText( + link, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), ), - ], - ), - const SizedBox(height: 8), - SingleChildScrollView( - child: SelectableText( - link, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.primary, - decoration: TextDecoration.underline, + ), + ], + ), + ); + } + + final embed = linkData.value!; + return Container( + constraints: const BoxConstraints(maxHeight: 120), // Increased height for rich preview + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Favicon and image + if (embed.imageUrl != null || embed.faviconUrl.isNotEmpty) + Container( + width: 60, + height: 60, + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.surfaceContainerHighest, ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: embed.imageUrl != null + ? Image.network( + embed.imageUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return _buildFaviconFallback(context, embed.faviconUrl); + }, + ) + : _buildFaviconFallback(context, embed.faviconUrl), + ), + ), + // Content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Site name + if (embed.siteName.isNotEmpty) + Text( + embed.siteName, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + // Title + Text( + embed.title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + // Description + if (embed.description != null && embed.description!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + embed.description!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + // URL + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + embed.url, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + decoration: TextDecoration.underline, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], ), ), ], ), ); } + + Widget _buildFaviconFallback(BuildContext context, String faviconUrl) { + if (faviconUrl.isNotEmpty) { + return Image.network( + faviconUrl, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Icon( + Symbols.link, + color: Theme.of(context).colorScheme.primary, + size: 24, + ); + }, + ); + } + return Icon( + Symbols.link, + color: Theme.of(context).colorScheme.primary, + size: 24, + ); + } } class _FilePreview extends StatelessWidget { diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index 34681e2..8ef6ff7 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -11,4 +11,4 @@ PRODUCT_NAME = Solian PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2025 Solsynth LLC. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2025 Solsynth. All rights reserved. diff --git a/pubspec.lock b/pubspec.lock index 28cd402..0229147 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2282,6 +2282,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + top_snackbar_flutter: + dependency: "direct main" + description: + name: top_snackbar_flutter + sha256: ad3f93062450e8c7db97b271d405c180536408cc2be4380a59da7022eb1d750c + url: "https://pub.dev" + source: hosted + version: "3.3.0" tuple: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 84b43d8..877fa75 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -121,6 +121,7 @@ dependencies: flutter_math_fork: ^0.7.4 share_plus: ^11.0.0 receive_sharing_intent: ^1.8.1 + top_snackbar_flutter: ^3.3.0 dev_dependencies: flutter_test: