♻️ Refactor post draft with drift db

This commit is contained in:
LittleSheep 2025-06-24 23:39:09 +08:00
parent 2287995cb4
commit 568d70fffb
11 changed files with 2263 additions and 639 deletions

View File

@ -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
View 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};
}

View File

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

View File

@ -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,7 +241,13 @@ class PostComposeScreen extends HookConsumerWidget {
}
// Build UI
return AppScaffold(
return PopScope(
onPopInvoked: (_) {
if (originalPost == null) {
ComposeLogic.saveDraft(ref, state);
}
},
child: AppScaffold(
noBackground: false,
appBar: AppBar(
leading: const PageBackButton(),
@ -247,15 +259,22 @@ class PostComposeScreen extends HookConsumerWidget {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => DraftManagerSheet(
builder:
(context) => DraftManagerSheet(
isArticle: false,
onDraftSelected: (draftId) {
final draft = ref.read(composeStorageNotifierProvider)[draftId];
final draft =
ref.read(
composeStorageNotifierProvider,
)[draftId];
if (draft != null) {
state.titleController.text = draft.title;
state.descriptionController.text = draft.description;
state.descriptionController.text =
draft.description;
state.contentController.text = draft.content;
state.visibility.value = _parseVisibility(draft.visibility);
state.visibility.value = _parseVisibility(
draft.visibility,
);
}
},
),
@ -263,6 +282,11 @@ class PostComposeScreen extends HookConsumerWidget {
},
tooltip: 'drafts'.tr(),
),
IconButton(
icon: const Icon(Symbols.save),
onPressed: () => ComposeLogic.saveDraft(ref, state),
tooltip: 'saveDraft'.tr(),
),
IconButton(
icon: const Icon(Symbols.settings),
onPressed: showSettingsSheet,
@ -295,7 +319,9 @@ class PostComposeScreen extends HookConsumerWidget {
),
).center()
: Icon(
originalPost != null ? Symbols.edit : Symbols.upload,
originalPost != null
? Symbols.edit
: Symbols.upload,
),
);
},
@ -429,6 +455,7 @@ class PostComposeScreen extends HookConsumerWidget {
),
],
),
),
);
}

View File

@ -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,7 +355,13 @@ class ArticleComposeScreen extends HookConsumerWidget {
);
}
return AppScaffold(
return PopScope(
onPopInvoked: (_) {
if (originalPost == null) {
_saveArticleDraft(ref, state);
}
},
child: AppScaffold(
noBackground: false,
appBar: AppBar(
leading: const PageBackButton(),
@ -382,7 +388,9 @@ class ArticleComposeScreen extends HookConsumerWidget {
isArticle: true,
onDraftSelected: (draftId) {
final draft =
ref.read(articleStorageNotifierProvider)[draftId];
ref.read(
articleStorageNotifierProvider,
)[draftId];
if (draft != null) {
state.titleController.text = draft.title;
state.descriptionController.text =
@ -398,6 +406,11 @@ class ArticleComposeScreen extends HookConsumerWidget {
},
tooltip: 'drafts'.tr(),
),
IconButton(
icon: const Icon(Symbols.save),
onPressed: () => _saveArticleDraft(ref, state),
tooltip: 'saveDraft'.tr(),
),
IconButton(
icon: const Icon(Symbols.settings),
onPressed: showSettingsSheet,
@ -435,7 +448,9 @@ class ArticleComposeScreen extends HookConsumerWidget {
),
).center()
: Icon(
originalPost != null ? Symbols.edit : Symbols.upload,
originalPost != null
? Symbols.edit
: Symbols.upload,
),
);
},
@ -491,13 +506,44 @@ class ArticleComposeScreen extends HookConsumerWidget {
),
],
),
),
);
}
// 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(
final draft = ArticleDraftModel(
id: state.draftId,
title: state.titleController.text,
description: state.descriptionController.text,
@ -511,7 +557,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
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) {

View File

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

View 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;
}
}
}

View File

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

View File

@ -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),
@ -151,7 +151,7 @@ class ComposeLogic {
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,

View File

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