Compare commits

..

No commits in common. "b89cffeb18be30ab1b950d9f276be5195adc217b" and "2287995cb471d2e7c14fb087e726ad32f1b739a9" have entirely different histories.

14 changed files with 735 additions and 2338 deletions

View File

@ -409,7 +409,6 @@
"noDrafts": "No drafts yet", "noDrafts": "No drafts yet",
"articleDrafts": "Article drafts", "articleDrafts": "Article drafts",
"postDrafts": "Post drafts", "postDrafts": "Post drafts",
"saveDraft": "Save draft",
"clearAllDrafts": "Clear All Drafts", "clearAllDrafts": "Clear All Drafts",
"clearAllDraftsConfirm": "Are you sure you want to delete all drafts? This action cannot be undone.", "clearAllDraftsConfirm": "Are you sure you want to delete all drafts? This action cannot be undone.",
"clearAll": "Clear All", "clearAll": "Clear All",

View File

@ -1,26 +0,0 @@
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
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
DateTimeColumn get lastModified => dateTime()();
@override
Set<Column> get primaryKey => {id};
}

View File

@ -1,17 +1,16 @@
import 'dart:convert'; import 'dart:convert';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:island/database/message.dart'; import 'package:island/database/message.dart';
import 'package:island/database/draft.dart';
part 'drift_db.g.dart'; part 'drift_db.g.dart';
// Define the database // Define the database
@DriftDatabase(tables: [ChatMessages, ComposeDrafts, ArticleDrafts]) @DriftDatabase(tables: [ChatMessages])
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
AppDatabase(super.e); AppDatabase(super.e);
@override @override
int get schemaVersion => 3; int get schemaVersion => 2;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@ -23,11 +22,6 @@ class AppDatabase extends _$AppDatabase {
// Add isRead column with default value false // Add isRead column with default value false
await m.addColumn(chatMessages, chatMessages.isRead); await m.addColumn(chatMessages, chatMessages.isRead);
} }
if (from < 3) {
// Add draft tables
await m.createTable(composeDrafts);
await m.createTable(articleDrafts);
}
}, },
); );
@ -97,52 +91,4 @@ class AppDatabase extends _$AppDatabase {
isRead: dbMessage.isRead, isRead: dbMessage.isRead,
); );
} }
// Methods for compose drafts
Future<List<ComposeDraft>> getAllComposeDrafts() {
return (select(composeDrafts)
..orderBy([(d) => OrderingTerm.desc(d.lastModified)]))
.get();
}
Future<ComposeDraft?> getComposeDraft(String id) {
return (select(composeDrafts)..where((d) => d.id.equals(id)))
.getSingleOrNull();
}
Future<int> saveComposeDraft(ComposeDraftsCompanion draft) {
return into(composeDrafts).insert(draft, mode: InsertMode.insertOrReplace);
}
Future<int> deleteComposeDraft(String id) {
return (delete(composeDrafts)..where((d) => d.id.equals(id))).go();
}
Future<int> clearAllComposeDrafts() {
return delete(composeDrafts).go();
}
// Methods for article drafts
Future<List<ArticleDraft>> getAllArticleDrafts() {
return (select(articleDrafts)
..orderBy([(d) => OrderingTerm.desc(d.lastModified)]))
.get();
}
Future<ArticleDraft?> getArticleDraft(String id) {
return (select(articleDrafts)..where((d) => d.id.equals(id)))
.getSingleOrNull();
}
Future<int> saveArticleDraft(ArticleDraftsCompanion draft) {
return into(articleDrafts).insert(draft, mode: InsertMode.insertOrReplace);
}
Future<int> deleteArticleDraft(String id) {
return (delete(articleDrafts)..where((d) => d.id.equals(id))).go();
}
Future<int> clearAllArticleDrafts() {
return delete(articleDrafts).go();
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -14,9 +14,6 @@ sealed class UniversalFile with _$UniversalFile {
required UniversalFileType type, required UniversalFileType type,
}) = _UniversalFile; }) = _UniversalFile;
factory UniversalFile.fromJson(Map<String, dynamic> json) =>
_$UniversalFileFromJson(json);
bool get isOnCloud => data is SnCloudFile; bool get isOnCloud => data is SnCloudFile;
bool get isOnDevice => !isOnCloud; bool get isOnDevice => !isOnCloud;

View File

@ -12,7 +12,6 @@ part of 'file.dart';
// dart format off // dart format off
T _$identity<T>(T value) => value; T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$UniversalFile { mixin _$UniversalFile {
@ -23,8 +22,6 @@ mixin _$UniversalFile {
@pragma('vm:prefer-inline') @pragma('vm:prefer-inline')
$UniversalFileCopyWith<UniversalFile> get copyWith => _$UniversalFileCopyWithImpl<UniversalFile>(this as UniversalFile, _$identity); $UniversalFileCopyWith<UniversalFile> get copyWith => _$UniversalFileCopyWithImpl<UniversalFile>(this as UniversalFile, _$identity);
/// Serializes this UniversalFile to a JSON map.
Map<String, dynamic> toJson();
@override @override
@ -32,7 +29,7 @@ bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)); return identical(this, other) || (other.runtimeType == runtimeType&&other is UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type));
} }
@JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type); int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type);
@ -78,11 +75,11 @@ as UniversalFileType,
/// @nodoc /// @nodoc
@JsonSerializable()
class _UniversalFile extends UniversalFile { class _UniversalFile extends UniversalFile {
const _UniversalFile({required this.data, required this.type}): super._(); const _UniversalFile({required this.data, required this.type}): super._();
factory _UniversalFile.fromJson(Map<String, dynamic> json) => _$UniversalFileFromJson(json);
@override final dynamic data; @override final dynamic data;
@override final UniversalFileType type; @override final UniversalFileType type;
@ -93,17 +90,14 @@ class _UniversalFile extends UniversalFile {
@pragma('vm:prefer-inline') @pragma('vm:prefer-inline')
_$UniversalFileCopyWith<_UniversalFile> get copyWith => __$UniversalFileCopyWithImpl<_UniversalFile>(this, _$identity); _$UniversalFileCopyWith<_UniversalFile> get copyWith => __$UniversalFileCopyWithImpl<_UniversalFile>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$UniversalFileToJson(this, );
}
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type));
} }
@JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type); int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type);

