import 'dart:async'; import 'dart:convert'; import 'dart:developer'; import 'dart:io'; import 'dart:math' as math; import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:mime/mime.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:surface/providers/post.dart'; import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_network.dart'; import 'package:surface/types/attachment.dart'; import 'package:surface/types/post.dart'; import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/universal_image.dart'; import 'package:video_compress/video_compress.dart'; class PostWriteMedia { late String name; late SnMediaType type; final SnAttachment? attachment; final XFile? file; final Uint8List? raw; PostWriteMedia? thumbnail; PostWriteMedia(this.attachment, {this.file, this.raw}) { name = attachment!.name; switch (attachment?.mimetype.split('/').firstOrNull) { case 'image': type = SnMediaType.image; break; case 'video': type = SnMediaType.video; break; case 'audio': type = SnMediaType.audio; break; default: type = SnMediaType.file; } } PostWriteMedia.fromFile(this.file, {this.attachment, this.raw}) { name = file!.name; String? mimetype = file!.mimeType; mimetype ??= lookupMimeType(file!.path); switch (mimetype?.split('/').firstOrNull) { case 'image': type = SnMediaType.image; break; case 'video': type = SnMediaType.video; break; case 'audio': type = SnMediaType.audio; break; default: type = SnMediaType.file; } } PostWriteMedia.fromBytes(this.raw, this.name, this.type, {this.attachment, this.file}); bool get isEmpty => attachment == null && file == null && raw == null; Future length() async { if (attachment != null) { return attachment!.size; } else if (file != null) { return await file!.length(); } else if (raw != null) { return raw!.length; } return null; } XFile? toFile() { if (file != null) { return file!; } else if (raw != null) { return XFile.fromData( raw!, name: name, ); } return null; } ImageProvider? getImageProvider( BuildContext context, { int? width, int? height, }) { if (attachment != null) { final sn = context.read(); final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid)); if (width != null && height != null) { return ResizeImage( provider, width: width, height: height, policy: ResizeImagePolicy.fit, ); } return provider; } else if (file != null) { final ImageProvider provider = kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path)); if (width != null && height != null) { return ResizeImage( provider, width: width, height: height, policy: ResizeImagePolicy.fit, ); } return provider; } else if (raw != null) { final provider = MemoryImage(raw!); if (width != null && height != null) { return ResizeImage( provider, width: width, height: height, policy: ResizeImagePolicy.fit, ); } return provider; } return null; } } class PostWriteController extends ChangeNotifier { static const Map kTitleMap = { 'stories': 'writePostTypeStory', 'articles': 'writePostTypeArticle', }; static const kAttachmentProgressWeight = 0.9; static const kPostingProgressWeight = 0.1; final TextEditingController contentController = TextEditingController(); final TextEditingController titleController = TextEditingController(); final TextEditingController descriptionController = TextEditingController(); final TextEditingController aliasController = TextEditingController(); PostWriteController() { titleController.addListener(() { _temporaryPlanSave(); notifyListeners(); }); descriptionController.addListener(() { _temporaryPlanSave(); notifyListeners(); }); contentController.addListener(() { _temporaryPlanSave(); }); _temporaryLoad(); } String mode = kTitleMap.keys.first; String get title => titleController.text; String get description => descriptionController.text; bool get isRelatedNull => ![editingPost, repostingPost, replyingPost].any((ele) => ele != null); bool isLoading = false, isBusy = false; double? progress; SnPublisher? publisher; SnPost? editingPost, repostingPost, replyingPost; int visibility = 0; List visibleUsers = List.empty(); List invisibleUsers = List.empty(); List tags = List.empty(); List categories = List.empty(); PostWriteMedia? thumbnail; List attachments = List.empty(growable: true); DateTime? publishedAt, publishedUntil; Future fetchRelatedPost( BuildContext context, { int? editing, int? reposting, int? replying, }) async { final pt = context.read(); isLoading = true; notifyListeners(); try { if (editing != null) { final post = await pt.getPost(editing); publisher = post.publisher; titleController.text = post.body['title'] ?? ''; descriptionController.text = post.body['description'] ?? ''; contentController.text = post.body['content'] ?? ''; aliasController.text = post.alias ?? ''; publishedAt = post.publishedAt; publishedUntil = post.publishedUntil; visibleUsers = List.from(post.visibleUsersList ?? [], growable: true); invisibleUsers = List.from(post.invisibleUsersList ?? [], growable: true); visibility = post.visibility; tags = List.from(post.tags.map((ele) => ele.alias), growable: true); categories = List.from(post.categories.map((ele) => ele.alias), growable: true); attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []); if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) { thumbnail = PostWriteMedia(post.preload!.thumbnail); } editingPost = post; } if (replying != null) { final post = await pt.getPost(replying); replyingPost = post; } if (reposting != null) { final post = await pt.getPost(reposting); repostingPost = post; } } catch (err) { if (!context.mounted) return; context.showErrorDialog(err); } finally { isLoading = false; notifyListeners(); } } Future _uploadAttachment(BuildContext context, PostWriteMedia media, {bool isCompressed = false}) async { final attach = context.read(); final place = await attach.chunkedUploadInitialize( (await media.length())!, media.name, 'interactive', null, mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null, ); var item = await attach.chunkedUploadParts( media.toFile()!, place.$1, place.$2, analyzeNow: media.type == SnMediaType.image, onProgress: (value) { progress = value; notifyListeners(); }, ); if (media.type == SnMediaType.video && !isCompressed && context.mounted) { try { final compressedAttachment = await _tryCompressVideoCopy(context, media); if (compressedAttachment != null) { item = await attach.updateOne(item, compressedId: compressedAttachment.id); } } catch (err) { if (context.mounted) context.showErrorDialog(err); } } return item; } Future _tryCompressVideoCopy(BuildContext context, PostWriteMedia media) async { if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) return null; if (media.type != SnMediaType.video) return null; if (media.file == null) return null; if (VideoCompress.isCompressing) return null; final confirm = await context.showConfirmDialog( 'attachmentVideoCompressHint'.tr(), 'attachmentVideoCompressHintDescription'.tr(args: [media.file!.name]), ); if (!confirm) return null; progress = null; notifyListeners(); final mediaInfo = await VideoCompress.compressVideo( media.file!.path, quality: VideoQuality.LowQuality, frameRate: 30, deleteOrigin: false, ); if (mediaInfo == null) return null; if (!context.mounted) return null; final compressedMedia = PostWriteMedia.fromFile(XFile(mediaInfo.path!)); final compressedAttachment = await _uploadAttachment(context, compressedMedia, isCompressed: true); return compressedAttachment; } static const kTemporaryStorageKey = 'int_draft_post'; Timer? _temporarySaveTimer; void _temporaryPlanSave() { _temporarySaveTimer?.cancel(); _temporarySaveTimer = Timer(const Duration(seconds: 1), () { _temporarySave(); log("[PostWriter] Temporary save saved."); }); } void _temporarySave() { SharedPreferences.getInstance().then((prefs) { if (titleController.text.isEmpty && descriptionController.text.isEmpty && contentController.text.isEmpty && thumbnail == null && attachments.isEmpty) { prefs.remove(kTemporaryStorageKey); return; } prefs.setString( kTemporaryStorageKey, jsonEncode({ 'publisher': publisher, 'content': contentController.text, if (aliasController.text.isNotEmpty) 'alias': aliasController.text, if (titleController.text.isNotEmpty) 'title': titleController.text, if (descriptionController.text.isNotEmpty) 'description': descriptionController.text, if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(), 'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.toJson()).toList(growable: true), 'tags': tags.map((ele) => {'alias': ele}).toList(growable: true), 'categories': categories.map((ele) => {'alias': ele}).toList(growable: true), 'visibility': visibility, 'visible_users_list': visibleUsers, 'invisible_users_list': invisibleUsers, if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(), if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(), if (replyingPost != null) 'reply_to': replyingPost!.toJson(), if (repostingPost != null) 'repost_to': repostingPost!.toJson(), }), ); }); } bool temporaryRestored = false; void _temporaryLoad() { SharedPreferences.getInstance().then((prefs) { final raw = prefs.getString(kTemporaryStorageKey); if (raw == null) return; final data = jsonDecode(raw); contentController.text = data['content']; aliasController.text = data['alias'] ?? ''; titleController.text = data['title'] ?? ''; descriptionController.text = data['description'] ?? ''; if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail'])); attachments .addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast()); tags = List.from(data['tags'].map((ele) => ele['alias'])); categories = List.from(data['categories'].map((ele) => ele['alias'])); visibility = data['visibility']; visibleUsers = List.from(data['visible_users_list'] ?? []); invisibleUsers = List.from(data['invisible_users_list'] ?? []); if (data['published_at'] != null) publishedAt = DateTime.tryParse(data['published_at'])?.toLocal(); if (data['published_until'] != null) publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal(); replyingPost = data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null; repostingPost = data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null; temporaryRestored = true; notifyListeners(); }); } Future uploadSingleAttachment(BuildContext context, int idx) async { if (isBusy) return; final media = idx == -1 ? thumbnail! : attachments[idx]; isBusy = true; notifyListeners(); final item = await _uploadAttachment(context, media); attachments[idx] = PostWriteMedia(item); isBusy = false; notifyListeners(); } Future sendPost(BuildContext context) async { if (isBusy || publisher == null) return; final sn = context.read(); final attach = context.read(); progress = 0; isBusy = true; notifyListeners(); // Uploading attachments try { if (thumbnail != null && thumbnail!.attachment == null) { final thumb = await _uploadAttachment(context, thumbnail!); thumbnail = PostWriteMedia(thumb); } for (int i = 0; i < attachments.length; i++) { final media = attachments[i]; if (media.attachment != null) continue; // Already uploaded, skip if (media.isEmpty) continue; // Nothing to do, skip final place = await attach.chunkedUploadInitialize( (await media.length())!, media.name, 'interactive', null, mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null, ); var item = await attach.chunkedUploadParts( media.toFile()!, place.$1, place.$2, onProgress: (value) { // Calculate overall progress for attachments progress = math.max(((i + value) / attachments.length) * kAttachmentProgressWeight, value); notifyListeners(); }, ); try { if (context.mounted) { final compressedAttachment = await _tryCompressVideoCopy(context, media); if (compressedAttachment != null) { item = await attach.updateOne(item, compressedId: compressedAttachment.id); } } } catch (err) { if (context.mounted) context.showErrorDialog(err); } progress = (i + 1) / attachments.length * kAttachmentProgressWeight; attachments[i] = PostWriteMedia(item); notifyListeners(); } } catch (err) { isBusy = false; notifyListeners(); if (!context.mounted) return; context.showErrorDialog(err); return; } progress = kAttachmentProgressWeight; notifyListeners(); // Posting the content try { final baseProgressVal = progress!; await sn.client.request( [ '/cgi/co/$mode', if (editingPost != null) '${editingPost!.id}', ].join('/'), data: { 'publisher': publisher!.id, 'content': contentController.text, if (aliasController.text.isNotEmpty) 'alias': aliasController.text, if (titleController.text.isNotEmpty) 'title': titleController.text, if (descriptionController.text.isNotEmpty) 'description': descriptionController.text, if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid, 'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(), 'tags': tags.map((ele) => {'alias': ele}).toList(), 'categories': categories.map((ele) => {'alias': ele}).toList(), 'visibility': visibility, 'visible_users_list': visibleUsers, 'invisible_users_list': invisibleUsers, if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(), if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(), if (replyingPost != null) 'reply_to': replyingPost!.id, if (repostingPost != null) 'repost_to': repostingPost!.id, }, onSendProgress: (count, total) { progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2); notifyListeners(); }, onReceiveProgress: (count, total) { progress = baseProgressVal + (kPostingProgressWeight / 2) + (count / total) * (kPostingProgressWeight / 2); notifyListeners(); }, options: Options( method: editingPost != null ? 'PUT' : 'POST', ), ); reset(); } catch (err) { if (!context.mounted) return; context.showErrorDialog(err); } finally { isBusy = false; notifyListeners(); } } void addAttachments(Iterable items) { attachments.addAll(items); notifyListeners(); } void setAttachmentAt(int idx, PostWriteMedia item) { if (idx == -1) { thumbnail = item; } else { attachments[idx] = item; } notifyListeners(); } void removeAttachmentAt(int idx) { if (idx == -1) { thumbnail = null; } else { attachments.removeAt(idx); } notifyListeners(); } void setThumbnail(int? idx) { if (idx == null) { attachments.add(thumbnail!); thumbnail = null; } else { if (thumbnail != null) { attachments.add(thumbnail!); } thumbnail = attachments[idx]; attachments.removeAt(idx); } notifyListeners(); } void setPublisher(SnPublisher? item) { publisher = item; _temporaryPlanSave(); notifyListeners(); } void setPublishedAt(DateTime? value) { publishedAt = value; _temporaryPlanSave(); notifyListeners(); } void setPublishedUntil(DateTime? value) { publishedUntil = value; _temporaryPlanSave(); notifyListeners(); } void setTags(List value) { tags = value; _temporaryPlanSave(); notifyListeners(); } void setCategories(List value) { categories = value; _temporaryPlanSave(); notifyListeners(); } void setVisibility(int value) { visibility = value; _temporaryPlanSave(); notifyListeners(); } void setVisibleUsers(List value) { visibleUsers = value; _temporaryPlanSave(); notifyListeners(); } void setInvisibleUsers(List value) { invisibleUsers = value; _temporaryPlanSave(); notifyListeners(); } void setProgress(double? value) { progress = value; _temporaryPlanSave(); notifyListeners(); } void setIsBusy(bool value) { isBusy = value; _temporaryPlanSave(); notifyListeners(); } void setMode(String value) { mode = value; _temporaryPlanSave(); notifyListeners(); } void reset() { publishedAt = null; publishedUntil = null; titleController.clear(); descriptionController.clear(); contentController.clear(); aliasController.clear(); tags.clear(); categories.clear(); attachments.clear(); editingPost = null; replyingPost = null; repostingPost = null; mode = kTitleMap.keys.first; temporaryRestored = false; SharedPreferences.getInstance().then((prefs) => prefs.remove(kTemporaryStorageKey)); notifyListeners(); } @override void dispose() { contentController.dispose(); titleController.dispose(); descriptionController.dispose(); aliasController.dispose(); super.dispose(); } }