diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 5f4cd04..7dc9808 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -405,6 +405,24 @@ "settingsKeyboardShortcutNewMessage": "New Message", "settingsKeyboardShortcutCloseDialog": "Close Dialog", "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", "contactMethodType": "Contact Type", "contactMethodTypeEmail": "Email", diff --git a/lib/screens/creators/publishers.dart b/lib/screens/creators/publishers.dart index 01b6957..6a4617f 100644 --- a/lib/screens/creators/publishers.dart +++ b/lib/screens/creators/publishers.dart @@ -15,6 +15,7 @@ import 'package:island/pods/network.dart'; import 'package:island/pods/userinfo.dart'; import 'package:island/screens/realm/realms.dart'; import 'package:island/services/file.dart'; +import 'package:island/services/responsive.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/content/cloud_files.dart'; @@ -190,128 +191,140 @@ class EditPublisherScreen extends HookConsumerWidget { title: Text(name == null ? 'createPublisher' : 'editPublisher').tr(), leading: const PageBackButton(), ), - body: Column( - children: [ - RealmSelectionDropdown( - value: currentRealm.value, - realms: joinedRealms.when( - data: (realms) => realms, - loading: () => [], - error: (_, _) => [], + body: SingleChildScrollView( + padding: getTabbedPadding(context, bottom: 16), + child: Column( + children: [ + RealmSelectionDropdown( + value: currentRealm.value, + realms: joinedRealms.when( + data: (realms) => realms, + loading: () => [], + error: (_, _) => [], + ), + onChanged: (SnRealm? value) { + currentRealm.value = value; + }, + isLoading: joinedRealms.isLoading, + error: joinedRealms.error?.toString(), ), - onChanged: (SnRealm? value) { - currentRealm.value = value; - }, - isLoading: joinedRealms.isLoading, - error: joinedRealms.error?.toString(), - ), - AspectRatio( - aspectRatio: 16 / 7, - child: Stack( - clipBehavior: Clip.none, - fit: StackFit.expand, - children: [ - GestureDetector( - child: Container( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - child: - background.value != null - ? CloudImageWidget( - fileId: background.value!, - fit: BoxFit.cover, - ) - : const SizedBox.shrink(), - ), - onTap: () { - setPicture('background'); - }, - ), - Positioned( - left: 20, - bottom: -32, - child: GestureDetector( - child: ProfilePictureWidget( - fileId: picture.value, - radius: 40, + AspectRatio( + aspectRatio: 16 / 7, + child: Stack( + clipBehavior: Clip.none, + fit: StackFit.expand, + children: [ + GestureDetector( + child: Container( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: + background.value != null + ? CloudImageWidget( + fileId: background.value!, + fit: BoxFit.cover, + ) + : const SizedBox.shrink(), ), onTap: () { - setPicture('picture'); + setPicture('background'); }, ), - ), - ], - ), - ).padding(bottom: 32), - Form( - key: formKey, - child: Column( - spacing: 16, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextFormField( - controller: nameController, - decoration: InputDecoration( - labelText: 'username'.tr(), - helperText: 'usernameCannotChangeHint'.tr(), - prefixText: '@', - ), - readOnly: name != null, - onTapOutside: - (_) => FocusManager.instance.primaryFocus?.unfocus(), - ), - TextFormField( - controller: nickController, - decoration: InputDecoration(labelText: 'nickname'.tr()), - onTapOutside: - (_) => FocusManager.instance.primaryFocus?.unfocus(), - ), - TextFormField( - controller: bioController, - decoration: InputDecoration(labelText: 'bio'.tr()), - minLines: 3, - maxLines: null, - onTapOutside: - (_) => FocusManager.instance.primaryFocus?.unfocus(), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton.icon( - onPressed: () { - if (currentRealm.value == null) { - final user = ref.watch(userInfoProvider); - nameController.text = user.value!.name; - nickController.text = user.value!.nick; - bioController.text = user.value!.profile.bio; - picture.value = user.value!.profile.picture?.id; - background.value = user.value!.profile.background?.id; - } else { - nameController.text = currentRealm.value!.slug; - nickController.text = currentRealm.value!.name; - bioController.text = currentRealm.value!.description; - picture.value = currentRealm.value!.picture?.id; - background.value = currentRealm.value!.background?.id; - } + Positioned( + left: 20, + bottom: -32, + child: GestureDetector( + child: ProfilePictureWidget( + fileId: picture.value, + radius: 40, + ), + onTap: () { + setPicture('picture'); }, - label: - Text( - currentRealm.value == null - ? 'syncPublisher' - : 'syncPublisherRealm', - ).tr(), - icon: const Icon(Symbols.link), ), - TextButton.icon( - onPressed: submitting.value ? null : performAction, - label: Text(name == null ? 'create' : 'saveChanges').tr(), - icon: const Icon(Symbols.save), + ), + ], + ), + ).padding(bottom: 32), + Form( + key: formKey, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 480), + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: nameController, + decoration: InputDecoration( + labelText: 'username'.tr(), + helperText: 'usernameCannotChangeHint'.tr(), + prefixText: '@', + ), + readOnly: name != null, + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + TextFormField( + controller: nickController, + decoration: InputDecoration(labelText: 'nickname'.tr()), + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + TextFormField( + controller: bioController, + decoration: InputDecoration(labelText: 'bio'.tr()), + minLines: 3, + maxLines: null, + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton.icon( + onPressed: () { + if (currentRealm.value == null) { + final user = ref.watch(userInfoProvider); + nameController.text = user.value!.name; + nickController.text = user.value!.nick; + bioController.text = user.value!.profile.bio; + picture.value = user.value!.profile.picture?.id; + background.value = + user.value!.profile.background?.id; + } else { + nameController.text = currentRealm.value!.slug; + nickController.text = currentRealm.value!.name; + bioController.text = + currentRealm.value!.description; + picture.value = currentRealm.value!.picture?.id; + background.value = + currentRealm.value!.background?.id; + } + }, + label: + Text( + currentRealm.value == null + ? 'syncPublisher' + : 'syncPublisherRealm', + ).tr(), + icon: const Icon(Symbols.link), + ), + TextButton.icon( + onPressed: submitting.value ? null : performAction, + label: + Text( + name == null ? 'create' : 'saveChanges', + ).tr(), + icon: const Icon(Symbols.save), + ), + ], ), ], - ), - ], - ).padding(horizontal: 24), - ), - ], + ).padding(horizontal: 24), + ).alignment(Alignment.topCenter), + ), + ], + ), ), ); } diff --git a/lib/screens/posts/compose.dart b/lib/screens/posts/compose.dart index 87278c8..194b7d3 100644 --- a/lib/screens/posts/compose.dart +++ b/lib/screens/posts/compose.dart @@ -17,6 +17,8 @@ import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/post/publishers_modal.dart'; import 'package:island/screens/posts/detail.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:styled_widget/styled_widget.dart'; @@ -60,6 +62,17 @@ class PostComposeScreen extends HookConsumerWidget { @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 Widget build(BuildContext context, WidgetRef ref) { // Determine the compose type: auto-detect from edited post or use query parameter @@ -88,6 +101,14 @@ class PostComposeScreen extends HookConsumerWidget { [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 useEffect(() { if (publishers.value?.isNotEmpty ?? false) { @@ -96,9 +117,38 @@ class PostComposeScreen extends HookConsumerWidget { return null; }, [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 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 @@ -190,6 +240,29 @@ class PostComposeScreen extends HookConsumerWidget { appBar: AppBar( leading: const PageBackButton(), 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( icon: const Icon(Symbols.settings), onPressed: showSettingsSheet, diff --git a/lib/screens/posts/compose_article.dart b/lib/screens/posts/compose_article.dart index 984e5f3..6d83323 100644 --- a/lib/screens/posts/compose_article.dart +++ b/lib/screens/posts/compose_article.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'dart:developer'; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.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/screens/posts/detail.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/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/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:styled_widget/styled_widget.dart'; @@ -60,6 +64,24 @@ class ArticleComposeScreen extends HookConsumerWidget { [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); // Initialize publisher once when data is available @@ -70,10 +92,40 @@ class ArticleComposeScreen extends HookConsumerWidget { return null; }, [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 useEffect(() { - return () => ComposeLogic.dispose(state); - }, []); + return () { + // Save final draft before disposing + if (originalPost == null) { + _saveArticleDraft(ref, state); + } + ComposeLogic.dispose(state); + }; + }, [state]); // Helper methods void showSettingsSheet() { @@ -318,6 +370,34 @@ class ArticleComposeScreen extends HookConsumerWidget { actions: [ // Info banner for article compose 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( icon: const Icon(Symbols.settings), onPressed: showSettingsSheet, @@ -413,4 +493,55 @@ class ArticleComposeScreen extends HookConsumerWidget { ), ); } + + // Helper method to save article draft + Future _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; + } + } } diff --git a/lib/services/compose_storage.dart b/lib/services/compose_storage.dart new file mode 100644 index 0000000..338b471 --- /dev/null +++ b/lib/services/compose_storage.dart @@ -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 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 toJson() => { + 'id': id, + 'title': title, + 'description': description, + 'content': content, + 'attachmentIds': attachmentIds, + 'visibility': visibility, + 'lastModified': lastModified.toIso8601String(), + }; + + factory ComposeDraft.fromJson(Map json) => ComposeDraft( + id: json['id'] as String, + title: json['title'] as String? ?? '', + description: json['description'] as String? ?? '', + content: json['content'] as String? ?? '', + attachmentIds: List.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? 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 toJson() => { + 'id': id, + 'title': title, + 'description': description, + 'content': content, + 'visibility': visibility, + 'lastModified': lastModified.toIso8601String(), + }; + + factory ArticleDraft.fromJson(Map 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 build() { + _loadDrafts(); + return {}; + } + + void _loadDrafts() { + final prefs = ref.read(sharedPreferencesProvider); + final draftsJson = prefs.getString(kComposeDraftStoreKey); + if (draftsJson != null) { + try { + final Map draftsMap = jsonDecode(draftsJson); + final drafts = {}; + 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 _saveDrafts() async { + final prefs = ref.read(sharedPreferencesProvider); + final draftsMap = {}; + for (final entry in state.entries) { + draftsMap[entry.key] = entry.value.toJson(); + } + await prefs.setString(kComposeDraftStoreKey, jsonEncode(draftsMap)); + } + + Future 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 deleteDraft(String id) async { + final newState = Map.from(state); + newState.remove(id); + state = newState; + await _saveDrafts(); + } + + ComposeDraft? getDraft(String id) { + return state[id]; + } + + List getAllDrafts() { + final drafts = state.values.toList(); + drafts.sort((a, b) => b.lastModified.compareTo(a.lastModified)); + return drafts; + } + + Future clearAllDrafts() async { + state = {}; + final prefs = ref.read(sharedPreferencesProvider); + await prefs.remove(kComposeDraftStoreKey); + } +} + +@riverpod +class ArticleStorageNotifier extends _$ArticleStorageNotifier { + @override + Map build() { + _loadDrafts(); + return {}; + } + + void _loadDrafts() { + final prefs = ref.read(sharedPreferencesProvider); + final draftsJson = prefs.getString(kArticleDraftStoreKey); + if (draftsJson != null) { + try { + final Map draftsMap = jsonDecode(draftsJson); + final drafts = {}; + 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 _saveDrafts() async { + final prefs = ref.read(sharedPreferencesProvider); + final draftsMap = {}; + for (final entry in state.entries) { + draftsMap[entry.key] = entry.value.toJson(); + } + await prefs.setString(kArticleDraftStoreKey, jsonEncode(draftsMap)); + } + + Future 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 deleteDraft(String id) async { + final newState = Map.from(state); + newState.remove(id); + state = newState; + await _saveDrafts(); + } + + ArticleDraft? getDraft(String id) { + return state[id]; + } + + List getAllDrafts() { + final drafts = state.values.toList(); + drafts.sort((a, b) => b.lastModified.compareTo(a.lastModified)); + return drafts; + } + + Future clearAllDrafts() async { + state = {}; + final prefs = ref.read(sharedPreferencesProvider); + await prefs.remove(kArticleDraftStoreKey); + } +} diff --git a/lib/services/compose_storage.g.dart b/lib/services/compose_storage.g.dart new file mode 100644 index 0000000..28c9652 --- /dev/null +++ b/lib/services/compose_storage.g.dart @@ -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 +>.internal( + ComposeStorageNotifier.new, + name: r'composeStorageNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$composeStorageNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$ComposeStorageNotifier = + AutoDisposeNotifier>; +String _$articleStorageNotifierHash() => + r'4a200878bfe7881fc3afd2164b334e84dc44f338'; + +/// See also [ArticleStorageNotifier]. +@ProviderFor(ArticleStorageNotifier) +final articleStorageNotifierProvider = AutoDisposeNotifierProvider< + ArticleStorageNotifier, + Map +>.internal( + ArticleStorageNotifier.new, + name: r'articleStorageNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$articleStorageNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$ArticleStorageNotifier = + AutoDisposeNotifier>; +// 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 diff --git a/lib/widgets/post/compose_shared.dart b/lib/widgets/post/compose_shared.dart index c161f4a..88e5a29 100644 --- a/lib/widgets/post/compose_shared.dart +++ b/lib/widgets/post/compose_shared.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:collection/collection.dart'; import 'package:dio/dio.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/network.dart'; import 'package:island/services/file.dart'; +import 'package:island/services/compose_storage.dart'; import 'package:island/widgets/alert.dart'; import 'package:pasteboard/pasteboard.dart'; +import 'dart:async'; class ComposeState { final ValueNotifier> attachments; @@ -21,6 +25,8 @@ class ComposeState { final ValueNotifier submitting; final ValueNotifier> attachmentProgress; final ValueNotifier currentPublisher; + final String draftId; + Timer? _autoSaveTimer; ComposeState({ required this.attachments, @@ -31,7 +37,20 @@ class ComposeState { required this.submitting, required this.attachmentProgress, 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 { @@ -39,7 +58,10 @@ class ComposeLogic { SnPost? originalPost, SnPost? forwardedPost, SnPost? repliedPost, + String? draftId, }) { + final id = draftId ?? DateTime.now().millisecondsSinceEpoch.toString(); + return ComposeState( attachments: ValueNotifier>( originalPost?.attachments @@ -70,9 +92,106 @@ class ComposeLogic { submitting: ValueNotifier(false), attachmentProgress: ValueNotifier>({}), currentPublisher: ValueNotifier(null), + draftId: id, ); } + static ComposeState createStateFromDraft(ComposeDraft draft) { + return ComposeState( + attachments: ValueNotifier>([]), + titleController: TextEditingController(text: draft.title), + descriptionController: TextEditingController(text: draft.description), + contentController: TextEditingController(text: draft.content), + visibility: ValueNotifier(_parseVisibility(draft.visibility)), + submitting: ValueNotifier(false), + attachmentProgress: ValueNotifier>({}), + currentPublisher: ValueNotifier(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 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 deleteDraft(WidgetRef ref, String draftId) async { + try { + await ref + .read(composeStorageNotifierProvider.notifier) + .deleteDraft(draftId); + } catch (e) { + // Silently fail + } + } + + static Future loadDraft(WidgetRef ref, String draftId) async { + try { + return ref + .read(composeStorageNotifierProvider.notifier) + .getDraft(draftId); + } catch (e) { + return null; + } + } + static String getMimeTypeFromFileType(UniversalFileType type) { return switch (type) { 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) { Navigator.of(context).maybePop(true); } @@ -297,6 +429,7 @@ class ComposeLogic { } static void dispose(ComposeState state) { + state.stopAutoSave(); state.titleController.dispose(); state.descriptionController.dispose(); state.contentController.dispose(); diff --git a/lib/widgets/post/draft_manager.dart b/lib/widgets/post/draft_manager.dart new file mode 100644 index 0000000..dc915af --- /dev/null +++ b/lib/widgets/post/draft_manager.dart @@ -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().toList(); + draftList.sort((a, b) => b.lastModified.compareTo(a.lastModified)); + return draftList; + } else { + final draftList = drafts.values.cast().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( + 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(); + } + } +} diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 23ae78b..d52c6f4 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -212,7 +212,9 @@ class PostItem extends HookConsumerWidget { MarkdownTextContent( content: item.content!, linesMargin: - item.type == 0 ? EdgeInsets.zero : null, + item.type == 0 + ? EdgeInsets.only(bottom: 4) + : null, ), // Show truncation hint if post is truncated if (item.isTruncated && !isFullPost) @@ -415,6 +417,10 @@ Widget _buildReferencePost(BuildContext context, SnPost item) { content: referencePost.content!, textStyle: const TextStyle(fontSize: 14), isSelectable: false, + linesMargin: + referencePost.type == 0 + ? EdgeInsets.only(bottom: 4) + : null, ).padding(bottom: 4), // Truncation hint for referenced post if (referencePost.isTruncated)