diff --git a/lib/services/compose_storage_db.dart b/lib/services/compose_storage_db.dart index b423e175..5b740e84 100644 --- a/lib/services/compose_storage_db.dart +++ b/lib/services/compose_storage_db.dart @@ -32,7 +32,6 @@ class ComposeStorageNotifier extends _$ComposeStorageNotifier { Future saveDraft(SnPost draft) async { final updatedDraft = draft.copyWith(updatedAt: DateTime.now()); - state = {...state, updatedDraft.id: updatedDraft}; try { final database = ref.read(databaseProvider); @@ -48,11 +47,11 @@ class ComposeStorageNotifier extends _$ComposeStorageNotifier { postData: Value(jsonEncode(updatedDraft.toJson())), ), ); + // Update state after successful database operation, delayed to avoid widget building issues + Future(() { + state = {...state, updatedDraft.id: updatedDraft}; + }); } catch (e) { - // Revert state on error - final newState = Map.from(state); - newState.remove(updatedDraft.id); - state = newState; rethrow; } } diff --git a/lib/widgets/post/compose_card.dart b/lib/widgets/post/compose_card.dart index a54f13cd..9113d511 100644 --- a/lib/widgets/post/compose_card.dart +++ b/lib/widgets/post/compose_card.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/file.dart'; import 'package:island/models/post.dart'; import 'package:island/pods/network.dart'; import 'package:island/screens/creators/publishers.dart'; @@ -31,7 +32,7 @@ class PostComposeCard extends HookConsumerWidget { final Function(SnPost)? onSubmit; final Function(ComposeState)? onStateChanged; - const PostComposeCard({ + PostComposeCard({ super.key, this.originalPost, this.initialState, @@ -42,6 +43,8 @@ class PostComposeCard extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final submitted = useState(false); + final repliedPost = initialState?.replyingTo ?? originalPost?.repliedPost; final forwardedPost = initialState?.forwardingTo ?? originalPost?.forwardedPost; @@ -49,6 +52,9 @@ class PostComposeCard extends HookConsumerWidget { final theme = Theme.of(context); final publishers = ref.watch(publishersManagedProvider); + // Capture the notifier to avoid using ref after dispose + final notifier = ref.read(composeStorageNotifierProvider.notifier); + // Create compose state final state = useMemoized( () => ComposeLogic.createState( @@ -110,7 +116,38 @@ class PostComposeCard extends HookConsumerWidget { // Dispose state when widget is disposed useEffect(() { - return () => ComposeLogic.dispose(state); + return () { + if (!submitted.value && + 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(), + ); + notifier + .saveDraft(draft) + .catchError((e) => debugPrint('Failed to save draft: $e')); + } + } + ComposeLogic.dispose(state); + }; }, []); // Reset form to clean state for new composition @@ -235,6 +272,9 @@ class PostComposeCard extends HookConsumerWidget { // 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) diff --git a/lib/widgets/post/compose_dialog.dart b/lib/widgets/post/compose_dialog.dart index c0184d16..8b5a41ab 100644 --- a/lib/widgets/post/compose_dialog.dart +++ b/lib/widgets/post/compose_dialog.dart @@ -1,7 +1,10 @@ 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/posts/compose.dart'; +import 'package:island/services/compose_storage_db.dart'; import 'package:island/widgets/post/compose_card.dart'; /// A dialog that wraps PostComposeCard for easy use in dialogs. @@ -30,17 +33,159 @@ class PostComposeDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final drafts = ref.watch(composeStorageNotifierProvider); + final restoredInitialState = useState(null); + final prompted = useState(false); + + useEffect(() { + if (!prompted.value && originalPost == null && drafts.isNotEmpty) { + prompted.value = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _showRestoreDialog(ref, restoredInitialState); + }); + } + return null; + }, [drafts, prompted.value]); + return Dialog( insetPadding: const EdgeInsets.all(16), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 600, maxHeight: 700), child: PostComposeCard( originalPost: originalPost, - initialState: initialState, + initialState: restoredInitialState.value ?? initialState, onCancel: () => Navigator.of(context).pop(), onSubmit: (post) => Navigator.of(context).pop(post), ), ), ); } + + Future _showRestoreDialog( + WidgetRef ref, + ValueNotifier restoredInitialState, + ) async { + final drafts = ref.read(composeStorageNotifierProvider); + if (drafts.isNotEmpty) { + final latestDraft = drafts.values.last; + + final restore = await showDialog( + context: ref.context, + builder: + (context) => AlertDialog( + title: const Text('Restore Draft'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('A draft was found. Do you want to restore it?'), + const SizedBox(height: 16), + _buildCompactDraftPreview(context, latestDraft), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('No'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Yes'), + ), + ], + ), + ); + if (restore == true) { + // Delete the old draft + await ref + .read(composeStorageNotifierProvider.notifier) + .deleteDraft(latestDraft.id); + restoredInitialState.value = PostComposeInitialState( + title: latestDraft.title, + description: latestDraft.description, + content: latestDraft.content, + visibility: latestDraft.visibility, + attachments: + latestDraft.attachments + .map((e) => UniversalFile.fromAttachment(e)) + .toList(), + ); + } + } + } + + Widget _buildCompactDraftPreview(BuildContext context, SnPost draft) { + return 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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.description, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Draft', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + if (draft.title?.isNotEmpty ?? false) + Text( + draft.title!, + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + color: Theme.of(context).colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (draft.content?.isNotEmpty ?? false) + Text( + draft.content!, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (draft.attachments.isNotEmpty) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.attach_file, + size: 12, + color: Theme.of(context).colorScheme.secondary, + ), + const SizedBox(width: 4), + Text( + '${draft.attachments.length} attachment${draft.attachments.length > 1 ? 's' : ''}', + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontSize: 11, + ), + ), + ], + ), + ], + ), + ); + } } diff --git a/lib/widgets/post/compose_toolbar.dart b/lib/widgets/post/compose_toolbar.dart index 2d0ef86d..a0554831 100644 --- a/lib/widgets/post/compose_toolbar.dart +++ b/lib/widgets/post/compose_toolbar.dart @@ -55,6 +55,7 @@ class ComposeToolbar extends HookConsumerWidget { showModalBottomSheet( context: context, isScrollControlled: true, + useRootNavigator: true, builder: (context) => ComposeEmbedSheet(state: state), ); } @@ -63,6 +64,7 @@ class ComposeToolbar extends HookConsumerWidget { showModalBottomSheet( context: context, isScrollControlled: true, + useRootNavigator: true, builder: (context) => DraftManagerSheet( onDraftSelected: (draftId) {