♻️ Refactor post draft with drift db
This commit is contained in:
parent
2287995cb4
commit
568d70fffb
@ -409,6 +409,7 @@
|
||||
"noDrafts": "No drafts yet",
|
||||
"articleDrafts": "Article drafts",
|
||||
"postDrafts": "Post drafts",
|
||||
"saveDraft": "Save draft",
|
||||
"clearAllDrafts": "Clear All Drafts",
|
||||
"clearAllDraftsConfirm": "Are you sure you want to delete all drafts? This action cannot be undone.",
|
||||
"clearAll": "Clear All",
|
||||
|
26
lib/database/draft.dart
Normal file
26
lib/database/draft.dart
Normal file
@ -0,0 +1,26 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
class ComposeDrafts extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get title => text().withDefault(const Constant(''))();
|
||||
TextColumn get description => text().withDefault(const Constant(''))();
|
||||
TextColumn get content => text().withDefault(const Constant(''))();
|
||||
TextColumn get attachmentIds => text().withDefault(const Constant('[]'))(); // JSON array as string
|
||||
TextColumn get visibility => text().withDefault(const Constant('public'))();
|
||||
DateTimeColumn get lastModified => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class ArticleDrafts extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get title => text().withDefault(const Constant(''))();
|
||||
TextColumn get description => text().withDefault(const Constant(''))();
|
||||
TextColumn get content => text().withDefault(const Constant(''))();
|
||||
TextColumn get visibility => text().withDefault(const Constant('public'))();
|
||||
DateTimeColumn get lastModified => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
@ -1,16 +1,17 @@
|
||||
import 'dart:convert';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:island/database/message.dart';
|
||||
import 'package:island/database/draft.dart';
|
||||
|
||||
part 'drift_db.g.dart';
|
||||
|
||||
// Define the database
|
||||
@DriftDatabase(tables: [ChatMessages])
|
||||
@DriftDatabase(tables: [ChatMessages, ComposeDrafts, ArticleDrafts])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase(super.e);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 2;
|
||||
int get schemaVersion => 3;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@ -22,6 +23,11 @@ class AppDatabase extends _$AppDatabase {
|
||||
// Add isRead column with default value false
|
||||
await m.addColumn(chatMessages, chatMessages.isRead);
|
||||
}
|
||||
if (from < 3) {
|
||||
// Add draft tables
|
||||
await m.createTable(composeDrafts);
|
||||
await m.createTable(articleDrafts);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -91,4 +97,52 @@ class AppDatabase extends _$AppDatabase {
|
||||
isRead: dbMessage.isRead,
|
||||
);
|
||||
}
|
||||
|
||||
// Methods for compose drafts
|
||||
Future<List<ComposeDraft>> getAllComposeDrafts() {
|
||||
return (select(composeDrafts)
|
||||
..orderBy([(d) => OrderingTerm.desc(d.lastModified)]))
|
||||
.get();
|
||||
}
|
||||
|
||||
Future<ComposeDraft?> getComposeDraft(String id) {
|
||||
return (select(composeDrafts)..where((d) => d.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<int> saveComposeDraft(ComposeDraftsCompanion draft) {
|
||||
return into(composeDrafts).insert(draft, mode: InsertMode.insertOrReplace);
|
||||
}
|
||||
|
||||
Future<int> deleteComposeDraft(String id) {
|
||||
return (delete(composeDrafts)..where((d) => d.id.equals(id))).go();
|
||||
}
|
||||
|
||||
Future<int> clearAllComposeDrafts() {
|
||||
return delete(composeDrafts).go();
|
||||
}
|
||||
|
||||
// Methods for article drafts
|
||||
Future<List<ArticleDraft>> getAllArticleDrafts() {
|
||||
return (select(articleDrafts)
|
||||
..orderBy([(d) => OrderingTerm.desc(d.lastModified)]))
|
||||
.get();
|
||||
}
|
||||
|
||||
Future<ArticleDraft?> getArticleDraft(String id) {
|
||||
return (select(articleDrafts)..where((d) => d.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<int> saveArticleDraft(ArticleDraftsCompanion draft) {
|
||||
return into(articleDrafts).insert(draft, mode: InsertMode.insertOrReplace);
|
||||
}
|
||||
|
||||
Future<int> deleteArticleDraft(String id) {
|
||||
return (delete(articleDrafts)..where((d) => d.id.equals(id))).go();
|
||||
}
|
||||
|
||||
Future<int> clearAllArticleDrafts() {
|
||||
return delete(articleDrafts).go();
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -17,7 +17,7 @@ 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/services/compose_storage_db.dart';
|
||||
import 'package:island/widgets/post/draft_manager.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
@ -65,11 +65,16 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
// 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;
|
||||
case 'public':
|
||||
return 0;
|
||||
case 'unlisted':
|
||||
return 1;
|
||||
case 'friends':
|
||||
return 2;
|
||||
case 'private':
|
||||
return 3;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,7 +108,8 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
|
||||
// Start auto-save when component mounts
|
||||
useEffect(() {
|
||||
if (originalPost == null) { // Only auto-save for new posts, not edits
|
||||
if (originalPost == null) {
|
||||
// Only auto-save for new posts, not edits
|
||||
state.startAutoSave(ref);
|
||||
}
|
||||
return () => state.stopAutoSave();
|
||||
@ -119,19 +125,24 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
|
||||
// Load draft if available (only for new posts)
|
||||
useEffect(() {
|
||||
if (originalPost == null && effectiveForwardedPost == null && effectiveRepliedPost == null) {
|
||||
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);
|
||||
|
||||
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);
|
||||
state.visibility.value = _parseVisibility(
|
||||
mostRecentDraft.visibility,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -141,12 +152,7 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
// Dispose state when widget is disposed
|
||||
useEffect(() {
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
@ -235,199 +241,220 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
// Build UI
|
||||
return AppScaffold(
|
||||
noBackground: false,
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
actions: [
|
||||
if (originalPost == null) // Only show drafts for new posts
|
||||
return PopScope(
|
||||
onPopInvoked: (_) {
|
||||
if (originalPost == null) {
|
||||
ComposeLogic.saveDraft(ref, state);
|
||||
}
|
||||
},
|
||||
child: AppScaffold(
|
||||
noBackground: false,
|
||||
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.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);
|
||||
}
|
||||
},
|
||||
),
|
||||
icon: const Icon(Symbols.save),
|
||||
onPressed: () => ComposeLogic.saveDraft(ref, state),
|
||||
tooltip: 'saveDraft'.tr(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.settings),
|
||||
onPressed: showSettingsSheet,
|
||||
tooltip: 'postSettings'.tr(),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: state.submitting,
|
||||
builder: (context, submitting, _) {
|
||||
return IconButton(
|
||||
onPressed:
|
||||
submitting
|
||||
? null
|
||||
: () => ComposeLogic.performAction(
|
||||
ref,
|
||||
state,
|
||||
context,
|
||||
originalPost: originalPost,
|
||||
repliedPost: repliedPost,
|
||||
forwardedPost: forwardedPost,
|
||||
postType: 0, // Regular post type
|
||||
),
|
||||
icon:
|
||||
submitting
|
||||
? SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: const CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
).center()
|
||||
: Icon(
|
||||
originalPost != null
|
||||
? Symbols.edit
|
||||
: Symbols.upload,
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'drafts'.tr(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.settings),
|
||||
onPressed: showSettingsSheet,
|
||||
tooltip: 'postSettings'.tr(),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: state.submitting,
|
||||
builder: (context, submitting, _) {
|
||||
return IconButton(
|
||||
onPressed:
|
||||
submitting
|
||||
? null
|
||||
: () => ComposeLogic.performAction(
|
||||
ref,
|
||||
state,
|
||||
context,
|
||||
originalPost: originalPost,
|
||||
repliedPost: repliedPost,
|
||||
forwardedPost: forwardedPost,
|
||||
postType: 0, // Regular post type
|
||||
),
|
||||
icon:
|
||||
submitting
|
||||
? SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: const CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
).center()
|
||||
: Icon(
|
||||
originalPost != null ? Symbols.edit : Symbols.upload,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Reply/Forward info section
|
||||
_buildInfoBanner(context),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Reply/Forward info section
|
||||
_buildInfoBanner(context),
|
||||
|
||||
// Main content area
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Publisher profile picture
|
||||
GestureDetector(
|
||||
child: ProfilePictureWidget(
|
||||
fileId: state.currentPublisher.value?.picture?.id,
|
||||
radius: 20,
|
||||
fallbackIcon:
|
||||
state.currentPublisher.value == null
|
||||
? Symbols.question_mark
|
||||
: null,
|
||||
),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => const PublisherModal(),
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
state.currentPublisher.value = value;
|
||||
}
|
||||
});
|
||||
},
|
||||
).padding(top: 16),
|
||||
// Main content area
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Publisher profile picture
|
||||
GestureDetector(
|
||||
child: ProfilePictureWidget(
|
||||
fileId: state.currentPublisher.value?.picture?.id,
|
||||
radius: 20,
|
||||
fallbackIcon:
|
||||
state.currentPublisher.value == null
|
||||
? Symbols.question_mark
|
||||
: null,
|
||||
),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => const PublisherModal(),
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
state.currentPublisher.value = value;
|
||||
}
|
||||
});
|
||||
},
|
||||
).padding(top: 16),
|
||||
|
||||
// Post content form
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Content field with borderless design
|
||||
RawKeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
onKey:
|
||||
(event) => ComposeLogic.handleKeyPress(
|
||||
event,
|
||||
state,
|
||||
ref,
|
||||
context,
|
||||
originalPost: originalPost,
|
||||
repliedPost: repliedPost,
|
||||
forwardedPost: forwardedPost,
|
||||
postType: 0, // Regular post type
|
||||
// Post content form
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Content field with borderless design
|
||||
RawKeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
onKey:
|
||||
(event) => ComposeLogic.handleKeyPress(
|
||||
event,
|
||||
state,
|
||||
ref,
|
||||
context,
|
||||
originalPost: originalPost,
|
||||
repliedPost: repliedPost,
|
||||
forwardedPost: forwardedPost,
|
||||
postType: 0, // Regular post type
|
||||
),
|
||||
child: TextField(
|
||||
controller: state.contentController,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'postContent'.tr(),
|
||||
contentPadding: const EdgeInsets.all(8),
|
||||
),
|
||||
child: TextField(
|
||||
controller: state.contentController,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'postContent'.tr(),
|
||||
contentPadding: const EdgeInsets.all(8),
|
||||
maxLines: null,
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus
|
||||
?.unfocus(),
|
||||
),
|
||||
maxLines: null,
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus
|
||||
?.unfocus(),
|
||||
),
|
||||
),
|
||||
|
||||
const Gap(8),
|
||||
const Gap(8),
|
||||
|
||||
// Attachments preview
|
||||
ValueListenableBuilder<List<UniversalFile>>(
|
||||
valueListenable: state.attachments,
|
||||
builder: (context, attachments, _) {
|
||||
if (attachments.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = isWideScreen(context);
|
||||
return isWide
|
||||
? buildWideAttachmentGrid()
|
||||
: buildNarrowAttachmentList();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
// Attachments preview
|
||||
ValueListenableBuilder<List<UniversalFile>>(
|
||||
valueListenable: state.attachments,
|
||||
builder: (context, attachments, _) {
|
||||
if (attachments.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = isWideScreen(context);
|
||||
return isWide
|
||||
? buildWideAttachmentGrid()
|
||||
: buildNarrowAttachmentList();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16),
|
||||
).alignment(Alignment.topCenter),
|
||||
),
|
||||
|
||||
// Bottom toolbar
|
||||
Material(
|
||||
elevation: 4,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => ComposeLogic.pickPhotoMedia(ref, state),
|
||||
icon: const Icon(Symbols.add_a_photo),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => ComposeLogic.pickVideoMedia(ref, state),
|
||||
icon: const Icon(Symbols.videocam),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16),
|
||||
).alignment(Alignment.topCenter),
|
||||
),
|
||||
|
||||
// Bottom toolbar
|
||||
Material(
|
||||
elevation: 4,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => ComposeLogic.pickPhotoMedia(ref, state),
|
||||
icon: const Icon(Symbols.add_a_photo),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => ComposeLogic.pickVideoMedia(ref, state),
|
||||
icon: const Icon(Symbols.videocam),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
],
|
||||
).padding(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||
horizontal: 16,
|
||||
top: 8,
|
||||
).padding(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||
horizontal: 16,
|
||||
top: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import 'dart:developer';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@ -17,8 +18,8 @@ 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/compose_settings_sheet.dart';
|
||||
import 'package:island/services/compose_storage_db.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';
|
||||
@ -74,10 +75,13 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
||||
});
|
||||
}
|
||||
return () {
|
||||
// Save final draft before cancelling timer
|
||||
// Stop auto-save first to prevent race conditions
|
||||
state.stopAutoSave();
|
||||
// Save final draft before disposing
|
||||
if (originalPost == null) {
|
||||
_saveArticleDraft(ref, state);
|
||||
}
|
||||
ComposeLogic.dispose(state);
|
||||
autoSaveTimer?.cancel();
|
||||
};
|
||||
}, [state]);
|
||||
@ -116,13 +120,10 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// Dispose state when widget is disposed
|
||||
// Auto-save cleanup
|
||||
useEffect(() {
|
||||
return () {
|
||||
// Save final draft before disposing
|
||||
if (originalPost == null) {
|
||||
_saveArticleDraft(ref, state);
|
||||
}
|
||||
state.stopAutoSave();
|
||||
ComposeLogic.dispose(state);
|
||||
};
|
||||
}, [state]);
|
||||
@ -273,13 +274,12 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
||||
child: RawKeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
onKey:
|
||||
(event) => ComposeLogic.handleKeyPress(
|
||||
(event) => _handleKeyPress(
|
||||
event,
|
||||
state,
|
||||
ref,
|
||||
context,
|
||||
originalPost: originalPost,
|
||||
postType: 1, // Article type
|
||||
),
|
||||
child: TextField(
|
||||
controller: state.contentController,
|
||||
@ -355,163 +355,209 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
noBackground: false,
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: state.titleController,
|
||||
builder: (context, titleValue, _) {
|
||||
return Text(
|
||||
titleValue.text.isEmpty ? 'postTitle'.tr() : titleValue.text,
|
||||
);
|
||||
},
|
||||
),
|
||||
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,
|
||||
tooltip: 'postSettings'.tr(),
|
||||
),
|
||||
Tooltip(
|
||||
message: 'togglePreview'.tr(),
|
||||
child: IconButton(
|
||||
icon: Icon(showPreview.value ? Symbols.edit : Symbols.preview),
|
||||
onPressed: () => showPreview.value = !showPreview.value,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: state.submitting,
|
||||
builder: (context, submitting, _) {
|
||||
return IconButton(
|
||||
onPressed:
|
||||
submitting
|
||||
? null
|
||||
: () => ComposeLogic.performAction(
|
||||
ref,
|
||||
state,
|
||||
context,
|
||||
originalPost: originalPost,
|
||||
postType: 1, // Article type
|
||||
),
|
||||
icon:
|
||||
submitting
|
||||
? SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: const CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
).center()
|
||||
: Icon(
|
||||
originalPost != null ? Symbols.edit : Symbols.upload,
|
||||
),
|
||||
return PopScope(
|
||||
onPopInvoked: (_) {
|
||||
if (originalPost == null) {
|
||||
_saveArticleDraft(ref, state);
|
||||
}
|
||||
},
|
||||
child: AppScaffold(
|
||||
noBackground: false,
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: state.titleController,
|
||||
builder: (context, titleValue, _) {
|
||||
return Text(
|
||||
titleValue.text.isEmpty ? 'postTitle'.tr() : titleValue.text,
|
||||
);
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||
child:
|
||||
isWideScreen(context)
|
||||
? Row(
|
||||
spacing: 16,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: showPreview.value ? 1 : 2,
|
||||
child: buildEditorPane(),
|
||||
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.save),
|
||||
onPressed: () => _saveArticleDraft(ref, state),
|
||||
tooltip: 'saveDraft'.tr(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.settings),
|
||||
onPressed: showSettingsSheet,
|
||||
tooltip: 'postSettings'.tr(),
|
||||
),
|
||||
Tooltip(
|
||||
message: 'togglePreview'.tr(),
|
||||
child: IconButton(
|
||||
icon: Icon(showPreview.value ? Symbols.edit : Symbols.preview),
|
||||
onPressed: () => showPreview.value = !showPreview.value,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: state.submitting,
|
||||
builder: (context, submitting, _) {
|
||||
return IconButton(
|
||||
onPressed:
|
||||
submitting
|
||||
? null
|
||||
: () => ComposeLogic.performAction(
|
||||
ref,
|
||||
state,
|
||||
context,
|
||||
originalPost: originalPost,
|
||||
postType: 1, // Article type
|
||||
),
|
||||
if (showPreview.value)
|
||||
Expanded(child: buildPreviewPane()),
|
||||
],
|
||||
)
|
||||
: showPreview.value
|
||||
? buildPreviewPane()
|
||||
: buildEditorPane(),
|
||||
icon:
|
||||
submitting
|
||||
? SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: const CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
).center()
|
||||
: Icon(
|
||||
originalPost != null
|
||||
? Symbols.edit
|
||||
: Symbols.upload,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||
child:
|
||||
isWideScreen(context)
|
||||
? Row(
|
||||
spacing: 16,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: showPreview.value ? 1 : 2,
|
||||
child: buildEditorPane(),
|
||||
),
|
||||
if (showPreview.value)
|
||||
Expanded(child: buildPreviewPane()),
|
||||
],
|
||||
)
|
||||
: showPreview.value
|
||||
? buildPreviewPane()
|
||||
: buildEditorPane(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom toolbar
|
||||
Material(
|
||||
elevation: 4,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => ComposeLogic.pickPhotoMedia(ref, state),
|
||||
icon: const Icon(Symbols.add_a_photo),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => ComposeLogic.pickVideoMedia(ref, state),
|
||||
icon: const Icon(Symbols.videocam),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
],
|
||||
).padding(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||
horizontal: 16,
|
||||
top: 8,
|
||||
// Bottom toolbar
|
||||
Material(
|
||||
elevation: 4,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => ComposeLogic.pickPhotoMedia(ref, state),
|
||||
icon: const Icon(Symbols.add_a_photo),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => ComposeLogic.pickVideoMedia(ref, state),
|
||||
icon: const Icon(Symbols.videocam),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
],
|
||||
).padding(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||
horizontal: 16,
|
||||
top: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Helper method to handle keyboard shortcuts
|
||||
void _handleKeyPress(
|
||||
RawKeyEvent event,
|
||||
ComposeState state,
|
||||
WidgetRef ref,
|
||||
BuildContext context, {
|
||||
SnPost? originalPost,
|
||||
}) {
|
||||
if (event is! RawKeyDownEvent) return;
|
||||
|
||||
final isPaste = event.logicalKey == LogicalKeyboardKey.keyV;
|
||||
final isSave = event.logicalKey == LogicalKeyboardKey.keyS;
|
||||
final isModifierPressed = event.isMetaPressed || event.isControlPressed;
|
||||
final isSubmit = event.logicalKey == LogicalKeyboardKey.enter;
|
||||
|
||||
if (isPaste && isModifierPressed) {
|
||||
ComposeLogic.handlePaste(state);
|
||||
} else if (isSave && isModifierPressed) {
|
||||
_saveArticleDraft(ref, state);
|
||||
} else if (isSubmit && isModifierPressed && !state.submitting.value) {
|
||||
ComposeLogic.performAction(
|
||||
ref,
|
||||
state,
|
||||
context,
|
||||
originalPost: originalPost,
|
||||
postType: 1, // Article type
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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(),
|
||||
);
|
||||
try {
|
||||
final draft = ArticleDraftModel(
|
||||
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
|
||||
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) {
|
||||
|
@ -1,270 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
341
lib/services/compose_storage_db.dart
Normal file
341
lib/services/compose_storage_db.dart
Normal file
@ -0,0 +1,341 @@
|
||||
import 'dart:convert';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:island/database/drift_db.dart';
|
||||
import 'package:island/pods/database.dart';
|
||||
|
||||
part 'compose_storage_db.g.dart';
|
||||
|
||||
class ComposeDraftModel {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final String content;
|
||||
final List<String> attachmentIds;
|
||||
final String visibility;
|
||||
final DateTime lastModified;
|
||||
|
||||
ComposeDraftModel({
|
||||
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 ComposeDraftModel.fromJson(Map<String, dynamic> json) => ComposeDraftModel(
|
||||
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),
|
||||
);
|
||||
|
||||
factory ComposeDraftModel.fromDbRow(ComposeDraft row) => ComposeDraftModel(
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
content: row.content,
|
||||
attachmentIds: List<String>.from(jsonDecode(row.attachmentIds)),
|
||||
visibility: row.visibility,
|
||||
lastModified: row.lastModified,
|
||||
);
|
||||
|
||||
ComposeDraftsCompanion toDbCompanion() => ComposeDraftsCompanion(
|
||||
id: Value(id),
|
||||
title: Value(title),
|
||||
description: Value(description),
|
||||
content: Value(content),
|
||||
attachmentIds: Value(jsonEncode(attachmentIds)),
|
||||
visibility: Value(visibility),
|
||||
lastModified: Value(lastModified),
|
||||
);
|
||||
|
||||
ComposeDraftModel copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? description,
|
||||
String? content,
|
||||
List<String>? attachmentIds,
|
||||
String? visibility,
|
||||
DateTime? lastModified,
|
||||
}) {
|
||||
return ComposeDraftModel(
|
||||
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 ArticleDraftModel {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final String content;
|
||||
final String visibility;
|
||||
final DateTime lastModified;
|
||||
|
||||
ArticleDraftModel({
|
||||
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 ArticleDraftModel.fromJson(Map<String, dynamic> json) => ArticleDraftModel(
|
||||
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),
|
||||
);
|
||||
|
||||
factory ArticleDraftModel.fromDbRow(ArticleDraft row) => ArticleDraftModel(
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
content: row.content,
|
||||
visibility: row.visibility,
|
||||
lastModified: row.lastModified,
|
||||
);
|
||||
|
||||
ArticleDraftsCompanion toDbCompanion() => ArticleDraftsCompanion(
|
||||
id: Value(id),
|
||||
title: Value(title),
|
||||
description: Value(description),
|
||||
content: Value(content),
|
||||
visibility: Value(visibility),
|
||||
lastModified: Value(lastModified),
|
||||
);
|
||||
|
||||
ArticleDraftModel copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? description,
|
||||
String? content,
|
||||
String? visibility,
|
||||
DateTime? lastModified,
|
||||
}) {
|
||||
return ArticleDraftModel(
|
||||
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, ComposeDraftModel> build() {
|
||||
_loadDrafts();
|
||||
return {};
|
||||
}
|
||||
|
||||
void _loadDrafts() async {
|
||||
try {
|
||||
final database = ref.read(databaseProvider);
|
||||
final dbDrafts = await database.getAllComposeDrafts();
|
||||
final drafts = <String, ComposeDraftModel>{};
|
||||
for (final dbDraft in dbDrafts) {
|
||||
final draft = ComposeDraftModel.fromDbRow(dbDraft);
|
||||
drafts[draft.id] = draft;
|
||||
}
|
||||
state = drafts;
|
||||
} catch (e) {
|
||||
// If there's an error loading drafts, start with empty state
|
||||
state = {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveDraft(ComposeDraftModel draft) async {
|
||||
if (draft.isEmpty) {
|
||||
await deleteDraft(draft.id);
|
||||
return;
|
||||
}
|
||||
|
||||
final updatedDraft = draft.copyWith(lastModified: DateTime.now());
|
||||
state = {...state, updatedDraft.id: updatedDraft};
|
||||
|
||||
try {
|
||||
final database = ref.read(databaseProvider);
|
||||
await database.saveComposeDraft(updatedDraft.toDbCompanion());
|
||||
} catch (e) {
|
||||
// Revert state on error
|
||||
final newState = Map<String, ComposeDraftModel>.from(state);
|
||||
newState.remove(updatedDraft.id);
|
||||
state = newState;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteDraft(String id) async {
|
||||
final oldDraft = state[id];
|
||||
final newState = Map<String, ComposeDraftModel>.from(state);
|
||||
newState.remove(id);
|
||||
state = newState;
|
||||
|
||||
try {
|
||||
final database = ref.read(databaseProvider);
|
||||
await database.deleteComposeDraft(id);
|
||||
} catch (e) {
|
||||
// Revert state on error
|
||||
if (oldDraft != null) {
|
||||
state = {...state, id: oldDraft};
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
ComposeDraftModel? getDraft(String id) {
|
||||
return state[id];
|
||||
}
|
||||
|
||||
List<ComposeDraftModel> getAllDrafts() {
|
||||
final drafts = state.values.toList();
|
||||
drafts.sort((a, b) => b.lastModified.compareTo(a.lastModified));
|
||||
return drafts;
|
||||
}
|
||||
|
||||
Future<void> clearAllDrafts() async {
|
||||
state = {};
|
||||
|
||||
try {
|
||||
final database = ref.read(databaseProvider);
|
||||
await database.clearAllComposeDrafts();
|
||||
} catch (e) {
|
||||
// If clearing fails, we might want to reload from database
|
||||
_loadDrafts();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class ArticleStorageNotifier extends _$ArticleStorageNotifier {
|
||||
@override
|
||||
Map<String, ArticleDraftModel> build() {
|
||||
_loadDrafts();
|
||||
return {};
|
||||
}
|
||||
|
||||
void _loadDrafts() async {
|
||||
try {
|
||||
final database = ref.read(databaseProvider);
|
||||
final dbDrafts = await database.getAllArticleDrafts();
|
||||
final drafts = <String, ArticleDraftModel>{};
|
||||
for (final dbDraft in dbDrafts) {
|
||||
final draft = ArticleDraftModel.fromDbRow(dbDraft);
|
||||
drafts[draft.id] = draft;
|
||||
}
|
||||
state = drafts;
|
||||
} catch (e) {
|
||||
// If there's an error loading drafts, start with empty state
|
||||
state = {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveDraft(ArticleDraftModel draft) async {
|
||||
if (draft.isEmpty) {
|
||||
await deleteDraft(draft.id);
|
||||
return;
|
||||
}
|
||||
|
||||
final updatedDraft = draft.copyWith(lastModified: DateTime.now());
|
||||
state = {...state, updatedDraft.id: updatedDraft};
|
||||
|
||||
try {
|
||||
final database = ref.read(databaseProvider);
|
||||
await database.saveArticleDraft(updatedDraft.toDbCompanion());
|
||||
} catch (e) {
|
||||
// Revert state on error
|
||||
final newState = Map<String, ArticleDraftModel>.from(state);
|
||||
newState.remove(updatedDraft.id);
|
||||
state = newState;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteDraft(String id) async {
|
||||
final oldDraft = state[id];
|
||||
final newState = Map<String, ArticleDraftModel>.from(state);
|
||||
newState.remove(id);
|
||||
state = newState;
|
||||
|
||||
try {
|
||||
final database = ref.read(databaseProvider);
|
||||
await database.deleteArticleDraft(id);
|
||||
} catch (e) {
|
||||
// Revert state on error
|
||||
if (oldDraft != null) {
|
||||
state = {...state, id: oldDraft};
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
ArticleDraftModel? getDraft(String id) {
|
||||
return state[id];
|
||||
}
|
||||
|
||||
List<ArticleDraftModel> getAllDrafts() {
|
||||
final drafts = state.values.toList();
|
||||
drafts.sort((a, b) => b.lastModified.compareTo(a.lastModified));
|
||||
return drafts;
|
||||
}
|
||||
|
||||
Future<void> clearAllDrafts() async {
|
||||
state = {};
|
||||
|
||||
try {
|
||||
final database = ref.read(databaseProvider);
|
||||
await database.clearAllArticleDrafts();
|
||||
} catch (e) {
|
||||
// If clearing fails, we might want to reload from database
|
||||
_loadDrafts();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,19 +1,19 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'compose_storage.dart';
|
||||
part of 'compose_storage_db.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$composeStorageNotifierHash() =>
|
||||
r'99c5e4070fa8af2771751064b56ca3251dbda27a';
|
||||
r'57d3812b8fd430e6144f72708c694ddceea34c17';
|
||||
|
||||
/// See also [ComposeStorageNotifier].
|
||||
@ProviderFor(ComposeStorageNotifier)
|
||||
final composeStorageNotifierProvider = AutoDisposeNotifierProvider<
|
||||
ComposeStorageNotifier,
|
||||
Map<String, ComposeDraft>
|
||||
Map<String, ComposeDraftModel>
|
||||
>.internal(
|
||||
ComposeStorageNotifier.new,
|
||||
name: r'composeStorageNotifierProvider',
|
||||
@ -26,15 +26,15 @@ final composeStorageNotifierProvider = AutoDisposeNotifierProvider<
|
||||
);
|
||||
|
||||
typedef _$ComposeStorageNotifier =
|
||||
AutoDisposeNotifier<Map<String, ComposeDraft>>;
|
||||
AutoDisposeNotifier<Map<String, ComposeDraftModel>>;
|
||||
String _$articleStorageNotifierHash() =>
|
||||
r'4a200878bfe7881fc3afd2164b334e84dc44f338';
|
||||
r'21ee0f8ee87528bebf8f5f4b0b2892cd8058e230';
|
||||
|
||||
/// See also [ArticleStorageNotifier].
|
||||
@ProviderFor(ArticleStorageNotifier)
|
||||
final articleStorageNotifierProvider = AutoDisposeNotifierProvider<
|
||||
ArticleStorageNotifier,
|
||||
Map<String, ArticleDraft>
|
||||
Map<String, ArticleDraftModel>
|
||||
>.internal(
|
||||
ArticleStorageNotifier.new,
|
||||
name: r'articleStorageNotifierProvider',
|
||||
@ -47,6 +47,6 @@ final articleStorageNotifierProvider = AutoDisposeNotifierProvider<
|
||||
);
|
||||
|
||||
typedef _$ArticleStorageNotifier =
|
||||
AutoDisposeNotifier<Map<String, ArticleDraft>>;
|
||||
AutoDisposeNotifier<Map<String, ArticleDraftModel>>;
|
||||
// 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
|
@ -11,7 +11,7 @@ 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/services/compose_storage_db.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'dart:async';
|
||||
@ -96,7 +96,7 @@ class ComposeLogic {
|
||||
);
|
||||
}
|
||||
|
||||
static ComposeState createStateFromDraft(ComposeDraft draft) {
|
||||
static ComposeState createStateFromDraft(ComposeDraftModel draft) {
|
||||
return ComposeState(
|
||||
attachments: ValueNotifier<List<UniversalFile>>([]),
|
||||
titleController: TextEditingController(text: draft.title),
|
||||
@ -150,8 +150,8 @@ class ComposeLogic {
|
||||
if (state._autoSaveTimer == null) {
|
||||
return; // Widget has been disposed, don't save
|
||||
}
|
||||
|
||||
final draft = ComposeDraft(
|
||||
|
||||
final draft = ComposeDraftModel(
|
||||
id: state.draftId,
|
||||
title: state.titleController.text,
|
||||
description: state.descriptionController.text,
|
||||
@ -182,7 +182,7 @@ class ComposeLogic {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<ComposeDraft?> loadDraft(WidgetRef ref, String draftId) async {
|
||||
static Future<ComposeDraftModel?> loadDraft(WidgetRef ref, String draftId) async {
|
||||
try {
|
||||
return ref
|
||||
.read(composeStorageNotifierProvider.notifier)
|
||||
@ -410,11 +410,14 @@ class ComposeLogic {
|
||||
if (event is! RawKeyDownEvent) return;
|
||||
|
||||
final isPaste = event.logicalKey == LogicalKeyboardKey.keyV;
|
||||
final isSave = event.logicalKey == LogicalKeyboardKey.keyS;
|
||||
final isModifierPressed = event.isMetaPressed || event.isControlPressed;
|
||||
final isSubmit = event.logicalKey == LogicalKeyboardKey.enter;
|
||||
|
||||
if (isPaste && isModifierPressed) {
|
||||
handlePaste(state);
|
||||
} else if (isSave && isModifierPressed) {
|
||||
saveDraft(ref, state);
|
||||
} else if (isSubmit && isModifierPressed && !state.submitting.value) {
|
||||
performAction(
|
||||
ref,
|
||||
|
@ -3,7 +3,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/services/compose_storage.dart';
|
||||
import 'package:island/services/compose_storage_db.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class DraftManagerSheet extends HookConsumerWidget {
|
||||
@ -28,11 +28,11 @@ class DraftManagerSheet extends HookConsumerWidget {
|
||||
|
||||
final sortedDrafts = useMemoized(() {
|
||||
if (isArticle) {
|
||||
final draftList = drafts.values.cast<ArticleDraft>().toList();
|
||||
final draftList = drafts.values.cast<ArticleDraftModel>().toList();
|
||||
draftList.sort((a, b) => b.lastModified.compareTo(a.lastModified));
|
||||
return draftList;
|
||||
} else {
|
||||
final draftList = drafts.values.cast<ComposeDraft>().toList();
|
||||
final draftList = drafts.values.cast<ComposeDraftModel>().toList();
|
||||
draftList.sort((a, b) => b.lastModified.compareTo(a.lastModified));
|
||||
return draftList;
|
||||
}
|
||||
@ -79,15 +79,15 @@ class DraftManagerSheet extends HookConsumerWidget {
|
||||
Navigator.of(context).pop();
|
||||
final draftId =
|
||||
isArticle
|
||||
? (draft as ArticleDraft).id
|
||||
: (draft as ComposeDraft).id;
|
||||
? (draft as ArticleDraftModel).id
|
||||
: (draft as ComposeDraftModel).id;
|
||||
onDraftSelected?.call(draftId);
|
||||
},
|
||||
onDelete: () async {
|
||||
final draftId =
|
||||
isArticle
|
||||
? (draft as ArticleDraft).id
|
||||
: (draft as ComposeDraft).id;
|
||||
? (draft as ArticleDraftModel).id
|
||||
: (draft as ComposeDraftModel).id;
|
||||
if (isArticle) {
|
||||
await ref
|
||||
.read(articleStorageNotifierProvider.notifier)
|
||||
@ -182,7 +182,7 @@ class _DraftItem extends StatelessWidget {
|
||||
final String visibility;
|
||||
|
||||
if (isArticle) {
|
||||
final articleDraft = draft as ArticleDraft;
|
||||
final articleDraft = draft as ArticleDraftModel;
|
||||
title =
|
||||
articleDraft.title.isNotEmpty ? articleDraft.title : 'untitled'.tr();
|
||||
content =
|
||||
@ -194,7 +194,7 @@ class _DraftItem extends StatelessWidget {
|
||||
lastModified = articleDraft.lastModified;
|
||||
visibility = _parseArticleVisibility(articleDraft.visibility);
|
||||
} else {
|
||||
final postDraft = draft as ComposeDraft;
|
||||
final postDraft = draft as ComposeDraftModel;
|
||||
title = postDraft.title.isNotEmpty ? postDraft.title : 'untitled'.tr();
|
||||
content =
|
||||
postDraft.content.isNotEmpty
|
||||
|
Loading…
x
Reference in New Issue
Block a user