🎨 Use feature based folder structure
This commit is contained in:
897
lib/posts/posts_widgets/post/compose_shared.dart
Normal file
897
lib/posts/posts_widgets/post/compose_shared.dart
Normal file
@@ -0,0 +1,897 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:island/core/services/event_bus.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_fund.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_link_attachments.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_poll.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_recorder.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_settings_sheet.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:island/drive/drive_models/file.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/posts/posts_models/post_category.dart';
|
||||
import 'package:island/posts/posts_models/publisher.dart';
|
||||
import 'package:island/realms/realms_models/realm.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/drive/drive_service.dart';
|
||||
import 'package:island/posts/compose_storage_db.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/drive/drive/file_pool.dart';
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/core/services/analytics_service.dart';
|
||||
|
||||
class ComposeState {
|
||||
final TextEditingController titleController;
|
||||
final TextEditingController descriptionController;
|
||||
final TextEditingController contentController;
|
||||
final TextEditingController slugController;
|
||||
final ValueNotifier<int> visibility;
|
||||
final ValueNotifier<List<UniversalFile>> attachments;
|
||||
final ValueNotifier<Map<int, double?>> attachmentProgress;
|
||||
final ValueNotifier<SnPublisher?> currentPublisher;
|
||||
final ValueNotifier<bool> submitting;
|
||||
final ValueNotifier<List<SnPostCategory>> categories;
|
||||
final ValueNotifier<List<String>> tags;
|
||||
final ValueNotifier<SnRealm?> realm;
|
||||
final ValueNotifier<SnPostEmbedView?> embedView;
|
||||
final String draftId;
|
||||
int postType;
|
||||
// Linked poll id for this compose session (nullable)
|
||||
final ValueNotifier<String?> pollId;
|
||||
// Linked fund id for this compose session (nullable)
|
||||
final ValueNotifier<String?> fundId;
|
||||
// Thumbnail id for article type post (nullable)
|
||||
final ValueNotifier<String?> thumbnailId;
|
||||
Timer? _autoSaveTimer;
|
||||
|
||||
ComposeState({
|
||||
required this.titleController,
|
||||
required this.descriptionController,
|
||||
required this.contentController,
|
||||
required this.slugController,
|
||||
required this.visibility,
|
||||
required this.attachments,
|
||||
required this.attachmentProgress,
|
||||
required this.currentPublisher,
|
||||
required this.submitting,
|
||||
required this.tags,
|
||||
required this.categories,
|
||||
required this.realm,
|
||||
required this.embedView,
|
||||
required this.draftId,
|
||||
this.postType = 0,
|
||||
String? pollId,
|
||||
String? fundId,
|
||||
String? thumbnailId,
|
||||
}) : pollId = ValueNotifier<String?>(pollId),
|
||||
fundId = ValueNotifier<String?>(fundId),
|
||||
thumbnailId = ValueNotifier<String?>(thumbnailId);
|
||||
|
||||
void startAutoSave(WidgetRef ref) {
|
||||
_autoSaveTimer?.cancel();
|
||||
_autoSaveTimer = Timer.periodic(const Duration(seconds: 3), (_) {
|
||||
ComposeLogic.saveDraftWithoutUpload(ref, this);
|
||||
});
|
||||
}
|
||||
|
||||
void stopAutoSave() {
|
||||
_autoSaveTimer?.cancel();
|
||||
_autoSaveTimer = null;
|
||||
}
|
||||
|
||||
bool get isEmpty =>
|
||||
attachments.value.isEmpty && contentController.text.isEmpty;
|
||||
}
|
||||
|
||||
class ComposeLogic {
|
||||
static ComposeState createState({
|
||||
SnPost? originalPost,
|
||||
SnPost? forwardedPost,
|
||||
SnPost? repliedPost,
|
||||
String? draftId,
|
||||
int postType = 0,
|
||||
}) {
|
||||
final id = draftId ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
// Initialize tags from original post
|
||||
final tags =
|
||||
originalPost?.tags.map((tag) => tag.slug).toList() ?? <String>[];
|
||||
|
||||
// Initialize categories from original post
|
||||
final categories = originalPost?.categories ?? <SnPostCategory>[];
|
||||
|
||||
// Extract poll and fund IDs from embeds
|
||||
String? pollId;
|
||||
String? fundId;
|
||||
if (originalPost?.meta?['embeds'] is List) {
|
||||
final embeds = (originalPost!.meta!['embeds'] as List)
|
||||
.cast<Map<String, dynamic>>();
|
||||
try {
|
||||
final pollEmbed = embeds.firstWhere((e) => e['type'] == 'poll');
|
||||
pollId = pollEmbed['id'];
|
||||
} catch (_) {}
|
||||
try {
|
||||
final fundEmbed = embeds.firstWhere((e) => e['type'] == 'fund');
|
||||
fundId = fundEmbed['id'];
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Extract thumbnail ID from meta
|
||||
final thumbnailId = originalPost?.meta?['thumbnail'] as String?;
|
||||
|
||||
return ComposeState(
|
||||
attachments: ValueNotifier<List<UniversalFile>>(
|
||||
originalPost?.attachments
|
||||
.map(
|
||||
(e) => UniversalFile(
|
||||
data: e,
|
||||
type: switch (e.mimeType?.split('/').firstOrNull) {
|
||||
'image' => UniversalFileType.image,
|
||||
'video' => UniversalFileType.video,
|
||||
'audio' => UniversalFileType.audio,
|
||||
_ => UniversalFileType.file,
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
[],
|
||||
),
|
||||
titleController: TextEditingController(text: originalPost?.title),
|
||||
descriptionController: TextEditingController(
|
||||
text: originalPost?.description,
|
||||
),
|
||||
contentController: TextEditingController(text: originalPost?.content),
|
||||
slugController: TextEditingController(text: originalPost?.slug),
|
||||
visibility: ValueNotifier<int>(originalPost?.visibility ?? 0),
|
||||
submitting: ValueNotifier<bool>(false),
|
||||
attachmentProgress: ValueNotifier<Map<int, double?>>({}),
|
||||
currentPublisher: ValueNotifier<SnPublisher?>(originalPost?.publisher),
|
||||
tags: ValueNotifier<List<String>>(tags),
|
||||
categories: ValueNotifier<List<SnPostCategory>>(categories),
|
||||
realm: ValueNotifier(originalPost?.realm),
|
||||
embedView: ValueNotifier<SnPostEmbedView?>(originalPost?.embedView),
|
||||
draftId: id,
|
||||
postType: postType,
|
||||
pollId: pollId,
|
||||
fundId: fundId,
|
||||
thumbnailId: thumbnailId,
|
||||
);
|
||||
}
|
||||
|
||||
static ComposeState createStateFromDraft(SnPost draft, {int postType = 0}) {
|
||||
final tags = draft.tags.map((tag) => tag.slug).toList();
|
||||
final thumbnailId = draft.meta?['thumbnail'] as String?;
|
||||
|
||||
return ComposeState(
|
||||
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),
|
||||
slugController: TextEditingController(text: draft.slug),
|
||||
visibility: ValueNotifier<int>(draft.visibility),
|
||||
submitting: ValueNotifier<bool>(false),
|
||||
attachmentProgress: ValueNotifier<Map<int, double?>>({}),
|
||||
currentPublisher: ValueNotifier<SnPublisher?>(null),
|
||||
tags: ValueNotifier<List<String>>(tags),
|
||||
categories: ValueNotifier<List<SnPostCategory>>(draft.categories),
|
||||
realm: ValueNotifier(draft.realm),
|
||||
embedView: ValueNotifier<SnPostEmbedView?>(draft.embedView),
|
||||
draftId: draft.id,
|
||||
postType: postType,
|
||||
pollId: null,
|
||||
// initialize without fund by default
|
||||
fundId: null,
|
||||
thumbnailId: thumbnailId,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> saveDraft(WidgetRef ref, ComposeState state) 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 {
|
||||
// Upload any local attachments first
|
||||
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 FileUploader.createCloudFile(
|
||||
ref: ref,
|
||||
fileData: attachment,
|
||||
).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) {
|
||||
talker.error('[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,
|
||||
language: null,
|
||||
editedAt: null,
|
||||
publishedAt: DateTime.now(),
|
||||
visibility: state.visibility.value,
|
||||
content: state.contentController.text,
|
||||
type: state.postType,
|
||||
meta: state.postType == 1 && state.thumbnailId.value != null
|
||||
? {'thumbnail': state.thumbnailId.value}
|
||||
: 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: [],
|
||||
embedView: state.embedView.value,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
deletedAt: null,
|
||||
);
|
||||
|
||||
await ref.read(composeStorageProvider.notifier).saveDraft(draft);
|
||||
} catch (e) {
|
||||
talker.error('[ComposeLogic] Failed to save draft, error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> saveDraftWithoutUpload(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
) 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 {
|
||||
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: state.postType,
|
||||
meta: state.postType == 1 && state.thumbnailId.value != null
|
||||
? {'thumbnail': state.thumbnailId.value}
|
||||
: 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: [],
|
||||
embedView: state.embedView.value,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
deletedAt: null,
|
||||
);
|
||||
|
||||
await ref.read(composeStorageProvider.notifier).saveDraft(draft);
|
||||
} catch (e) {
|
||||
talker.error(
|
||||
'[ComposeLogic] Failed to save draft without upload, error: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> saveDraftManually(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
BuildContext context,
|
||||
) async {
|
||||
try {
|
||||
await saveDraft(ref, state);
|
||||
|
||||
if (context.mounted) {
|
||||
showSnackBar('draftSaved'.tr());
|
||||
}
|
||||
} catch (e) {
|
||||
talker.error('[ComposeLogic] Failed to save draft manually, error: $e');
|
||||
if (context.mounted) {
|
||||
showSnackBar('draftSaveFailed'.tr());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> deleteDraft(WidgetRef ref, String draftId) async {
|
||||
try {
|
||||
await ref.read(composeStorageProvider.notifier).deleteDraft(draftId);
|
||||
} catch (e) {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
static Future<SnPost?> loadDraft(WidgetRef ref, String draftId) async {
|
||||
try {
|
||||
return ref.read(composeStorageProvider.notifier).getDraft(draftId);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String getMimeTypeFromFileType(UniversalFileType type) {
|
||||
return switch (type) {
|
||||
UniversalFileType.image => 'image/unknown',
|
||||
UniversalFileType.video => 'video/unknown',
|
||||
UniversalFileType.audio => 'audio/unknown',
|
||||
UniversalFileType.file => 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
|
||||
static Future<void> pickGeneralFile(WidgetRef ref, ComposeState state) async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.any,
|
||||
allowMultiple: true,
|
||||
);
|
||||
if (result == null || result.count == 0) return;
|
||||
|
||||
final newFiles = <UniversalFile>[];
|
||||
|
||||
for (final f in result.files) {
|
||||
if (f.path == null) continue;
|
||||
|
||||
final mimeType =
|
||||
lookupMimeType(f.path!, headerBytes: f.bytes) ??
|
||||
'application/octet-stream';
|
||||
final xfile = XFile(f.path!, name: f.name, mimeType: mimeType);
|
||||
|
||||
final uf = UniversalFile(data: xfile, type: UniversalFileType.file);
|
||||
newFiles.add(uf);
|
||||
}
|
||||
|
||||
state.attachments.value = [...state.attachments.value, ...newFiles];
|
||||
}
|
||||
|
||||
static Future<void> pickPhotoMedia(WidgetRef ref, ComposeState state) async {
|
||||
final ImagePicker picker = ImagePicker();
|
||||
final List<XFile> results = await picker.pickMultiImage();
|
||||
if (results.isEmpty) return;
|
||||
state.attachments.value = [
|
||||
...state.attachments.value,
|
||||
...results.map(
|
||||
(xfile) => UniversalFile(data: xfile, type: UniversalFileType.image),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static Future<void> pickVideoMedia(WidgetRef ref, ComposeState state) async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.video,
|
||||
allowMultiple: true,
|
||||
allowCompression: false,
|
||||
);
|
||||
if (result == null || result.count == 0) return;
|
||||
state.attachments.value = [
|
||||
...state.attachments.value,
|
||||
...result.files.map(
|
||||
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.video),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static Future<void> recordAudioMedia(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
BuildContext context,
|
||||
) async {
|
||||
final audioPath = await showModalBottomSheet<String?>(
|
||||
context: context,
|
||||
builder: (context) => ComposeRecorder(),
|
||||
);
|
||||
if (audioPath == null) return;
|
||||
|
||||
state.attachments.value = [
|
||||
...state.attachments.value,
|
||||
UniversalFile(
|
||||
data: XFile(audioPath, mimeType: 'audio/m4a'),
|
||||
type: UniversalFileType.audio,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static Future<void> linkAttachment(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
BuildContext context,
|
||||
) async {
|
||||
final cloudFile = await showModalBottomSheet<SnCloudFile?>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComposeLinkAttachment(),
|
||||
);
|
||||
if (cloudFile == null) return;
|
||||
|
||||
state.attachments.value = [
|
||||
...state.attachments.value,
|
||||
UniversalFile(
|
||||
data: cloudFile,
|
||||
type: switch (cloudFile.mimeType?.split('/').firstOrNull) {
|
||||
'image' => UniversalFileType.image,
|
||||
'video' => UniversalFileType.video,
|
||||
'audio' => UniversalFileType.audio,
|
||||
_ => UniversalFileType.file,
|
||||
},
|
||||
isLink: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static void updateAttachment(
|
||||
ComposeState state,
|
||||
UniversalFile value,
|
||||
int index,
|
||||
) {
|
||||
state.attachments.value = state.attachments.value.mapIndexed((idx, ele) {
|
||||
if (idx == index) return value;
|
||||
return ele;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
static Future<void> uploadAttachment(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
int index, {
|
||||
String? poolId,
|
||||
}) async {
|
||||
final attachment = state.attachments.value[index];
|
||||
if (attachment.isOnCloud) return;
|
||||
|
||||
try {
|
||||
state.attachmentProgress.value = {
|
||||
...state.attachmentProgress.value,
|
||||
index: 0.0,
|
||||
};
|
||||
|
||||
SnCloudFile? cloudFile;
|
||||
|
||||
final pools = await ref.read(poolsProvider.future);
|
||||
final selectedPoolId = resolveDefaultPoolId(ref, pools);
|
||||
|
||||
cloudFile = await FileUploader.createCloudFile(
|
||||
ref: ref,
|
||||
fileData: attachment,
|
||||
poolId: poolId ?? selectedPoolId,
|
||||
mode: attachment.type == UniversalFileType.file
|
||||
? FileUploadMode.generic
|
||||
: FileUploadMode.mediaSafe,
|
||||
onProgress: (progress, _) {
|
||||
state.attachmentProgress.value = {
|
||||
...state.attachmentProgress.value,
|
||||
index: progress ?? 0.0,
|
||||
};
|
||||
},
|
||||
).future;
|
||||
|
||||
if (cloudFile == null) {
|
||||
throw ArgumentError('Failed to upload the file...');
|
||||
}
|
||||
|
||||
final clone = List.of(state.attachments.value);
|
||||
clone[index] = UniversalFile(data: cloudFile, type: attachment.type);
|
||||
state.attachments.value = clone;
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
state.attachmentProgress.value = {...state.attachmentProgress.value}
|
||||
..remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
static List<UniversalFile> moveAttachment(
|
||||
List<UniversalFile> attachments,
|
||||
int idx,
|
||||
int delta,
|
||||
) {
|
||||
if (idx + delta < 0 || idx + delta >= attachments.length) {
|
||||
return attachments;
|
||||
}
|
||||
final clone = List.of(attachments);
|
||||
clone.insert(idx + delta, clone.removeAt(idx));
|
||||
return clone;
|
||||
}
|
||||
|
||||
static Future<void> deleteAttachment(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
int index,
|
||||
) async {
|
||||
final attachment = state.attachments.value[index];
|
||||
if (attachment.isOnCloud && !attachment.isLink) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
await client.delete('/drive/files/${attachment.data.id}');
|
||||
}
|
||||
final clone = List.of(state.attachments.value);
|
||||
clone.removeAt(index);
|
||||
state.attachments.value = clone;
|
||||
}
|
||||
|
||||
static void insertAttachment(WidgetRef ref, ComposeState state, int index) {
|
||||
final attachment = state.attachments.value[index];
|
||||
if (!attachment.isOnCloud) {
|
||||
return;
|
||||
}
|
||||
final cloudFile = attachment.data as SnCloudFile;
|
||||
final markdown = '';
|
||||
final controller = state.contentController;
|
||||
final text = controller.text;
|
||||
final selection = controller.selection;
|
||||
final newText = text.replaceRange(selection.start, selection.end, markdown);
|
||||
controller.text = newText;
|
||||
controller.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: selection.start + markdown.length),
|
||||
);
|
||||
}
|
||||
|
||||
static void setEmbedView(ComposeState state, SnPostEmbedView embedView) {
|
||||
state.embedView.value = embedView;
|
||||
}
|
||||
|
||||
static void updateEmbedView(ComposeState state, SnPostEmbedView embedView) {
|
||||
state.embedView.value = embedView;
|
||||
}
|
||||
|
||||
static void deleteEmbedView(ComposeState state) {
|
||||
state.embedView.value = null;
|
||||
}
|
||||
|
||||
static void setThumbnail(ComposeState state, String? thumbnailId) {
|
||||
state.thumbnailId.value = thumbnailId;
|
||||
}
|
||||
|
||||
static Future<void> pickPoll(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
BuildContext context,
|
||||
) async {
|
||||
if (state.pollId.value != null) {
|
||||
state.pollId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final poll = await showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComposePollSheet(pub: state.currentPublisher.value),
|
||||
);
|
||||
|
||||
if (poll == null) return;
|
||||
state.pollId.value = poll.id;
|
||||
}
|
||||
|
||||
static Future<void> pickFund(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
BuildContext context,
|
||||
) async {
|
||||
if (state.fundId.value != null) {
|
||||
state.fundId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final fund = await showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const ComposeFundSheet(),
|
||||
);
|
||||
|
||||
if (fund == null) return;
|
||||
state.fundId.value = fund.id;
|
||||
}
|
||||
|
||||
/// Unified submit method that returns the created/updated post.
|
||||
static Future<SnPost> performSubmit(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
BuildContext context, {
|
||||
SnPost? originalPost,
|
||||
SnPost? repliedPost,
|
||||
SnPost? forwardedPost,
|
||||
required Function() onSuccess,
|
||||
}) async {
|
||||
if (state.submitting.value) {
|
||||
throw Exception('Already submitting');
|
||||
}
|
||||
|
||||
// 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) {
|
||||
showErrorAlert('postContentEmpty'.tr());
|
||||
throw Exception('Post content is empty'); // Don't submit empty posts
|
||||
}
|
||||
|
||||
try {
|
||||
state.submitting.value = true;
|
||||
|
||||
// Upload any local attachments first
|
||||
await Future.wait(
|
||||
state.attachments.value
|
||||
.asMap()
|
||||
.entries
|
||||
.where((entry) => entry.value.isOnDevice)
|
||||
.map(
|
||||
(entry) => ComposeLogic.uploadAttachment(ref, state, entry.key),
|
||||
),
|
||||
);
|
||||
|
||||
// Prepare API request
|
||||
final client = ref.read(apiClientProvider);
|
||||
final isNewPost = originalPost == null;
|
||||
final endpoint =
|
||||
'/sphere${isNewPost ? '/posts' : '/posts/${originalPost.id}'}';
|
||||
|
||||
// Create request payload
|
||||
final payload = {
|
||||
'title': state.titleController.text,
|
||||
'description': state.descriptionController.text,
|
||||
'content': state.contentController.text,
|
||||
if (state.slugController.text.isNotEmpty)
|
||||
'slug': state.slugController.text,
|
||||
'visibility': state.visibility.value,
|
||||
'attachments': state.attachments.value
|
||||
.where((e) => e.isOnCloud)
|
||||
.map((e) => e.data.id)
|
||||
.toList(),
|
||||
'type': state.postType,
|
||||
if (repliedPost != null) 'replied_post_id': repliedPost.id,
|
||||
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
|
||||
'tags': state.tags.value,
|
||||
'categories': state.categories.value.map((e) => e.slug).toList(),
|
||||
if (state.realm.value != null) 'realm_id': state.realm.value?.id,
|
||||
if (state.pollId.value != null) 'poll_id': state.pollId.value,
|
||||
if (state.fundId.value != null) 'fund_id': state.fundId.value,
|
||||
if (state.postType == 1 && state.thumbnailId.value != null)
|
||||
'thumbnail_id': state.thumbnailId.value,
|
||||
if (state.embedView.value != null)
|
||||
'embed_view': state.embedView.value!.toJson(),
|
||||
};
|
||||
|
||||
// Send request
|
||||
final response = await client.request(
|
||||
endpoint,
|
||||
queryParameters: {'pub': state.currentPublisher.value?.name},
|
||||
data: payload,
|
||||
options: Options(method: isNewPost ? 'POST' : 'PATCH'),
|
||||
);
|
||||
|
||||
// Parse the response into a SnPost
|
||||
final post = SnPost.fromJson(response.data);
|
||||
|
||||
// Call the success callback
|
||||
onSuccess();
|
||||
eventBus.fire(PostCreatedEvent());
|
||||
|
||||
final postTypeStr = state.postType == 0 ? 'regular' : 'article';
|
||||
final visibilityStr = state.visibility.value.toString();
|
||||
final publisherId = state.currentPublisher.value?.id ?? 'unknown';
|
||||
|
||||
AnalyticsService().logPostCreated(
|
||||
postTypeStr,
|
||||
visibilityStr,
|
||||
state.attachments.value.isNotEmpty,
|
||||
publisherId,
|
||||
);
|
||||
|
||||
return post;
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
rethrow;
|
||||
} finally {
|
||||
state.submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> performAction(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
BuildContext context, {
|
||||
SnPost? originalPost,
|
||||
SnPost? repliedPost,
|
||||
SnPost? forwardedPost,
|
||||
}) async {
|
||||
await ComposeLogic.performSubmit(
|
||||
ref,
|
||||
state,
|
||||
context,
|
||||
originalPost: originalPost,
|
||||
repliedPost: repliedPost,
|
||||
forwardedPost: forwardedPost,
|
||||
onSuccess: () async {
|
||||
// Delete draft after successful submission
|
||||
if (state.postType == 1) {
|
||||
// Delete article draft
|
||||
await ref
|
||||
.read(composeStorageProvider.notifier)
|
||||
.deleteDraft(state.draftId);
|
||||
} else {
|
||||
// Delete regular post draft
|
||||
await ref
|
||||
.read(composeStorageProvider.notifier)
|
||||
.deleteDraft(state.draftId);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).maybePop(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows the settings sheet modal.
|
||||
static void showSettingsSheet(BuildContext context, ComposeState state) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComposeSettingsSheet(state: state),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> handlePaste(ComposeState state) async {
|
||||
final clipboard = await Pasteboard.image;
|
||||
if (clipboard == null) return;
|
||||
|
||||
state.attachments.value = [
|
||||
...state.attachments.value,
|
||||
UniversalFile(
|
||||
displayName: 'image.jpeg',
|
||||
data: XFile.fromData(
|
||||
clipboard,
|
||||
mimeType: "image/jpeg",
|
||||
name: 'image.jpeg',
|
||||
),
|
||||
type: UniversalFileType.image,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static void handleKeyPress(
|
||||
KeyEvent event,
|
||||
ComposeState state,
|
||||
WidgetRef ref,
|
||||
BuildContext context, {
|
||||
SnPost? originalPost,
|
||||
SnPost? repliedPost,
|
||||
SnPost? forwardedPost,
|
||||
}) {
|
||||
if (event is! KeyDownEvent) return;
|
||||
|
||||
final isPaste = event.logicalKey == LogicalKeyboardKey.keyV;
|
||||
final isSave = event.logicalKey == LogicalKeyboardKey.keyS;
|
||||
final isModifierPressed =
|
||||
HardwareKeyboard.instance.isMetaPressed ||
|
||||
HardwareKeyboard.instance.isControlPressed;
|
||||
final isSubmit = event.logicalKey == LogicalKeyboardKey.enter;
|
||||
|
||||
if (isPaste && isModifierPressed) {
|
||||
handlePaste(state);
|
||||
} else if (isSave && isModifierPressed) {
|
||||
saveDraftManually(ref, state, context);
|
||||
} else if (isSubmit && isModifierPressed && !state.submitting.value) {
|
||||
performAction(
|
||||
ref,
|
||||
state,
|
||||
context,
|
||||
originalPost: originalPost,
|
||||
repliedPost: repliedPost,
|
||||
forwardedPost: forwardedPost,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static void dispose(ComposeState state) {
|
||||
state.stopAutoSave();
|
||||
state.titleController.dispose();
|
||||
state.descriptionController.dispose();
|
||||
state.contentController.dispose();
|
||||
state.attachments.dispose();
|
||||
state.visibility.dispose();
|
||||
state.submitting.dispose();
|
||||
state.attachmentProgress.dispose();
|
||||
state.currentPublisher.dispose();
|
||||
state.tags.dispose();
|
||||
state.categories.dispose();
|
||||
state.realm.dispose();
|
||||
state.embedView.dispose();
|
||||
state.pollId.dispose();
|
||||
state.fundId.dispose();
|
||||
state.thumbnailId.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user