♻️ Refactor post
This commit is contained in:
@@ -77,16 +77,32 @@ class ChatInput extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> handlePaste() async {
|
Future<void> handlePaste() async {
|
||||||
final clipboard = await Pasteboard.image;
|
final image = await Pasteboard.image;
|
||||||
if (clipboard == null) return;
|
if (image != null) {
|
||||||
|
onAttachmentsChanged([
|
||||||
|
...attachments,
|
||||||
|
UniversalFile(
|
||||||
|
data: XFile.fromData(image, mimeType: "image/jpeg"),
|
||||||
|
type: UniversalFileType.image,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onAttachmentsChanged([
|
final textData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
...attachments,
|
if (textData != null && textData.text != null) {
|
||||||
UniversalFile(
|
final text = messageController.text;
|
||||||
data: XFile.fromData(clipboard, mimeType: "image/jpeg"),
|
final selection = messageController.selection;
|
||||||
type: UniversalFileType.image,
|
final start = selection.start >= 0 ? selection.start : text.length;
|
||||||
),
|
final end = selection.end >= 0 ? selection.end : text.length;
|
||||||
]);
|
final newText = text.replaceRange(start, end, textData.text!);
|
||||||
|
messageController.value = TextEditingValue(
|
||||||
|
text: newText,
|
||||||
|
selection: TextSelection.collapsed(
|
||||||
|
offset: start + textData.text!.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inputFocusNode.onKeyEvent = (node, event) {
|
inputFocusNode.onKeyEvent = (node, event) {
|
||||||
|
202
lib/widgets/post/compose_attachments.dart
Normal file
202
lib/widgets/post/compose_attachments.dart
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:island/models/file.dart';
|
||||||
|
import 'package:island/services/responsive.dart';
|
||||||
|
import 'package:island/widgets/attachment_uploader.dart';
|
||||||
|
import 'package:island/widgets/content/attachment_preview.dart';
|
||||||
|
import 'package:island/widgets/post/compose_shared.dart';
|
||||||
|
|
||||||
|
/// A reusable widget for displaying attachments in compose screens.
|
||||||
|
/// Supports both grid and list layouts based on screen width.
|
||||||
|
class ComposeAttachments extends ConsumerWidget {
|
||||||
|
final ComposeState state;
|
||||||
|
final bool isCompact;
|
||||||
|
|
||||||
|
const ComposeAttachments({
|
||||||
|
super.key,
|
||||||
|
required this.state,
|
||||||
|
this.isCompact = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
if (state.attachments.value.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final isWide = isWideScreen(context);
|
||||||
|
return isWide ? _buildWideGrid(ref) : _buildNarrowList(ref);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWideGrid(WidgetRef ref) {
|
||||||
|
return GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
),
|
||||||
|
itemCount: state.attachments.value.length,
|
||||||
|
itemBuilder: (context, idx) {
|
||||||
|
return _buildAttachmentItem(ref, idx, isCompact: true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNarrowList(WidgetRef ref) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
for (var idx = 0; idx < state.attachments.value.length; idx++)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: _buildAttachmentItem(ref, idx, isCompact: false),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAttachmentItem(
|
||||||
|
WidgetRef ref,
|
||||||
|
int idx, {
|
||||||
|
required bool isCompact,
|
||||||
|
}) {
|
||||||
|
final progressMap = state.attachmentProgress.value;
|
||||||
|
return AttachmentPreview(
|
||||||
|
isCompact: isCompact,
|
||||||
|
item: state.attachments.value[idx],
|
||||||
|
progress: progressMap[idx],
|
||||||
|
onRequestUpload: () async {
|
||||||
|
final config = await showModalBottomSheet<AttachmentUploadConfig>(
|
||||||
|
context: ref.context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useRootNavigator: true,
|
||||||
|
builder:
|
||||||
|
(context) =>
|
||||||
|
AttachmentUploaderSheet(ref: ref, state: state, index: idx),
|
||||||
|
);
|
||||||
|
if (config != null) {
|
||||||
|
await ComposeLogic.uploadAttachment(
|
||||||
|
ref,
|
||||||
|
state,
|
||||||
|
idx,
|
||||||
|
poolId: config.poolId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
|
||||||
|
onUpdate: (value) => ComposeLogic.updateAttachment(state, value, idx),
|
||||||
|
onMove: (delta) {
|
||||||
|
state.attachments.value = ComposeLogic.moveAttachment(
|
||||||
|
state.attachments.value,
|
||||||
|
idx,
|
||||||
|
delta,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A specialized attachment widget for article compose with expansion tile.
|
||||||
|
class ArticleComposeAttachments extends ConsumerWidget {
|
||||||
|
final ComposeState state;
|
||||||
|
|
||||||
|
const ArticleComposeAttachments({super.key, required this.state});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return ValueListenableBuilder<List<UniversalFile>>(
|
||||||
|
valueListenable: state.attachments,
|
||||||
|
builder: (context, attachments, _) {
|
||||||
|
if (attachments.isEmpty) return const SizedBox.shrink();
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||||
|
child: ExpansionTile(
|
||||||
|
initiallyExpanded: true,
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('attachments'),
|
||||||
|
Text(
|
||||||
|
'articleAttachmentHint',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
ValueListenableBuilder<Map<int, double>>(
|
||||||
|
valueListenable: state.attachmentProgress,
|
||||||
|
builder: (context, progressMap, _) {
|
||||||
|
return Wrap(
|
||||||
|
runSpacing: 8,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
for (var idx = 0; idx < attachments.length; idx++)
|
||||||
|
SizedBox(
|
||||||
|
width: 180,
|
||||||
|
height: 180,
|
||||||
|
child: AttachmentPreview(
|
||||||
|
isCompact: true,
|
||||||
|
item: attachments[idx],
|
||||||
|
progress: progressMap[idx],
|
||||||
|
onRequestUpload: () async {
|
||||||
|
final config = await showModalBottomSheet<
|
||||||
|
AttachmentUploadConfig
|
||||||
|
>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder:
|
||||||
|
(context) => AttachmentUploaderSheet(
|
||||||
|
ref: ref,
|
||||||
|
state: state,
|
||||||
|
index: idx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (config != null) {
|
||||||
|
await ComposeLogic.uploadAttachment(
|
||||||
|
ref,
|
||||||
|
state,
|
||||||
|
idx,
|
||||||
|
poolId: config.poolId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUpdate:
|
||||||
|
(value) => ComposeLogic.updateAttachment(
|
||||||
|
state,
|
||||||
|
value,
|
||||||
|
idx,
|
||||||
|
),
|
||||||
|
onDelete:
|
||||||
|
() => ComposeLogic.deleteAttachment(
|
||||||
|
ref,
|
||||||
|
state,
|
||||||
|
idx,
|
||||||
|
),
|
||||||
|
onInsert:
|
||||||
|
() => ComposeLogic.insertAttachment(
|
||||||
|
ref,
|
||||||
|
state,
|
||||||
|
idx,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,4 +1,3 @@
|
|||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
@@ -8,19 +7,20 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:island/models/file.dart';
|
import 'package:island/models/file.dart';
|
||||||
import 'package:island/models/post.dart';
|
import 'package:island/models/post.dart';
|
||||||
import 'package:island/models/publisher.dart';
|
import 'package:island/models/publisher.dart';
|
||||||
import 'package:island/pods/network.dart';
|
|
||||||
import 'package:island/screens/creators/publishers_form.dart';
|
import 'package:island/screens/creators/publishers_form.dart';
|
||||||
import 'package:island/screens/posts/compose.dart';
|
import 'package:island/screens/posts/compose.dart';
|
||||||
import 'package:island/services/compose_storage_db.dart';
|
import 'package:island/services/compose_storage_db.dart';
|
||||||
import 'package:island/services/responsive.dart';
|
|
||||||
import 'package:island/widgets/attachment_uploader.dart';
|
|
||||||
import 'package:island/widgets/content/attachment_preview.dart';
|
|
||||||
import 'package:island/widgets/content/cloud_files.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_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/post_item.dart';
|
||||||
import 'package:island/widgets/post/publishers_modal.dart';
|
import 'package:island/widgets/post/publishers_modal.dart';
|
||||||
import 'package:island/widgets/post/compose_settings_sheet.dart';
|
|
||||||
import 'package:island/widgets/post/compose_toolbar.dart';
|
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
|
||||||
@@ -54,7 +54,6 @@ class PostComposeCard extends HookConsumerWidget {
|
|||||||
initialState?.forwardingTo ?? originalPost?.forwardedPost;
|
initialState?.forwardingTo ?? originalPost?.forwardedPost;
|
||||||
|
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final publishers = ref.watch(publishersManagedProvider);
|
|
||||||
|
|
||||||
// Capture the notifier to avoid using ref after dispose
|
// Capture the notifier to avoid using ref after dispose
|
||||||
final notifier = ref.read(composeStorageNotifierProvider.notifier);
|
final notifier = ref.read(composeStorageNotifierProvider.notifier);
|
||||||
@@ -92,31 +91,9 @@ class PostComposeCard extends HookConsumerWidget {
|
|||||||
return null;
|
return null;
|
||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
// Initialize publisher once when data is available
|
// Use shared state management utilities
|
||||||
useEffect(() {
|
ComposeStateUtils.usePublisherInitialization(ref, state);
|
||||||
if (publishers.value?.isNotEmpty ?? false) {
|
ComposeStateUtils.useInitialStateLoader(state, initialState);
|
||||||
if (state.currentPublisher.value == null) {
|
|
||||||
state.currentPublisher.value = publishers.value!.first;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [publishers]);
|
|
||||||
|
|
||||||
// Load initial state if provided
|
|
||||||
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]);
|
|
||||||
|
|
||||||
// Dispose state when widget is disposed
|
// Dispose state when widget is disposed
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
@@ -154,43 +131,6 @@ class PostComposeCard extends HookConsumerWidget {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Reset form to clean state for new composition
|
|
||||||
void resetForm() {
|
|
||||||
// Clear text fields
|
|
||||||
state.titleController.clear();
|
|
||||||
state.descriptionController.clear();
|
|
||||||
state.contentController.clear();
|
|
||||||
state.slugController.clear();
|
|
||||||
|
|
||||||
// Reset visibility to default (0 = public)
|
|
||||||
state.visibility.value = 0;
|
|
||||||
|
|
||||||
// Clear attachments
|
|
||||||
state.attachments.value = [];
|
|
||||||
|
|
||||||
// Clear attachment progress
|
|
||||||
state.attachmentProgress.value = {};
|
|
||||||
|
|
||||||
// Clear tags
|
|
||||||
state.tagsController.clearTags();
|
|
||||||
|
|
||||||
// Clear categories
|
|
||||||
state.categories.value = [];
|
|
||||||
|
|
||||||
// Clear embed view
|
|
||||||
state.embedView.value = null;
|
|
||||||
|
|
||||||
// Clear poll
|
|
||||||
state.pollId.value = null;
|
|
||||||
|
|
||||||
// Clear realm
|
|
||||||
state.realm.value = null;
|
|
||||||
|
|
||||||
// Generate new draft ID for fresh composition
|
|
||||||
// Note: We don't recreate the entire state, just reset the fields
|
|
||||||
// The existing state object is reused for continuity
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper methods
|
// Helper methods
|
||||||
void showSettingsSheet() {
|
void showSettingsSheet() {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
@@ -202,211 +142,28 @@ class PostComposeCard extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> performSubmit() async {
|
Future<void> performSubmit() async {
|
||||||
if (state.submitting.value) return;
|
await ComposeSubmitUtils.performSubmit(
|
||||||
|
ref,
|
||||||
|
state,
|
||||||
|
context,
|
||||||
|
originalPost: originalPost,
|
||||||
|
repliedPost: repliedPost,
|
||||||
|
forwardedPost: forwardedPost,
|
||||||
|
onSuccess: () {
|
||||||
|
// Mark as submitted
|
||||||
|
submitted.value = true;
|
||||||
|
|
||||||
// Don't submit empty posts (no content and no attachments)
|
// Delete draft after successful submission
|
||||||
final hasContent =
|
ref
|
||||||
state.titleController.text.trim().isNotEmpty ||
|
.read(composeStorageNotifierProvider.notifier)
|
||||||
state.descriptionController.text.trim().isNotEmpty ||
|
.deleteDraft(state.draftId);
|
||||||
state.contentController.text.trim().isNotEmpty;
|
|
||||||
final hasAttachments = state.attachments.value.isNotEmpty;
|
|
||||||
|
|
||||||
if (!hasContent && !hasAttachments) {
|
// Reset the form for new composition
|
||||||
// Show error message if context is mounted
|
ComposeStateUtils.resetForm(state);
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text('postContentEmpty'.tr())));
|
|
||||||
}
|
|
||||||
return; // 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.tagsController.getTags,
|
|
||||||
'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'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create the post object from the response for the callback
|
|
||||||
final post = SnPost.fromJson(response.data);
|
|
||||||
|
|
||||||
// Mark as submitted
|
|
||||||
submitted.value = true;
|
|
||||||
|
|
||||||
// Delete draft after successful submission
|
|
||||||
await ref
|
|
||||||
.read(composeStorageNotifierProvider.notifier)
|
|
||||||
.deleteDraft(state.draftId);
|
|
||||||
|
|
||||||
// Reset the form for new composition
|
|
||||||
resetForm();
|
|
||||||
|
|
||||||
// Call the success callback with the created/updated post
|
|
||||||
onSubmit?.call(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildWideAttachmentGrid() {
|
|
||||||
return GridView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: 2,
|
|
||||||
crossAxisSpacing: 8,
|
|
||||||
mainAxisSpacing: 8,
|
|
||||||
),
|
|
||||||
itemCount: state.attachments.value.length,
|
|
||||||
itemBuilder: (context, idx) {
|
|
||||||
final progressMap = state.attachmentProgress.value;
|
|
||||||
return AttachmentPreview(
|
|
||||||
isCompact: true,
|
|
||||||
item: state.attachments.value[idx],
|
|
||||||
progress: progressMap[idx],
|
|
||||||
onRequestUpload: () async {
|
|
||||||
final config = await showModalBottomSheet<AttachmentUploadConfig>(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
useRootNavigator: true,
|
|
||||||
builder:
|
|
||||||
(context) => AttachmentUploaderSheet(
|
|
||||||
ref: ref,
|
|
||||||
state: state,
|
|
||||||
index: idx,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (config != null) {
|
|
||||||
await ComposeLogic.uploadAttachment(
|
|
||||||
ref,
|
|
||||||
state,
|
|
||||||
idx,
|
|
||||||
poolId: config.poolId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
|
|
||||||
onUpdate:
|
|
||||||
(value) => ComposeLogic.updateAttachment(state, value, idx),
|
|
||||||
onMove: (delta) {
|
|
||||||
state.attachments.value = ComposeLogic.moveAttachment(
|
|
||||||
state.attachments.value,
|
|
||||||
idx,
|
|
||||||
delta,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildNarrowAttachmentList() {
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
for (var idx = 0; idx < state.attachments.value.length; idx++)
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: () {
|
|
||||||
final progressMap = state.attachmentProgress.value;
|
|
||||||
return AttachmentPreview(
|
|
||||||
item: state.attachments.value[idx],
|
|
||||||
progress: progressMap[idx],
|
|
||||||
onRequestUpload: () async {
|
|
||||||
final config =
|
|
||||||
await showModalBottomSheet<AttachmentUploadConfig>(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
useRootNavigator: true,
|
|
||||||
builder:
|
|
||||||
(context) => AttachmentUploaderSheet(
|
|
||||||
ref: ref,
|
|
||||||
state: state,
|
|
||||||
index: idx,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (config != null) {
|
|
||||||
await ComposeLogic.uploadAttachment(
|
|
||||||
ref,
|
|
||||||
state,
|
|
||||||
idx,
|
|
||||||
poolId: config.poolId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDelete:
|
|
||||||
() => ComposeLogic.deleteAttachment(ref, state, idx),
|
|
||||||
onUpdate:
|
|
||||||
(value) =>
|
|
||||||
ComposeLogic.updateAttachment(state, value, idx),
|
|
||||||
onMove: (delta) {
|
|
||||||
state.attachments.value = ComposeLogic.moveAttachment(
|
|
||||||
state.attachments.value,
|
|
||||||
idx,
|
|
||||||
delta,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
@@ -489,7 +246,58 @@ class PostComposeCard extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Info banner (reply/forward)
|
// Info banner (reply/forward)
|
||||||
_buildInfoBanner(context),
|
ComposeInfoBanner(
|
||||||
|
originalPost: originalPost,
|
||||||
|
replyingTo: repliedPost,
|
||||||
|
forwardingTo: forwardedPost,
|
||||||
|
onReferencePostTap: (context, post) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useRootNavigator: 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
|
// Main content area
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -557,114 +365,44 @@ class PostComposeCard extends HookConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (state.currentPublisher.value == null)
|
ComposeFormFields(
|
||||||
Container(
|
state: state,
|
||||||
padding: const EdgeInsets.all(12),
|
showPublisherAvatar: false,
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
onPublisherTap: () {
|
||||||
decoration: BoxDecoration(
|
if (state.currentPublisher.value == null) {
|
||||||
color:
|
// No publisher loaded, guide user to create one
|
||||||
theme.colorScheme.surfaceContainerHigh,
|
if (isInDialog) {
|
||||||
borderRadius: BorderRadius.circular(8),
|
Navigator.of(context).pop();
|
||||||
),
|
}
|
||||||
child: Row(
|
context.pushNamed('creatorNew').then((
|
||||||
crossAxisAlignment:
|
value,
|
||||||
CrossAxisAlignment.start,
|
) {
|
||||||
children: [
|
if (value != null) {
|
||||||
Icon(
|
state.currentPublisher.value =
|
||||||
Symbols.info,
|
value as SnPublisher;
|
||||||
size: 16,
|
ref.invalidate(
|
||||||
color: theme.colorScheme.primary,
|
publishersManagedProvider,
|
||||||
),
|
);
|
||||||
const Gap(8),
|
}
|
||||||
Expanded(
|
});
|
||||||
child: Text(
|
} else {
|
||||||
'Tap the avatar to create a publisher and start composing.',
|
// Show modal to select from existing publishers
|
||||||
style: theme.textTheme.bodySmall
|
showModalBottomSheet(
|
||||||
?.copyWith(
|
isScrollControlled: true,
|
||||||
color:
|
useRootNavigator: true,
|
||||||
theme
|
context: context,
|
||||||
.colorScheme
|
builder:
|
||||||
.onSurfaceVariant,
|
(context) => const PublisherModal(),
|
||||||
),
|
).then((value) {
|
||||||
),
|
if (value != null) {
|
||||||
),
|
state.currentPublisher.value = value;
|
||||||
],
|
}
|
||||||
),
|
});
|
||||||
),
|
}
|
||||||
TextField(
|
},
|
||||||
controller: state.titleController,
|
|
||||||
enabled: state.currentPublisher.value != null,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'postTitle'.tr(),
|
|
||||||
border: InputBorder.none,
|
|
||||||
isCollapsed: true,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 8,
|
|
||||||
horizontal: 8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
style: theme.textTheme.titleMedium,
|
|
||||||
onTapOutside:
|
|
||||||
(_) =>
|
|
||||||
FocusManager.instance.primaryFocus
|
|
||||||
?.unfocus(),
|
|
||||||
),
|
),
|
||||||
TextField(
|
|
||||||
controller: state.descriptionController,
|
|
||||||
enabled: state.currentPublisher.value != null,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'postDescription'.tr(),
|
|
||||||
border: InputBorder.none,
|
|
||||||
isCollapsed: true,
|
|
||||||
contentPadding: const EdgeInsets.fromLTRB(
|
|
||||||
8,
|
|
||||||
4,
|
|
||||||
8,
|
|
||||||
12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
style: theme.textTheme.bodyMedium,
|
|
||||||
minLines: 1,
|
|
||||||
maxLines: 3,
|
|
||||||
onTapOutside:
|
|
||||||
(_) =>
|
|
||||||
FocusManager.instance.primaryFocus
|
|
||||||
?.unfocus(),
|
|
||||||
),
|
|
||||||
TextField(
|
|
||||||
controller: state.contentController,
|
|
||||||
enabled: state.currentPublisher.value != null,
|
|
||||||
style: theme.textTheme.bodyMedium,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
border: InputBorder.none,
|
|
||||||
hintText: 'postContent'.tr(),
|
|
||||||
isCollapsed: true,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 8,
|
|
||||||
horizontal: 8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
maxLines: null,
|
|
||||||
onTapOutside:
|
|
||||||
(_) =>
|
|
||||||
FocusManager.instance.primaryFocus
|
|
||||||
?.unfocus(),
|
|
||||||
),
|
|
||||||
|
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
|
ComposeAttachments(state: state, isCompact: true),
|
||||||
// Attachments preview
|
|
||||||
if (state.attachments.value.isNotEmpty)
|
|
||||||
LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final isWide = isWideScreen(context);
|
|
||||||
return isWide
|
|
||||||
? buildWideAttachmentGrid()
|
|
||||||
: buildNarrowAttachmentList();
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else
|
|
||||||
const SizedBox.shrink(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -695,262 +433,4 @@ class PostComposeCard extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoBanner(BuildContext context) {
|
|
||||||
final effectiveRepliedPost =
|
|
||||||
initialState?.replyingTo ?? originalPost?.repliedPost;
|
|
||||||
final effectiveForwardedPost =
|
|
||||||
initialState?.forwardingTo ?? originalPost?.forwardedPost;
|
|
||||||
|
|
||||||
// Show editing banner when editing a post
|
|
||||||
if (originalPost != null) {
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Symbols.edit,
|
|
||||||
size: 16,
|
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
const Gap(8),
|
|
||||||
Text(
|
|
||||||
'postEditing'.tr(),
|
|
||||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
).padding(horizontal: 16, vertical: 8),
|
|
||||||
),
|
|
||||||
// Show reply/forward banners below editing banner if they exist
|
|
||||||
if (effectiveRepliedPost != null)
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Symbols.reply, size: 16),
|
|
||||||
const Gap(4),
|
|
||||||
Text(
|
|
||||||
'postReplyingTo'.tr(),
|
|
||||||
style: Theme.of(context).textTheme.labelMedium,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Gap(8),
|
|
||||||
_buildCompactReferencePost(context, effectiveRepliedPost),
|
|
||||||
],
|
|
||||||
).padding(all: 16),
|
|
||||||
),
|
|
||||||
if (effectiveForwardedPost != null)
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Symbols.forward, size: 16),
|
|
||||||
const Gap(4),
|
|
||||||
Text(
|
|
||||||
'postForwardingTo'.tr(),
|
|
||||||
style: Theme.of(context).textTheme.labelMedium,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Gap(8),
|
|
||||||
_buildCompactReferencePost(context, effectiveForwardedPost),
|
|
||||||
],
|
|
||||||
).padding(all: 16),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show banner for replies
|
|
||||||
if (effectiveRepliedPost != null) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Symbols.reply, size: 16),
|
|
||||||
const Gap(4),
|
|
||||||
Text(
|
|
||||||
'postReplyingTo'.tr(),
|
|
||||||
style: Theme.of(context).textTheme.labelMedium,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Gap(8),
|
|
||||||
_buildCompactReferencePost(context, effectiveRepliedPost),
|
|
||||||
],
|
|
||||||
).padding(all: 16),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show banner for forwards
|
|
||||||
if (effectiveForwardedPost != null) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Symbols.forward, size: 16),
|
|
||||||
const Gap(4),
|
|
||||||
Text(
|
|
||||||
'postForwardingTo'.tr(),
|
|
||||||
style: Theme.of(context).textTheme.labelMedium,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Gap(8),
|
|
||||||
_buildCompactReferencePost(context, effectiveForwardedPost),
|
|
||||||
],
|
|
||||||
).padding(all: 16),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildCompactReferencePost(BuildContext context, SnPost post) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
useRootNavigator: 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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.surface,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(
|
|
||||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
ProfilePictureWidget(
|
|
||||||
fileId: post.publisher.picture?.id,
|
|
||||||
radius: 16,
|
|
||||||
),
|
|
||||||
const Gap(8),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
post.publisher.nick,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (post.title?.isNotEmpty ?? false)
|
|
||||||
Text(
|
|
||||||
post.title!,
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
fontSize: 13,
|
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
if (post.content?.isNotEmpty ?? false)
|
|
||||||
Text(
|
|
||||||
post.content!,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
if (post.attachments.isNotEmpty)
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Symbols.attach_file,
|
|
||||||
size: 12,
|
|
||||||
color: Theme.of(context).colorScheme.secondary,
|
|
||||||
),
|
|
||||||
const Gap(4),
|
|
||||||
Text(
|
|
||||||
'postHasAttachments'.plural(post.attachments.length),
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context).colorScheme.secondary,
|
|
||||||
fontSize: 11,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Icon(
|
|
||||||
Symbols.open_in_full,
|
|
||||||
size: 16,
|
|
||||||
color: Theme.of(context).colorScheme.outline,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
217
lib/widgets/post/compose_form_fields.dart
Normal file
217
lib/widgets/post/compose_form_fields.dart
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:island/widgets/content/cloud_files.dart';
|
||||||
|
import 'package:island/widgets/post/compose_shared.dart';
|
||||||
|
|
||||||
|
/// A reusable widget for the form fields in compose screens.
|
||||||
|
/// Includes title, description, and content text fields.
|
||||||
|
class ComposeFormFields extends StatelessWidget {
|
||||||
|
final ComposeState state;
|
||||||
|
final bool enabled;
|
||||||
|
final bool showPublisherAvatar;
|
||||||
|
final VoidCallback? onPublisherTap;
|
||||||
|
|
||||||
|
const ComposeFormFields({
|
||||||
|
super.key,
|
||||||
|
required this.state,
|
||||||
|
this.enabled = true,
|
||||||
|
this.showPublisherAvatar = true,
|
||||||
|
this.onPublisherTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
spacing: 12,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Publisher profile picture
|
||||||
|
if (showPublisherAvatar)
|
||||||
|
GestureDetector(
|
||||||
|
child: ProfilePictureWidget(
|
||||||
|
fileId: state.currentPublisher.value?.picture?.id,
|
||||||
|
radius: 20,
|
||||||
|
fallbackIcon:
|
||||||
|
state.currentPublisher.value == null
|
||||||
|
? Icons.question_mark
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onTap: onPublisherTap,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Post content form
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (state.currentPublisher.value == null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info,
|
||||||
|
size: 16,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Tap the avatar to create a publisher and start composing.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Title field
|
||||||
|
TextField(
|
||||||
|
controller: state.titleController,
|
||||||
|
enabled: enabled && state.currentPublisher.value != null,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'postTitle',
|
||||||
|
border: InputBorder.none,
|
||||||
|
isCollapsed: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 8,
|
||||||
|
horizontal: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
onTapOutside:
|
||||||
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Description field
|
||||||
|
TextField(
|
||||||
|
controller: state.descriptionController,
|
||||||
|
enabled: enabled && state.currentPublisher.value != null,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'postDescription',
|
||||||
|
border: InputBorder.none,
|
||||||
|
isCollapsed: true,
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
|
||||||
|
),
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 3,
|
||||||
|
onTapOutside:
|
||||||
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Content field
|
||||||
|
TextField(
|
||||||
|
controller: state.contentController,
|
||||||
|
enabled: enabled && state.currentPublisher.value != null,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: InputBorder.none,
|
||||||
|
hintText: 'postContent',
|
||||||
|
isCollapsed: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 8,
|
||||||
|
horizontal: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
maxLines: null,
|
||||||
|
onTapOutside:
|
||||||
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A specialized form fields widget for article compose with expanded content field.
|
||||||
|
class ArticleComposeFormFields extends StatelessWidget {
|
||||||
|
final ComposeState state;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
const ArticleComposeFormFields({
|
||||||
|
super.key,
|
||||||
|
required this.state,
|
||||||
|
this.enabled = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Title field
|
||||||
|
TextField(
|
||||||
|
controller: state.titleController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'postTitle',
|
||||||
|
border: InputBorder.none,
|
||||||
|
isCollapsed: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 8,
|
||||||
|
horizontal: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
onTapOutside:
|
||||||
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Description field
|
||||||
|
TextField(
|
||||||
|
controller: state.descriptionController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'postDescription',
|
||||||
|
border: InputBorder.none,
|
||||||
|
isCollapsed: true,
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
|
||||||
|
),
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 3,
|
||||||
|
onTapOutside:
|
||||||
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Content field (expanded)
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: state.contentController,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: InputBorder.none,
|
||||||
|
hintText: 'postContent',
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 16,
|
||||||
|
horizontal: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
maxLines: null,
|
||||||
|
expands: true,
|
||||||
|
textAlignVertical: TextAlignVertical.top,
|
||||||
|
onTapOutside:
|
||||||
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
221
lib/widgets/post/compose_info_banner.dart
Normal file
221
lib/widgets/post/compose_info_banner.dart
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:island/models/post.dart';
|
||||||
|
import 'package:island/widgets/content/cloud_files.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
|
||||||
|
/// A reusable widget for displaying info banners in compose screens.
|
||||||
|
/// Shows editing, reply, and forward information.
|
||||||
|
class ComposeInfoBanner extends StatelessWidget {
|
||||||
|
final SnPost? originalPost;
|
||||||
|
final SnPost? replyingTo;
|
||||||
|
final SnPost? forwardingTo;
|
||||||
|
final Function(BuildContext, SnPost)? onReferencePostTap;
|
||||||
|
|
||||||
|
const ComposeInfoBanner({
|
||||||
|
super.key,
|
||||||
|
this.originalPost,
|
||||||
|
this.replyingTo,
|
||||||
|
this.forwardingTo,
|
||||||
|
this.onReferencePostTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final effectiveRepliedPost = replyingTo ?? originalPost?.repliedPost;
|
||||||
|
final effectiveForwardedPost = forwardingTo ?? originalPost?.forwardedPost;
|
||||||
|
|
||||||
|
// Show editing banner when editing a post
|
||||||
|
if (originalPost != null) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: Theme.of(context).colorScheme.primaryContainer,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.edit,
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Text(
|
||||||
|
'postEditing',
|
||||||
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 16, vertical: 8),
|
||||||
|
),
|
||||||
|
// Show reply/forward banners below editing banner if they exist
|
||||||
|
if (effectiveRepliedPost != null)
|
||||||
|
_buildReferenceBanner(
|
||||||
|
context,
|
||||||
|
effectiveRepliedPost,
|
||||||
|
Symbols.reply,
|
||||||
|
'postReplyingTo',
|
||||||
|
),
|
||||||
|
if (effectiveForwardedPost != null)
|
||||||
|
_buildReferenceBanner(
|
||||||
|
context,
|
||||||
|
effectiveForwardedPost,
|
||||||
|
Symbols.forward,
|
||||||
|
'postForwardingTo',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show banner for replies
|
||||||
|
if (effectiveRepliedPost != null) {
|
||||||
|
return _buildReferenceBanner(
|
||||||
|
context,
|
||||||
|
effectiveRepliedPost,
|
||||||
|
Symbols.reply,
|
||||||
|
'postReplyingTo',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show banner for forwards
|
||||||
|
if (effectiveForwardedPost != null) {
|
||||||
|
return _buildReferenceBanner(
|
||||||
|
context,
|
||||||
|
effectiveForwardedPost,
|
||||||
|
Symbols.forward,
|
||||||
|
'postForwardingTo',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildReferenceBanner(
|
||||||
|
BuildContext context,
|
||||||
|
SnPost post,
|
||||||
|
IconData icon,
|
||||||
|
String labelKey,
|
||||||
|
) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16),
|
||||||
|
const Gap(4),
|
||||||
|
Text(labelKey, style: Theme.of(context).textTheme.labelMedium),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
CompactReferencePost(
|
||||||
|
post: post,
|
||||||
|
onTap:
|
||||||
|
onReferencePostTap != null
|
||||||
|
? () => onReferencePostTap!(context, post)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(all: 16),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compact widget for displaying reference posts (replies/forwards).
|
||||||
|
class CompactReferencePost extends StatelessWidget {
|
||||||
|
final SnPost post;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const CompactReferencePost({super.key, required this.post, this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: theme.colorScheme.outline.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ProfilePictureWidget(
|
||||||
|
fileId: post.publisher.picture?.id,
|
||||||
|
radius: 16,
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
post.publisher.nick,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (post.title?.isNotEmpty ?? false)
|
||||||
|
Text(
|
||||||
|
post.title!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 13,
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (post.content?.isNotEmpty ?? false)
|
||||||
|
Text(
|
||||||
|
post.content!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (post.attachments.isNotEmpty)
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.attach_file,
|
||||||
|
size: 12,
|
||||||
|
color: theme.colorScheme.secondary,
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
Text(
|
||||||
|
'postHasAttachments'.plural(post.attachments.length),
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.secondary,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onTap != null)
|
||||||
|
Icon(
|
||||||
|
Symbols.open_in_full,
|
||||||
|
size: 16,
|
||||||
|
color: theme.colorScheme.outline,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
194
lib/widgets/post/compose_state_utils.dart
Normal file
194
lib/widgets/post/compose_state_utils.dart
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.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.dart';
|
||||||
|
import 'package:island/services/compose_storage_db.dart';
|
||||||
|
import 'package:island/widgets/post/compose_shared.dart';
|
||||||
|
|
||||||
|
/// Utility class for common compose state management logic.
|
||||||
|
class ComposeStateUtils {
|
||||||
|
/// Initializes publisher when data becomes available.
|
||||||
|
static void usePublisherInitialization(WidgetRef ref, ComposeState state) {
|
||||||
|
final publishers = ref.watch(publishersManagedProvider);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
if (publishers.value?.isNotEmpty ?? false) {
|
||||||
|
if (state.currentPublisher.value == null) {
|
||||||
|
state.currentPublisher.value = publishers.value!.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [publishers]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads initial state from provided parameters.
|
||||||
|
static void useInitialStateLoader(
|
||||||
|
ComposeState state,
|
||||||
|
PostComposeInitialState? initialState,
|
||||||
|
) {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads draft if available (for new posts without initial state).
|
||||||
|
static void useDraftLoader(
|
||||||
|
WidgetRef ref,
|
||||||
|
ComposeState state,
|
||||||
|
SnPost? originalPost,
|
||||||
|
SnPost? repliedPost,
|
||||||
|
SnPost? forwardedPost,
|
||||||
|
PostComposeInitialState? initialState,
|
||||||
|
) {
|
||||||
|
useEffect(() {
|
||||||
|
if (originalPost == null &&
|
||||||
|
forwardedPost == null &&
|
||||||
|
repliedPost == 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;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles auto-save functionality for new posts.
|
||||||
|
static void useAutoSave(WidgetRef ref, ComposeState state, bool isNewPost) {
|
||||||
|
useEffect(() {
|
||||||
|
if (isNewPost) {
|
||||||
|
state.startAutoSave(ref);
|
||||||
|
}
|
||||||
|
return () => state.stopAutoSave();
|
||||||
|
}, [state]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles disposal and draft saving logic.
|
||||||
|
static void useDisposalHandler(
|
||||||
|
WidgetRef ref,
|
||||||
|
ComposeState state,
|
||||||
|
SnPost? originalPost,
|
||||||
|
bool submitted,
|
||||||
|
) {
|
||||||
|
useEffect(() {
|
||||||
|
return () {
|
||||||
|
if (!submitted &&
|
||||||
|
originalPost == null &&
|
||||||
|
state.currentPublisher.value != null) {
|
||||||
|
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) {
|
||||||
|
final draft = SnPost(
|
||||||
|
id: state.draftId,
|
||||||
|
title: state.titleController.text,
|
||||||
|
description: state.descriptionController.text,
|
||||||
|
content: state.contentController.text,
|
||||||
|
visibility: state.visibility.value,
|
||||||
|
type: state.postType,
|
||||||
|
attachments:
|
||||||
|
state.attachments.value
|
||||||
|
.where((e) => e.isOnCloud)
|
||||||
|
.map((e) => e.data as SnCloudFile)
|
||||||
|
.toList(),
|
||||||
|
publisher: state.currentPublisher.value!,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
ref
|
||||||
|
.read(composeStorageNotifierProvider.notifier)
|
||||||
|
.saveDraft(draft)
|
||||||
|
.catchError((e) => debugPrint('Failed to save draft: $e'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ComposeLogic.dispose(state);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates and manages the state notifier for rebuilds.
|
||||||
|
static Listenable useStateNotifier(ComposeState state) {
|
||||||
|
return useMemoized(
|
||||||
|
() => Listenable.merge([
|
||||||
|
state.titleController,
|
||||||
|
state.descriptionController,
|
||||||
|
state.contentController,
|
||||||
|
state.visibility,
|
||||||
|
state.attachments,
|
||||||
|
state.attachmentProgress,
|
||||||
|
state.currentPublisher,
|
||||||
|
state.submitting,
|
||||||
|
]),
|
||||||
|
[state],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets form to clean state for new composition.
|
||||||
|
static void resetForm(ComposeState state) {
|
||||||
|
// Clear text fields
|
||||||
|
state.titleController.clear();
|
||||||
|
state.descriptionController.clear();
|
||||||
|
state.contentController.clear();
|
||||||
|
state.slugController.clear();
|
||||||
|
|
||||||
|
// Reset visibility to default (0 = public)
|
||||||
|
state.visibility.value = 0;
|
||||||
|
|
||||||
|
// Clear attachments
|
||||||
|
state.attachments.value = [];
|
||||||
|
|
||||||
|
// Clear attachment progress
|
||||||
|
state.attachmentProgress.value = {};
|
||||||
|
|
||||||
|
// Clear tags
|
||||||
|
state.tagsController.clearTags();
|
||||||
|
|
||||||
|
// Clear categories
|
||||||
|
state.categories.value = [];
|
||||||
|
|
||||||
|
// Clear embed view
|
||||||
|
state.embedView.value = null;
|
||||||
|
|
||||||
|
// Clear poll
|
||||||
|
state.pollId.value = null;
|
||||||
|
|
||||||
|
// Clear realm
|
||||||
|
state.realm.value = null;
|
||||||
|
|
||||||
|
// Generate new draft ID for fresh composition
|
||||||
|
// Note: We don't recreate the entire state, just reset the fields
|
||||||
|
// The existing state object is reused for continuity
|
||||||
|
}
|
||||||
|
}
|
136
lib/widgets/post/compose_submit_utils.dart
Normal file
136
lib/widgets/post/compose_submit_utils.dart
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
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/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<void> performSubmit(
|
||||||
|
WidgetRef ref,
|
||||||
|
ComposeState state,
|
||||||
|
BuildContext context, {
|
||||||
|
SnPost? originalPost,
|
||||||
|
SnPost? repliedPost,
|
||||||
|
SnPost? forwardedPost,
|
||||||
|
required VoidCallback onSuccess,
|
||||||
|
}) async {
|
||||||
|
if (state.submitting.value) return;
|
||||||
|
|
||||||
|
// 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')));
|
||||||
|
}
|
||||||
|
return; // 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.tagsController.getTags,
|
||||||
|
'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
|
||||||
|
client.request(
|
||||||
|
endpoint,
|
||||||
|
queryParameters: {'pub': state.currentPublisher.value?.name},
|
||||||
|
data: payload,
|
||||||
|
options: Options(method: isNewPost ? 'POST' : 'PATCH'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Call the success callback with the created/updated post
|
||||||
|
onSuccess();
|
||||||
|
} 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user