View File

@ -6,25 +6,6 @@ part of 'file.dart';
// JsonSerializableGenerator // JsonSerializableGenerator
// ************************************************************************** // **************************************************************************
_UniversalFile _$UniversalFileFromJson(Map<String, dynamic> json) =>
_UniversalFile(
data: json['data'],
type: $enumDecode(_$UniversalFileTypeEnumMap, json['type']),
);
Map<String, dynamic> _$UniversalFileToJson(_UniversalFile instance) =>
<String, dynamic>{
'data': instance.data,
'type': _$UniversalFileTypeEnumMap[instance.type]!,
};
const _$UniversalFileTypeEnumMap = {
UniversalFileType.image: 'image',
UniversalFileType.video: 'video',
UniversalFileType.audio: 'audio',
UniversalFileType.file: 'file',
};
_SnCloudFile _$SnCloudFileFromJson(Map<String, dynamic> json) => _SnCloudFile( _SnCloudFile _$SnCloudFileFromJson(Map<String, dynamic> json) => _SnCloudFile(
id: json['id'] as String, id: json['id'] as String,
name: json['name'] as String, name: json['name'] as String,

View File

@ -17,7 +17,7 @@ import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/publishers_modal.dart'; import 'package:island/widgets/post/publishers_modal.dart';
import 'package:island/screens/posts/detail.dart'; import 'package:island/screens/posts/detail.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart'; import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/services/compose_storage_db.dart'; import 'package:island/services/compose_storage.dart';
import 'package:island/widgets/post/draft_manager.dart'; import 'package:island/widgets/post/draft_manager.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@ -62,7 +62,16 @@ class PostComposeScreen extends HookConsumerWidget {
@QueryParam('type') this.type, @QueryParam('type') this.type,
}); });
// Helper method to parse visibility
int _parseVisibility(String visibility) {
switch (visibility) {
case 'public': return 0;
case 'unlisted': return 1;
case 'friends': return 2;
case 'private': return 3;
default: return 0;
}
}
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -94,8 +103,7 @@ class PostComposeScreen extends HookConsumerWidget {
// Start auto-save when component mounts // Start auto-save when component mounts
useEffect(() { useEffect(() {
if (originalPost == null) { if (originalPost == null) { // Only auto-save for new posts, not edits
// Only auto-save for new posts, not edits
state.startAutoSave(ref); state.startAutoSave(ref);
} }
return () => state.stopAutoSave(); return () => state.stopAutoSave();
@ -111,22 +119,19 @@ class PostComposeScreen extends HookConsumerWidget {
// Load draft if available (only for new posts) // Load draft if available (only for new posts)
useEffect(() { useEffect(() {
if (originalPost == null && if (originalPost == null && effectiveForwardedPost == null && effectiveRepliedPost == null) {
effectiveForwardedPost == null &&
effectiveRepliedPost == null) {
// Try to load the most recent draft // Try to load the most recent draft
final drafts = ref.read(composeStorageNotifierProvider); final drafts = ref.read(composeStorageNotifierProvider);
if (drafts.isNotEmpty) { if (drafts.isNotEmpty) {
final mostRecentDraft = drafts.values.reduce( final mostRecentDraft = drafts.values.reduce((a, b) =>
(a, b) => a.lastModified.isAfter(b.lastModified) ? a : b, a.lastModified.isAfter(b.lastModified) ? a : b);
);
// Only load if the draft has meaningful content // Only load if the draft has meaningful content
if (!mostRecentDraft.isEmpty) { if (!mostRecentDraft.isEmpty) {
state.titleController.text = mostRecentDraft.title; state.titleController.text = mostRecentDraft.title;
state.descriptionController.text = mostRecentDraft.description; state.descriptionController.text = mostRecentDraft.description;
state.contentController.text = mostRecentDraft.content; state.contentController.text = mostRecentDraft.content;
state.visibility.value = mostRecentDraft.visibility; state.visibility.value = _parseVisibility(mostRecentDraft.visibility);
} }
} }
} }
@ -136,7 +141,12 @@ class PostComposeScreen extends HookConsumerWidget {
// Dispose state when widget is disposed // Dispose state when widget is disposed
useEffect(() { useEffect(() {
return () { return () {
// Stop auto-save first to prevent race conditions
state.stopAutoSave(); state.stopAutoSave();
// Save final draft before disposing
if (originalPost == null) {
ComposeLogic.saveDraft(ref, state);
}
ComposeLogic.dispose(state); ComposeLogic.dispose(state);
}; };
}, []); }, []);
@ -225,218 +235,199 @@ class PostComposeScreen extends HookConsumerWidget {
} }
// Build UI // Build UI
return PopScope( return AppScaffold(
onPopInvoked: (_) { noBackground: false,
if (originalPost == null) { appBar: AppBar(
ComposeLogic.saveDraft(ref, state); leading: const PageBackButton(),
} actions: [
}, if (originalPost == null) // Only show drafts for new posts
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 = draft.visibility;
}
},
),
);
},
tooltip: 'drafts'.tr(),
),
IconButton( IconButton(
icon: const Icon(Symbols.save), icon: const Icon(Symbols.draft),
onPressed: () => ComposeLogic.saveDraft(ref, state), onPressed: () {
tooltip: 'saveDraft'.tr(), showModalBottomSheet(
), context: context,
IconButton( isScrollControlled: true,
icon: const Icon(Symbols.settings), builder: (context) => DraftManagerSheet(
onPressed: showSettingsSheet, isArticle: false,
tooltip: 'postSettings'.tr(), onDraftSelected: (draftId) {
), final draft = ref.read(composeStorageNotifierProvider)[draftId];
ValueListenableBuilder<bool>( if (draft != null) {
valueListenable: state.submitting, state.titleController.text = draft.title;
builder: (context, submitting, _) { state.descriptionController.text = draft.description;
return IconButton( state.contentController.text = draft.content;
onPressed: state.visibility.value = _parseVisibility(draft.visibility);
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(),
), ),
const Gap(8), IconButton(
], icon: const Icon(Symbols.settings),
), onPressed: showSettingsSheet,
body: Column( tooltip: 'postSettings'.tr(),
crossAxisAlignment: CrossAxisAlignment.start, ),
children: [ ValueListenableBuilder<bool>(
// Reply/Forward info section valueListenable: state.submitting,
_buildInfoBanner(context), builder: (context, submitting, _) {
return IconButton(
// Main content area onPressed:
Expanded( submitting
child: ConstrainedBox( ? null
constraints: const BoxConstraints(maxWidth: 480), : () => ComposeLogic.performAction(
child: Row( ref,
spacing: 12, state,
crossAxisAlignment: CrossAxisAlignment.start, context,
children: [ originalPost: originalPost,
// Publisher profile picture repliedPost: repliedPost,
GestureDetector( forwardedPost: forwardedPost,
child: ProfilePictureWidget( postType: 0, // Regular post type
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
),
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(),
),
),
const Gap(8),
// Attachments preview
ValueListenableBuilder<List<UniversalFile>>(
valueListenable: state.attachments,
builder: (context, attachments, _) {
if (attachments.isEmpty) {
return const SizedBox.shrink();
}
return LayoutBuilder(
builder: (context, constraints) {
final isWide = isWideScreen(context);
return isWide
? buildWideAttachmentGrid()
: buildNarrowAttachmentList();
},
);
},
),
],
), ),
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),
// 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
),
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(),
),
),
const Gap(8),
// Attachments preview
ValueListenableBuilder<List<UniversalFile>>(
valueListenable: state.attachments,
builder: (context, attachments, _) {
if (attachments.isEmpty) {
return const SizedBox.shrink();
}
return LayoutBuilder(
builder: (context, constraints) {
final isWide = isWideScreen(context);
return isWide
? buildWideAttachmentGrid()
: buildNarrowAttachmentList();
},
);
},
),
],
), ),
), ),
],
).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( ).padding(horizontal: 16),
bottom: MediaQuery.of(context).padding.bottom + 16, ).alignment(Alignment.topCenter),
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,
), ),
], ),
), ],
), ),
); );
} }

