♻️ Refactored post draft system

This commit is contained in:
LittleSheep 2025-06-25 02:15:45 +08:00
parent b89cffeb18
commit 47c31ddec2
19 changed files with 776 additions and 1844 deletions

View File

@ -410,6 +410,8 @@
"articleDrafts": "Article drafts",
"postDrafts": "Post drafts",
"saveDraft": "Save draft",
"draftSaved": "Draft saved",
"draftSaveFailed": "Failed to save draft",
"clearAllDrafts": "Clear All Drafts",
"clearAllDraftsConfirm": "Are you sure you want to delete all drafts? This action cannot be undone.",
"clearAll": "Clear All",
@ -441,6 +443,7 @@
"contactMethodDelete": "Delete Contact",
"contactMethodNew": "New Contact Method",
"contactMethodContentEmpty": "Contact content cannot be empty",
"postContentEmpty": "Post content cannot be empty",
"contactMethodVerificationSent": "Verification code sent to your contact method",
"contactMethodVerificationNeeded": "The contact method is added, but not verified yet. You can verify it by tapping it and select verify.",
"accountContactMethod": "Contact Methods",

View File

@ -319,5 +319,21 @@
"processingPayment": "处理付款中...",
"pleaseWait": "请稍候",
"paymentFailed": "付款失败,请重试。",
"paymentSuccess": "付款成功完成!"
"paymentSuccess": "付款成功完成!",
"drafts": "草稿",
"noDrafts": "暂无草稿",
"articleDrafts": "文章草稿",
"postDrafts": "帖子草稿",
"saveDraft": "保存草稿",
"draftSaved": "草稿已保存",
"draftSaveFailed": "保存草稿失败",
"clearAllDrafts": "清空所有草稿",
"clearAllDraftsConfirm": "确定要删除所有草稿吗?此操作无法撤销。",
"clearAll": "清空全部",
"untitled": "无标题",
"noContent": "无内容",
"justNow": "刚刚",
"minutesAgo": "{} 分钟前",
"hoursAgo": "{} 小时前",
"postContentEmpty": "帖子内容不能为空"
}

View File

@ -334,5 +334,21 @@
"membershipFeatureAllNova": "所有新星功能",
"membershipFeatureExclusiveContent": "獨家內容",
"membershipFeatureVipSupport": "VIP 支援",
"membershipCurrentBadge": "目前"
"membershipCurrentBadge": "目前",
"drafts": "草稿",
"noDrafts": "暫無草稿",
"articleDrafts": "文章草稿",
"postDrafts": "貼文草稿",
"saveDraft": "儲存草稿",
"draftSaved": "草稿已儲存",
"draftSaveFailed": "儲存草稿失敗",
"clearAllDrafts": "清空所有草稿",
"clearAllDraftsConfirm": "確定要刪除所有草稿嗎?此操作無法復原。",
"clearAll": "清空全部",
"untitled": "無標題",
"noContent": "無內容",
"justNow": "剛剛",
"minutesAgo": "{} 分鐘前",
"hoursAgo": "{} 小時前",
"postContentEmpty": "貼文內容不能為空"
}

View File

@ -1,24 +1,8 @@
import 'package:drift/drift.dart';
class ComposeDrafts extends Table {
class PostDrafts 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
IntColumn get visibility => integer().withDefault(const Constant(0))(); // 0=public, 1=unlisted, 2=friends, 3=selected, 4=private
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(''))();
IntColumn get visibility => integer().withDefault(const Constant(0))(); // 0=public, 1=unlisted, 2=friends, 3=private
TextColumn get post => text()(); // Store SnPost model as JSON string
DateTimeColumn get lastModified => dateTime()();
@override

View File

