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'; import 'package:image_picker/image_picker.dart'; import 'package:island/models/file.dart'; import 'package:island/models/post.dart'; import 'package:island/pods/config.dart'; import 'package:island/pods/network.dart'; import 'package:island/services/file.dart'; import 'package:island/services/compose_storage_db.dart'; import 'package:island/widgets/alert.dart'; import 'package:pasteboard/pasteboard.dart'; import 'dart:async'; import 'dart:developer'; class ComposeState { final ValueNotifier> attachments; final TextEditingController titleController; final TextEditingController descriptionController; final TextEditingController contentController; final ValueNotifier visibility; final ValueNotifier submitting; final ValueNotifier> attachmentProgress; final ValueNotifier currentPublisher; final String draftId; Timer? _autoSaveTimer; ComposeState({ required this.attachments, required this.titleController, required this.descriptionController, required this.contentController, required this.visibility, required this.submitting, required this.attachmentProgress, required this.currentPublisher, required this.draftId, }); void startAutoSave(WidgetRef ref, {int postType = 0}) { _autoSaveTimer?.cancel(); _autoSaveTimer = Timer.periodic(const Duration(seconds: 3), (_) { ComposeLogic.saveDraftWithoutUpload(ref, this, postType: postType); }); } void stopAutoSave() { _autoSaveTimer?.cancel(); _autoSaveTimer = null; } } class ComposeLogic { static ComposeState createState({ SnPost? originalPost, SnPost? forwardedPost, SnPost? repliedPost, String? draftId, }) { final id = draftId ?? DateTime.now().millisecondsSinceEpoch.toString(); return ComposeState( attachments: ValueNotifier>( 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 ?? (forwardedPost != null ? '> ${forwardedPost.content}\n\n' : null), ), visibility: ValueNotifier(originalPost?.visibility ?? 0), submitting: ValueNotifier(false), attachmentProgress: ValueNotifier>({}), currentPublisher: ValueNotifier(null), draftId: id, ); } static ComposeState createStateFromDraft(SnPost draft) { return ComposeState( attachments: ValueNotifier>( draft.attachments.map((e) => UniversalFile.fromAttachment(e)).toList(), ), titleController: TextEditingController(text: draft.title), descriptionController: TextEditingController(text: draft.description), contentController: TextEditingController(text: draft.content), visibility: ValueNotifier(draft.visibility), submitting: ValueNotifier(false), attachmentProgress: ValueNotifier>({}), currentPublisher: ValueNotifier(null), draftId: draft.id, ); } static Future 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; if (!hasContent && !hasAttachments) { return; // Don't save empty posts } 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, 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() .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'); } } static Future 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() .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 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()); } } } static Future deleteDraft(WidgetRef ref, String draftId) async { try { await ref .read(composeStorageNotifierProvider.notifier) .deleteDraft(draftId); } catch (e) { // Silently fail } } static Future loadDraft(WidgetRef ref, String draftId) async { try { return ref .read(composeStorageNotifierProvider.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 pickPhotoMedia(WidgetRef ref, ComposeState state) async { final result = await ref .watch(imagePickerProvider) .pickMultiImage(requestFullMetadata: true); if (result.isEmpty) return; state.attachments.value = [ ...state.attachments.value, ...result.map( (e) => UniversalFile(data: e, type: UniversalFileType.image), ), ]; } static Future pickVideoMedia(WidgetRef ref, ComposeState state) async { final result = await ref .watch(imagePickerProvider) .pickVideo(source: ImageSource.gallery); if (result == null) return; state.attachments.value = [ ...state.attachments.value, UniversalFile(data: result, type: UniversalFileType.video), ]; } static Future uploadAttachment( WidgetRef ref, ComposeState state, int index, ) async { final attachment = state.attachments.value[index]; if (attachment.isOnCloud) return; final baseUrl = ref.watch(serverUrlProvider); final token = await getToken(ref.watch(tokenProvider)); if (token == null) throw ArgumentError('Token is null'); try { // Update progress state state.attachmentProgress.value = { ...state.attachmentProgress.value, index: 0, }; // Upload file to cloud final cloudFile = await putMediaToCloud( fileData: attachment, atk: token, baseUrl: baseUrl, filename: attachment.data.name ?? 'Post media', mimetype: attachment.data.mimeType ?? getMimeTypeFromFileType(attachment.type), onProgress: (progress, _) { state.attachmentProgress.value = { ...state.attachmentProgress.value, index: progress, }; }, ).future; if (cloudFile == null) { throw ArgumentError('Failed to upload the file...'); } // Update attachments list with cloud 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 { // Clean up progress state state.attachmentProgress.value = {...state.attachmentProgress.value} ..remove(index); } } static List moveAttachment( List 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 deleteAttachment( WidgetRef ref, ComposeState state, int index, ) async { final attachment = state.attachments.value[index]; if (attachment.isOnCloud) { final client = ref.watch(apiClientProvider); await client.delete('/files/${attachment.data.id}'); } final clone = List.of(state.attachments.value); clone.removeAt(index); state.attachments.value = clone; } static Future performAction( WidgetRef ref, ComposeState state, BuildContext context, { SnPost? originalPost, SnPost? repliedPost, SnPost? forwardedPost, int? postType, // 0 for regular post, 1 for article }) 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; // Upload any local attachments first await Future.wait( state.attachments.value .asMap() .entries .where((entry) => entry.value.isOnDevice) .map((entry) => uploadAttachment(ref, state, entry.key)), ); // Prepare API request final client = ref.watch(apiClientProvider); final isNewPost = originalPost == null; final endpoint = isNewPost ? '/posts' : '/posts/${originalPost.id}'; // Create request payload final payload = { 'title': state.titleController.text, 'description': state.descriptionController.text, 'content': state.contentController.text, 'visibility': state.visibility.value, 'attachments': state.attachments.value .where((e) => e.isOnCloud) .map((e) => e.data.id) .toList(), if (postType != null) 'type': postType, if (repliedPost != null) 'replied_post_id': repliedPost.id, if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id, }; // Send request await client.request( endpoint, data: payload, options: Options( headers: {'X-Pub': state.currentPublisher.value?.name}, method: isNewPost ? 'POST' : 'PATCH', ), ); // Delete draft after successful submission if (postType == 1) { // Delete article draft await ref .read(composeStorageNotifierProvider.notifier) .deleteDraft(state.draftId); } else { // Delete regular post draft await ref .read(composeStorageNotifierProvider.notifier) .deleteDraft(state.draftId); } if (context.mounted) { Navigator.of(context).maybePop(true); } } catch (err) { showErrorAlert(err); } finally { state.submitting.value = false; } } static Future handlePaste(ComposeState state) async { final clipboard = await Pasteboard.image; if (clipboard == null) return; state.attachments.value = [ ...state.attachments.value, UniversalFile( data: XFile.fromData(clipboard, mimeType: "image/jpeg"), type: UniversalFileType.image, ), ]; } static void handleKeyPress( RawKeyEvent event, ComposeState state, WidgetRef ref, BuildContext context, { SnPost? originalPost, SnPost? repliedPost, SnPost? forwardedPost, int? postType, }) { if (event is! RawKeyDownEvent) return; final isPaste = event.logicalKey == LogicalKeyboardKey.keyV; final isSave = event.logicalKey == LogicalKeyboardKey.keyS; final isModifierPressed = event.isMetaPressed || event.isControlPressed; final isSubmit = event.logicalKey == LogicalKeyboardKey.enter; if (isPaste && isModifierPressed) { handlePaste(state); } else if (isSave && isModifierPressed) { saveDraftManually(ref, state, context); } else if (isSubmit && isModifierPressed && !state.submitting.value) { performAction( ref, state, context, originalPost: originalPost, repliedPost: repliedPost, forwardedPost: forwardedPost, postType: postType, ); } } 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(); } }