diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index f2f48767..fd5d7fba 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -1306,5 +1306,6 @@ "activities": "Activities", "presenceTypeGaming": "Playing", "presenceTypeMusic": "Listening to Music", - "presenceTypeWorkout": "Working out" + "presenceTypeWorkout": "Working out", + "articleCompose": "Compose Article" } diff --git a/lib/route.dart b/lib/route.dart index a92ca726..372fedec 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -50,6 +50,7 @@ import 'package:island/screens/creators/publishers_form.dart'; import 'package:island/screens/creators/webfeed/webfeed_list.dart'; import 'package:island/screens/poll/poll_editor.dart'; import 'package:island/screens/posts/compose.dart'; +import 'package:island/screens/posts/compose_article.dart'; import 'package:island/screens/posts/post_detail.dart'; import 'package:island/screens/posts/publisher_profile.dart'; import 'package:island/screens/auth/login.dart'; @@ -106,11 +107,19 @@ final routerProvider = Provider((ref) { routes: [ // Standalone routes without bottom navigation GoRoute( - name: 'postEdit', - path: '/posts/:id/edit', + name: 'articleCompose', + path: '/articles/compose', + builder: + (context, state) => ArticleComposeScreen( + initialState: state.extra as PostComposeInitialState?, + ), + ), + GoRoute( + name: 'articleEdit', + path: '/articles/:id/edit', builder: (context, state) { final id = state.pathParameters['id']!; - return PostEditScreen(id: id); + return ArticleEditScreen(id: id); }, ), GoRoute( diff --git a/lib/screens/creators/hub.dart b/lib/screens/creators/hub.dart index 515c6b24..2c7f02b0 100644 --- a/lib/screens/creators/hub.dart +++ b/lib/screens/creators/hub.dart @@ -177,8 +177,14 @@ class PublisherSelector extends StatelessWidget { iconStyleData: IconStyleData( icon: Icon(Icons.arrow_drop_down), iconSize: 19, - iconEnabledColor: Theme.of(context).appBarTheme.foregroundColor!, - iconDisabledColor: Theme.of(context).appBarTheme.foregroundColor!, + iconEnabledColor: + isWideScreen(context) + ? null + : Theme.of(context).appBarTheme.foregroundColor!, + iconDisabledColor: + isWideScreen(context) + ? null + : Theme.of(context).appBarTheme.foregroundColor!, ), ), ); @@ -561,6 +567,7 @@ class CreatorHubScreen extends HookConsumerWidget { ? Column( spacing: 8, children: [ + const SizedBox.shrink(), PublisherSelector( currentPublisher: currentPublisher.value, publishersMenu: publishersMenu, diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index 4529aa52..38384d1b 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -16,6 +16,7 @@ import 'package:island/services/responsive.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/models/post.dart'; import 'package:island/widgets/check_in.dart'; +import 'package:island/widgets/navigation/fab_menu.dart'; import 'package:island/widgets/post/post_featured.dart'; import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/post/compose_card.dart'; @@ -72,6 +73,22 @@ class ExploreScreen extends HookConsumerWidget { final tabController = useTabController(initialLength: 3); final currentFilter = useState(null); + useEffect(() { + // Set FAB type to chat + final fabMenuNotifier = ref.read(fabMenuTypeProvider.notifier); + Future(() { + fabMenuNotifier.state = FabMenuType.compose; + }); + return () { + // Clean up: reset FAB type to main + WidgetsBinding.instance.addPostFrameCallback((_) { + if (fabMenuNotifier.state == FabMenuType.compose) { + fabMenuNotifier.state = FabMenuType.main; + } + }); + }; + }, []); + useEffect(() { void listener() { switch (tabController.index) { @@ -703,7 +720,9 @@ class ActivityListNotifier extends _$ActivityListNotifier fetch(cursor: null); @override - Future> fetch({required String? cursor}) async { + Future> fetch({ + required String? cursor, + }) async { final client = ref.read(apiClientProvider); final take = 20; diff --git a/lib/screens/posts/compose.dart b/lib/screens/posts/compose.dart index 9488807e..aca519a6 100644 --- a/lib/screens/posts/compose.dart +++ b/lib/screens/posts/compose.dart @@ -1,27 +1,6 @@ -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'; import 'package:island/models/post.dart'; -import 'package:island/screens/creators/publishers_form.dart'; -import 'package:island/screens/posts/compose_article.dart'; -import 'package:island/screens/posts/post_detail.dart'; -import 'package:island/services/compose_storage_db.dart'; -import 'package:island/widgets/app_scaffold.dart'; -import 'package:island/widgets/content/cloud_files.dart'; -import 'package:island/widgets/post/compose_attachments.dart'; -import 'package:island/widgets/post/compose_form_fields.dart'; -import 'package:island/widgets/post/compose_info_banner.dart'; -import 'package:island/widgets/post/compose_settings_sheet.dart'; -import 'package:island/widgets/post/compose_shared.dart'; -import 'package:island/widgets/post/compose_toolbar.dart'; -import 'package:island/widgets/post/post_item.dart'; -import 'package:island/widgets/post/publishers_modal.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'package:styled_widget/styled_widget.dart'; part 'compose.freezed.dart'; part 'compose.g.dart'; @@ -41,358 +20,3 @@ sealed class PostComposeInitialState with _$PostComposeInitialState { factory PostComposeInitialState.fromJson(Map json) => _$PostComposeInitialStateFromJson(json); } - -class PostEditScreen extends HookConsumerWidget { - final String id; - const PostEditScreen({super.key, required this.id}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final post = ref.watch(postProvider(id)); - return post.when( - data: (post) => PostComposeScreen(originalPost: post), - loading: - () => AppScaffold( - isNoBackground: false, - appBar: AppBar(leading: const PageBackButton()), - body: const Center(child: CircularProgressIndicator()), - ), - error: - (e, _) => AppScaffold( - isNoBackground: false, - appBar: AppBar(leading: const PageBackButton()), - body: Text('Error: $e', textAlign: TextAlign.center), - ), - ); - } -} - -class PostComposeScreen extends HookConsumerWidget { - final SnPost? originalPost; - final int? type; - final PostComposeInitialState? initialState; - const PostComposeScreen({ - super.key, - this.type, - this.initialState, - this.originalPost, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - // Determine the compose type: auto-detect from edited post or use query parameter - final composeType = originalPost?.type ?? type ?? 0; - final repliedPost = initialState?.replyingTo ?? originalPost?.repliedPost; - final forwardedPost = - initialState?.forwardingTo ?? originalPost?.forwardedPost; - - // If type is 1 (article), return ArticleComposeScreen - if (composeType == 1) { - return ArticleComposeScreen(originalPost: originalPost); - } - - // When editing, preserve the original replied/forwarded post references - final effectiveRepliedPost = repliedPost ?? originalPost?.repliedPost; - final effectiveForwardedPost = forwardedPost ?? originalPost?.forwardedPost; - - final publishers = ref.watch(publishersManagedProvider); - final state = useMemoized( - () => ComposeLogic.createState( - originalPost: originalPost, - forwardedPost: effectiveForwardedPost, - repliedPost: effectiveRepliedPost, - postType: 0, // Regular post type - ), - [originalPost, effectiveForwardedPost, effectiveRepliedPost], - ); - - // Add a listener to the entire state to trigger rebuilds - final stateNotifier = useMemoized( - () => Listenable.merge([ - state.titleController, - state.descriptionController, - state.contentController, - state.visibility, - state.attachments, - state.attachmentProgress, - state.currentPublisher, - state.submitting, - ]), - [state], - ); - useListenable(stateNotifier); - - // Start auto-save when component mounts - useEffect(() { - if (originalPost == null) { - // Only auto-save for new posts, not edits - state.startAutoSave(ref); - } - return () => state.stopAutoSave(); - }, [state]); - - // Initialize publisher once when data is available - useEffect(() { - if (publishers.value?.isNotEmpty ?? false) { - if (state.currentPublisher.value == null) { - // If no publisher is set, use the first available one - state.currentPublisher.value = publishers.value!.first; - } - } - return null; - }, [publishers]); - - // 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 && - initialState == null) { - // Try to load the most recent draft - final drafts = ref.read(composeStorageNotifierProvider); - if (drafts.isNotEmpty) { - final mostRecentDraft = drafts.values.reduce( - (a, b) => - (a.updatedAt ?? DateTime(0)).isAfter(b.updatedAt ?? DateTime(0)) - ? a - : b, - ); - - // Only load if the draft has meaningful content - if (mostRecentDraft.content?.isNotEmpty == true || - mostRecentDraft.title?.isNotEmpty == true) { - state.titleController.text = mostRecentDraft.title ?? ''; - state.descriptionController.text = - mostRecentDraft.description ?? ''; - state.contentController.text = mostRecentDraft.content ?? ''; - state.visibility.value = mostRecentDraft.visibility; - } - } - } - return null; - }, []); - - // Dispose state when widget is disposed - useEffect(() { - return () { - state.stopAutoSave(); - ComposeLogic.dispose(state); - }; - }, []); - - // Helper methods - - void showSettingsSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => ComposeSettingsSheet(state: state), - ); - } - - return PopScope( - onPopInvoked: (_) { - if (originalPost == null) { - ComposeLogic.saveDraft(ref, state); - } - }, - child: AppScaffold( - isNoBackground: false, - appBar: AppBar( - leading: const PageBackButton(), - actions: [ - IconButton( - icon: const Icon(Symbols.settings), - onPressed: showSettingsSheet, - tooltip: 'postSettings'.tr(), - ), - IconButton( - onPressed: - state.submitting.value - ? null - : () => ComposeLogic.performAction( - ref, - state, - context, - originalPost: originalPost, - repliedPost: repliedPost, - forwardedPost: forwardedPost, - ), - icon: - state.submitting.value - ? SizedBox( - width: 28, - height: 28, - child: const CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2.5, - ), - ).center() - : Icon( - originalPost != null ? Symbols.edit : Symbols.upload, - ), - ), - const Gap(8), - ], - ), - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Reply/Forward info section - ComposeInfoBanner( - originalPost: originalPost, - replyingTo: repliedPost, - forwardingTo: forwardedPost, - onReferencePostTap: (context, post) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: - (context) => DraggableScrollableSheet( - initialChildSize: 0.7, - maxChildSize: 0.9, - minChildSize: 0.5, - builder: - (context, scrollController) => Container( - decoration: BoxDecoration( - color: - Theme.of(context).scaffoldBackgroundColor, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(16), - ), - ), - child: Column( - children: [ - Container( - width: 40, - height: 4, - margin: const EdgeInsets.symmetric( - vertical: 8, - ), - decoration: BoxDecoration( - color: - Theme.of(context).colorScheme.outline, - borderRadius: BorderRadius.circular(2), - ), - ), - Expanded( - child: SingleChildScrollView( - controller: scrollController, - padding: const EdgeInsets.all(16), - child: PostItem(item: post), - ), - ), - ], - ), - ), - ), - ); - }, - ), - - // Main content area - Expanded( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: Row( - spacing: 12, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Publisher profile picture - GestureDetector( - child: ProfilePictureWidget( - fileId: state.currentPublisher.value?.picture?.id, - radius: 20, - fallbackIcon: - state.currentPublisher.value == null - ? Symbols.question_mark - : null, - ), - onTap: () { - showModalBottomSheet( - isScrollControlled: true, - context: context, - builder: (context) => const PublisherModal(), - ).then((value) { - if (value != null) { - state.currentPublisher.value = value; - } - }); - }, - ).padding(top: 16), - - // Post content form - Expanded( - child: KeyboardListener( - focusNode: FocusNode(), - onKeyEvent: - (event) => ComposeLogic.handleKeyPress( - event, - state, - ref, - context, - originalPost: originalPost, - repliedPost: repliedPost, - forwardedPost: forwardedPost, - ), - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ComposeFormFields( - state: state, - showPublisherAvatar: false, - onPublisherTap: () { - showModalBottomSheet( - isScrollControlled: true, - context: context, - builder: - (context) => const PublisherModal(), - ).then((value) { - if (value != null) { - state.currentPublisher.value = value; - } - }); - }, - ), - const Gap(8), - ComposeAttachments( - state: state, - isCompact: false, - ), - ], - ), - ), - ), - ), - ], - ).padding(horizontal: 16), - ).alignment(Alignment.topCenter), - ), - - // Bottom toolbar - ComposeToolbar(state: state, originalPost: originalPost), - ], - ), - ), - ); - } -} diff --git a/lib/screens/posts/compose_article.dart b/lib/screens/posts/compose_article.dart index 39abf0c1..5fbf6b45 100644 --- a/lib/screens/posts/compose_article.dart +++ b/lib/screens/posts/compose_article.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/file.dart'; import 'package:island/models/post.dart'; import 'package:island/screens/creators/publishers_form.dart'; +import 'package:island/screens/posts/compose.dart'; import 'package:island/screens/posts/post_detail.dart'; import 'package:island/services/compose_storage_db.dart'; import 'package:island/services/responsive.dart'; @@ -49,8 +50,9 @@ class ArticleEditScreen extends HookConsumerWidget { class ArticleComposeScreen extends HookConsumerWidget { final SnPost? originalPost; + final PostComposeInitialState? initialState; - const ArticleComposeScreen({super.key, this.originalPost}); + const ArticleComposeScreen({super.key, this.originalPost, this.initialState}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -100,9 +102,25 @@ class ArticleComposeScreen extends HookConsumerWidget { return null; }, [publishers]); + // 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 articles) useEffect(() { - if (originalPost == null) { + if (originalPost == null && initialState == null) { // Try to load the most recent article draft final drafts = ref.read(composeStorageNotifierProvider); if (drafts.isNotEmpty) { @@ -199,6 +217,7 @@ class ArticleComposeScreen extends HookConsumerWidget { border: Border.all(color: colorScheme.outline.withOpacity(0.3)), borderRadius: BorderRadius.circular(8), ), + margin: const EdgeInsets.symmetric(vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -219,7 +238,12 @@ class ArticleComposeScreen extends HookConsumerWidget { ], ), ), - Expanded(child: widgetItem), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: widgetItem, + ), + ), ], ), ); @@ -246,7 +270,7 @@ class ArticleComposeScreen extends HookConsumerWidget { } }); }, - ), + ).padding(top: 16), // Attachments preview ValueListenableBuilder>( diff --git a/lib/screens/posts/post_detail.dart b/lib/screens/posts/post_detail.dart index 2a31e01c..928c46f6 100644 --- a/lib/screens/posts/post_detail.dart +++ b/lib/screens/posts/post_detail.dart @@ -108,13 +108,21 @@ class PostActionButtons extends HookConsumerWidget { final editButtons = [ FilledButton.tonal( onPressed: () { - context.pushNamed('postEdit', pathParameters: {'id': post.id}).then( - (value) { - if (value != null) { + if (post.type == 1) { + context + .pushNamed('articleEdit', pathParameters: {'id': post.id}) + .then((value) { + if (value != null) { + onRefresh?.call(); + } + }); + } else { + PostComposeSheet.show(context, originalPost: post).then((value) { + if (value == true) { onRefresh?.call(); } - }, - ); + }); + } }, style: FilledButton.styleFrom( shape: const RoundedRectangleBorder( diff --git a/lib/screens/tabs.dart b/lib/screens/tabs.dart index 8e81d2df..5329f07f 100644 --- a/lib/screens/tabs.dart +++ b/lib/screens/tabs.dart @@ -51,33 +51,33 @@ class TabsScreen extends HookConsumerWidget { final destinations = [ NavigationDestination( label: 'explore'.tr(), - icon: const Icon(Symbols.explore), + icon: const Icon(Symbols.explore_rounded), ), NavigationDestination( label: 'chat'.tr(), - icon: const Icon(Symbols.chat_rounded), + icon: const Icon(Symbols.forum_rounded), ), NavigationDestination( label: 'realms'.tr(), - icon: const Icon(Symbols.group), + icon: const Icon(Symbols.group_rounded), ), NavigationDestination( label: 'account'.tr(), icon: Badge.count( count: notificationUnreadCount.value ?? 0, isLabelVisible: (notificationUnreadCount.value ?? 0) > 0, - child: const Icon(Symbols.account_circle), + child: const Icon(Symbols.person_rounded), ), ), if (wideScreen) NavigationDestination( label: 'creatorHub'.tr(), - icon: const Icon(Symbols.ink_pen), + icon: const Icon(Symbols.design_services_rounded), ), if (wideScreen) NavigationDestination( label: 'developerHub'.tr(), - icon: const Icon(Symbols.data_object), + icon: const Icon(Symbols.data_object_rounded), ), ]; diff --git a/lib/widgets/navigation/fab_menu.dart b/lib/widgets/navigation/fab_menu.dart index 35d237a6..04e60b56 100644 --- a/lib/widgets/navigation/fab_menu.dart +++ b/lib/widgets/navigation/fab_menu.dart @@ -11,7 +11,7 @@ import 'package:island/widgets/alert.dart'; import 'package:island/widgets/post/compose_sheet.dart'; import 'package:material_symbols_icons/symbols.dart'; -enum FabMenuType { main, chat, realm } +enum FabMenuType { main, compose, chat, realm } /// Global state provider for FAB menu type final fabMenuTypeProvider = StateProvider( @@ -69,7 +69,7 @@ class FabMenu extends HookConsumerWidget { contentPadding: const EdgeInsets.symmetric(horizontal: 24), leading: const Icon(Symbols.notifications), trailing: Badge( - label: Text(notificationCount.toString()), + label: Text(notificationCount.value.toString()), isLabelVisible: notificationCount.value! > 0, ), title: Text('notifications').tr(), @@ -88,6 +88,38 @@ class FabMenu extends HookConsumerWidget { ]; switch (fabType) { + case FabMenuType.compose: + icon = Symbols.create; + useRootNavigator = false; + menuContent = Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Gap(24), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Symbols.post_add_rounded), + title: Text('postCompose').tr(), + onTap: () async { + Navigator.of(context).pop(); + await PostComposeSheet.show(context); + }, + ), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Symbols.article), + title: Text('articleCompose').tr(), + onTap: () async { + Navigator.of(context).pop(); + GoRouter.of(context).pushNamed('articleCompose'); + }, + ), + const Divider(), + ...commonEntires, + Gap(MediaQuery.of(context).padding.bottom + 16), + ], + ); + break; + case FabMenuType.chat: icon = Symbols.chat_add_on; useRootNavigator = true; @@ -160,16 +192,6 @@ class FabMenu extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ const Gap(24), - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - leading: const Icon(Symbols.post_add_rounded), - title: Text('postCompose').tr(), - onTap: () async { - Navigator.of(context).pop(); - await PostComposeSheet.show(context); - }, - ), - const Divider(), ...commonEntires, Gap(MediaQuery.of(context).padding.bottom + 16), ], diff --git a/lib/widgets/post/post_item_creator.dart b/lib/widgets/post/post_item_creator.dart index 261e85f0..4ddb991c 100644 --- a/lib/widgets/post/post_item_creator.dart +++ b/lib/widgets/post/post_item_creator.dart @@ -10,6 +10,7 @@ import 'package:island/services/time.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/post/post_shared.dart'; +import 'package:island/widgets/post/compose_sheet.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:super_context_menu/super_context_menu.dart'; @@ -45,13 +46,23 @@ class PostItemCreator extends HookConsumerWidget { title: 'edit'.tr(), image: MenuImage.icon(Symbols.edit), callback: () { - context - .pushNamed('postEdit', pathParameters: {'id': item.id}) - .then((value) { - if (value != null) { - onRefresh?.call(); - } - }); + if (item.type == 1) { + context + .pushNamed('articleEdit', pathParameters: {'id': item.id}) + .then((value) { + if (value != null) { + onRefresh?.call(); + } + }); + } else { + PostComposeSheet.show(context, originalPost: item).then(( + value, + ) { + if (value == true) { + onRefresh?.call(); + } + }); + } }, ), MenuAction(