@ -2,16 +2,17 @@ import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:island/database/message.dart';
import 'package:island/database/draft.dart';
import 'package:island/models/post.dart';
part 'drift_db.g.dart';
// Define the database
@DriftDatabase(tables: [ChatMessages, ComposeDrafts, ArticleDrafts])
@DriftDatabase(tables: [ChatMessages, PostDrafts])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@override
int get schemaVersion => 3;
int get schemaVersion => 4;
@override
MigrationStrategy get migration => MigrationStrategy(
@ -23,10 +24,9 @@ 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);
if (from < 4) {
// Drop old draft tables if they exist
await m.createTable(postDrafts);
}
},
);
@ -98,51 +98,23 @@ class AppDatabase extends _$AppDatabase {
);
}
// Methods for compose drafts
Future<List<ComposeDraft>> getAllComposeDrafts() {
return (select(composeDrafts)
..orderBy([(d) => OrderingTerm.desc(d.lastModified)]))
.get();
// Methods for post drafts
Future<List<SnPost>> getAllPostDrafts() async {
final drafts = await select(postDrafts).get();
return drafts
.map((draft) => SnPost.fromJson(jsonDecode(draft.post)))
.toList();
}
Future<ComposeDraft?> getComposeDraft(String id) {
return (select(composeDrafts)..where((d) => d.id.equals(id)))
.getSingleOrNull();
Future<void> addPostDraft(PostDraftsCompanion entry) async {
await into(postDrafts).insert(entry, mode: InsertMode.replace);
}
Future<int> saveComposeDraft(ComposeDraftsCompanion draft) {
return into(composeDrafts).insert(draft, mode: InsertMode.insertOrReplace);
Future<void> deletePostDraft(String id) async {
await (delete(postDrafts)..where((tbl) => tbl.id.equals(id))).go();
}
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();
Future<void> clearAllPostDrafts() async {
await delete(postDrafts).go();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -9,36 +9,36 @@ part 'post.g.dart';
sealed class SnPost with _$SnPost {
const factory SnPost({
required String id,
required String? title,
required String? description,
required String? language,
required DateTime? editedAt,
required DateTime publishedAt,
required int visibility,
required String? content,
required int type,
required Map<String, dynamic>? meta,
required int viewsUnique,
required int viewsTotal,
required int upvotes,
required int downvotes,
required int repliesCount,
required String? threadedPostId,
required SnPost? threadedPost,
required String? repliedPostId,
required SnPost? repliedPost,
required String? forwardedPostId,
required SnPost? forwardedPost,
required List<SnCloudFile> attachments,
required SnPublisher publisher,
String? title,
String? description,
String? language,
DateTime? editedAt,
@Default(null) DateTime? publishedAt,
@Default(0) int visibility,
String? content,
@Default(0) int type,
Map<String, dynamic>? meta,
@Default(0) int viewsUnique,
@Default(0) int viewsTotal,
@Default(0) int upvotes,
@Default(0) int downvotes,
@Default(0) int repliesCount,
String? threadedPostId,
SnPost? threadedPost,
String? repliedPostId,
SnPost? repliedPost,
String? forwardedPostId,
SnPost? forwardedPost,
@Default([]) List<SnCloudFile> attachments,
@Default(SnPublisher()) SnPublisher publisher,
@Default({}) Map<String, int> reactionsCount,
required List<dynamic> reactions,
required List<dynamic> tags,
required List<dynamic> categories,
required List<dynamic> collections,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
@Default([]) List<dynamic> reactions,
@Default([]) List<dynamic> tags,
@Default([]) List<dynamic> categories,
@Default([]) List<dynamic> collections,
@Default(null) DateTime? createdAt,
@Default(null) DateTime? updatedAt,
DateTime? deletedAt,
@Default(false) bool isTruncated,
}) = _SnPost;
@ -48,20 +48,20 @@ sealed class SnPost with _$SnPost {
@freezed
sealed class SnPublisher with _$SnPublisher {
const factory SnPublisher({
required String id,
required int type,
required String name,
required String nick,
@Default('') String id,
@Default(0) int type,
@Default('') String name,
@Default('') String nick,
@Default('') String bio,
required SnCloudFile? picture,
required SnCloudFile? background,
required SnAccount? account,
required String? accountId,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String? realmId,
required SnVerificationMark? verification,
SnCloudFile? picture,
SnCloudFile? background,
SnAccount? account,
String? accountId,
@Default(null) DateTime? createdAt,
@Default(null) DateTime? updatedAt,
DateTime? deletedAt,
String? realmId,
SnVerificationMark? verification,
}) = _SnPublisher;
factory SnPublisher.fromJson(Map<String, dynamic> json) =>

View File

@ -16,7 +16,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SnPost {
String get id; String? get title; String? get description; String? get language; DateTime? get editedAt; DateTime get publishedAt; int get visibility; String? get content; int get type; Map<String, dynamic>? get meta; int get viewsUnique; int get viewsTotal; int get upvotes; int get downvotes; int get repliesCount; String? get threadedPostId; SnPost? get threadedPost; String? get repliedPostId; SnPost? get repliedPost; String? get forwardedPostId; SnPost? get forwardedPost; List<SnCloudFile> get attachments; SnPublisher get publisher; Map<String, int> get reactionsCount; List<dynamic> get reactions; List<dynamic> get tags; List<dynamic> get categories; List<dynamic> get collections; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; bool get isTruncated;
String get id; String? get title; String? get description; String? get language; DateTime? get editedAt; DateTime? get publishedAt; int get visibility; String? get content; int get type; Map<String, dynamic>? get meta; int get viewsUnique; int get viewsTotal; int get upvotes; int get downvotes; int get repliesCount; String? get threadedPostId; SnPost? get threadedPost; String? get repliedPostId; SnPost? get repliedPost; String? get forwardedPostId; SnPost? get forwardedPost; List<SnCloudFile> get attachments; SnPublisher get publisher; Map<String, int> get reactionsCount; List<dynamic> get reactions; List<dynamic> get tags; List<dynamic> get categories; List<dynamic> get collections; DateTime? get createdAt; DateTime? get updatedAt; DateTime? get deletedAt; bool get isTruncated;
/// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@ -49,7 +49,7 @@ abstract mixin class $SnPostCopyWith<$Res> {
factory $SnPostCopyWith(SnPost value, $Res Function(SnPost) _then) = _$SnPostCopyWithImpl;
@useResult
$Res call({
String id, String? title, String? description, String? language, DateTime? editedAt, DateTime publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, int repliesCount, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, List<dynamic> reactions, List<dynamic> tags, List<dynamic> categories, List<dynamic> collections, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, bool isTruncated
String id, String? title, String? description, String? language, DateTime? editedAt, DateTime? publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, int repliesCount, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, List<dynamic> reactions, List<dynamic> tags, List<dynamic> categories, List<dynamic> collections, DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, bool isTruncated
});
@ -66,15 +66,15 @@ class _$SnPostCopyWithImpl<$Res>
/// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = freezed,Object? description = freezed,Object? language = freezed,Object? editedAt = freezed,Object? publishedAt = null,Object? visibility = null,Object? content = freezed,Object? type = null,Object? meta = freezed,Object? viewsUnique = null,Object? viewsTotal = null,Object? upvotes = null,Object? downvotes = null,Object? repliesCount = null,Object? threadedPostId = freezed,Object? threadedPost = freezed,Object? repliedPostId = freezed,Object? repliedPost = freezed,Object? forwardedPostId = freezed,Object? forwardedPost = freezed,Object? attachments = null,Object? publisher = null,Object? reactionsCount = null,Object? reactions = null,Object? tags = null,Object? categories = null,Object? collections = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? isTruncated = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = freezed,Object? description = freezed,Object? language = freezed,Object? editedAt = freezed,Object? publishedAt = freezed,Object? visibility = null,Object? content = freezed,Object? type = null,Object? meta = freezed,Object? viewsUnique = null,Object? viewsTotal = null,Object? upvotes = null,Object? downvotes = null,Object? repliesCount = null,Object? threadedPostId = freezed,Object? threadedPost = freezed,Object? repliedPostId = freezed,Object? repliedPost = freezed,Object? forwardedPostId = freezed,Object? forwardedPost = freezed,Object? attachments = null,Object? publisher = null,Object? reactionsCount = null,Object? reactions = null,Object? tags = null,Object? categories = null,Object? collections = null,Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,Object? isTruncated = null,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,language: freezed == language ? _self.language : language // ignore: cast_nullable_to_non_nullable
as String?,editedAt: freezed == editedAt ? _self.editedAt : editedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,publishedAt: null == publishedAt ? _self.publishedAt : publishedAt // ignore: cast_nullable_to_non_nullable
as DateTime,visibility: null == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable
as DateTime?,publishedAt: freezed == publishedAt ? _self.publishedAt : publishedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,visibility: null == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable
as int,content: freezed == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as int,meta: freezed == meta ? _self.meta : meta // ignore: cast_nullable_to_non_nullable
@ -96,9 +96,9 @@ as Map<String, int>,reactions: null == reactions ? _self.reactions : reactions /
as List<dynamic>,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable
as List<dynamic>,categories: null == categories ? _self.categories : categories // ignore: cast_nullable_to_non_nullable
as List<dynamic>,collections: null == collections ? _self.collections : collections // ignore: cast_nullable_to_non_nullable
as List<dynamic>,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as List<dynamic>,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,isTruncated: null == isTruncated ? _self.isTruncated : isTruncated // ignore: cast_nullable_to_non_nullable
as bool,
));
@ -156,7 +156,7 @@ $SnPublisherCopyWith<$Res> get publisher {
@JsonSerializable()
class _SnPost implements SnPost {
const _SnPost({required this.id, required this.title, required this.description, required this.language, required this.editedAt, required this.publishedAt, required this.visibility, required this.content, required this.type, required final Map<String, dynamic>? meta, required this.viewsUnique, required this.viewsTotal, required this.upvotes, required this.downvotes, required this.repliesCount, required this.threadedPostId, required this.threadedPost, required this.repliedPostId, required this.repliedPost, required this.forwardedPostId, required this.forwardedPost, required final List<SnCloudFile> attachments, required this.publisher, final Map<String, int> reactionsCount = const {}, required final List<dynamic> reactions, required final List<dynamic> tags, required final List<dynamic> categories, required final List<dynamic> collections, required this.createdAt, required this.updatedAt, required this.deletedAt, this.isTruncated = false}): _meta = meta,_attachments = attachments,_reactionsCount = reactionsCount,_reactions = reactions,_tags = tags,_categories = categories,_collections = collections;
const _SnPost({required this.id, this.title, this.description, this.language, this.editedAt, this.publishedAt = null, this.visibility = 0, this.content, this.type = 0, final Map<String, dynamic>? meta, this.viewsUnique = 0, this.viewsTotal = 0, this.upvotes = 0, this.downvotes = 0, this.repliesCount = 0, this.threadedPostId, this.threadedPost, this.repliedPostId, this.repliedPost, this.forwardedPostId, this.forwardedPost, final List<SnCloudFile> attachments = const [], this.publisher = const SnPublisher(), final Map<String, int> reactionsCount = const {}, final List<dynamic> reactions = const [], final List<dynamic> tags = const [], final List<dynamic> categories = const [], final List<dynamic> collections = const [], this.createdAt = null, this.updatedAt = null, this.deletedAt, this.isTruncated = false}): _meta = meta,_attachments = attachments,_reactionsCount = reactionsCount,_reactions = reactions,_tags = tags,_categories = categories,_collections = collections;
factory _SnPost.fromJson(Map<String, dynamic> json) => _$SnPostFromJson(json);
@override final String id;
@ -164,10 +164,10 @@ class _SnPost implements SnPost {
@override final String? description;
@override final String? language;
@override final DateTime? editedAt;
@override final DateTime publishedAt;
@override final int visibility;
@override@JsonKey() final DateTime? publishedAt;
@override@JsonKey() final int visibility;
@override final String? content;
@override final int type;
@override@JsonKey() final int type;
final Map<String, dynamic>? _meta;
@override Map<String, dynamic>? get meta {
final value = _meta;
@ -177,11 +177,11 @@ class _SnPost implements SnPost {
return EqualUnmodifiableMapView(value);
}
@override final int viewsUnique;
@override final int viewsTotal;
@override final int upvotes;
@override final int downvotes;
@override final int repliesCount;
@override@JsonKey() final int viewsUnique;
@override@JsonKey() final int viewsTotal;
@override@JsonKey() final int upvotes;
@override@JsonKey() final int downvotes;
@override@JsonKey() final int repliesCount;
@override final String? threadedPostId;
@override final SnPost? threadedPost;
@override final String? repliedPostId;
@ -189,13 +189,13 @@ class _SnPost implements SnPost {
@override final String? forwardedPostId;
@override final SnPost? forwardedPost;
final List<SnCloudFile> _attachments;
@override List<SnCloudFile> get attachments {
@override@JsonKey() List<SnCloudFile> get attachments {
if (_attachments is EqualUnmodifiableListView) return _attachments;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_attachments);
}
@override final SnPublisher publisher;
@override@JsonKey() final SnPublisher publisher;
final Map<String, int> _reactionsCount;
@override@JsonKey() Map<String, int> get reactionsCount {
if (_reactionsCount is EqualUnmodifiableMapView) return _reactionsCount;
@ -204,35 +204,35 @@ class _SnPost implements SnPost {
}
final List<dynamic> _reactions;
@override List<dynamic> get reactions {
@override@JsonKey() List<dynamic> get reactions {
if (_reactions is EqualUnmodifiableListView) return _reactions;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_reactions);
}
final List<dynamic> _tags;
@override List<dynamic> get tags {
@override@JsonKey() List<dynamic> get tags {
if (_tags is EqualUnmodifiableListView) return _tags;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_tags);
}
final List<dynamic> _categories;
@override List<dynamic> get categories {
@override@JsonKey() List<dynamic> get categories {
if (_categories is EqualUnmodifiableListView) return _categories;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_categories);
}
final List<dynamic> _collections;
@override List<dynamic> get collections {
@override@JsonKey() List<dynamic> get collections {
if (_collections is EqualUnmodifiableListView) return _collections;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_collections);
}
@override final DateTime createdAt;
@override final DateTime updatedAt;
@override@JsonKey() final DateTime? createdAt;
@override@JsonKey() final DateTime? updatedAt;
@override final DateTime? deletedAt;
@override@JsonKey() final bool isTruncated;
@ -269,7 +269,7 @@ abstract mixin class _$SnPostCopyWith<$Res> implements $SnPostCopyWith<$Res> {
factory _$SnPostCopyWith(_SnPost value, $Res Function(_SnPost) _then) = __$SnPostCopyWithImpl;
@override @useResult
$Res call({
String id, String? title, String? description, String? language, DateTime? editedAt, DateTime publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, int repliesCount, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, List<dynamic> reactions, List<dynamic> tags, List<dynamic> categories, List<dynamic> collections, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, bool isTruncated
String id, String? title, String? description, String? language, DateTime? editedAt, DateTime? publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, int repliesCount, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, List<dynamic> reactions, List<dynamic> tags, List<dynamic> categories, List<dynamic> collections, DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, bool isTruncated
});
@ -286,15 +286,15 @@ class __$SnPostCopyWithImpl<$Res>
/// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = freezed,Object? description = freezed,Object? language = freezed,Object? editedAt = freezed,Object? publishedAt = null,Object? visibility = null,Object? content = freezed,Object? type = null,Object? meta = freezed,Object? viewsUnique = null,Object? viewsTotal = null,Object? upvotes = null,Object? downvotes = null,Object? repliesCount = null,Object? threadedPostId = freezed,Object? threadedPost = freezed,Object? repliedPostId = freezed,Object? repliedPost = freezed,Object? forwardedPostId = freezed,Object? forwardedPost = freezed,Object? attachments = null,Object? publisher = null,Object? reactionsCount = null,Object? reactions = null,Object? tags = null,Object? categories = null,Object? collections = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? isTruncated = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = freezed,Object? description = freezed,Object? language = freezed,Object? editedAt = freezed,Object? publishedAt = freezed,Object? visibility = null,Object? content = freezed,Object? type = null,Object? meta = freezed,Object? viewsUnique = null,Object? viewsTotal = null,Object? upvotes = null,Object? downvotes = null,Object? repliesCount = null,Object? threadedPostId = freezed,Object? threadedPost = freezed,Object? repliedPostId = freezed,Object? repliedPost = freezed,Object? forwardedPostId = freezed,Object? forwardedPost = freezed,Object? attachments = null,Object? publisher = null,Object? reactionsCount = null,Object? reactions = null,Object? tags = null,Object? categories = null,Object? collections = null,Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,Object? isTruncated = null,}) {
return _then(_SnPost(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,language: freezed == language ? _self.language : language // ignore: cast_nullable_to_non_nullable
as String?,editedAt: freezed == editedAt ? _self.editedAt : editedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,publishedAt: null == publishedAt ? _self.publishedAt : publishedAt // ignore: cast_nullable_to_non_nullable
as DateTime,visibility: null == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable
as DateTime?,publishedAt: freezed == publishedAt ? _self.publishedAt : publishedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,visibility: null == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable
as int,content: freezed == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as int,meta: freezed == meta ? _self._meta : meta // ignore: cast_nullable_to_non_nullable
@ -316,9 +316,9 @@ as Map<String, int>,reactions: null == reactions ? _self._reactions : reactions
as List<dynamic>,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable
as List<dynamic>,categories: null == categories ? _self._categories : categories // ignore: cast_nullable_to_non_nullable
as List<dynamic>,collections: null == collections ? _self._collections : collections // ignore: cast_nullable_to_non_nullable
as List<dynamic>,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as List<dynamic>,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,isTruncated: null == isTruncated ? _self.isTruncated : isTruncated // ignore: cast_nullable_to_non_nullable
as bool,
));
@ -376,7 +376,7 @@ $SnPublisherCopyWith<$Res> get publisher {
/// @nodoc
mixin _$SnPublisher {
String get id; int get type; String get name; String get nick; String get bio; SnCloudFile? get picture; SnCloudFile? get background; SnAccount? get account; String? get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String? get realmId; SnVerificationMark? get verification;
String get id; int get type; String get name; String get nick; String get bio; SnCloudFile? get picture; SnCloudFile? get background; SnAccount? get account; String? get accountId; DateTime? get createdAt; DateTime? get updatedAt; DateTime? get deletedAt; String? get realmId; SnVerificationMark? get verification;
/// Create a copy of SnPublisher
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@ -409,7 +409,7 @@ abstract mixin class $SnPublisherCopyWith<$Res> {
factory $SnPublisherCopyWith(SnPublisher value, $Res Function(SnPublisher) _then) = _$SnPublisherCopyWithImpl;
@useResult
$Res call({
String id, int type, String name, String nick, String bio, SnCloudFile? picture, SnCloudFile? background, SnAccount? account, String? accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? realmId, SnVerificationMark? verification
String id, int type, String name, String nick, String bio, SnCloudFile? picture, SnCloudFile? background, SnAccount? account, String? accountId, DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String? realmId, SnVerificationMark? verification
});
@ -426,7 +426,7 @@ class _$SnPublisherCopyWithImpl<$Res>
/// Create a copy of SnPublisher
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? name = null,Object? nick = null,Object? bio = null,Object? picture = freezed,Object? background = freezed,Object? account = freezed,Object? accountId = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? realmId = freezed,Object? verification = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? name = null,Object? nick = null,Object? bio = null,Object? picture = freezed,Object? background = freezed,Object? account = freezed,Object? accountId = freezed,Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,Object? realmId = freezed,Object? verification = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
@ -437,9 +437,9 @@ as String,picture: freezed == picture ? _self.picture : picture // ignore: cast_
as SnCloudFile?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable
as SnCloudFile?,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount?,accountId: freezed == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as String?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,realmId: freezed == realmId ? _self.realmId : realmId // ignore: cast_nullable_to_non_nullable
as String?,verification: freezed == verification ? _self.verification : verification // ignore: cast_nullable_to_non_nullable
as SnVerificationMark?,
@ -501,20 +501,20 @@ $SnVerificationMarkCopyWith<$Res>? get verification {
@JsonSerializable()
class _SnPublisher implements SnPublisher {
const _SnPublisher({required this.id, required this.type, required this.name, required this.nick, this.bio = '', required this.picture, required this.background, required this.account, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt, required this.realmId, required this.verification});
const _SnPublisher({this.id = '', this.type = 0, this.name = '', this.nick = '', this.bio = '', this.picture, this.background, this.account, this.accountId, this.createdAt = null, this.updatedAt = null, this.deletedAt, this.realmId, this.verification});
factory _SnPublisher.fromJson(Map<String, dynamic> json) => _$SnPublisherFromJson(json);
@override final String id;
@override final int type;
@override final String name;
@override final String nick;
@override@JsonKey() final String id;
@override@JsonKey() final int type;
@override@JsonKey() final String name;
@override@JsonKey() final String nick;
@override@JsonKey() final String bio;
@override final SnCloudFile? picture;
@override final SnCloudFile? background;
@override final SnAccount? account;
@override final String? accountId;
@override final DateTime createdAt;
@override final DateTime updatedAt;
@override@JsonKey() final DateTime? createdAt;
@override@JsonKey() final DateTime? updatedAt;
@override final DateTime? deletedAt;
@override final String? realmId;
@override final SnVerificationMark? verification;
@ -552,7 +552,7 @@ abstract mixin class _$SnPublisherCopyWith<$Res> implements $SnPublisherCopyWith
factory _$SnPublisherCopyWith(_SnPublisher value, $Res Function(_SnPublisher) _then) = __$SnPublisherCopyWithImpl;
@override @useResult
$Res call({
String id, int type, String name, String nick, String bio, SnCloudFile? picture, SnCloudFile? background, SnAccount? account, String? accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? realmId, SnVerificationMark? verification
String id, int type, String name, String nick, String bio, SnCloudFile? picture, SnCloudFile? background, SnAccount? account, String? accountId, DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String? realmId, SnVerificationMark? verification
});
@ -569,7 +569,7 @@ class __$SnPublisherCopyWithImpl<$Res>
/// Create a copy of SnPublisher
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? name = null,Object? nick = null,Object? bio = null,Object? picture = freezed,Object? background = freezed,Object? account = freezed,Object? accountId = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? realmId = freezed,Object? verification = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? name = null,Object? nick = null,Object? bio = null,Object? picture = freezed,Object? background = freezed,Object? account = freezed,Object? accountId = freezed,Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,Object? realmId = freezed,Object? verification = freezed,}) {
return _then(_SnPublisher(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
@ -580,9 +580,9 @@ as String,picture: freezed == picture ? _self.picture : picture // ignore: cast_
as SnCloudFile?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable
as SnCloudFile?,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount?,accountId: freezed == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as String?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,realmId: freezed == realmId ? _self.realmId : realmId // ignore: cast_nullable_to_non_nullable
as String?,verification: freezed == verification ? _self.verification : verification // ignore: cast_nullable_to_non_nullable
as SnVerificationMark?,

View File

@ -15,16 +15,19 @@ _SnPost _$SnPostFromJson(Map<String, dynamic> json) => _SnPost(
json['edited_at'] == null
? null
: DateTime.parse(json['edited_at'] as String),
publishedAt: DateTime.parse(json['published_at'] as String),
visibility: (json['visibility'] as num).toInt(),
publishedAt:
json['published_at'] == null
? null
: DateTime.parse(json['published_at'] as String),
visibility: (json['visibility'] as num?)?.toInt() ?? 0,
content: json['content'] as String?,
type: (json['type'] as num).toInt(),
type: (json['type'] as num?)?.toInt() ?? 0,
meta: json['meta'] as Map<String, dynamic>?,
viewsUnique: (json['views_unique'] as num).toInt(),
viewsTotal: (json['views_total'] as num).toInt(),
upvotes: (json['upvotes'] as num).toInt(),
downvotes: (json['downvotes'] as num).toInt(),
repliesCount: (json['replies_count'] as num).toInt(),
viewsUnique: (json['views_unique'] as num?)?.toInt() ?? 0,
viewsTotal: (json['views_total'] as num?)?.toInt() ?? 0,
upvotes: (json['upvotes'] as num?)?.toInt() ?? 0,
downvotes: (json['downvotes'] as num?)?.toInt() ?? 0,
repliesCount: (json['replies_count'] as num?)?.toInt() ?? 0,
threadedPostId: json['threaded_post_id'] as String?,
threadedPost:
json['threaded_post'] == null
@ -41,21 +44,31 @@ _SnPost _$SnPostFromJson(Map<String, dynamic> json) => _SnPost(
? null
: SnPost.fromJson(json['forwarded_post'] as Map<String, dynamic>),
attachments:
(json['attachments'] as List<dynamic>)
.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
.toList(),
publisher: SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>),
(json['attachments'] as List<dynamic>?)
?.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
publisher:
json['publisher'] == null
? const SnPublisher()
: SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>),
reactionsCount:
(json['reactions_count'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toInt()),
) ??
const {},
reactions: json['reactions'] as List<dynamic>,
tags: json['tags'] as List<dynamic>,
categories: json['categories'] as List<dynamic>,
collections: json['collections'] as List<dynamic>,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
reactions: json['reactions'] as List<dynamic>? ?? const [],
tags: json['tags'] as List<dynamic>? ?? const [],
categories: json['categories'] as List<dynamic>? ?? const [],
collections: json['collections'] as List<dynamic>? ?? const [],
createdAt:
json['created_at'] == null
? null
: DateTime.parse(json['created_at'] as String),
updatedAt:
json['updated_at'] == null
? null
: DateTime.parse(json['updated_at'] as String),
deletedAt:
json['deleted_at'] == null
? null
@ -69,7 +82,7 @@ Map<String, dynamic> _$SnPostToJson(_SnPost instance) => <String, dynamic>{
'description': instance.description,
'language': instance.language,
'edited_at': instance.editedAt?.toIso8601String(),
'published_at': instance.publishedAt.toIso8601String(),
'published_at': instance.publishedAt?.toIso8601String(),
'visibility': instance.visibility,
'content': instance.content,
'type': instance.type,
@ -92,17 +105,17 @@ Map<String, dynamic> _$SnPostToJson(_SnPost instance) => <String, dynamic>{
'tags': instance.tags,
'categories': instance.categories,
'collections': instance.collections,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'created_at': instance.createdAt?.toIso8601String(),
'updated_at': instance.updatedAt?.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'is_truncated': instance.isTruncated,
};
_SnPublisher _$SnPublisherFromJson(Map<String, dynamic> json) => _SnPublisher(
id: json['id'] as String,
type: (json['type'] as num).toInt(),
name: json['name'] as String,
nick: json['nick'] as String,
id: json['id'] as String? ?? '',
type: (json['type'] as num?)?.toInt() ?? 0,
name: json['name'] as String? ?? '',
nick: json['nick'] as String? ?? '',
bio: json['bio'] as String? ?? '',
picture:
json['picture'] == null
@ -117,8 +130,14 @@ _SnPublisher _$SnPublisherFromJson(Map<String, dynamic> json) => _SnPublisher(
? null
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
accountId: json['account_id'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
createdAt:
json['created_at'] == null
? null
: DateTime.parse(json['created_at'] as String),
updatedAt:
json['updated_at'] == null
? null
: DateTime.parse(json['updated_at'] as String),
deletedAt:
json['deleted_at'] == null
? null
@ -143,8 +162,8 @@ Map<String, dynamic> _$SnPublisherToJson(_SnPublisher instance) =>
'background': instance.background?.toJson(),
'account': instance.account?.toJson(),
'account_id': instance.accountId,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'created_at': instance.createdAt?.toIso8601String(),
'updated_at': instance.updatedAt?.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'realm_id': instance.realmId,
'verification': instance.verification?.toJson(),

View File

@ -102,6 +102,7 @@ Future<ThemeData> createAppTheme(
),
snackBarTheme: SnackBarThemeData(
behavior: useM3 ? SnackBarBehavior.floating : SnackBarBehavior.fixed,
width: 560,
),
appBarTheme: AppBarTheme(
centerTitle: true,

View File

@ -61,7 +61,7 @@ class EventCalanderScreen extends HookConsumerWidget {
child: Column(
children: [
Card(
margin: EdgeInsets.all(16),
margin: EdgeInsets.only(left: 16, right: 16, top: 16),
child: Column(
children: [
// Use the reusable EventCalendarWidget
@ -77,7 +77,6 @@ class EventCalanderScreen extends HookConsumerWidget {
),
// Add the fortune graph widget
const Divider(height: 1),
FortuneGraphWidget(
events: events,
constrainWidth: true,

View File

@ -62,8 +62,6 @@ class PostComposeScreen extends HookConsumerWidget {
@QueryParam('type') this.type,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Determine the compose type: auto-detect from edited post or use query parameter
@ -96,7 +94,7 @@ class PostComposeScreen extends HookConsumerWidget {
useEffect(() {
if (originalPost == null) {
// Only auto-save for new posts, not edits
state.startAutoSave(ref);
state.startAutoSave(ref, postType: 0);
}
return () => state.stopAutoSave();
}, [state]);
@ -118,14 +116,14 @@ class PostComposeScreen extends HookConsumerWidget {
final drafts = ref.read(composeStorageNotifierProvider);
if (drafts.isNotEmpty) {
final mostRecentDraft = drafts.values.reduce(
(a, b) => a.lastModified.isAfter(b.lastModified) ? a : b,
(a, b) => (a.updatedAt ?? DateTime(0)).isAfter(b.updatedAt ?? DateTime(0)) ? 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;
if (mostRecentDraft.content?.isNotEmpty == true || mostRecentDraft.title?.isNotEmpty == true) {
state.titleController.text = mostRecentDraft.title ?? '';
state.descriptionController.text = mostRecentDraft.description ?? '';
state.contentController.text = mostRecentDraft.content ?? '';
state.visibility.value = mostRecentDraft.visibility;
}
}
@ -162,9 +160,10 @@ class PostComposeScreen extends HookConsumerWidget {
Widget buildWideAttachmentGrid() {
return GridView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
@ -245,17 +244,16 @@ class PostComposeScreen extends HookConsumerWidget {
isScrollControlled: true,
builder:
(context) => DraftManagerSheet(
isArticle: false,
onDraftSelected: (draftId) {
final draft =
ref.read(
composeStorageNotifierProvider,
)[draftId];
if (draft != null) {
state.titleController.text = draft.title;
state.titleController.text = draft.title ?? '';
state.descriptionController.text =
draft.description;
state.contentController.text = draft.content;
draft.description ?? '';
state.contentController.text = draft.content ?? '';
state.visibility.value = draft.visibility;
}
},
@ -320,7 +318,7 @@ class PostComposeScreen extends HookConsumerWidget {
// Main content area
Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
constraints: const BoxConstraints(maxWidth: 560),
child: Row(
spacing: 12,
crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -1,5 +1,5 @@
import 'dart:async';
import 'dart:developer';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@ -9,6 +9,7 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/models/post.dart';
import 'package:island/screens/creators/publishers.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/app_scaffold.dart';
@ -21,6 +22,7 @@ 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/widgets/post/draft_manager.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
@ -71,7 +73,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
if (originalPost == null) {
// Only auto-save for new articles, not edits
autoSaveTimer = Timer.periodic(const Duration(seconds: 3), (_) {
_saveArticleDraft(ref, state);
ComposeLogic.saveDraftWithoutUpload(ref, state, postType: 1);
});
}
return () {
@ -79,7 +81,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
state.stopAutoSave();
// Save final draft before disposing
if (originalPost == null) {
_saveArticleDraft(ref, state);
ComposeLogic.saveDraftWithoutUpload(ref, state, postType: 1);
}
ComposeLogic.dispose(state);
autoSaveTimer?.cancel();
@ -100,17 +102,22 @@ class ArticleComposeScreen extends HookConsumerWidget {
useEffect(() {
if (originalPost == null) {
// Try to load the most recent article draft
final drafts = ref.read(articleStorageNotifierProvider);
final drafts = ref.read(composeStorageNotifierProvider);
if (drafts.isNotEmpty) {
final mostRecentDraft = drafts.values.reduce(
(a, b) => a.lastModified.isAfter(b.lastModified) ? a : b,
(a, b) =>
(a.updatedAt ?? DateTime(0)).isAfter(b.updatedAt ?? DateTime(0))
? 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;
if (mostRecentDraft.content?.isNotEmpty == true ||
mostRecentDraft.title?.isNotEmpty == true) {
state.titleController.text = mostRecentDraft.title ?? '';
state.descriptionController.text =
mostRecentDraft.description ?? '';
state.contentController.text = mostRecentDraft.content ?? '';
state.visibility.value = mostRecentDraft.visibility;
}
}
@ -356,7 +363,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
return PopScope(
onPopInvoked: (_) {
if (originalPost == null) {
_saveArticleDraft(ref, state);
ComposeLogic.saveDraftWithoutUpload(ref, state, postType: 1);
}
},
child: AppScaffold(
@ -383,17 +390,17 @@ class ArticleComposeScreen extends HookConsumerWidget {
isScrollControlled: true,
builder:
(context) => DraftManagerSheet(
isArticle: true,
onDraftSelected: (draftId) {
final draft =
ref.read(
articleStorageNotifierProvider,
composeStorageNotifierProvider,
)[draftId];
if (draft != null) {
state.titleController.text = draft.title;
state.titleController.text = draft.title ?? '';
state.descriptionController.text =
draft.description;
state.contentController.text = draft.content;
draft.description ?? '';
state.contentController.text =
draft.content ?? '';
state.visibility.value = draft.visibility;
}
},
@ -404,7 +411,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
),
IconButton(
icon: const Icon(Symbols.save),
onPressed: () => _saveArticleDraft(ref, state),
onPressed: () => ComposeLogic.saveDraft(ref, state, postType: 1),
tooltip: 'saveDraft'.tr(),
),
IconButton(
@ -524,7 +531,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
if (isPaste && isModifierPressed) {
ComposeLogic.handlePaste(state);
} else if (isSave && isModifierPressed) {
_saveArticleDraft(ref, state);
ComposeLogic.saveDraft(ref, state, postType: 1);
} else if (isSubmit && isModifierPressed && !state.submitting.value) {
ComposeLogic.performAction(
ref,
@ -537,23 +544,5 @@ class ArticleComposeScreen extends HookConsumerWidget {
}
// Helper method to save article draft
Future<void> _saveArticleDraft(WidgetRef ref, ComposeState state) async {
try {
final draft = ArticleDraftModel(
id: state.draftId,
title: state.titleController.text,
description: state.descriptionController.text,
content: state.contentController.text,
visibility: 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
}
}
}

View File

@ -1,183 +1,16 @@
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/models/post.dart';
import 'package:island/pods/database.dart';
import 'package:island/services/file.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'compose_storage_db.g.dart';
class ComposeDraftModel {
final String id;
final String title;
final String description;
final String content;
final List<UniversalFile> attachments;
final int visibility;
final DateTime lastModified;
ComposeDraftModel({
required this.id,
required this.title,
required this.description,
required this.content,
required this.attachments,
required this.visibility,
required this.lastModified,
});
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'description': description,
'content': content,
'attachments': attachments.map((e) => e.toJson()).toList(),
'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? ?? '',
attachments: (json['attachments'] as List? ?? [])
.map((e) => UniversalFile.fromJson(e as Map<String, dynamic>))
.toList(),
visibility: json['visibility'] as int? ?? 0,
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,
attachments: (jsonDecode(row.attachmentIds) as List)
.map((e) => UniversalFile.fromJson(e as Map<String, dynamic>))
.toList(),
visibility: row.visibility,
lastModified: row.lastModified,
);
ComposeDraftsCompanion toDbCompanion() => ComposeDraftsCompanion(
id: Value(id),
title: Value(title),
description: Value(description),
content: Value(content),
attachmentIds: Value(jsonEncode(attachments.map((e) => e.toJson()).toList())),
visibility: Value(visibility),
lastModified: Value(lastModified),
);
ComposeDraftModel copyWith({
String? id,
String? title,
String? description,
String? content,
List<UniversalFile>? attachments,
int? visibility,
DateTime? lastModified,
}) {
return ComposeDraftModel(
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
content: content ?? this.content,
attachments: attachments ?? this.attachments,
visibility: visibility ?? this.visibility,
lastModified: lastModified ?? this.lastModified,
);
}
bool get isEmpty =>
title.isEmpty &&
description.isEmpty &&
content.isEmpty &&
attachments.isEmpty;
}
class ArticleDraftModel {
final String id;
final String title;
final String description;
final String content;
final int 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 int? ?? 0,
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,
int? 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() {
Map<String, SnPost> build() {
_loadDrafts();
return {};
}
@ -185,10 +18,9 @@ class ComposeStorageNotifier extends _$ComposeStorageNotifier {
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);
final dbDrafts = await database.getAllPostDrafts();
final drafts = <String, SnPost>{};
for (final draft in dbDrafts) {
drafts[draft.id] = draft;
}
state = drafts;
@ -198,52 +30,22 @@ class ComposeStorageNotifier extends _$ComposeStorageNotifier {
}
}
Future<void> saveDraft(ComposeDraftModel draft) async {
if (draft.isEmpty) {
await deleteDraft(draft.id);
return;
}
// Upload all attachments that are not yet uploaded
final uploadedAttachments = <UniversalFile>[];
final serverUrl = ref.read(serverUrlProvider);
final token = ref.read(tokenProvider);
for (final attachment in draft.attachments) {
if (!attachment.isOnCloud) {
try {
final completer = putMediaToCloud(
fileData: attachment,
atk: token?.token ?? '',
baseUrl: serverUrl,
);
final uploadedFile = await completer.future;
if (uploadedFile != null) {
uploadedAttachments.add(UniversalFile.fromAttachment(uploadedFile));
} else {
uploadedAttachments.add(attachment);
}
} catch (e) {
// If upload fails, keep the original file
uploadedAttachments.add(attachment);
}
} else {
uploadedAttachments.add(attachment);
}
}
final updatedDraft = draft.copyWith(
attachments: uploadedAttachments,
lastModified: DateTime.now(),
);
Future<void> saveDraft(SnPost draft) async {
final updatedDraft = draft.copyWith(updatedAt: DateTime.now());
state = {...state, updatedDraft.id: updatedDraft};
try {
final database = ref.read(databaseProvider);
await database.saveComposeDraft(updatedDraft.toDbCompanion());
await database.addPostDraft(
PostDraftsCompanion(
id: Value(updatedDraft.id),
post: Value(jsonEncode(updatedDraft.toJson())),
lastModified: Value(updatedDraft.updatedAt ?? DateTime.now()),
),
);
} catch (e) {
// Revert state on error
final newState = Map<String, ComposeDraftModel>.from(state);
final newState = Map<String, SnPost>.from(state);
newState.remove(updatedDraft.id);
state = newState;
rethrow;
@ -252,13 +54,13 @@ class ComposeStorageNotifier extends _$ComposeStorageNotifier {
Future<void> deleteDraft(String id) async {
final oldDraft = state[id];
final newState = Map<String, ComposeDraftModel>.from(state);
final newState = Map<String, SnPost>.from(state);
newState.remove(id);
state = newState;
try {
final database = ref.read(databaseProvider);
await database.deleteComposeDraft(id);
await database.deletePostDraft(id);
} catch (e) {
// Revert state on error
if (oldDraft != null) {
@ -268,13 +70,13 @@ class ComposeStorageNotifier extends _$ComposeStorageNotifier {
}
}
ComposeDraftModel? getDraft(String id) {
SnPost? getDraft(String id) {
return state[id];
}
List<ComposeDraftModel> getAllDrafts() {
List<SnPost> getAllDrafts() {
final drafts = state.values.toList();
drafts.sort((a, b) => b.lastModified.compareTo(a.lastModified));
drafts.sort((a, b) => b.updatedAt!.compareTo(a.updatedAt!));
return drafts;
}
@ -283,94 +85,7 @@ class ComposeStorageNotifier extends _$ComposeStorageNotifier {
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();
await database.clearAllPostDrafts();
} catch (e) {
// If clearing fails, we might want to reload from database
_loadDrafts();

View File

@ -7,13 +7,13 @@ part of 'compose_storage_db.dart';
// **************************************************************************
String _$composeStorageNotifierHash() =>
r'fcdb006dca44d30916a20804922e93d0caad49ca';
r'3de7a01a93d999d45a32fb68617b77f194589686';
/// See also [ComposeStorageNotifier].
@ProviderFor(ComposeStorageNotifier)
final composeStorageNotifierProvider = AutoDisposeNotifierProvider<
ComposeStorageNotifier,
Map<String, ComposeDraftModel>
Map<String, SnPost>
>.internal(
ComposeStorageNotifier.new,
name: r'composeStorageNotifierProvider',
@ -25,28 +25,6 @@ final composeStorageNotifierProvider = AutoDisposeNotifierProvider<
allTransitiveDependencies: null,
);
typedef _$ComposeStorageNotifier =
AutoDisposeNotifier<Map<String, ComposeDraftModel>>;
String _$articleStorageNotifierHash() =>
r'21ee0f8ee87528bebf8f5f4b0b2892cd8058e230';
/// See also [ArticleStorageNotifier].
@ProviderFor(ArticleStorageNotifier)
final articleStorageNotifierProvider = AutoDisposeNotifierProvider<
ArticleStorageNotifier,
Map<String, ArticleDraftModel>
>.internal(
ArticleStorageNotifier.new,
name: r'articleStorageNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$articleStorageNotifierHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ArticleStorageNotifier =
AutoDisposeNotifier<Map<String, ArticleDraftModel>>;
typedef _$ComposeStorageNotifier = AutoDisposeNotifier<Map<String, SnPost>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -1,7 +1,6 @@
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -15,6 +14,7 @@ import 'package:island/services/compose_storage_db.dart';
import 'package:island/widgets/alert.dart';
import 'package:pasteboard/pasteboard.dart';
import 'dart:async';
import 'dart:developer';
class ComposeState {
final ValueNotifier<List<UniversalFile>> attachments;
@ -40,10 +40,10 @@ class ComposeState {
required this.draftId,
});
void startAutoSave(WidgetRef ref) {
void startAutoSave(WidgetRef ref, {int postType = 0}) {
_autoSaveTimer?.cancel();
_autoSaveTimer = Timer.periodic(const Duration(seconds: 3), (_) {
ComposeLogic.saveDraft(ref, this);
ComposeLogic.saveDraftWithoutUpload(ref, this, postType: postType);
});
}
@ -96,9 +96,11 @@ class ComposeLogic {
);
}
static ComposeState createStateFromDraft(ComposeDraftModel draft) {
static ComposeState createStateFromDraft(SnPost draft) {
return ComposeState(
attachments: ValueNotifier<List<UniversalFile>>([]),
attachments: ValueNotifier<List<UniversalFile>>(
draft.attachments.map((e) => UniversalFile.fromAttachment(e)).toList(),
),
titleController: TextEditingController(text: draft.title),
descriptionController: TextEditingController(text: draft.description),
contentController: TextEditingController(text: draft.content),
@ -110,29 +112,247 @@ class ComposeLogic {
);
}
static Future<void> saveDraft(WidgetRef ref, ComposeState state, {int postType = 0}) async {
final hasContent =
state.titleController.text.trim().isNotEmpty ||
state.descriptionController.text.trim().isNotEmpty ||
state.contentController.text.trim().isNotEmpty;
final hasAttachments = state.attachments.value.isNotEmpty;
static Future<void> saveDraft(WidgetRef ref, ComposeState state) async {
try {
// Check if the auto-save timer is still active (widget not disposed)
if (state._autoSaveTimer == null) {
return; // Widget has been disposed, don't save
if (!hasContent && !hasAttachments) {
return; // Don't save empty posts
}
final draft = ComposeDraftModel(
try {
if (state._autoSaveTimer == null) {
return;
}
// Upload any local attachments first
final baseUrl = ref.watch(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider));
if (token == null) throw ArgumentError('Token is null');
for (int i = 0; i < state.attachments.value.length; i++) {
final attachment = state.attachments.value[i];
if (attachment.data is! SnCloudFile) {
try {
final cloudFile =
await putMediaToCloud(
fileData: attachment,
atk: token,
baseUrl: baseUrl,
filename: attachment.data.name ?? (postType == 1 ? 'Article media' : 'Post media'),
mimetype:
attachment.data.mimeType ??
ComposeLogic.getMimeTypeFromFileType(attachment.type),
).future;
if (cloudFile != null) {
// Update attachments list with cloud file
final clone = List.of(state.attachments.value);
clone[i] = UniversalFile(data: cloudFile, type: attachment.type);
state.attachments.value = clone;
}
} catch (err) {
log('[ComposeLogic] Failed to upload attachment: $err');
// Continue with other attachments even if one fails
}
}
}
final draft = SnPost(
id: state.draftId,
title: state.titleController.text,
description: state.descriptionController.text,
content: state.contentController.text,
attachments: state.attachments.value,
language: null,
editedAt: null,
publishedAt: DateTime.now(),
visibility: state.visibility.value,
lastModified: DateTime.now(),
content: state.contentController.text,
type: postType,
meta: null,
viewsUnique: 0,
viewsTotal: 0,
upvotes: 0,
downvotes: 0,
repliesCount: 0,
threadedPostId: null,
threadedPost: null,
repliedPostId: null,
repliedPost: null,
forwardedPostId: null,
forwardedPost: null,
attachments:
state.attachments.value
.map((e) => e.data)
.whereType<SnCloudFile>()
.toList(),
publisher: SnPublisher(
id: '',
type: 0,
name: '',
nick: '',
picture: null,
background: null,
account: null,
accountId: null,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
deletedAt: null,
realmId: null,
verification: null,
),
reactions: [],
tags: [],
categories: [],
collections: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
deletedAt: null,
);
await ref.read(composeStorageNotifierProvider.notifier).saveDraft(draft);
} catch (e) {
log('[ComposeLogic] Failed to save draft, error: $e');
// Silently fail for auto-save to avoid disrupting user experience
}
}
static Future<void> saveDraftWithoutUpload(WidgetRef ref, ComposeState state, {int postType = 0}) async {
final hasContent =
state.titleController.text.trim().isNotEmpty ||
state.descriptionController.text.trim().isNotEmpty ||
state.contentController.text.trim().isNotEmpty;
final hasAttachments = state.attachments.value.isNotEmpty;
if (!hasContent && !hasAttachments) {
return; // Don't save empty posts
}
try {
if (state._autoSaveTimer == null) {
return;
}
final draft = SnPost(
id: state.draftId,
title: state.titleController.text,
description: state.descriptionController.text,
language: null,
editedAt: null,
publishedAt: DateTime.now(),
visibility: state.visibility.value,
content: state.contentController.text,
type: postType,
meta: null,
viewsUnique: 0,
viewsTotal: 0,
upvotes: 0,
downvotes: 0,
repliesCount: 0,
threadedPostId: null,
threadedPost: null,
repliedPostId: null,
repliedPost: null,
forwardedPostId: null,
forwardedPost: null,
attachments:
state.attachments.value
.map((e) => e.data)
.whereType<SnCloudFile>()
.toList(),
publisher: SnPublisher(
id: '',
type: 0,
name: '',
nick: '',
picture: null,
background: null,
account: null,
accountId: null,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
deletedAt: null,
realmId: null,
verification: null,
),
reactions: [],
tags: [],
categories: [],
collections: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
deletedAt: null,
);
await ref.read(composeStorageNotifierProvider.notifier).saveDraft(draft);
} catch (e) {
log('[ComposeLogic] Failed to save draft without upload, error: $e');
}
}
static Future<void> saveDraftManually(
WidgetRef ref,
ComposeState state,
BuildContext context,
) async {
try {
final draft = SnPost(
id: state.draftId,
title: state.titleController.text,
description: state.descriptionController.text,
language: null,
editedAt: null,
publishedAt: DateTime.now(),
visibility: state.visibility.value,
content: state.contentController.text,
type: 0,
meta: null,
viewsUnique: 0,
viewsTotal: 0,
upvotes: 0,
downvotes: 0,
repliesCount: 0,
threadedPostId: null,
threadedPost: null,
repliedPostId: null,
repliedPost: null,
forwardedPostId: null,
forwardedPost: null,
attachments: [], // TODO: Handle attachments
publisher: SnPublisher(
id: '',
type: 0,
name: '',
nick: '',
picture: null,
background: null,
account: null,
accountId: null,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
deletedAt: null,
realmId: null,
verification: null,
),
reactions: [],
tags: [],
categories: [],
collections: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
deletedAt: null,
);
await ref.read(composeStorageNotifierProvider.notifier).saveDraft(draft);
if (context.mounted) {
showSnackBar(context, 'draftSaved'.tr());
}
} catch (e) {
log('[ComposeLogic] Failed to save draft manually, error: $e');
if (context.mounted) {
showSnackBar(context, 'draftSaveFailed'.tr());
}
}
}
@ -146,7 +366,7 @@ class ComposeLogic {
}
}
static Future<ComposeDraftModel?> loadDraft(WidgetRef ref, String draftId) async {
static Future<SnPost?> loadDraft(WidgetRef ref, String draftId) async {
try {
return ref
.read(composeStorageNotifierProvider.notifier)
@ -282,6 +502,20 @@ class ComposeLogic {
}) async {
if (state.submitting.value) return;
// Don't submit empty posts (no content and no attachments)
final hasContent =
state.titleController.text.trim().isNotEmpty ||
state.descriptionController.text.trim().isNotEmpty ||
state.contentController.text.trim().isNotEmpty;
final hasAttachments = state.attachments.value.isNotEmpty;
if (!hasContent && !hasAttachments) {
if (context.mounted) {
showSnackBar(context, 'postContentEmpty'.tr());
}
return; // Don't submit empty posts
}
try {
state.submitting.value = true;
@ -329,7 +563,7 @@ class ComposeLogic {
if (postType == 1) {
// Delete article draft
await ref
.read(articleStorageNotifierProvider.notifier)
.read(composeStorageNotifierProvider.notifier)
.deleteDraft(state.draftId);
} else {
// Delete regular post draft
@ -381,7 +615,7 @@ class ComposeLogic {
if (isPaste && isModifierPressed) {
handlePaste(state);
} else if (isSave && isModifierPressed) {
saveDraft(ref, state);
saveDraftManually(ref, state, context);
} else if (isSubmit && isModifierPressed && !state.submitting.value) {
performAction(
ref,

View File

@ -7,42 +7,48 @@ import 'package:island/services/compose_storage_db.dart';
import 'package:material_symbols_icons/symbols.dart';
class DraftManagerSheet extends HookConsumerWidget {
final bool isArticle;
final Function(String draftId)? onDraftSelected;
const DraftManagerSheet({
super.key,
this.isArticle = false,
this.onDraftSelected,
});
const DraftManagerSheet({super.key, this.onDraftSelected});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isLoading = useState(true);
final drafts =
isArticle
? ref.watch(articleStorageNotifierProvider)
: ref.watch(composeStorageNotifierProvider);
final drafts = ref.watch(composeStorageNotifierProvider);
final sortedDrafts = useMemoized(() {
if (isArticle) {
final draftList = drafts.values.cast<ArticleDraftModel>().toList();
draftList.sort((a, b) => b.lastModified.compareTo(a.lastModified));
return draftList;
} else {
final draftList = drafts.values.cast<ComposeDraftModel>().toList();
draftList.sort((a, b) => b.lastModified.compareTo(a.lastModified));
return draftList;
// Track loading state based on drafts being loaded
useEffect(() {
// Set loading to false after drafts are loaded
// We consider drafts loaded when the provider has been initialized
Future.microtask(() {
if (isLoading.value) {
isLoading.value = false;
}
});
return null;
}, [drafts]);
final sortedDrafts = useMemoized(
() {
final draftList = drafts.values.toList();
draftList.sort((a, b) => b.updatedAt!.compareTo(a.updatedAt!));
return draftList;
},
[
drafts.length,
drafts.values.map((e) => e.updatedAt!.millisecondsSinceEpoch).join(),
],
);
return Scaffold(
appBar: AppBar(
title: Text(isArticle ? 'articleDrafts'.tr() : 'postDrafts'.tr()),
),
body: Column(
appBar: AppBar(title: Text('drafts'.tr())),
body:
isLoading.value
? const Center(child: CircularProgressIndicator())
: Column(
children: [
if (sortedDrafts.isEmpty)
Expanded(
@ -74,29 +80,14 @@ class DraftManagerSheet extends HookConsumerWidget {
final draft = sortedDrafts[index];
return _DraftItem(
draft: draft,
isArticle: isArticle,
onTap: () {
Navigator.of(context).pop();
final draftId =
isArticle
? (draft as ArticleDraftModel).id
: (draft as ComposeDraftModel).id;
onDraftSelected?.call(draftId);
onDraftSelected?.call(draft.id);
},
onDelete: () async {
final draftId =
isArticle
? (draft as ArticleDraftModel).id
: (draft as ComposeDraftModel).id;
if (isArticle) {
await ref
.read(articleStorageNotifierProvider.notifier)
.deleteDraft(draftId);
} else {
await ref
.read(composeStorageNotifierProvider.notifier)
.deleteDraft(draftId);
}
.deleteDraft(draft.id);
},
);
},
@ -116,16 +107,22 @@ class DraftManagerSheet extends HookConsumerWidget {
builder:
(context) => AlertDialog(
title: Text('clearAllDrafts'.tr()),
content: Text('clearAllDraftsConfirm'.tr()),
content: Text(
'clearAllDraftsConfirm'.tr(),
),
actions: [
TextButton(
onPressed:
() => Navigator.of(context).pop(false),
() => Navigator.of(
context,
).pop(false),
child: Text('cancel'.tr()),
),
TextButton(
onPressed:
() => Navigator.of(context).pop(true),
() => Navigator.of(
context,
).pop(true),
child: Text('confirm'.tr()),
),
],
@ -133,15 +130,11 @@ class DraftManagerSheet extends HookConsumerWidget {
);
if (confirmed == true) {
if (isArticle) {
await ref
.read(articleStorageNotifierProvider.notifier)
.read(
composeStorageNotifierProvider.notifier,
)
.clearAllDrafts();
} else {
await ref
.read(composeStorageNotifierProvider.notifier)
.clearAllDrafts();
}
}
},
icon: const Icon(Symbols.delete_sweep),
@ -159,56 +152,23 @@ class DraftManagerSheet extends HookConsumerWidget {
}
class _DraftItem extends StatelessWidget {
final dynamic draft; // ComposeDraft or ArticleDraft
final bool isArticle;
final dynamic draft;
final VoidCallback? onTap;
final VoidCallback? onDelete;
const _DraftItem({
required this.draft,
required this.isArticle,
this.onTap,
this.onDelete,
});
const _DraftItem({required this.draft, this.onTap, this.onDelete});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final String title;
final String content;
final DateTime lastModified;
final String visibility;
if (isArticle) {
final articleDraft = draft as ArticleDraftModel;
title =
articleDraft.title.isNotEmpty ? articleDraft.title : 'untitled'.tr();
content =
articleDraft.content.isNotEmpty
? articleDraft.content
: (articleDraft.description.isNotEmpty
? articleDraft.description
: 'noContent'.tr());
lastModified = articleDraft.lastModified;
visibility = _parseArticleVisibility(articleDraft.visibility);
} else {
final postDraft = draft as ComposeDraftModel;
title = postDraft.title.isNotEmpty ? postDraft.title : 'untitled'.tr();
content =
postDraft.content.isNotEmpty
? postDraft.content
: (postDraft.description.isNotEmpty
? postDraft.description
: 'noContent'.tr());
lastModified = postDraft.lastModified;
visibility = _parseArticleVisibility(postDraft.visibility);
}
final title = draft.title ?? 'untitled'.tr();
final content = draft.content ?? (draft.description ?? 'noContent'.tr());
final preview =
content.length > 100 ? '${content.substring(0, 100)}...' : content;
final timeAgo = _formatTimeAgo(lastModified);
final timeAgo = _formatTimeAgo(draft.updatedAt!);
final visibility = _parseVisibility(draft.visibility);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
@ -223,7 +183,7 @@ class _DraftItem extends StatelessWidget {
Row(
children: [
Icon(
isArticle ? Symbols.article : Symbols.post_add,
draft.type == 1 ? Symbols.article : Symbols.post_add,
size: 20,
color: colorScheme.primary,
),
@ -316,7 +276,7 @@ class _DraftItem extends StatelessWidget {
}
}
String _parseArticleVisibility(int visibility) {
String _parseVisibility(int visibility) {
switch (visibility) {
case 0:
return 'public'.tr();

View File

@ -162,8 +162,8 @@ class PostItem extends HookConsumerWidget {
Spacer(),
Text(
isFullPost
? item.publishedAt.formatSystem()
: item.publishedAt.formatRelative(context),
? item.publishedAt?.formatSystem() ?? ''
: item.publishedAt?.formatRelative(context) ?? '',
).fontSize(11).alignment(Alignment.bottomRight),
const Gap(4),
],
@ -213,12 +213,14 @@ class PostItem extends HookConsumerWidget {
content: item.content!,
linesMargin:
item.type == 0
? EdgeInsets.only(bottom: 4)
? EdgeInsets.only(bottom: 8)
: null,
),
// Show truncation hint if post is truncated
if (item.isTruncated && !isFullPost)
_PostTruncateHint(),
_PostTruncateHint().padding(
bottom: item.attachments.isNotEmpty ? 8 : null,
),
if ((item.repliedPost != null ||
item.forwardedPost != null) &&
showReferencePost)
@ -234,7 +236,7 @@ class PostItem extends HookConsumerWidget {
MediaQuery.of(context).size.width * 0.9,
kWideScreenWidth - 160,
),
).padding(top: 4),
),
// Render embed links
if (item.meta?['embeds'] != null)
...((item.meta!['embeds'] as List<dynamic>)
@ -248,7 +250,8 @@ class PostItem extends HookConsumerWidget {
MediaQuery.of(context).size.width * 0.85,
kWideScreenWidth - 160,
),
).padding(top: 4),
margin: EdgeInsets.only(top: 8),
),
)),
],
),
@ -323,7 +326,6 @@ Widget _buildReferencePost(BuildContext context, SnPost item) {
final isReply = item.repliedPost != null;
return Container(
margin: const EdgeInsets.only(top: 8, bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),

View File

@ -153,7 +153,7 @@ class PostItemCreator extends HookConsumerWidget {
),
const Gap(8),
Text(
item.publishedAt.formatSystem(),
item.publishedAt?.formatSystem() ?? '',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.secondary,
@ -291,7 +291,7 @@ class PostItemCreator extends HookConsumerWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Created: ${item.createdAt.formatSystem()}',
'Created: ${item.createdAt?.formatSystem() ?? ''}',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.secondary,