♻️ Better draft, post saving and auto restore hint
This commit is contained in:
		@@ -32,7 +32,6 @@ class ComposeStorageNotifier extends _$ComposeStorageNotifier {
 | 
			
		||||
 | 
			
		||||
  Future<void> 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<String, SnPost>.from(state);
 | 
			
		||||
      newState.remove(updatedDraft.id);
 | 
			
		||||
      state = newState;
 | 
			
		||||
      rethrow;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -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<PostComposeInitialState?>(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<void> _showRestoreDialog(
 | 
			
		||||
    WidgetRef ref,
 | 
			
		||||
    ValueNotifier<PostComposeInitialState?> restoredInitialState,
 | 
			
		||||
  ) async {
 | 
			
		||||
    final drafts = ref.read(composeStorageNotifierProvider);
 | 
			
		||||
    if (drafts.isNotEmpty) {
 | 
			
		||||
      final latestDraft = drafts.values.last;
 | 
			
		||||
 | 
			
		||||
      final restore = await showDialog<bool>(
 | 
			
		||||
        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,
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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) {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user