View File

@ -3,7 +3,6 @@ import 'dart:developer';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -18,8 +17,8 @@ import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/markdown.dart'; import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/post/compose_shared.dart'; import 'package:island/widgets/post/compose_shared.dart';
import 'package:island/widgets/post/compose_settings_sheet.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/widgets/post/publishers_modal.dart';
import 'package:island/services/compose_storage.dart';
import 'package:island/widgets/post/draft_manager.dart'; import 'package:island/widgets/post/draft_manager.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@ -75,13 +74,10 @@ class ArticleComposeScreen extends HookConsumerWidget {
}); });
} }
return () { return () {
// Stop auto-save first to prevent race conditions // Save final draft before cancelling timer
state.stopAutoSave();
// Save final draft before disposing
if (originalPost == null) { if (originalPost == null) {
_saveArticleDraft(ref, state); _saveArticleDraft(ref, state);
} }
ComposeLogic.dispose(state);
autoSaveTimer?.cancel(); autoSaveTimer?.cancel();
}; };
}, [state]); }, [state]);
@ -111,17 +107,22 @@ class ArticleComposeScreen extends HookConsumerWidget {
state.titleController.text = mostRecentDraft.title; state.titleController.text = mostRecentDraft.title;
state.descriptionController.text = mostRecentDraft.description; state.descriptionController.text = mostRecentDraft.description;
state.contentController.text = mostRecentDraft.content; state.contentController.text = mostRecentDraft.content;
state.visibility.value = mostRecentDraft.visibility; state.visibility.value = _parseArticleVisibility(
mostRecentDraft.visibility,
);
} }
} }
} }
return null; return null;
}, []); }, []);
// Auto-save cleanup // Dispose state when widget is disposed
useEffect(() { useEffect(() {
return () { return () {
state.stopAutoSave(); // Save final draft before disposing
if (originalPost == null) {
_saveArticleDraft(ref, state);
}
ComposeLogic.dispose(state); ComposeLogic.dispose(state);
}; };
}, [state]); }, [state]);
@ -272,12 +273,13 @@ class ArticleComposeScreen extends HookConsumerWidget {
child: RawKeyboardListener( child: RawKeyboardListener(
focusNode: FocusNode(), focusNode: FocusNode(),
onKey: onKey:
(event) => _handleKeyPress( (event) => ComposeLogic.handleKeyPress(
event, event,
state, state,
ref, ref,
context, context,
originalPost: originalPost, originalPost: originalPost,
postType: 1, // Article type
), ),
child: TextField( child: TextField(
controller: state.contentController, controller: state.contentController,
@ -353,207 +355,193 @@ class ArticleComposeScreen extends HookConsumerWidget {
); );
} }
return PopScope( return AppScaffold(
onPopInvoked: (_) { noBackground: false,
if (originalPost == null) { appBar: AppBar(
_saveArticleDraft(ref, state); leading: const PageBackButton(),
} title: ValueListenableBuilder<TextEditingValue>(
}, valueListenable: state.titleController,
child: AppScaffold( builder: (context, titleValue, _) {
noBackground: false, return Text(
appBar: AppBar( titleValue.text.isEmpty ? 'postTitle'.tr() : titleValue.text,
leading: const PageBackButton(), );
title: ValueListenableBuilder<TextEditingValue>( },
valueListenable: state.titleController, ),
builder: (context, titleValue, _) { actions: [
return Text( // Info banner for article compose
titleValue.text.isEmpty ? 'postTitle'.tr() : titleValue.text, const SizedBox.shrink(),
if (originalPost == null) // Only show drafts for new articles
IconButton(
icon: const Icon(Symbols.draft),
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => DraftManagerSheet(
isArticle: true,
onDraftSelected: (draftId) {
final draft =
ref.read(articleStorageNotifierProvider)[draftId];
if (draft != null) {
state.titleController.text = draft.title;
state.descriptionController.text =
draft.description;
state.contentController.text = draft.content;
state.visibility.value = _parseArticleVisibility(
draft.visibility,
);
}
},
),
);
},
tooltip: 'drafts'.tr(),
),
IconButton(
icon: const Icon(Symbols.settings),
onPressed: showSettingsSheet,
tooltip: 'postSettings'.tr(),
),
Tooltip(
message: 'togglePreview'.tr(),
child: IconButton(
icon: Icon(showPreview.value ? Symbols.edit : Symbols.preview),
onPressed: () => showPreview.value = !showPreview.value,
),
),
ValueListenableBuilder<bool>(
valueListenable: state.submitting,
builder: (context, submitting, _) {
return IconButton(
onPressed:
submitting
? null
: () => ComposeLogic.performAction(
ref,
state,
context,
originalPost: originalPost,
postType: 1, // Article type
),
icon:
submitting
? SizedBox(
width: 28,
height: 28,
child: const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
).center()
: Icon(
originalPost != null ? Symbols.edit : Symbols.upload,
),
); );
}, },
), ),
actions: [ const Gap(8),
// Info banner for article compose ],
const SizedBox.shrink(), ),
if (originalPost == null) // Only show drafts for new articles body: Column(
IconButton( children: [
icon: const Icon(Symbols.draft), Expanded(
onPressed: () { child: Padding(
showModalBottomSheet( padding: const EdgeInsets.only(left: 16, right: 16),
context: context, child:
isScrollControlled: true, isWideScreen(context)
builder: ? Row(
(context) => DraftManagerSheet( spacing: 16,
isArticle: true, children: [
onDraftSelected: (draftId) { Expanded(
final draft = flex: showPreview.value ? 1 : 2,
ref.read( child: buildEditorPane(),
articleStorageNotifierProvider,
)[draftId];
if (draft != null) {
state.titleController.text = draft.title;
state.descriptionController.text =
draft.description;
state.contentController.text = draft.content;
state.visibility.value = draft.visibility;
}
},
),
);
},
tooltip: 'drafts'.tr(),
),
IconButton(
icon: const Icon(Symbols.save),
onPressed: () => _saveArticleDraft(ref, state),
tooltip: 'saveDraft'.tr(),
),
IconButton(
icon: const Icon(Symbols.settings),
onPressed: showSettingsSheet,
tooltip: 'postSettings'.tr(),
),
Tooltip(
message: 'togglePreview'.tr(),
child: IconButton(
icon: Icon(showPreview.value ? Symbols.edit : Symbols.preview),
onPressed: () => showPreview.value = !showPreview.value,
),
),
ValueListenableBuilder<bool>(
valueListenable: state.submitting,
builder: (context, submitting, _) {
return IconButton(
onPressed:
submitting
? null
: () => ComposeLogic.performAction(
ref,
state,
context,
originalPost: originalPost,
postType: 1, // Article type
), ),
icon: if (showPreview.value)
submitting Expanded(child: buildPreviewPane()),
? SizedBox( ],
width: 28, )
height: 28, : showPreview.value
child: const CircularProgressIndicator( ? buildPreviewPane()
color: Colors.white, : buildEditorPane(),
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 // Bottom toolbar
Material( Material(
elevation: 4, elevation: 4,
child: Row( child: Row(
children: [ children: [
IconButton( IconButton(
onPressed: () => ComposeLogic.pickPhotoMedia(ref, state), onPressed: () => ComposeLogic.pickPhotoMedia(ref, state),
icon: const Icon(Symbols.add_a_photo), icon: const Icon(Symbols.add_a_photo),
color: colorScheme.primary, color: colorScheme.primary,
), ),
IconButton( IconButton(
onPressed: () => ComposeLogic.pickVideoMedia(ref, state), onPressed: () => ComposeLogic.pickVideoMedia(ref, state),
icon: const Icon(Symbols.videocam), icon: const Icon(Symbols.videocam),
color: colorScheme.primary, color: colorScheme.primary,
), ),
], ],
).padding( ).padding(
bottom: MediaQuery.of(context).padding.bottom + 16, bottom: MediaQuery.of(context).padding.bottom + 16,
horizontal: 16, horizontal: 16,
top: 8, 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 // Helper method to save article draft
Future<void> _saveArticleDraft(WidgetRef ref, ComposeState state) async { Future<void> _saveArticleDraft(WidgetRef ref, ComposeState state) async {
try { try {
final draft = ArticleDraftModel( final draft = ArticleDraft(
id: state.draftId, id: state.draftId,
title: state.titleController.text, title: state.titleController.text,
description: state.descriptionController.text, description: state.descriptionController.text,
content: state.contentController.text, content: state.contentController.text,
visibility: state.visibility.value, visibility: _visibilityToString(state.visibility.value),
lastModified: DateTime.now(), lastModified: DateTime.now(),
); );
await ref.read(articleStorageNotifierProvider.notifier).saveDraft(draft); await ref.read(articleStorageNotifierProvider.notifier).saveDraft(draft);
} catch (e) { } catch (e) {
log('[ArticleCompose] Failed to save draft, error: $e'); log('[ArticleCompose] Failed to save draft, error: $e');
// Silently fail for auto-save to avoid disrupting user experience // Silently fail for auto-save to avoid disrupting user experience
}
}
// Helper method to convert visibility int to string
String _visibilityToString(int visibility) {
switch (visibility) {
case 0:
return 'public';
case 1:
return 'unlisted';
case 2:
return 'friends';
case 3:
return 'private';
default:
return 'public';
} }
} }
// Helper method to parse article visibility
int _parseArticleVisibility(String visibility) {
switch (visibility) {
case 'public':
return 0;
case 'unlisted':
return 1;
case 'friends':
return 2;
case 'private':
return 3;
default:
return 0;
}
}
} }

View File

@ -0,0 +1,270 @@
import 'dart:convert';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/pods/config.dart';
part 'compose_storage.g.dart';
const kComposeDraftStoreKey = 'compose_drafts';
const kArticleDraftStoreKey = 'article_drafts';
class ComposeDraft {
final String id;
final String title;
final String description;
final String content;
final List<String> attachmentIds;
final String visibility;
final DateTime lastModified;
ComposeDraft({
required this.id,
required this.title,
required this.description,
required this.content,
required this.attachmentIds,
required this.visibility,
required this.lastModified,
});
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'description': description,
'content': content,
'attachmentIds': attachmentIds,
'visibility': visibility,
'lastModified': lastModified.toIso8601String(),
};
factory ComposeDraft.fromJson(Map<String, dynamic> json) => ComposeDraft(
id: json['id'] as String,
title: json['title'] as String? ?? '',
description: json['description'] as String? ?? '',
content: json['content'] as String? ?? '',
attachmentIds: List<String>.from(json['attachmentIds'] as List? ?? []),
visibility: json['visibility'] as String? ?? 'public',
lastModified: DateTime.parse(json['lastModified'] as String),
);
ComposeDraft copyWith({
String? id,
String? title,
String? description,
String? content,
List<String>? attachmentIds,
String? visibility,
DateTime? lastModified,
}) {
return ComposeDraft(
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
content: content ?? this.content,
attachmentIds: attachmentIds ?? this.attachmentIds,
visibility: visibility ?? this.visibility,
lastModified: lastModified ?? this.lastModified,
);
}
bool get isEmpty =>
title.isEmpty &&
description.isEmpty &&
content.isEmpty &&
attachmentIds.isEmpty;
}
class ArticleDraft {
final String id;
final String title;
final String description;
final String content;
final String visibility;
final DateTime lastModified;
ArticleDraft({
required this.id,
required this.title,
required this.description,
required this.content,
required this.visibility,
required this.lastModified,
});
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'description': description,
'content': content,
'visibility': visibility,
'lastModified': lastModified.toIso8601String(),
};
factory ArticleDraft.fromJson(Map<String, dynamic> json) => ArticleDraft(
id: json['id'] as String,
title: json['title'] as String? ?? '',
description: json['description'] as String? ?? '',
content: json['content'] as String? ?? '',
visibility: json['visibility'] as String? ?? 'public',
lastModified: DateTime.parse(json['lastModified'] as String),
);
ArticleDraft copyWith({
String? id,
String? title,
String? description,
String? content,
String? visibility,
DateTime? lastModified,
}) {
return ArticleDraft(
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
content: content ?? this.content,
visibility: visibility ?? this.visibility,
lastModified: lastModified ?? this.lastModified,
);
}
bool get isEmpty => title.isEmpty && description.isEmpty && content.isEmpty;
}
@riverpod
class ComposeStorageNotifier extends _$ComposeStorageNotifier {
@override
Map<String, ComposeDraft> build() {
_loadDrafts();
return {};
}
void _loadDrafts() {
final prefs = ref.read(sharedPreferencesProvider);
final draftsJson = prefs.getString(kComposeDraftStoreKey);
if (draftsJson != null) {
try {
final Map<String, dynamic> draftsMap = jsonDecode(draftsJson);
final drafts = <String, ComposeDraft>{};
for (final entry in draftsMap.entries) {
drafts[entry.key] = ComposeDraft.fromJson(entry.value);
}
state = drafts;
} catch (e) {
// If there's an error loading drafts, start with empty state
state = {};
}
}
}
Future<void> _saveDrafts() async {
final prefs = ref.read(sharedPreferencesProvider);
final draftsMap = <String, dynamic>{};
for (final entry in state.entries) {
draftsMap[entry.key] = entry.value.toJson();
}
await prefs.setString(kComposeDraftStoreKey, jsonEncode(draftsMap));
}
Future<void> saveDraft(ComposeDraft draft) async {
if (draft.isEmpty) {
await deleteDraft(draft.id);
return;
}
final updatedDraft = draft.copyWith(lastModified: DateTime.now());
state = {...state, updatedDraft.id: updatedDraft};
await _saveDrafts();
}
Future<void> deleteDraft(String id) async {
final newState = Map<String, ComposeDraft>.from(state);
newState.remove(id);
state = newState;
await _saveDrafts();
}
ComposeDraft? getDraft(String id) {
return state[id];
}
List<ComposeDraft> getAllDrafts() {
final drafts = state.values.toList();
drafts.sort((a, b) => b.lastModified.compareTo(a.lastModified));
return drafts;
}
Future<void> clearAllDrafts() async {
state = {};
final prefs = ref.read(sharedPreferencesProvider);
await prefs.remove(kComposeDraftStoreKey);
}
}
@riverpod
class ArticleStorageNotifier extends _$ArticleStorageNotifier {
@override
Map<String, ArticleDraft> build() {
_loadDrafts();
return {};
}
void _loadDrafts() {
final prefs = ref.read(sharedPreferencesProvider);
final draftsJson = prefs.getString(kArticleDraftStoreKey);
if (draftsJson != null) {
try {
final Map<String, dynamic> draftsMap = jsonDecode(draftsJson);
final drafts = <String, ArticleDraft>{};
for (final entry in draftsMap.entries) {
drafts[entry.key] = ArticleDraft.fromJson(entry.value);
}
state = drafts;
} catch (e) {
// If there's an error loading drafts, start with empty state
state = {};
}
}
}
Future<void> _saveDrafts() async {
final prefs = ref.read(sharedPreferencesProvider);
final draftsMap = <String, dynamic>{};
for (final entry in state.entries) {
draftsMap[entry.key] = entry.value.toJson();
}
await prefs.setString(kArticleDraftStoreKey, jsonEncode(draftsMap));
}
Future<void> saveDraft(ArticleDraft draft) async {
if (draft.isEmpty) {
await deleteDraft(draft.id);
return;
}
final updatedDraft = draft.copyWith(lastModified: DateTime.now());
state = {...state, updatedDraft.id: updatedDraft};
await _saveDrafts();
}
Future<void> deleteDraft(String id) async {
final newState = Map<String, ArticleDraft>.from(state);
newState.remove(id);
state = newState;
await _saveDrafts();
}
ArticleDraft? getDraft(String id) {
return state[id];
}
List<ArticleDraft> getAllDrafts() {
final drafts = state.values.toList();
drafts.sort((a, b) => b.lastModified.compareTo(a.lastModified));
return drafts;
}
Future<void> clearAllDrafts() async {
state = {};
final prefs = ref.read(sharedPreferencesProvider);
await prefs.remove(kArticleDraftStoreKey);
}
}

View File

@ -1,19 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
part of 'compose_storage_db.dart'; part of 'compose_storage.dart';
// ************************************************************************** // **************************************************************************
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$composeStorageNotifierHash() => String _$composeStorageNotifierHash() =>
r'fcdb006dca44d30916a20804922e93d0caad49ca'; r'99c5e4070fa8af2771751064b56ca3251dbda27a';
/// See also [ComposeStorageNotifier]. /// See also [ComposeStorageNotifier].
@ProviderFor(ComposeStorageNotifier) @ProviderFor(ComposeStorageNotifier)
final composeStorageNotifierProvider = AutoDisposeNotifierProvider< final composeStorageNotifierProvider = AutoDisposeNotifierProvider<
ComposeStorageNotifier, ComposeStorageNotifier,
Map<String, ComposeDraftModel> Map<String, ComposeDraft>
>.internal( >.internal(
ComposeStorageNotifier.new, ComposeStorageNotifier.new,
name: r'composeStorageNotifierProvider', name: r'composeStorageNotifierProvider',
@ -26,15 +26,15 @@ final composeStorageNotifierProvider = AutoDisposeNotifierProvider<
); );
typedef _$ComposeStorageNotifier = typedef _$ComposeStorageNotifier =
AutoDisposeNotifier<Map<String, ComposeDraftModel>>; AutoDisposeNotifier<Map<String, ComposeDraft>>;
String _$articleStorageNotifierHash() => String _$articleStorageNotifierHash() =>
r'21ee0f8ee87528bebf8f5f4b0b2892cd8058e230'; r'4a200878bfe7881fc3afd2164b334e84dc44f338';
/// See also [ArticleStorageNotifier]. /// See also [ArticleStorageNotifier].
@ProviderFor(ArticleStorageNotifier) @ProviderFor(ArticleStorageNotifier)
final articleStorageNotifierProvider = AutoDisposeNotifierProvider< final articleStorageNotifierProvider = AutoDisposeNotifierProvider<
ArticleStorageNotifier, ArticleStorageNotifier,
Map<String, ArticleDraftModel> Map<String, ArticleDraft>
>.internal( >.internal(
ArticleStorageNotifier.new, ArticleStorageNotifier.new,
name: r'articleStorageNotifierProvider', name: r'articleStorageNotifierProvider',
@ -47,6 +47,6 @@ final articleStorageNotifierProvider = AutoDisposeNotifierProvider<
); );
typedef _$ArticleStorageNotifier = typedef _$ArticleStorageNotifier =
AutoDisposeNotifier<Map<String, ArticleDraftModel>>; AutoDisposeNotifier<Map<String, ArticleDraft>>;
// ignore_for_file: type=lint // 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 // 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,380 +0,0 @@
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';
import 'package:island/services/file.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.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() {
_loadDrafts();
return {};
}
void _loadDrafts() async {
try {
final database = ref.read(databaseProvider);
final dbDrafts = await database.getAllComposeDrafts();
final drafts = <String, ComposeDraftModel>{};
for (final dbDraft in dbDrafts) {
final draft = ComposeDraftModel.fromDbRow(dbDraft);
drafts[draft.id] = draft;
}
state = drafts;
} catch (e) {
// If there's an error loading drafts, start with empty state
state = {};
}
}
Future<void> saveDraft(ComposeDraftModel draft) async {
if (draft.isEmpty) {
await deleteDraft(draft.id);
return;
}
// 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(),
);
state = {...state, updatedDraft.id: updatedDraft};
try {
final database = ref.read(databaseProvider);
await database.saveComposeDraft(updatedDraft.toDbCompanion());
} catch (e) {
// Revert state on error
final newState = Map<String, ComposeDraftModel>.from(state);
newState.remove(updatedDraft.id);
state = newState;
rethrow;
}
}
Future<void> deleteDraft(String id) async {
final oldDraft = state[id];
final newState = Map<String, ComposeDraftModel>.from(state);
newState.remove(id);
state = newState;
try {
final database = ref.read(databaseProvider);
await database.deleteComposeDraft(id);
} catch (e) {
// Revert state on error
if (oldDraft != null) {
state = {...state, id: oldDraft};
}
rethrow;
}
}
ComposeDraftModel? getDraft(String id) {
return state[id];
}
List<ComposeDraftModel> getAllDrafts() {
final drafts = state.values.toList();
drafts.sort((a, b) => b.lastModified.compareTo(a.lastModified));
return drafts;
}
Future<void> clearAllDrafts() async {
state = {};
try {
final database = ref.read(databaseProvider);
await database.clearAllComposeDrafts();
} catch (e) {
// If clearing fails, we might want to reload from database
_loadDrafts();
rethrow;
}
}
}
@riverpod
class ArticleStorageNotifier extends _$ArticleStorageNotifier {
@override
Map<String, ArticleDraftModel> build() {
_loadDrafts();
return {};
}
void _loadDrafts() async {
try {
final database = ref.read(databaseProvider);
final dbDrafts = await database.getAllArticleDrafts();
final drafts = <String, ArticleDraftModel>{};
for (final dbDraft in dbDrafts) {
final draft = ArticleDraftModel.fromDbRow(dbDraft);
drafts[draft.id] = draft;
}
state = drafts;
} catch (e) {
// If there's an error loading drafts, start with empty state
state = {};
}
}
Future<void> saveDraft(ArticleDraftModel draft) async {
if (draft.isEmpty) {
await deleteDraft(draft.id);
return;
}
final updatedDraft = draft.copyWith(lastModified: DateTime.now());
state = {...state, updatedDraft.id: updatedDraft};
try {
final database = ref.read(databaseProvider);
await database.saveArticleDraft(updatedDraft.toDbCompanion());
} catch (e) {
// Revert state on error
final newState = Map<String, ArticleDraftModel>.from(state);
newState.remove(updatedDraft.id);
state = newState;
rethrow;
}
}
Future<void> deleteDraft(String id) async {
final oldDraft = state[id];
final newState = Map<String, ArticleDraftModel>.from(state);
newState.remove(id);
state = newState;
try {
final database = ref.read(databaseProvider);
await database.deleteArticleDraft(id);
} catch (e) {
// Revert state on error
if (oldDraft != null) {
state = {...state, id: oldDraft};
}
rethrow;
}
}
ArticleDraftModel? getDraft(String id) {
return state[id];
}
List<ArticleDraftModel> getAllDrafts() {
final drafts = state.values.toList();
drafts.sort((a, b) => b.lastModified.compareTo(a.lastModified));
return drafts;
}
Future<void> clearAllDrafts() async {
state = {};
try {
final database = ref.read(databaseProvider);
await database.clearAllArticleDrafts();
} catch (e) {
// If clearing fails, we might want to reload from database
_loadDrafts();
rethrow;
}
}
}

View File

@ -11,7 +11,7 @@ import 'package:island/models/post.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/services/file.dart'; import 'package:island/services/file.dart';
import 'package:island/services/compose_storage_db.dart'; import 'package:island/services/compose_storage.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:pasteboard/pasteboard.dart'; import 'package:pasteboard/pasteboard.dart';
import 'dart:async'; import 'dart:async';
@ -96,13 +96,13 @@ class ComposeLogic {
); );
} }
static ComposeState createStateFromDraft(ComposeDraftModel draft) { static ComposeState createStateFromDraft(ComposeDraft draft) {
return ComposeState( return ComposeState(
attachments: ValueNotifier<List<UniversalFile>>([]), attachments: ValueNotifier<List<UniversalFile>>([]),
titleController: TextEditingController(text: draft.title), titleController: TextEditingController(text: draft.title),
descriptionController: TextEditingController(text: draft.description), descriptionController: TextEditingController(text: draft.description),
contentController: TextEditingController(text: draft.content), contentController: TextEditingController(text: draft.content),
visibility: ValueNotifier<int>(draft.visibility), visibility: ValueNotifier<int>(_parseVisibility(draft.visibility)),
submitting: ValueNotifier<bool>(false), submitting: ValueNotifier<bool>(false),
attachmentProgress: ValueNotifier<Map<int, double>>({}), attachmentProgress: ValueNotifier<Map<int, double>>({}),
currentPublisher: ValueNotifier<SnPublisher?>(null), currentPublisher: ValueNotifier<SnPublisher?>(null),
@ -110,7 +110,39 @@ class ComposeLogic {
); );
} }
static int _parseVisibility(String visibility) {
switch (visibility.toLowerCase()) {
case 'public':
return 0;
case 'unlisted':
return 1;
case 'friends':
return 2;
case 'selected':
return 3;
case 'private':
return 4;
default:
return 0;
}
}
static String _visibilityToString(int visibility) {
switch (visibility) {
case 0:
return 'public';
case 1:
return 'unlisted';
case 2:
return 'friends';
case 3:
return 'selected';
case 4:
return 'private';
default:
return 'public';
}
}
static Future<void> saveDraft(WidgetRef ref, ComposeState state) async { static Future<void> saveDraft(WidgetRef ref, ComposeState state) async {
try { try {
@ -118,14 +150,18 @@ class ComposeLogic {
if (state._autoSaveTimer == null) { if (state._autoSaveTimer == null) {
return; // Widget has been disposed, don't save return; // Widget has been disposed, don't save
} }
final draft = ComposeDraftModel( final draft = ComposeDraft(
id: state.draftId, id: state.draftId,
title: state.titleController.text, title: state.titleController.text,
description: state.descriptionController.text, description: state.descriptionController.text,
content: state.contentController.text, content: state.contentController.text,
attachments: state.attachments.value, attachmentIds:
visibility: state.visibility.value, state.attachments.value
.where((e) => e.isOnCloud)
.map((e) => e.data.id.toString())
.toList(),
visibility: _visibilityToString(state.visibility.value),
lastModified: DateTime.now(), lastModified: DateTime.now(),
); );
@ -146,7 +182,7 @@ class ComposeLogic {
} }
} }
static Future<ComposeDraftModel?> loadDraft(WidgetRef ref, String draftId) async { static Future<ComposeDraft?> loadDraft(WidgetRef ref, String draftId) async {
try { try {
return ref return ref
.read(composeStorageNotifierProvider.notifier) .read(composeStorageNotifierProvider.notifier)
@ -374,14 +410,11 @@ class ComposeLogic {
if (event is! RawKeyDownEvent) return; if (event is! RawKeyDownEvent) return;
final isPaste = event.logicalKey == LogicalKeyboardKey.keyV; final isPaste = event.logicalKey == LogicalKeyboardKey.keyV;
final isSave = event.logicalKey == LogicalKeyboardKey.keyS;
final isModifierPressed = event.isMetaPressed || event.isControlPressed; final isModifierPressed = event.isMetaPressed || event.isControlPressed;
final isSubmit = event.logicalKey == LogicalKeyboardKey.enter; final isSubmit = event.logicalKey == LogicalKeyboardKey.enter;
if (isPaste && isModifierPressed) { if (isPaste && isModifierPressed) {
handlePaste(state); handlePaste(state);
} else if (isSave && isModifierPressed) {
saveDraft(ref, state);
} else if (isSubmit && isModifierPressed && !state.submitting.value) { } else if (isSubmit && isModifierPressed && !state.submitting.value) {
performAction( performAction(
ref, ref,

View File

@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/services/compose_storage_db.dart'; import 'package:island/services/compose_storage.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
class DraftManagerSheet extends HookConsumerWidget { class DraftManagerSheet extends HookConsumerWidget {
@ -28,11 +28,11 @@ class DraftManagerSheet extends HookConsumerWidget {
final sortedDrafts = useMemoized(() { final sortedDrafts = useMemoized(() {
if (isArticle) { if (isArticle) {
final draftList = drafts.values.cast<ArticleDraftModel>().toList(); final draftList = drafts.values.cast<ArticleDraft>().toList();
draftList.sort((a, b) => b.lastModified.compareTo(a.lastModified)); draftList.sort((a, b) => b.lastModified.compareTo(a.lastModified));
return draftList; return draftList;
} else { } else {
final draftList = drafts.values.cast<ComposeDraftModel>().toList(); final draftList = drafts.values.cast<ComposeDraft>().toList();
draftList.sort((a, b) => b.lastModified.compareTo(a.lastModified)); draftList.sort((a, b) => b.lastModified.compareTo(a.lastModified));
return draftList; return draftList;
} }
@ -79,15 +79,15 @@ class DraftManagerSheet extends HookConsumerWidget {
Navigator.of(context).pop(); Navigator.of(context).pop();
final draftId = final draftId =
isArticle isArticle
? (draft as ArticleDraftModel).id ? (draft as ArticleDraft).id
: (draft as ComposeDraftModel).id; : (draft as ComposeDraft).id;
onDraftSelected?.call(draftId); onDraftSelected?.call(draftId);
}, },
onDelete: () async { onDelete: () async {
final draftId = final draftId =
isArticle isArticle
? (draft as ArticleDraftModel).id ? (draft as ArticleDraft).id
: (draft as ComposeDraftModel).id; : (draft as ComposeDraft).id;
if (isArticle) { if (isArticle) {
await ref await ref
.read(articleStorageNotifierProvider.notifier) .read(articleStorageNotifierProvider.notifier)
@ -182,7 +182,7 @@ class _DraftItem extends StatelessWidget {
final String visibility; final String visibility;
if (isArticle) { if (isArticle) {
final articleDraft = draft as ArticleDraftModel; final articleDraft = draft as ArticleDraft;
title = title =
articleDraft.title.isNotEmpty ? articleDraft.title : 'untitled'.tr(); articleDraft.title.isNotEmpty ? articleDraft.title : 'untitled'.tr();
content = content =
@ -194,7 +194,7 @@ class _DraftItem extends StatelessWidget {
lastModified = articleDraft.lastModified; lastModified = articleDraft.lastModified;
visibility = _parseArticleVisibility(articleDraft.visibility); visibility = _parseArticleVisibility(articleDraft.visibility);
} else { } else {
final postDraft = draft as ComposeDraftModel; final postDraft = draft as ComposeDraft;
title = postDraft.title.isNotEmpty ? postDraft.title : 'untitled'.tr(); title = postDraft.title.isNotEmpty ? postDraft.title : 'untitled'.tr();
content = content =
postDraft.content.isNotEmpty postDraft.content.isNotEmpty
@ -203,7 +203,7 @@ class _DraftItem extends StatelessWidget {
? postDraft.description ? postDraft.description
: 'noContent'.tr()); : 'noContent'.tr());
lastModified = postDraft.lastModified; lastModified = postDraft.lastModified;
visibility = _parseArticleVisibility(postDraft.visibility); visibility = postDraft.visibility;
} }
final preview = final preview =
@ -316,17 +316,17 @@ class _DraftItem extends StatelessWidget {
} }
} }
String _parseArticleVisibility(int visibility) { String _parseArticleVisibility(String visibility) {
switch (visibility) { switch (visibility.toLowerCase()) {
case 0: case 'public':
return 'public'.tr(); return 'public'.tr();
case 1: case 'unlisted':
return 'unlisted'.tr(); return 'unlisted'.tr();
case 2: case 'friends':
return 'friends'.tr(); return 'friends'.tr();
case 3: case 'selected':
return 'selected'.tr(); return 'selected'.tr();
case 4: case 'private':
return 'private'.tr(); return 'private'.tr();
default: default:
return 'unknown'.tr(); return 'unknown'.tr();