Auto save post draft

🐛 Fix publisher edit screen overflow
This commit is contained in:
LittleSheep 2025-06-24 01:00:29 +08:00
parent ffbe399614
commit 6d68b20122
9 changed files with 1150 additions and 119 deletions

View File

@ -405,6 +405,24 @@
"settingsKeyboardShortcutNewMessage": "New Message", "settingsKeyboardShortcutNewMessage": "New Message",
"settingsKeyboardShortcutCloseDialog": "Close Dialog", "settingsKeyboardShortcutCloseDialog": "Close Dialog",
"close": "Close", "close": "Close",
"drafts": "Drafts",
"noDrafts": "No drafts yet",
"articleDrafts": "Article drafts",
"postDrafts": "Post drafts",
"clearAllDrafts": "Clear All Drafts",
"clearAllDraftsConfirm": "Are you sure you want to delete all drafts? This action cannot be undone.",
"clearAll": "Clear All",
"untitled": "Untitled",
"noContent": "No content",
"justNow": "Just now",
"minutesAgo": "{} minutes ago",
"hoursAgo": "{} hours ago",
"daysAgo": "{} days ago",
"public": "Public",
"unlisted": "Unlisted",
"friends": "Friends",
"selected": "Selected",
"private": "Private",
"contactMethod": "Contact Method", "contactMethod": "Contact Method",
"contactMethodType": "Contact Type", "contactMethodType": "Contact Type",
"contactMethodTypeEmail": "Email", "contactMethodTypeEmail": "Email",

View File

@ -15,6 +15,7 @@ import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/screens/realm/realms.dart'; import 'package:island/screens/realm/realms.dart';
import 'package:island/services/file.dart'; import 'package:island/services/file.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
@ -190,7 +191,9 @@ class EditPublisherScreen extends HookConsumerWidget {
title: Text(name == null ? 'createPublisher' : 'editPublisher').tr(), title: Text(name == null ? 'createPublisher' : 'editPublisher').tr(),
leading: const PageBackButton(), leading: const PageBackButton(),
), ),
body: Column( body: SingleChildScrollView(
padding: getTabbedPadding(context, bottom: 16),
child: Column(
children: [ children: [
RealmSelectionDropdown( RealmSelectionDropdown(
value: currentRealm.value, value: currentRealm.value,
@ -244,6 +247,8 @@ class EditPublisherScreen extends HookConsumerWidget {
).padding(bottom: 32), ).padding(bottom: 32),
Form( Form(
key: formKey, key: formKey,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 480),
child: Column( child: Column(
spacing: 16, spacing: 16,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -284,13 +289,16 @@ class EditPublisherScreen extends HookConsumerWidget {
nickController.text = user.value!.nick; nickController.text = user.value!.nick;
bioController.text = user.value!.profile.bio; bioController.text = user.value!.profile.bio;
picture.value = user.value!.profile.picture?.id; picture.value = user.value!.profile.picture?.id;
background.value = user.value!.profile.background?.id; background.value =
user.value!.profile.background?.id;
} else { } else {
nameController.text = currentRealm.value!.slug; nameController.text = currentRealm.value!.slug;
nickController.text = currentRealm.value!.name; nickController.text = currentRealm.value!.name;
bioController.text = currentRealm.value!.description; bioController.text =
currentRealm.value!.description;
picture.value = currentRealm.value!.picture?.id; picture.value = currentRealm.value!.picture?.id;
background.value = currentRealm.value!.background?.id; background.value =
currentRealm.value!.background?.id;
} }
}, },
label: label:
@ -303,16 +311,21 @@ class EditPublisherScreen extends HookConsumerWidget {
), ),
TextButton.icon( TextButton.icon(
onPressed: submitting.value ? null : performAction, onPressed: submitting.value ? null : performAction,
label: Text(name == null ? 'create' : 'saveChanges').tr(), label:
Text(
name == null ? 'create' : 'saveChanges',
).tr(),
icon: const Icon(Symbols.save), icon: const Icon(Symbols.save),
), ),
], ],
), ),
], ],
).padding(horizontal: 24), ).padding(horizontal: 24),
).alignment(Alignment.topCenter),
), ),
], ],
), ),
),
); );
} }
} }

View File

