783 lines
		
	
	
		
			24 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			783 lines
		
	
	
		
			24 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
import 'dart:async';
 | 
						|
 | 
						|
import 'package:collection/collection.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/models/file.dart';
 | 
						|
import 'package:island/models/post.dart';
 | 
						|
import 'package:island/models/post_category.dart';
 | 
						|
import 'package:island/models/publisher.dart';
 | 
						|
import 'package:island/models/realm.dart';
 | 
						|
import 'package:island/pods/network.dart';
 | 
						|
import 'package:island/services/file_uploader.dart';
 | 
						|
import 'package:island/services/compose_storage_db.dart';
 | 
						|
import 'package:island/widgets/alert.dart';
 | 
						|
import 'package:island/widgets/post/compose_link_attachments.dart';
 | 
						|
import 'package:island/widgets/post/compose_poll.dart';
 | 
						|
import 'package:island/widgets/post/compose_recorder.dart';
 | 
						|
import 'package:island/pods/file_pool.dart';
 | 
						|
import 'package:pasteboard/pasteboard.dart';
 | 
						|
import 'package:island/talker.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;
 | 
						|
  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,
 | 
						|
  }) : pollId = ValueNotifier<String?>(pollId);
 | 
						|
 | 
						|
  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>[];
 | 
						|
 | 
						|
    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,
 | 
						|
      // initialize without poll by default
 | 
						|
      pollId: null,
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  static ComposeState createStateFromDraft(SnPost draft, {int postType = 0}) {
 | 
						|
    final tags = draft.tags.map((tag) => tag.slug).toList();
 | 
						|
 | 
						|
    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,
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  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(
 | 
						|
                  client: ref.read(apiClientProvider),
 | 
						|
                  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: 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(composeStorageNotifierProvider.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: 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(composeStorageNotifierProvider.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(composeStorageNotifierProvider.notifier)
 | 
						|
          .deleteDraft(draftId);
 | 
						|
    } catch (e) {
 | 
						|
      // Silently fail
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  static Future<SnPost?> 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<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 result = await FilePicker.platform.pickFiles(
 | 
						|
      type: FileType.image,
 | 
						|
      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.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,
 | 
						|
      };
 | 
						|
 | 
						|
      SnCloudFile? cloudFile;
 | 
						|
 | 
						|
      final pools = await ref.read(poolsProvider.future);
 | 
						|
      final selectedPoolId = resolveDefaultPoolId(ref, pools);
 | 
						|
 | 
						|
      cloudFile =
 | 
						|
          await FileUploader.createCloudFile(
 | 
						|
            client: ref.read(apiClientProvider),
 | 
						|
            fileData: attachment,
 | 
						|
            poolId: poolId ?? selectedPoolId,
 | 
						|
            mode:
 | 
						|
                attachment.type == UniversalFileType.file
 | 
						|
                    ? FileUploadMode.generic
 | 
						|
                    : FileUploadMode.mediaSafe,
 | 
						|
            onProgress: (progress, _) {
 | 
						|
              state.attachmentProgress.value = {
 | 
						|
                ...state.attachmentProgress.value,
 | 
						|
                index: progress,
 | 
						|
              };
 | 
						|
            },
 | 
						|
          ).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 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) => const ComposePollSheet(),
 | 
						|
    );
 | 
						|
 | 
						|
    if (poll == null) return;
 | 
						|
    state.pollId.value = poll.id;
 | 
						|
  }
 | 
						|
 | 
						|
  static Future<void> performAction(
 | 
						|
    WidgetRef ref,
 | 
						|
    ComposeState state,
 | 
						|
    BuildContext context, {
 | 
						|
    SnPost? originalPost,
 | 
						|
    SnPost? repliedPost,
 | 
						|
    SnPost? forwardedPost,
 | 
						|
  }) 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('postContentEmpty'.tr());
 | 
						|
      }
 | 
						|
      return; // Don't submit empty posts
 | 
						|
    }
 | 
						|
 | 
						|
    try {
 | 
						|
      state.submitting.value = true;
 | 
						|
 | 
						|
      // pload 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 =
 | 
						|
          '/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.embedView.value != null)
 | 
						|
          'embed_view': state.embedView.value!.toJson(),
 | 
						|
      };
 | 
						|
 | 
						|
      // Send request
 | 
						|
      await client.request(
 | 
						|
        endpoint,
 | 
						|
        queryParameters: {'pub': state.currentPublisher.value?.name},
 | 
						|
        data: payload,
 | 
						|
        options: Options(method: isNewPost ? 'POST' : 'PATCH'),
 | 
						|
      );
 | 
						|
 | 
						|
      // Delete draft after successful submission
 | 
						|
      if (state.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<void> 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(
 | 
						|
    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();
 | 
						|
  }
 | 
						|
}
 |