diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 7dc9808..ec4051e 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -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", diff --git a/lib/database/draft.dart b/lib/database/draft.dart new file mode 100644 index 0000000..076397a --- /dev/null +++ b/lib/database/draft.dart @@ -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 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 get primaryKey => {id}; +} \ No newline at end of file diff --git a/lib/database/drift_db.dart b/lib/database/drift_db.dart index 436477b..3748f6c 100644 --- a/lib/database/drift_db.dart +++ b/lib/database/drift_db.dart @@ -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> getAllComposeDrafts() { + return (select(composeDrafts) + ..orderBy([(d) => OrderingTerm.desc(d.lastModified)])) + .get(); + } + + Future getComposeDraft(String id) { + return (select(composeDrafts)..where((d) => d.id.equals(id))) + .getSingleOrNull(); + } + + Future saveComposeDraft(ComposeDraftsCompanion draft) { + return into(composeDrafts).insert(draft, mode: InsertMode.insertOrReplace); + } + + Future deleteComposeDraft(String id) { + return (delete(composeDrafts)..where((d) => d.id.equals(id))).go(); + } + + Future clearAllComposeDrafts() { + return delete(composeDrafts).go(); + } + + // Methods for article drafts + Future> getAllArticleDrafts() { + return (select(articleDrafts) + ..orderBy([(d) => OrderingTerm.desc(d.lastModified)])) + .get(); + } + + Future getArticleDraft(String id) { + return (select(articleDrafts)..where((d) => d.id.equals(id))) + .getSingleOrNull(); + } + + Future saveArticleDraft(ArticleDraftsCompanion draft) { + return into(articleDrafts).insert(draft, mode: InsertMode.insertOrReplace); + } + + Future deleteArticleDraft(String id) { + return (delete(articleDrafts)..where((d) => d.id.equals(id))).go(); + } + + Future clearAllArticleDrafts() { + return delete(articleDrafts).go(); + } } diff --git a/lib/database/drift_db.g.dart b/lib/database/drift_db.g.dart index dd298e5..3cdabf1 100644 --- a/lib/database/drift_db.g.dart +++ b/lib/database/drift_db.g.dart @@ -569,15 +569,914 @@ class ChatMessagesCompanion extends UpdateCompanion { } } +class $ComposeDraftsTable extends ComposeDrafts + with TableInfo<$ComposeDraftsTable, ComposeDraft> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ComposeDraftsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant(''), + ); + static const VerificationMeta _descriptionMeta = const VerificationMeta( + 'description', + ); + @override + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant(''), + ); + static const VerificationMeta _contentMeta = const VerificationMeta( + 'content', + ); + @override + late final GeneratedColumn content = GeneratedColumn( + 'content', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant(''), + ); + static const VerificationMeta _attachmentIdsMeta = const VerificationMeta( + 'attachmentIds', + ); + @override + late final GeneratedColumn attachmentIds = GeneratedColumn( + 'attachment_ids', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('[]'), + ); + static const VerificationMeta _visibilityMeta = const VerificationMeta( + 'visibility', + ); + @override + late final GeneratedColumn visibility = GeneratedColumn( + 'visibility', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('public'), + ); + static const VerificationMeta _lastModifiedMeta = const VerificationMeta( + 'lastModified', + ); + @override + late final GeneratedColumn lastModified = GeneratedColumn( + 'last_modified', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + title, + description, + content, + attachmentIds, + visibility, + lastModified, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'compose_drafts'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } + if (data.containsKey('description')) { + context.handle( + _descriptionMeta, + description.isAcceptableOrUnknown( + data['description']!, + _descriptionMeta, + ), + ); + } + if (data.containsKey('content')) { + context.handle( + _contentMeta, + content.isAcceptableOrUnknown(data['content']!, _contentMeta), + ); + } + if (data.containsKey('attachment_ids')) { + context.handle( + _attachmentIdsMeta, + attachmentIds.isAcceptableOrUnknown( + data['attachment_ids']!, + _attachmentIdsMeta, + ), + ); + } + if (data.containsKey('visibility')) { + context.handle( + _visibilityMeta, + visibility.isAcceptableOrUnknown(data['visibility']!, _visibilityMeta), + ); + } + if (data.containsKey('last_modified')) { + context.handle( + _lastModifiedMeta, + lastModified.isAcceptableOrUnknown( + data['last_modified']!, + _lastModifiedMeta, + ), + ); + } else if (isInserting) { + context.missing(_lastModifiedMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ComposeDraft map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ComposeDraft( + id: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + title: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + description: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + )!, + content: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}content'], + )!, + attachmentIds: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}attachment_ids'], + )!, + visibility: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}visibility'], + )!, + lastModified: + attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_modified'], + )!, + ); + } + + @override + $ComposeDraftsTable createAlias(String alias) { + return $ComposeDraftsTable(attachedDatabase, alias); + } +} + +class ComposeDraft extends DataClass implements Insertable { + final String id; + final String title; + final String description; + final String content; + final String attachmentIds; + final String visibility; + final DateTime lastModified; + const ComposeDraft({ + required this.id, + required this.title, + required this.description, + required this.content, + required this.attachmentIds, + required this.visibility, + required this.lastModified, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['title'] = Variable(title); + map['description'] = Variable(description); + map['content'] = Variable(content); + map['attachment_ids'] = Variable(attachmentIds); + map['visibility'] = Variable(visibility); + map['last_modified'] = Variable(lastModified); + return map; + } + + ComposeDraftsCompanion toCompanion(bool nullToAbsent) { + return ComposeDraftsCompanion( + id: Value(id), + title: Value(title), + description: Value(description), + content: Value(content), + attachmentIds: Value(attachmentIds), + visibility: Value(visibility), + lastModified: Value(lastModified), + ); + } + + factory ComposeDraft.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ComposeDraft( + id: serializer.fromJson(json['id']), + title: serializer.fromJson(json['title']), + description: serializer.fromJson(json['description']), + content: serializer.fromJson(json['content']), + attachmentIds: serializer.fromJson(json['attachmentIds']), + visibility: serializer.fromJson(json['visibility']), + lastModified: serializer.fromJson(json['lastModified']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'title': serializer.toJson(title), + 'description': serializer.toJson(description), + 'content': serializer.toJson(content), + 'attachmentIds': serializer.toJson(attachmentIds), + 'visibility': serializer.toJson(visibility), + 'lastModified': serializer.toJson(lastModified), + }; + } + + ComposeDraft copyWith({ + String? id, + String? title, + String? description, + String? content, + String? attachmentIds, + String? visibility, + DateTime? lastModified, + }) => 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, + ); + ComposeDraft copyWithCompanion(ComposeDraftsCompanion data) { + return ComposeDraft( + id: data.id.present ? data.id.value : this.id, + title: data.title.present ? data.title.value : this.title, + description: + data.description.present ? data.description.value : this.description, + content: data.content.present ? data.content.value : this.content, + attachmentIds: + data.attachmentIds.present + ? data.attachmentIds.value + : this.attachmentIds, + visibility: + data.visibility.present ? data.visibility.value : this.visibility, + lastModified: + data.lastModified.present + ? data.lastModified.value + : this.lastModified, + ); + } + + @override + String toString() { + return (StringBuffer('ComposeDraft(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('description: $description, ') + ..write('content: $content, ') + ..write('attachmentIds: $attachmentIds, ') + ..write('visibility: $visibility, ') + ..write('lastModified: $lastModified') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + title, + description, + content, + attachmentIds, + visibility, + lastModified, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ComposeDraft && + other.id == this.id && + other.title == this.title && + other.description == this.description && + other.content == this.content && + other.attachmentIds == this.attachmentIds && + other.visibility == this.visibility && + other.lastModified == this.lastModified); +} + +class ComposeDraftsCompanion extends UpdateCompanion { + final Value id; + final Value title; + final Value description; + final Value content; + final Value attachmentIds; + final Value visibility; + final Value lastModified; + final Value rowid; + const ComposeDraftsCompanion({ + this.id = const Value.absent(), + this.title = const Value.absent(), + this.description = const Value.absent(), + this.content = const Value.absent(), + this.attachmentIds = const Value.absent(), + this.visibility = const Value.absent(), + this.lastModified = const Value.absent(), + this.rowid = const Value.absent(), + }); + ComposeDraftsCompanion.insert({ + required String id, + this.title = const Value.absent(), + this.description = const Value.absent(), + this.content = const Value.absent(), + this.attachmentIds = const Value.absent(), + this.visibility = const Value.absent(), + required DateTime lastModified, + this.rowid = const Value.absent(), + }) : id = Value(id), + lastModified = Value(lastModified); + static Insertable custom({ + Expression? id, + Expression? title, + Expression? description, + Expression? content, + Expression? attachmentIds, + Expression? visibility, + Expression? lastModified, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (title != null) 'title': title, + if (description != null) 'description': description, + if (content != null) 'content': content, + if (attachmentIds != null) 'attachment_ids': attachmentIds, + if (visibility != null) 'visibility': visibility, + if (lastModified != null) 'last_modified': lastModified, + if (rowid != null) 'rowid': rowid, + }); + } + + ComposeDraftsCompanion copyWith({ + Value? id, + Value? title, + Value? description, + Value? content, + Value? attachmentIds, + Value? visibility, + Value? lastModified, + Value? rowid, + }) { + return ComposeDraftsCompanion( + 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, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (content.present) { + map['content'] = Variable(content.value); + } + if (attachmentIds.present) { + map['attachment_ids'] = Variable(attachmentIds.value); + } + if (visibility.present) { + map['visibility'] = Variable(visibility.value); + } + if (lastModified.present) { + map['last_modified'] = Variable(lastModified.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ComposeDraftsCompanion(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('description: $description, ') + ..write('content: $content, ') + ..write('attachmentIds: $attachmentIds, ') + ..write('visibility: $visibility, ') + ..write('lastModified: $lastModified, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $ArticleDraftsTable extends ArticleDrafts + with TableInfo<$ArticleDraftsTable, ArticleDraft> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ArticleDraftsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant(''), + ); + static const VerificationMeta _descriptionMeta = const VerificationMeta( + 'description', + ); + @override + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant(''), + ); + static const VerificationMeta _contentMeta = const VerificationMeta( + 'content', + ); + @override + late final GeneratedColumn content = GeneratedColumn( + 'content', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant(''), + ); + static const VerificationMeta _visibilityMeta = const VerificationMeta( + 'visibility', + ); + @override + late final GeneratedColumn visibility = GeneratedColumn( + 'visibility', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('public'), + ); + static const VerificationMeta _lastModifiedMeta = const VerificationMeta( + 'lastModified', + ); + @override + late final GeneratedColumn lastModified = GeneratedColumn( + 'last_modified', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + title, + description, + content, + visibility, + lastModified, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'article_drafts'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } + if (data.containsKey('description')) { + context.handle( + _descriptionMeta, + description.isAcceptableOrUnknown( + data['description']!, + _descriptionMeta, + ), + ); + } + if (data.containsKey('content')) { + context.handle( + _contentMeta, + content.isAcceptableOrUnknown(data['content']!, _contentMeta), + ); + } + if (data.containsKey('visibility')) { + context.handle( + _visibilityMeta, + visibility.isAcceptableOrUnknown(data['visibility']!, _visibilityMeta), + ); + } + if (data.containsKey('last_modified')) { + context.handle( + _lastModifiedMeta, + lastModified.isAcceptableOrUnknown( + data['last_modified']!, + _lastModifiedMeta, + ), + ); + } else if (isInserting) { + context.missing(_lastModifiedMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ArticleDraft map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ArticleDraft( + id: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + title: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + description: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + )!, + content: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}content'], + )!, + visibility: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}visibility'], + )!, + lastModified: + attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_modified'], + )!, + ); + } + + @override + $ArticleDraftsTable createAlias(String alias) { + return $ArticleDraftsTable(attachedDatabase, alias); + } +} + +class ArticleDraft extends DataClass implements Insertable { + final String id; + final String title; + final String description; + final String content; + final String visibility; + final DateTime lastModified; + const ArticleDraft({ + required this.id, + required this.title, + required this.description, + required this.content, + required this.visibility, + required this.lastModified, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['title'] = Variable(title); + map['description'] = Variable(description); + map['content'] = Variable(content); + map['visibility'] = Variable(visibility); + map['last_modified'] = Variable(lastModified); + return map; + } + + ArticleDraftsCompanion toCompanion(bool nullToAbsent) { + return ArticleDraftsCompanion( + id: Value(id), + title: Value(title), + description: Value(description), + content: Value(content), + visibility: Value(visibility), + lastModified: Value(lastModified), + ); + } + + factory ArticleDraft.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ArticleDraft( + id: serializer.fromJson(json['id']), + title: serializer.fromJson(json['title']), + description: serializer.fromJson(json['description']), + content: serializer.fromJson(json['content']), + visibility: serializer.fromJson(json['visibility']), + lastModified: serializer.fromJson(json['lastModified']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'title': serializer.toJson(title), + 'description': serializer.toJson(description), + 'content': serializer.toJson(content), + 'visibility': serializer.toJson(visibility), + 'lastModified': serializer.toJson(lastModified), + }; + } + + ArticleDraft copyWith({ + String? id, + String? title, + String? description, + String? content, + String? visibility, + DateTime? lastModified, + }) => 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, + ); + ArticleDraft copyWithCompanion(ArticleDraftsCompanion data) { + return ArticleDraft( + id: data.id.present ? data.id.value : this.id, + title: data.title.present ? data.title.value : this.title, + description: + data.description.present ? data.description.value : this.description, + content: data.content.present ? data.content.value : this.content, + visibility: + data.visibility.present ? data.visibility.value : this.visibility, + lastModified: + data.lastModified.present + ? data.lastModified.value + : this.lastModified, + ); + } + + @override + String toString() { + return (StringBuffer('ArticleDraft(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('description: $description, ') + ..write('content: $content, ') + ..write('visibility: $visibility, ') + ..write('lastModified: $lastModified') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, title, description, content, visibility, lastModified); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ArticleDraft && + other.id == this.id && + other.title == this.title && + other.description == this.description && + other.content == this.content && + other.visibility == this.visibility && + other.lastModified == this.lastModified); +} + +class ArticleDraftsCompanion extends UpdateCompanion { + final Value id; + final Value title; + final Value description; + final Value content; + final Value visibility; + final Value lastModified; + final Value rowid; + const ArticleDraftsCompanion({ + this.id = const Value.absent(), + this.title = const Value.absent(), + this.description = const Value.absent(), + this.content = const Value.absent(), + this.visibility = const Value.absent(), + this.lastModified = const Value.absent(), + this.rowid = const Value.absent(), + }); + ArticleDraftsCompanion.insert({ + required String id, + this.title = const Value.absent(), + this.description = const Value.absent(), + this.content = const Value.absent(), + this.visibility = const Value.absent(), + required DateTime lastModified, + this.rowid = const Value.absent(), + }) : id = Value(id), + lastModified = Value(lastModified); + static Insertable custom({ + Expression? id, + Expression? title, + Expression? description, + Expression? content, + Expression? visibility, + Expression? lastModified, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (title != null) 'title': title, + if (description != null) 'description': description, + if (content != null) 'content': content, + if (visibility != null) 'visibility': visibility, + if (lastModified != null) 'last_modified': lastModified, + if (rowid != null) 'rowid': rowid, + }); + } + + ArticleDraftsCompanion copyWith({ + Value? id, + Value? title, + Value? description, + Value? content, + Value? visibility, + Value? lastModified, + Value? rowid, + }) { + return ArticleDraftsCompanion( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + content: content ?? this.content, + visibility: visibility ?? this.visibility, + lastModified: lastModified ?? this.lastModified, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (content.present) { + map['content'] = Variable(content.value); + } + if (visibility.present) { + map['visibility'] = Variable(visibility.value); + } + if (lastModified.present) { + map['last_modified'] = Variable(lastModified.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ArticleDraftsCompanion(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('description: $description, ') + ..write('content: $content, ') + ..write('visibility: $visibility, ') + ..write('lastModified: $lastModified, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); late final $ChatMessagesTable chatMessages = $ChatMessagesTable(this); + late final $ComposeDraftsTable composeDrafts = $ComposeDraftsTable(this); + late final $ArticleDraftsTable articleDrafts = $ArticleDraftsTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @override - List get allSchemaEntities => [chatMessages]; + List get allSchemaEntities => [ + chatMessages, + composeDrafts, + articleDrafts, + ]; } typedef $$ChatMessagesTableCreateCompanionBuilder = @@ -865,10 +1764,507 @@ typedef $$ChatMessagesTableProcessedTableManager = ChatMessage, PrefetchHooks Function() >; +typedef $$ComposeDraftsTableCreateCompanionBuilder = + ComposeDraftsCompanion Function({ + required String id, + Value title, + Value description, + Value content, + Value attachmentIds, + Value visibility, + required DateTime lastModified, + Value rowid, + }); +typedef $$ComposeDraftsTableUpdateCompanionBuilder = + ComposeDraftsCompanion Function({ + Value id, + Value title, + Value description, + Value content, + Value attachmentIds, + Value visibility, + Value lastModified, + Value rowid, + }); + +class $$ComposeDraftsTableFilterComposer + extends Composer<_$AppDatabase, $ComposeDraftsTable> { + $$ComposeDraftsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get content => $composableBuilder( + column: $table.content, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get attachmentIds => $composableBuilder( + column: $table.attachmentIds, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get visibility => $composableBuilder( + column: $table.visibility, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastModified => $composableBuilder( + column: $table.lastModified, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ComposeDraftsTableOrderingComposer + extends Composer<_$AppDatabase, $ComposeDraftsTable> { + $$ComposeDraftsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get content => $composableBuilder( + column: $table.content, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get attachmentIds => $composableBuilder( + column: $table.attachmentIds, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get visibility => $composableBuilder( + column: $table.visibility, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastModified => $composableBuilder( + column: $table.lastModified, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ComposeDraftsTableAnnotationComposer + extends Composer<_$AppDatabase, $ComposeDraftsTable> { + $$ComposeDraftsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, + builder: (column) => column, + ); + + GeneratedColumn get content => + $composableBuilder(column: $table.content, builder: (column) => column); + + GeneratedColumn get attachmentIds => $composableBuilder( + column: $table.attachmentIds, + builder: (column) => column, + ); + + GeneratedColumn get visibility => $composableBuilder( + column: $table.visibility, + builder: (column) => column, + ); + + GeneratedColumn get lastModified => $composableBuilder( + column: $table.lastModified, + builder: (column) => column, + ); +} + +class $$ComposeDraftsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ComposeDraftsTable, + ComposeDraft, + $$ComposeDraftsTableFilterComposer, + $$ComposeDraftsTableOrderingComposer, + $$ComposeDraftsTableAnnotationComposer, + $$ComposeDraftsTableCreateCompanionBuilder, + $$ComposeDraftsTableUpdateCompanionBuilder, + ( + ComposeDraft, + BaseReferences<_$AppDatabase, $ComposeDraftsTable, ComposeDraft>, + ), + ComposeDraft, + PrefetchHooks Function() + > { + $$ComposeDraftsTableTableManager(_$AppDatabase db, $ComposeDraftsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: + () => $$ComposeDraftsTableFilterComposer($db: db, $table: table), + createOrderingComposer: + () => + $$ComposeDraftsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: + () => $$ComposeDraftsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value title = const Value.absent(), + Value description = const Value.absent(), + Value content = const Value.absent(), + Value attachmentIds = const Value.absent(), + Value visibility = const Value.absent(), + Value lastModified = const Value.absent(), + Value rowid = const Value.absent(), + }) => ComposeDraftsCompanion( + id: id, + title: title, + description: description, + content: content, + attachmentIds: attachmentIds, + visibility: visibility, + lastModified: lastModified, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + Value title = const Value.absent(), + Value description = const Value.absent(), + Value content = const Value.absent(), + Value attachmentIds = const Value.absent(), + Value visibility = const Value.absent(), + required DateTime lastModified, + Value rowid = const Value.absent(), + }) => ComposeDraftsCompanion.insert( + id: id, + title: title, + description: description, + content: content, + attachmentIds: attachmentIds, + visibility: visibility, + lastModified: lastModified, + rowid: rowid, + ), + withReferenceMapper: + (p0) => + p0 + .map( + (e) => ( + e.readTable(table), + BaseReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ComposeDraftsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ComposeDraftsTable, + ComposeDraft, + $$ComposeDraftsTableFilterComposer, + $$ComposeDraftsTableOrderingComposer, + $$ComposeDraftsTableAnnotationComposer, + $$ComposeDraftsTableCreateCompanionBuilder, + $$ComposeDraftsTableUpdateCompanionBuilder, + ( + ComposeDraft, + BaseReferences<_$AppDatabase, $ComposeDraftsTable, ComposeDraft>, + ), + ComposeDraft, + PrefetchHooks Function() + >; +typedef $$ArticleDraftsTableCreateCompanionBuilder = + ArticleDraftsCompanion Function({ + required String id, + Value title, + Value description, + Value content, + Value visibility, + required DateTime lastModified, + Value rowid, + }); +typedef $$ArticleDraftsTableUpdateCompanionBuilder = + ArticleDraftsCompanion Function({ + Value id, + Value title, + Value description, + Value content, + Value visibility, + Value lastModified, + Value rowid, + }); + +class $$ArticleDraftsTableFilterComposer + extends Composer<_$AppDatabase, $ArticleDraftsTable> { + $$ArticleDraftsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get content => $composableBuilder( + column: $table.content, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get visibility => $composableBuilder( + column: $table.visibility, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastModified => $composableBuilder( + column: $table.lastModified, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ArticleDraftsTableOrderingComposer + extends Composer<_$AppDatabase, $ArticleDraftsTable> { + $$ArticleDraftsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get content => $composableBuilder( + column: $table.content, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get visibility => $composableBuilder( + column: $table.visibility, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastModified => $composableBuilder( + column: $table.lastModified, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ArticleDraftsTableAnnotationComposer + extends Composer<_$AppDatabase, $ArticleDraftsTable> { + $$ArticleDraftsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, + builder: (column) => column, + ); + + GeneratedColumn get content => + $composableBuilder(column: $table.content, builder: (column) => column); + + GeneratedColumn get visibility => $composableBuilder( + column: $table.visibility, + builder: (column) => column, + ); + + GeneratedColumn get lastModified => $composableBuilder( + column: $table.lastModified, + builder: (column) => column, + ); +} + +class $$ArticleDraftsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ArticleDraftsTable, + ArticleDraft, + $$ArticleDraftsTableFilterComposer, + $$ArticleDraftsTableOrderingComposer, + $$ArticleDraftsTableAnnotationComposer, + $$ArticleDraftsTableCreateCompanionBuilder, + $$ArticleDraftsTableUpdateCompanionBuilder, + ( + ArticleDraft, + BaseReferences<_$AppDatabase, $ArticleDraftsTable, ArticleDraft>, + ), + ArticleDraft, + PrefetchHooks Function() + > { + $$ArticleDraftsTableTableManager(_$AppDatabase db, $ArticleDraftsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: + () => $$ArticleDraftsTableFilterComposer($db: db, $table: table), + createOrderingComposer: + () => + $$ArticleDraftsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: + () => $$ArticleDraftsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value title = const Value.absent(), + Value description = const Value.absent(), + Value content = const Value.absent(), + Value visibility = const Value.absent(), + Value lastModified = const Value.absent(), + Value rowid = const Value.absent(), + }) => ArticleDraftsCompanion( + id: id, + title: title, + description: description, + content: content, + visibility: visibility, + lastModified: lastModified, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + Value title = const Value.absent(), + Value description = const Value.absent(), + Value content = const Value.absent(), + Value visibility = const Value.absent(), + required DateTime lastModified, + Value rowid = const Value.absent(), + }) => ArticleDraftsCompanion.insert( + id: id, + title: title, + description: description, + content: content, + visibility: visibility, + lastModified: lastModified, + rowid: rowid, + ), + withReferenceMapper: + (p0) => + p0 + .map( + (e) => ( + e.readTable(table), + BaseReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ArticleDraftsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ArticleDraftsTable, + ArticleDraft, + $$ArticleDraftsTableFilterComposer, + $$ArticleDraftsTableOrderingComposer, + $$ArticleDraftsTableAnnotationComposer, + $$ArticleDraftsTableCreateCompanionBuilder, + $$ArticleDraftsTableUpdateCompanionBuilder, + ( + ArticleDraft, + BaseReferences<_$AppDatabase, $ArticleDraftsTable, ArticleDraft>, + ), + ArticleDraft, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; $AppDatabaseManager(this._db); $$ChatMessagesTableTableManager get chatMessages => $$ChatMessagesTableTableManager(_db, _db.chatMessages); + $$ComposeDraftsTableTableManager get composeDrafts => + $$ComposeDraftsTableTableManager(_db, _db.composeDrafts); + $$ArticleDraftsTableTableManager get articleDrafts => + $$ArticleDraftsTableTableManager(_db, _db.articleDrafts); } diff --git a/lib/screens/posts/compose.dart b/lib/screens/posts/compose.dart index 194b7d3..2d262e9 100644 --- a/lib/screens/posts/compose.dart +++ b/lib/screens/posts/compose.dart @@ -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( + 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( - 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>( - 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>( + 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, + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/screens/posts/compose_article.dart b/lib/screens/posts/compose_article.dart index 6d83323..7ea492b 100644 --- a/lib/screens/posts/compose_article.dart +++ b/lib/screens/posts/compose_article.dart @@ -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( - 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( - 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( + 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( + 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 _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) { diff --git a/lib/services/compose_storage.dart b/lib/services/compose_storage.dart deleted file mode 100644 index 338b471..0000000 --- a/lib/services/compose_storage.dart +++ /dev/null @@ -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 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_db.dart b/lib/services/compose_storage_db.dart new file mode 100644 index 0000000..4e6ca26 --- /dev/null +++ b/lib/services/compose_storage_db.dart @@ -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 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 toJson() => { + 'id': id, + 'title': title, + 'description': description, + 'content': content, + 'attachmentIds': attachmentIds, + 'visibility': visibility, + 'lastModified': lastModified.toIso8601String(), + }; + + factory ComposeDraftModel.fromJson(Map json) => ComposeDraftModel( + 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), + ); + + factory ComposeDraftModel.fromDbRow(ComposeDraft row) => ComposeDraftModel( + id: row.id, + title: row.title, + description: row.description, + content: row.content, + attachmentIds: List.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? 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 toJson() => { + 'id': id, + 'title': title, + 'description': description, + 'content': content, + 'visibility': visibility, + 'lastModified': lastModified.toIso8601String(), + }; + + factory ArticleDraftModel.fromJson(Map 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 build() { + _loadDrafts(); + return {}; + } + + void _loadDrafts() async { + try { + final database = ref.read(databaseProvider); + final dbDrafts = await database.getAllComposeDrafts(); + final drafts = {}; + 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 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.from(state); + newState.remove(updatedDraft.id); + state = newState; + rethrow; + } + } + + Future deleteDraft(String id) async { + final oldDraft = state[id]; + final newState = Map.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 getAllDrafts() { + final drafts = state.values.toList(); + drafts.sort((a, b) => b.lastModified.compareTo(a.lastModified)); + return drafts; + } + + Future 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 build() { + _loadDrafts(); + return {}; + } + + void _loadDrafts() async { + try { + final database = ref.read(databaseProvider); + final dbDrafts = await database.getAllArticleDrafts(); + final drafts = {}; + 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 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.from(state); + newState.remove(updatedDraft.id); + state = newState; + rethrow; + } + } + + Future deleteDraft(String id) async { + final oldDraft = state[id]; + final newState = Map.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 getAllDrafts() { + final drafts = state.values.toList(); + drafts.sort((a, b) => b.lastModified.compareTo(a.lastModified)); + return drafts; + } + + Future 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; + } + } +} \ No newline at end of file diff --git a/lib/services/compose_storage.g.dart b/lib/services/compose_storage_db.g.dart similarity index 82% rename from lib/services/compose_storage.g.dart rename to lib/services/compose_storage_db.g.dart index 28c9652..aa9eb85 100644 --- a/lib/services/compose_storage.g.dart +++ b/lib/services/compose_storage_db.g.dart @@ -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 + Map >.internal( ComposeStorageNotifier.new, name: r'composeStorageNotifierProvider', @@ -26,15 +26,15 @@ final composeStorageNotifierProvider = AutoDisposeNotifierProvider< ); typedef _$ComposeStorageNotifier = - AutoDisposeNotifier>; + AutoDisposeNotifier>; String _$articleStorageNotifierHash() => - r'4a200878bfe7881fc3afd2164b334e84dc44f338'; + r'21ee0f8ee87528bebf8f5f4b0b2892cd8058e230'; /// See also [ArticleStorageNotifier]. @ProviderFor(ArticleStorageNotifier) final articleStorageNotifierProvider = AutoDisposeNotifierProvider< ArticleStorageNotifier, - Map + Map >.internal( ArticleStorageNotifier.new, name: r'articleStorageNotifierProvider', @@ -47,6 +47,6 @@ final articleStorageNotifierProvider = AutoDisposeNotifierProvider< ); typedef _$ArticleStorageNotifier = - AutoDisposeNotifier>; + 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 88e5a29..974fd47 100644 --- a/lib/widgets/post/compose_shared.dart +++ b/lib/widgets/post/compose_shared.dart @@ -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>([]), 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 loadDraft(WidgetRef ref, String draftId) async { + static Future 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, diff --git a/lib/widgets/post/draft_manager.dart b/lib/widgets/post/draft_manager.dart index dc915af..f51295a 100644 --- a/lib/widgets/post/draft_manager.dart +++ b/lib/widgets/post/draft_manager.dart @@ -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().toList(); + final draftList = drafts.values.cast().toList(); draftList.sort((a, b) => b.lastModified.compareTo(a.lastModified)); return draftList; } else { - final draftList = drafts.values.cast().toList(); + final draftList = drafts.values.cast().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