♻️ Better draft, post saving and auto restore hint

This commit is contained in:
2025-09-30 00:04:51 +08:00
parent 97792ae734
commit 2255d3d591
4 changed files with 194 additions and 8 deletions

View File

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

View File

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

View File

@@ -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) {