@ -17,6 +17,8 @@ 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/screens/posts/detail.dart'; import 'package:island/screens/posts/detail.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart'; import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/services/compose_storage.dart';
import 'package:island/widgets/post/draft_manager.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';
@ -60,6 +62,17 @@ class PostComposeScreen extends HookConsumerWidget {
@QueryParam('type') this.type, @QueryParam('type') this.type,
}); });
// Helper method to parse visibility
int _parseVisibility(String visibility) {
switch (visibility) {
case 'public': return 0;
case 'unlisted': return 1;
case 'friends': return 2;
case 'private': return 3;
default: return 0;
}
}
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// Determine the compose type: auto-detect from edited post or use query parameter // Determine the compose type: auto-detect from edited post or use query parameter
@ -88,6 +101,14 @@ class PostComposeScreen extends HookConsumerWidget {
[originalPost, effectiveForwardedPost, effectiveRepliedPost], [originalPost, effectiveForwardedPost, effectiveRepliedPost],
); );
// Start auto-save when component mounts
useEffect(() {
if (originalPost == null) { // Only auto-save for new posts, not edits
state.startAutoSave(ref);
}
return () => state.stopAutoSave();
}, [state]);
// Initialize publisher once when data is available // Initialize publisher once when data is available
useEffect(() { useEffect(() {
if (publishers.value?.isNotEmpty ?? false) { if (publishers.value?.isNotEmpty ?? false) {
@ -96,9 +117,38 @@ class PostComposeScreen extends HookConsumerWidget {
return null; return null;
}, [publishers]); }, [publishers]);
// Load draft if available (only for new posts)
useEffect(() {
if (originalPost == null && effectiveForwardedPost == null && effectiveRepliedPost == null) {
// Try to load the most recent draft
final drafts = ref.read(composeStorageNotifierProvider);
if (drafts.isNotEmpty) {
final mostRecentDraft = drafts.values.reduce((a, b) =>
a.lastModified.isAfter(b.lastModified) ? a : b);
// Only load if the draft has meaningful content
if (!mostRecentDraft.isEmpty) {
state.titleController.text = mostRecentDraft.title;
state.descriptionController.text = mostRecentDraft.description;
state.contentController.text = mostRecentDraft.content;
state.visibility.value = _parseVisibility(mostRecentDraft.visibility);
}
}
}
return null;
}, []);
// Dispose state when widget is disposed // Dispose state when widget is disposed
useEffect(() { useEffect(() {
return () => ComposeLogic.dispose(state); return () {
// Stop auto-save first to prevent race conditions
state.stopAutoSave();
// Save final draft before disposing
if (originalPost == null) {
ComposeLogic.saveDraft(ref, state);
}
ComposeLogic.dispose(state);
};
}, []); }, []);
// Helper methods // Helper methods
@ -190,6 +240,29 @@ class PostComposeScreen extends HookConsumerWidget {
appBar: AppBar( appBar: AppBar(
leading: const PageBackButton(), leading: const PageBackButton(),
actions: [ actions: [
if (originalPost == null) // Only show drafts for new posts
IconButton(
icon: const Icon(Symbols.draft),
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => DraftManagerSheet(
isArticle: false,
onDraftSelected: (draftId) {
final draft = ref.read(composeStorageNotifierProvider)[draftId];
if (draft != null) {
state.titleController.text = draft.title;
state.descriptionController.text = draft.description;
state.contentController.text = draft.content;
state.visibility.value = _parseVisibility(draft.visibility);
}
},
),
);
},
tooltip: 'drafts'.tr(),
),
IconButton( IconButton(
icon: const Icon(Symbols.settings), icon: const Icon(Symbols.settings),
onPressed: showSettingsSheet, onPressed: showSettingsSheet,

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'dart:developer';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.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';
@ -11,11 +13,13 @@ import 'package:island/services/responsive.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/screens/posts/detail.dart'; import 'package:island/screens/posts/detail.dart';
import 'package:island/widgets/content/attachment_preview.dart'; import 'package:island/widgets/content/attachment_preview.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/markdown.dart'; import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/post/compose_shared.dart'; import 'package:island/widgets/post/compose_shared.dart';
import 'package:island/widgets/post/publishers_modal.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart'; import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/widgets/post/publishers_modal.dart';
import 'package:island/services/compose_storage.dart';
import 'package:island/widgets/post/draft_manager.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';
@ -60,6 +64,24 @@ class ArticleComposeScreen extends HookConsumerWidget {
[originalPost], [originalPost],
); );
// Start auto-save when component mounts
useEffect(() {
Timer? autoSaveTimer;
if (originalPost == null) {
// Only auto-save for new articles, not edits
autoSaveTimer = Timer.periodic(const Duration(seconds: 3), (_) {
_saveArticleDraft(ref, state);
});
}
return () {
// Save final draft before cancelling timer
if (originalPost == null) {
_saveArticleDraft(ref, state);
}
autoSaveTimer?.cancel();
};
}, [state]);
final showPreview = useState(false); final showPreview = useState(false);
// Initialize publisher once when data is available // Initialize publisher once when data is available
@ -70,10 +92,40 @@ class ArticleComposeScreen extends HookConsumerWidget {
return null; return null;
}, [publishers]); }, [publishers]);
// Load draft if available (only for new articles)
useEffect(() {
if (originalPost == null) {
// Try to load the most recent article draft
final drafts = ref.read(articleStorageNotifierProvider);
if (drafts.isNotEmpty) {
final mostRecentDraft = drafts.values.reduce(
(a, b) => a.lastModified.isAfter(b.lastModified) ? a : b,
);
// Only load if the draft has meaningful content
if (!mostRecentDraft.isEmpty) {
state.titleController.text = mostRecentDraft.title;
state.descriptionController.text = mostRecentDraft.description;
state.contentController.text = mostRecentDraft.content;
state.visibility.value = _parseArticleVisibility(
mostRecentDraft.visibility,
);
}
}
}
return null;
}, []);
// Dispose state when widget is disposed // Dispose state when widget is disposed
useEffect(() { useEffect(() {
return () => ComposeLogic.dispose(state); return () {
}, []); // Save final draft before disposing
if (originalPost == null) {
_saveArticleDraft(ref, state);
}
ComposeLogic.dispose(state);
};
}, [state]);
// Helper methods // Helper methods
void showSettingsSheet() { void showSettingsSheet() {
@ -318,6 +370,34 @@ class ArticleComposeScreen extends HookConsumerWidget {
actions: [ actions: [
// Info banner for article compose // Info banner for article compose
const SizedBox.shrink(), const SizedBox.shrink(),
if (originalPost == null) // Only show drafts for new articles
IconButton(
icon: const Icon(Symbols.draft),
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => DraftManagerSheet(
isArticle: true,
onDraftSelected: (draftId) {
final draft =
ref.read(articleStorageNotifierProvider)[draftId];
if (draft != null) {
state.titleController.text = draft.title;
state.descriptionController.text =
draft.description;
state.contentController.text = draft.content;
state.visibility.value = _parseArticleVisibility(
draft.visibility,
);
}
},
),
);
},
tooltip: 'drafts'.tr(),
),
IconButton( IconButton(
icon: const Icon(Symbols.settings), icon: const Icon(Symbols.settings),
onPressed: showSettingsSheet, onPressed: showSettingsSheet,
@ -413,4 +493,55 @@ class ArticleComposeScreen extends HookConsumerWidget {
), ),
); );
} }
// Helper method to save article draft
Future<void> _saveArticleDraft(WidgetRef ref, ComposeState state) async {
try {
final draft = ArticleDraft(
id: state.draftId,
title: state.titleController.text,
description: state.descriptionController.text,
content: state.contentController.text,
visibility: _visibilityToString(state.visibility.value),
lastModified: DateTime.now(),
);
await ref.read(articleStorageNotifierProvider.notifier).saveDraft(draft);
} catch (e) {
log('[ArticleCompose] Failed to save draft, error: $e');
// Silently fail for auto-save to avoid disrupting user experience
}
}
// Helper method to convert visibility int to string
String _visibilityToString(int visibility) {
switch (visibility) {
case 0:
return 'public';
case 1:
return 'unlisted';
case 2:
return 'friends';
case 3:
return 'private';
default:
return 'public';
}
}
// Helper method to parse article visibility
int _parseArticleVisibility(String visibility) {
switch (visibility) {
case 'public':
return 0;
case 'unlisted':
return 1;
case 'friends':
return 2;
case 'private':
return 3;
default:
return 0;
}
}
} }

View File

@ -0,0 +1,270 @@
import 'dart:convert';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/pods/config.dart';
part 'compose_storage.g.dart';
const kComposeDraftStoreKey = 'compose_drafts';
const kArticleDraftStoreKey = 'article_drafts';
class ComposeDraft {
final String id;
final String title;
final String description;
final String content;
final List<String> attachmentIds;
final String visibility;
final DateTime lastModified;
ComposeDraft({
required this.id,
required this.title,
required this.description,
required this.content,
required this.attachmentIds,
required this.visibility,
required this.lastModified,
});
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'description': description,
'content': content,
'attachmentIds': attachmentIds,
'visibility': visibility,
'lastModified': lastModified.toIso8601String(),
};
factory ComposeDraft.fromJson(Map<String, dynamic> json) => ComposeDraft(
id: json['id'] as String,
title: json['title'] as String? ?? '',
description: json['description'] as String? ?? '',
content: json['content'] as String? ?? '',
attachmentIds: List<String>.from(json['attachmentIds'] as List? ?? []),
visibility: json['visibility'] as String? ?? 'public',
lastModified: DateTime.parse(json['lastModified'] as String),
);
ComposeDraft copyWith({
String? id,
String? title,
String? description,
String? content,
List<String>? attachmentIds,
String? visibility,
DateTime? lastModified,
}) {
return ComposeDraft(
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
content: content ?? this.content,
attachmentIds: attachmentIds ?? this.attachmentIds,
visibility: visibility ?? this.visibility,
lastModified: lastModified ?? this.lastModified,
);
}
bool get isEmpty =>
title.isEmpty &&
description.isEmpty &&
content.isEmpty &&
attachmentIds.isEmpty;
}
class ArticleDraft {
final String id;
final String title;
final String description;
final String content;
final String visibility;
final DateTime lastModified;
ArticleDraft({
required this.id,
required this.title,
required this.description,
required this.content,
required this.visibility,
required this.lastModified,
});
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'description': description,
'content': content,
'visibility': visibility,
'lastModified': lastModified.toIso8601String(),
};
factory ArticleDraft.fromJson(Map<String, dynamic> json) => ArticleDraft(
id: json['id'] as String,
title: json['title'] as String? ?? '',
description: json['description'] as String? ?? '',
content: json['content'] as String? ?? '',
visibility: json['visibility'] as String? ?? 'public',
lastModified: DateTime.parse(json['lastModified'] as String),
);
ArticleDraft copyWith({
String? id,
String? title,
String? description,
String? content,
String? visibility,
DateTime? lastModified,
}) {
return ArticleDraft(
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
content: content ?? this.content,
visibility: visibility ?? this.visibility,
lastModified: lastModified ?? this.lastModified,
);
}
bool get isEmpty => title.isEmpty && description.isEmpty && content.isEmpty;
}
@riverpod
class ComposeStorageNotifier extends _$ComposeStorageNotifier {
@override
Map<String, ComposeDraft> build() {
_loadDrafts();
return {};
}
void _loadDrafts() {
final prefs = ref.read(sharedPreferencesProvider);
final draftsJson = prefs.getString(kComposeDraftStoreKey);
if (draftsJson != null) {
try {
final Map<String, dynamic> draftsMap = jsonDecode(draftsJson);
final drafts = <String, ComposeDraft>{};
for (final entry in draftsMap.entries) {
drafts[entry.key] = ComposeDraft.fromJson(entry.value);
}
state = drafts;
} catch (e) {
// If there's an error loading drafts, start with empty state
state = {};
}
}
}
Future<void> _saveDrafts() async {
final prefs = ref.read(sharedPreferencesProvider);
final draftsMap = <String, dynamic>{};
for (final entry in state.entries) {
draftsMap[entry.key] = entry.value.toJson();
}
await prefs.setString(kComposeDraftStoreKey, jsonEncode(draftsMap));
}
Future<void> saveDraft(ComposeDraft draft) async {
if (draft.isEmpty) {
await deleteDraft(draft.id);
return;
}
final updatedDraft = draft.copyWith(lastModified: DateTime.now());
state = {...state, updatedDraft.id: updatedDraft};
await _saveDrafts();
}
Future<void> deleteDraft(String id) async {
final newState = Map<String, ComposeDraft>.from(state);
newState.remove(id);
state = newState;
await _saveDrafts();
}
ComposeDraft? getDraft(String id) {
return state[id];
}
List<ComposeDraft> getAllDrafts() {
final drafts = state.values.toList();
drafts.sort((a, b) => b.lastModified.compareTo(a.lastModified));
return drafts;
}
Future<void> clearAllDrafts() async {
state = {};
final prefs = ref.read(sharedPreferencesProvider);
await prefs.remove(kComposeDraftStoreKey);
}
}
@riverpod
class ArticleStorageNotifier extends _$ArticleStorageNotifier {
@override
Map<String, ArticleDraft> build() {
_loadDrafts();
return {};
}
void _loadDrafts() {
final prefs = ref.read(sharedPreferencesProvider);
final draftsJson = prefs.getString(kArticleDraftStoreKey);
if (draftsJson != null) {
try {
final Map<String, dynamic> draftsMap = jsonDecode(draftsJson);
final drafts = <String, ArticleDraft>{};
for (final entry in draftsMap.entries) {
drafts[entry.key] = ArticleDraft.fromJson(entry.value);
}
state = drafts;
} catch (e) {
// If there's an error loading drafts, start with empty state
state = {};
}
}
}
Future<void> _saveDrafts() async {
final prefs = ref.read(sharedPreferencesProvider);
final draftsMap = <String, dynamic>{};
for (final entry in state.entries) {
draftsMap[entry.key] = entry.value.toJson();
}
await prefs.setString(kArticleDraftStoreKey, jsonEncode(draftsMap));
}
Future<void> saveDraft(ArticleDraft draft) async {
if (draft.isEmpty) {
await deleteDraft(draft.id);
return;
}
final updatedDraft = draft.copyWith(lastModified: DateTime.now());
state = {...state, updatedDraft.id: updatedDraft};
await _saveDrafts();
}
Future<void> deleteDraft(String id) async {
final newState = Map<String, ArticleDraft>.from(state);
newState.remove(id);
state = newState;
await _saveDrafts();
}
ArticleDraft? getDraft(String id) {
return state[id];
}
List<ArticleDraft> getAllDrafts() {
final drafts = state.values.toList();
drafts.sort((a, b) => b.lastModified.compareTo(a.lastModified));
return drafts;
}
Future<void> clearAllDrafts() async {
state = {};
final prefs = ref.read(sharedPreferencesProvider);
await prefs.remove(kArticleDraftStoreKey);
}
}

