diff --git a/lib/widgets/post/compose_card.dart b/lib/widgets/post/compose_card.dart index 7ad27b42..9562dd1e 100644 --- a/lib/widgets/post/compose_card.dart +++ b/lib/widgets/post/compose_card.dart @@ -16,10 +16,8 @@ import 'package:island/widgets/content/sheet.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_state_utils.dart'; -import 'package:island/widgets/post/compose_submit_utils.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'; @@ -143,16 +141,11 @@ class PostComposeCard extends HookConsumerWidget { // Helper methods void showSettingsSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - useRootNavigator: true, - builder: (context) => ComposeSettingsSheet(state: composeState), - ); + ComposeLogic.showSettingsSheet(context, composeState); } Future performSubmit() async { - await ComposeSubmitUtils.performSubmit( + await ComposeLogic.performSubmit( ref, composeState, context, diff --git a/lib/widgets/post/compose_dialog.dart b/lib/widgets/post/compose_dialog.dart index 348d2080..be4c0328 100644 --- a/lib/widgets/post/compose_dialog.dart +++ b/lib/widgets/post/compose_dialog.dart @@ -9,10 +9,8 @@ import 'package:island/services/compose_storage_db.dart'; import 'package:island/services/event_bus.dart'; import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/post/compose_card.dart'; -import 'package:island/widgets/post/compose_settings_sheet.dart'; import 'package:island/widgets/post/compose_shared.dart'; import 'package:island/widgets/post/compose_state_utils.dart'; -import 'package:island/widgets/post/compose_submit_utils.dart'; import 'package:material_symbols_icons/symbols.dart'; /// A dialog that wraps PostComposeCard for easy use in dialogs. @@ -104,16 +102,11 @@ class PostComposeDialog extends HookConsumerWidget { // Helper methods for actions void showSettingsSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - useRootNavigator: true, - builder: (context) => ComposeSettingsSheet(state: state), - ); + ComposeLogic.showSettingsSheet(context, state); } Future performSubmit() async { - await ComposeSubmitUtils.performSubmit( + await ComposeLogic.performSubmit( ref, state, context, diff --git a/lib/widgets/post/compose_shared.dart b/lib/widgets/post/compose_shared.dart index 35d7db5f..78198ed3 100644 --- a/lib/widgets/post/compose_shared.dart +++ b/lib/widgets/post/compose_shared.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:island/services/event_bus.dart'; +import 'package:island/widgets/post/compose_settings_sheet.dart'; import 'package:mime/mime.dart'; import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -21,6 +22,7 @@ import 'package:island/services/compose_storage_db.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/post/compose_link_attachments.dart'; import 'package:island/widgets/post/compose_poll.dart'; +import 'package:island/widgets/post/compose_fund.dart'; import 'package:island/widgets/post/compose_recorder.dart'; import 'package:island/pods/file_pool.dart'; import 'package:pasteboard/pasteboard.dart'; @@ -44,6 +46,8 @@ class ComposeState { int postType; // Linked poll id for this compose session (nullable) final ValueNotifier pollId; + // Linked fund id for this compose session (nullable) + final ValueNotifier fundId; Timer? _autoSaveTimer; ComposeState({ @@ -63,7 +67,9 @@ class ComposeState { required this.draftId, this.postType = 0, String? pollId, - }) : pollId = ValueNotifier(pollId); + String? fundId, + }) : pollId = ValueNotifier(pollId), + fundId = ValueNotifier(fundId); void startAutoSave(WidgetRef ref) { _autoSaveTimer?.cancel(); @@ -133,6 +139,8 @@ class ComposeLogic { postType: postType, // initialize without poll by default pollId: null, + // initialize without fund by default + fundId: null, ); } @@ -158,6 +166,8 @@ class ComposeLogic { draftId: draft.id, postType: postType, pollId: null, + // initialize without fund by default + fundId: null, ); } @@ -618,15 +628,40 @@ class ComposeLogic { state.pollId.value = poll.id; } - static Future performAction( + static Future pickFund( + WidgetRef ref, + ComposeState state, + BuildContext context, + ) async { + if (state.fundId.value != null) { + state.fundId.value = null; + return; + } + + final fund = await showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + builder: (context) => const ComposeFundSheet(), + ); + + if (fund == null) return; + state.fundId.value = fund.id; + } + + /// Unified submit method that returns the created/updated post. + static Future performSubmit( WidgetRef ref, ComposeState state, BuildContext context, { SnPost? originalPost, SnPost? repliedPost, SnPost? forwardedPost, + required Function() onSuccess, }) async { - if (state.submitting.value) return; + if (state.submitting.value) { + throw Exception('Already submitting'); + } // Don't submit empty posts (no content and no attachments) final hasContent = @@ -636,25 +671,31 @@ class ComposeLogic { final hasAttachments = state.attachments.value.isNotEmpty; if (!hasContent && !hasAttachments) { + // Show error message if context is mounted if (context.mounted) { - showSnackBar('postContentEmpty'.tr()); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('postContentEmpty'))); } - return; // Don't submit empty posts + throw Exception('Post content is empty'); // Don't submit empty posts } try { state.submitting.value = true; - // pload any local attachments first + // Upload any local attachments first await Future.wait( state.attachments.value .asMap() .entries .where((entry) => entry.value.isOnDevice) - .map((entry) => uploadAttachment(ref, state, entry.key)), + .map( + (entry) => ComposeLogic.uploadAttachment(ref, state, entry.key), + ), ); + // Prepare API request - final client = ref.watch(apiClientProvider); + final client = ref.read(apiClientProvider); final isNewPost = originalPost == null; final endpoint = '/sphere${isNewPost ? '/posts' : '/posts/${originalPost.id}'}'; @@ -679,43 +720,85 @@ class ComposeLogic { 'categories': state.categories.value.map((e) => e.slug).toList(), if (state.realm.value != null) 'realm_id': state.realm.value?.id, if (state.pollId.value != null) 'poll_id': state.pollId.value, + if (state.fundId.value != null) 'fund_id': state.fundId.value, if (state.embedView.value != null) 'embed_view': state.embedView.value!.toJson(), }; // Send request - await client.request( + final response = await client.request( endpoint, queryParameters: {'pub': state.currentPublisher.value?.name}, data: payload, options: Options(method: isNewPost ? 'POST' : 'PATCH'), ); - // Delete draft after successful submission - if (state.postType == 1) { - // Delete article draft - await ref - .read(composeStorageNotifierProvider.notifier) - .deleteDraft(state.draftId); - } else { - // Delete regular post draft - await ref - .read(composeStorageNotifierProvider.notifier) - .deleteDraft(state.draftId); - } - - if (context.mounted) { - Navigator.of(context).maybePop(true); - } + // Parse the response into a SnPost + final post = SnPost.fromJson(response.data); + // Call the success callback + onSuccess(); eventBus.fire(PostCreatedEvent()); + + return post; } catch (err) { - showErrorAlert(err); + // Show error message if context is mounted + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $err'))); + } + rethrow; } finally { state.submitting.value = false; } } + static Future performAction( + WidgetRef ref, + ComposeState state, + BuildContext context, { + SnPost? originalPost, + SnPost? repliedPost, + SnPost? forwardedPost, + }) async { + await ComposeLogic.performSubmit( + ref, + state, + context, + originalPost: originalPost, + repliedPost: repliedPost, + forwardedPost: forwardedPost, + onSuccess: () async { + // Delete draft after successful submission + if (state.postType == 1) { + // Delete article draft + await ref + .read(composeStorageNotifierProvider.notifier) + .deleteDraft(state.draftId); + } else { + // Delete regular post draft + await ref + .read(composeStorageNotifierProvider.notifier) + .deleteDraft(state.draftId); + } + + if (context.mounted) { + Navigator.of(context).maybePop(true); + } + }, + ); + } + + /// Shows the settings sheet modal. + static void showSettingsSheet(BuildContext context, ComposeState state) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => ComposeSettingsSheet(state: state), + ); + } + static Future handlePaste(ComposeState state) async { final clipboard = await Pasteboard.image; if (clipboard == null) return; @@ -778,5 +861,6 @@ class ComposeLogic { state.realm.dispose(); state.embedView.dispose(); state.pollId.dispose(); + state.fundId.dispose(); } } diff --git a/lib/widgets/post/compose_sheet.dart b/lib/widgets/post/compose_sheet.dart index 6dd1fd91..363917c7 100644 --- a/lib/widgets/post/compose_sheet.dart +++ b/lib/widgets/post/compose_sheet.dart @@ -8,10 +8,8 @@ import 'package:island/screens/posts/compose.dart'; import 'package:island/services/compose_storage_db.dart'; import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/post/compose_card.dart'; -import 'package:island/widgets/post/compose_settings_sheet.dart'; import 'package:island/widgets/post/compose_shared.dart'; import 'package:island/widgets/post/compose_state_utils.dart'; -import 'package:island/widgets/post/compose_submit_utils.dart'; import 'package:material_symbols_icons/symbols.dart'; /// A dialog that wraps PostComposeCard for easy use in dialogs. @@ -106,16 +104,11 @@ class PostComposeSheet extends HookConsumerWidget { // Helper methods for actions void showSettingsSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - useRootNavigator: true, - builder: (context) => ComposeSettingsSheet(state: state), - ); + ComposeLogic.showSettingsSheet(context, state); } Future performSubmit() async { - await ComposeSubmitUtils.performSubmit( + await ComposeLogic.performSubmit( ref, state, context, diff --git a/lib/widgets/post/compose_submit_utils.dart b/lib/widgets/post/compose_submit_utils.dart deleted file mode 100644 index 3e55a0b4..00000000 --- a/lib/widgets/post/compose_submit_utils.dart +++ /dev/null @@ -1,145 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:island/models/post.dart'; -import 'package:island/pods/network.dart'; -import 'package:island/services/event_bus.dart'; -import 'package:island/widgets/post/compose_settings_sheet.dart'; -import 'package:island/widgets/post/compose_shared.dart'; - -/// Utility class for common compose submit logic. -class ComposeSubmitUtils { - /// Performs the submit action for posts. - static Future performSubmit( - WidgetRef ref, - ComposeState state, - BuildContext context, { - SnPost? originalPost, - SnPost? repliedPost, - SnPost? forwardedPost, - required Function() onSuccess, - }) async { - if (state.submitting.value) { - throw Exception('Already submitting'); - } - - // Don't submit empty posts (no content and no attachments) - final hasContent = - state.titleController.text.trim().isNotEmpty || - state.descriptionController.text.trim().isNotEmpty || - state.contentController.text.trim().isNotEmpty; - final hasAttachments = state.attachments.value.isNotEmpty; - - if (!hasContent && !hasAttachments) { - // Show error message if context is mounted - if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('postContentEmpty'))); - } - throw Exception('Post content is empty'); // Don't submit empty posts - } - - try { - state.submitting.value = true; - - // Upload any local attachments first - await Future.wait( - state.attachments.value - .asMap() - .entries - .where((entry) => entry.value.isOnDevice) - .map( - (entry) => ComposeLogic.uploadAttachment(ref, state, entry.key), - ), - ); - - // Prepare API request - final client = ref.read(apiClientProvider); - final isNewPost = originalPost == null; - final endpoint = - '/sphere${isNewPost ? '/posts' : '/posts/${originalPost.id}'}'; - - // Create request payload - final payload = { - 'title': state.titleController.text, - 'description': state.descriptionController.text, - 'content': state.contentController.text, - if (state.slugController.text.isNotEmpty) - 'slug': state.slugController.text, - 'visibility': state.visibility.value, - 'attachments': - state.attachments.value - .where((e) => e.isOnCloud) - .map((e) => e.data.id) - .toList(), - 'type': state.postType, - if (repliedPost != null) 'replied_post_id': repliedPost.id, - if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id, - 'tags': state.tags.value, - 'categories': state.categories.value.map((e) => e.slug).toList(), - if (state.realm.value != null) 'realm_id': state.realm.value?.id, - if (state.pollId.value != null) 'poll_id': state.pollId.value, - if (state.embedView.value != null) - 'embed_view': state.embedView.value!.toJson(), - }; - - // Send request - final response = await client.request( - endpoint, - queryParameters: {'pub': state.currentPublisher.value?.name}, - data: payload, - options: Options(method: isNewPost ? 'POST' : 'PATCH'), - ); - - // Parse the response into a SnPost - final post = SnPost.fromJson(response.data); - - // Call the success callback - onSuccess(); - eventBus.fire(PostCreatedEvent()); - - return post; - } catch (err) { - // Show error message if context is mounted - if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Error: $err'))); - } - rethrow; - } finally { - state.submitting.value = false; - } - } - - /// Shows the settings sheet modal. - static void showSettingsSheet(BuildContext context, ComposeState state) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => ComposeSettingsSheet(state: state), - ); - } - - /// Handles keyboard press events for compose shortcuts. - static void handleKeyPress( - KeyEvent event, - ComposeState state, - WidgetRef ref, - BuildContext context, { - SnPost? originalPost, - SnPost? repliedPost, - SnPost? forwardedPost, - }) { - ComposeLogic.handleKeyPress( - event, - state, - ref, - context, - originalPost: originalPost, - repliedPost: repliedPost, - forwardedPost: forwardedPost, - ); - } -} diff --git a/lib/widgets/post/compose_toolbar.dart b/lib/widgets/post/compose_toolbar.dart index ba97c8a9..af8ba5bf 100644 --- a/lib/widgets/post/compose_toolbar.dart +++ b/lib/widgets/post/compose_toolbar.dart @@ -54,6 +54,10 @@ class ComposeToolbar extends HookConsumerWidget { ComposeLogic.pickPoll(ref, state, context); } + void pickFund() { + ComposeLogic.pickFund(ref, state, context); + } + void showEmbedSheet() { showModalBottomSheet( context: context, @@ -143,6 +147,29 @@ class ComposeToolbar extends HookConsumerWidget { ); }, ), + // Fund button with visual state when a fund is linked + ListenableBuilder( + listenable: state.fundId, + builder: (context, _) { + return IconButton( + onPressed: pickFund, + icon: const Icon(Symbols.account_balance_wallet), + tooltip: 'fund'.tr(), + color: colorScheme.primary, + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -2, + ), + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll( + state.fundId.value != null + ? colorScheme.primary.withOpacity(0.15) + : null, + ), + ), + ); + }, + ), // Embed button with visual state when embed is present ListenableBuilder( listenable: state.embedView, @@ -252,6 +279,25 @@ class ComposeToolbar extends HookConsumerWidget { ); }, ), + // Fund button with visual state when a fund is linked + ListenableBuilder( + listenable: state.fundId, + builder: (context, _) { + return IconButton( + onPressed: pickFund, + icon: const Icon(Symbols.account_balance_wallet), + tooltip: 'fund'.tr(), + color: colorScheme.primary, + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll( + state.fundId.value != null + ? colorScheme.primary.withOpacity(0.15) + : null, + ), + ), + ); + }, + ), // Embed button with visual state when embed is present ListenableBuilder( listenable: state.embedView,