♻️ No longer two submit post function

This commit is contained in:
2025-11-16 23:54:50 +08:00
parent 3f9881e943
commit 7edc02a1d3
6 changed files with 162 additions and 198 deletions

View File

@@ -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<void> performSubmit() async {
await ComposeSubmitUtils.performSubmit(
await ComposeLogic.performSubmit(
ref,
composeState,
context,

View File

@@ -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<void> performSubmit() async {
await ComposeSubmitUtils.performSubmit(
await ComposeLogic.performSubmit(
ref,
state,
context,

View File

@@ -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<String?> pollId;
// Linked fund id for this compose session (nullable)
final ValueNotifier<String?> fundId;
Timer? _autoSaveTimer;
ComposeState({
@@ -63,7 +67,9 @@ class ComposeState {
required this.draftId,
this.postType = 0,
String? pollId,
}) : pollId = ValueNotifier<String?>(pollId);
String? fundId,
}) : pollId = ValueNotifier<String?>(pollId),
fundId = ValueNotifier<String?>(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<void> performAction(
static Future<void> 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<SnPost> 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<void> 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<void> 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();
}
}

View File

@@ -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<void> performSubmit() async {
await ComposeSubmitUtils.performSubmit(
await ComposeLogic.performSubmit(
ref,
state,
context,

View File

@@ -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<SnPost> 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,
);
}
}

View File

@@ -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,