View File

@ -0,0 +1,52 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'compose_storage.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$composeStorageNotifierHash() =>
r'99c5e4070fa8af2771751064b56ca3251dbda27a';
/// See also [ComposeStorageNotifier].
@ProviderFor(ComposeStorageNotifier)
final composeStorageNotifierProvider = AutoDisposeNotifierProvider<
ComposeStorageNotifier,
Map<String, ComposeDraft>
>.internal(
ComposeStorageNotifier.new,
name: r'composeStorageNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$composeStorageNotifierHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ComposeStorageNotifier =
AutoDisposeNotifier<Map<String, ComposeDraft>>;
String _$articleStorageNotifierHash() =>
r'4a200878bfe7881fc3afd2164b334e84dc44f338';
/// See also [ArticleStorageNotifier].
@ProviderFor(ArticleStorageNotifier)
final articleStorageNotifierProvider = AutoDisposeNotifierProvider<
ArticleStorageNotifier,
Map<String, ArticleDraft>
>.internal(
ArticleStorageNotifier.new,
name: r'articleStorageNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$articleStorageNotifierHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ArticleStorageNotifier =
AutoDisposeNotifier<Map<String, ArticleDraft>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -1,3 +1,5 @@
import 'dart:developer';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -9,8 +11,10 @@ import 'package:island/models/post.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/services/file.dart'; import 'package:island/services/file.dart';
import 'package:island/services/compose_storage.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:pasteboard/pasteboard.dart'; import 'package:pasteboard/pasteboard.dart';
import 'dart:async';
class ComposeState { class ComposeState {
final ValueNotifier<List<UniversalFile>> attachments; final ValueNotifier<List<UniversalFile>> attachments;
@ -21,6 +25,8 @@ class ComposeState {
final ValueNotifier<bool> submitting; final ValueNotifier<bool> submitting;
final ValueNotifier<Map<int, double>> attachmentProgress; final ValueNotifier<Map<int, double>> attachmentProgress;
final ValueNotifier<SnPublisher?> currentPublisher; final ValueNotifier<SnPublisher?> currentPublisher;
final String draftId;
Timer? _autoSaveTimer;
ComposeState({ ComposeState({
required this.attachments, required this.attachments,
@ -31,7 +37,20 @@ class ComposeState {
required this.submitting, required this.submitting,
required this.attachmentProgress, required this.attachmentProgress,
required this.currentPublisher, required this.currentPublisher,
required this.draftId,
}); });
void startAutoSave(WidgetRef ref) {
_autoSaveTimer?.cancel();
_autoSaveTimer = Timer.periodic(const Duration(seconds: 3), (_) {
ComposeLogic.saveDraft(ref, this);
});
}
void stopAutoSave() {
_autoSaveTimer?.cancel();
_autoSaveTimer = null;
}
} }
class ComposeLogic { class ComposeLogic {
@ -39,7 +58,10 @@ class ComposeLogic {
SnPost? originalPost, SnPost? originalPost,
SnPost? forwardedPost, SnPost? forwardedPost,
SnPost? repliedPost, SnPost? repliedPost,
String? draftId,
}) { }) {
final id = draftId ?? DateTime.now().millisecondsSinceEpoch.toString();
return ComposeState( return ComposeState(
attachments: ValueNotifier<List<UniversalFile>>( attachments: ValueNotifier<List<UniversalFile>>(
originalPost?.attachments originalPost?.attachments
@ -70,9 +92,106 @@ class ComposeLogic {
submitting: ValueNotifier<bool>(false), submitting: ValueNotifier<bool>(false),
attachmentProgress: ValueNotifier<Map<int, double>>({}), attachmentProgress: ValueNotifier<Map<int, double>>({}),
currentPublisher: ValueNotifier<SnPublisher?>(null), currentPublisher: ValueNotifier<SnPublisher?>(null),
draftId: id,
); );
} }
static ComposeState createStateFromDraft(ComposeDraft draft) {
return ComposeState(
attachments: ValueNotifier<List<UniversalFile>>([]),
titleController: TextEditingController(text: draft.title),
descriptionController: TextEditingController(text: draft.description),
contentController: TextEditingController(text: draft.content),
visibility: ValueNotifier<int>(_parseVisibility(draft.visibility)),
submitting: ValueNotifier<bool>(false),
attachmentProgress: ValueNotifier<Map<int, double>>({}),
currentPublisher: ValueNotifier<SnPublisher?>(null),
draftId: draft.id,
);
}
static int _parseVisibility(String visibility) {
switch (visibility.toLowerCase()) {
case 'public':
return 0;
case 'unlisted':
return 1;
case 'friends':
return 2;
case 'selected':
return 3;
case 'private':
return 4;
default:
return 0;
}
}
static String _visibilityToString(int visibility) {
switch (visibility) {
case 0:
return 'public';
case 1:
return 'unlisted';
case 2:
return 'friends';
case 3:
return 'selected';
case 4:
return 'private';
default:
return 'public';
}
}
static Future<void> saveDraft(WidgetRef ref, ComposeState state) async {
try {
// Check if the auto-save timer is still active (widget not disposed)
if (state._autoSaveTimer == null) {
return; // Widget has been disposed, don't save
}
final draft = ComposeDraft(
id: state.draftId,
title: state.titleController.text,
description: state.descriptionController.text,
content: state.contentController.text,
attachmentIds:
state.attachments.value
.where((e) => e.isOnCloud)
.map((e) => e.data.id.toString())
.toList(),
visibility: _visibilityToString(state.visibility.value),
lastModified: DateTime.now(),
);
await ref.read(composeStorageNotifierProvider.notifier).saveDraft(draft);
} catch (e) {
log('[ComposeLogic] Failed to save draft, error: $e');
// Silently fail for auto-save to avoid disrupting user experience
}
}
static Future<void> deleteDraft(WidgetRef ref, String draftId) async {
try {
await ref
.read(composeStorageNotifierProvider.notifier)
.deleteDraft(draftId);
} catch (e) {
// Silently fail
}
}
static Future<ComposeDraft?> loadDraft(WidgetRef ref, String draftId) async {
try {
return ref
.read(composeStorageNotifierProvider.notifier)
.getDraft(draftId);
} catch (e) {
return null;
}
}
static String getMimeTypeFromFileType(UniversalFileType type) { static String getMimeTypeFromFileType(UniversalFileType type) {
return switch (type) { return switch (type) {
UniversalFileType.image => 'image/unknown', UniversalFileType.image => 'image/unknown',
@ -242,6 +361,19 @@ class ComposeLogic {
), ),
); );
// Delete draft after successful submission
if (postType == 1) {
// Delete article draft
await ref
.read(articleStorageNotifierProvider.notifier)
.deleteDraft(state.draftId);
} else {
// Delete regular post draft
await ref
.read(composeStorageNotifierProvider.notifier)
.deleteDraft(state.draftId);
}
if (context.mounted) { if (context.mounted) {
Navigator.of(context).maybePop(true); Navigator.of(context).maybePop(true);
} }
@ -297,6 +429,7 @@ class ComposeLogic {
} }
static void dispose(ComposeState state) { static void dispose(ComposeState state) {
state.stopAutoSave();
state.titleController.dispose(); state.titleController.dispose();
state.descriptionController.dispose(); state.descriptionController.dispose();
state.contentController.dispose(); state.contentController.dispose();

View File

@ -0,0 +1,335 @@
import 'package:easy_localization/easy_localization.dart';
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/services/compose_storage.dart';
import 'package:material_symbols_icons/symbols.dart';
class DraftManagerSheet extends HookConsumerWidget {
final bool isArticle;
final Function(String draftId)? onDraftSelected;
const DraftManagerSheet({
super.key,
this.isArticle = false,
this.onDraftSelected,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final drafts =
isArticle
? ref.watch(articleStorageNotifierProvider)
: ref.watch(composeStorageNotifierProvider);
final sortedDrafts = useMemoized(() {
if (isArticle) {
final draftList = drafts.values.cast<ArticleDraft>().toList();
draftList.sort((a, b) => b.lastModified.compareTo(a.lastModified));
return draftList;
} else {
final draftList = drafts.values.cast<ComposeDraft>().toList();
draftList.sort((a, b) => b.lastModified.compareTo(a.lastModified));
return draftList;
}
}, [drafts]);
return Scaffold(
appBar: AppBar(
title: Text(isArticle ? 'articleDrafts'.tr() : 'postDrafts'.tr()),
),
body: Column(
children: [
if (sortedDrafts.isEmpty)
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Symbols.draft,
size: 64,
color: colorScheme.onSurface.withOpacity(0.3),
),
const Gap(16),
Text(
'noDrafts'.tr(),
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
),
)
else
Expanded(
child: ListView.builder(
itemCount: sortedDrafts.length,
itemBuilder: (context, index) {
final draft = sortedDrafts[index];
return _DraftItem(
draft: draft,
isArticle: isArticle,
onTap: () {
Navigator.of(context).pop();
final draftId =
isArticle
? (draft as ArticleDraft).id
: (draft as ComposeDraft).id;
onDraftSelected?.call(draftId);
},
onDelete: () async {
final draftId =
isArticle
? (draft as ArticleDraft).id
: (draft as ComposeDraft).id;
if (isArticle) {
await ref
.read(articleStorageNotifierProvider.notifier)
.deleteDraft(draftId);
} else {
await ref
.read(composeStorageNotifierProvider.notifier)
.deleteDraft(draftId);
}
},
);
},
),
),
if (sortedDrafts.isNotEmpty) ...[
const Divider(),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () async {
final confirmed = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: Text('clearAllDrafts'.tr()),
content: Text('clearAllDraftsConfirm'.tr()),
actions: [
TextButton(
onPressed:
() => Navigator.of(context).pop(false),
child: Text('cancel'.tr()),
),
TextButton(
onPressed:
() => Navigator.of(context).pop(true),
child: Text('confirm'.tr()),
),
],
),
);
if (confirmed == true) {
if (isArticle) {
await ref
.read(articleStorageNotifierProvider.notifier)
.clearAllDrafts();
} else {
await ref
.read(composeStorageNotifierProvider.notifier)
.clearAllDrafts();
}
}
},
icon: const Icon(Symbols.delete_sweep),
label: Text('clearAll'.tr()),
),
),
],
),
),
],
],
),
);
}
}
class _DraftItem extends StatelessWidget {
final dynamic draft; // ComposeDraft or ArticleDraft
final bool isArticle;
final VoidCallback? onTap;
final VoidCallback? onDelete;
const _DraftItem({
required this.draft,
required this.isArticle,
this.onTap,
this.onDelete,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final String title;
final String content;
final DateTime lastModified;
final String visibility;
if (isArticle) {
final articleDraft = draft as ArticleDraft;
title =
articleDraft.title.isNotEmpty ? articleDraft.title : 'untitled'.tr();
content =
articleDraft.content.isNotEmpty
? articleDraft.content
: (articleDraft.description.isNotEmpty
? articleDraft.description
: 'noContent'.tr());
lastModified = articleDraft.lastModified;
visibility = _parseArticleVisibility(articleDraft.visibility);
} else {
final postDraft = draft as ComposeDraft;
title = postDraft.title.isNotEmpty ? postDraft.title : 'untitled'.tr();
content =
postDraft.content.isNotEmpty
? postDraft.content
: (postDraft.description.isNotEmpty
? postDraft.description
: 'noContent'.tr());
lastModified = postDraft.lastModified;
visibility = postDraft.visibility;
}
final preview =
content.length > 100 ? '${content.substring(0, 100)}...' : content;
final timeAgo = _formatTimeAgo(lastModified);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
isArticle ? Symbols.article : Symbols.post_add,
size: 20,
color: colorScheme.primary,
),
const Gap(8),
Expanded(
child: Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
onPressed: onDelete,
icon: const Icon(Symbols.delete),
iconSize: 20,
visualDensity: VisualDensity.compact,
),
],
),
if (preview.isNotEmpty) ...[
const Gap(8),
Text(
preview,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const Gap(8),
Row(
children: [
Icon(
Symbols.schedule,
size: 16,
color: colorScheme.onSurface.withOpacity(0.5),
),
const Gap(4),
Text(
timeAgo,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
visibility,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w500,
),
),
),
],
),
],
),
),
),
);
}
String _formatTimeAgo(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inMinutes < 1) {
return 'justNow'.tr();
} else if (difference.inHours < 1) {
return 'minutesAgo'.tr(args: [difference.inMinutes.toString()]);
} else if (difference.inDays < 1) {
return 'hoursAgo'.tr(args: [difference.inHours.toString()]);
} else if (difference.inDays < 7) {
return 'daysAgo'.tr(args: [difference.inDays.toString()]);
} else {
return DateFormat('MMM dd, yyyy').format(dateTime);
}
}
String _parseArticleVisibility(String visibility) {
switch (visibility.toLowerCase()) {
case 'public':
return 'public'.tr();
case 'unlisted':
return 'unlisted'.tr();
case 'friends':
return 'friends'.tr();
case 'selected':
return 'selected'.tr();
case 'private':
return 'private'.tr();
default:
return 'unknown'.tr();
}
}
}

View File

@ -212,7 +212,9 @@ class PostItem extends HookConsumerWidget {
MarkdownTextContent( MarkdownTextContent(
content: item.content!, content: item.content!,
linesMargin: linesMargin:
item.type == 0 ? EdgeInsets.zero : null, item.type == 0
? EdgeInsets.only(bottom: 4)
: null,
), ),
// Show truncation hint if post is truncated // Show truncation hint if post is truncated
if (item.isTruncated && !isFullPost) if (item.isTruncated && !isFullPost)
@ -415,6 +417,10 @@ Widget _buildReferencePost(BuildContext context, SnPost item) {
content: referencePost.content!, content: referencePost.content!,
textStyle: const TextStyle(fontSize: 14), textStyle: const TextStyle(fontSize: 14),
isSelectable: false, isSelectable: false,
linesMargin:
referencePost.type == 0
? EdgeInsets.only(bottom: 4)
: null,
).padding(bottom: 4), ).padding(bottom: 4),
// Truncation hint for referenced post // Truncation hint for referenced post
if (referencePost.isTruncated) if (referencePost.isTruncated)