diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 2093070..4274881 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -37,6 +37,7 @@ "report": "Report", "repost": "Repost", "reply": "Reply", + "unset": "Unset", "untitled": "Untitled", "postDetail": "Post detail", "postNoun": "Post", @@ -82,6 +83,8 @@ "fieldPostTitle": "Title", "fieldPostDescription": "Description", "postPublish": "Publish", + "postPublishedAt": "Published At", + "postPublishedUntil": "Published Until", "postEditingNotice": "You're about to editing a post that posted {}.", "postReplyingNotice": "You're about to reply to a post that posted {}.", "postRepostingNotice": "You're about to repost a post that posted {}.", diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 9499893..182273e 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -37,6 +37,7 @@ "report": "检举", "repost": "转帖", "reply": "回贴", + "unset": "未设置", "untitled": "无题", "postDetail": "帖子详情", "postNoun": "帖子", @@ -82,6 +83,8 @@ "fieldPostTitle": "标题", "fieldPostDescription": "描述", "postPublish": "发布", + "postPublishedAt": "发布于", + "postPublishedUntil": "取消发布于", "postEditingNotice": "你正在修改由 {} 发布的帖子。", "postReplyingNotice": "你正在回复由 {} 发布的帖子。", "postRepostingNotice": "你正在转发由 {} 发布的帖子。", diff --git a/lib/controllers/post_write_controller.dart b/lib/controllers/post_write_controller.dart new file mode 100644 index 0000000..e66a6b5 --- /dev/null +++ b/lib/controllers/post_write_controller.dart @@ -0,0 +1,354 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:provider/provider.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'; + +enum PostWriteMediaType { + image, + video, + audio, + file, +} + +class PostWriteMedia { + late String name; + late PostWriteMediaType type; + final SnAttachment? attachment; + final XFile? file; + final Uint8List? raw; + + PostWriteMedia(this.attachment, {this.file, this.raw}) { + name = attachment!.name; + + switch (attachment?.mimetype.split('/').firstOrNull) { + case 'image': + type = PostWriteMediaType.image; + break; + case 'video': + type = PostWriteMediaType.video; + break; + case 'audio': + type = PostWriteMediaType.audio; + break; + default: + type = PostWriteMediaType.file; + } + } + + PostWriteMedia.fromFile(this.file, {this.attachment, this.raw}) { + name = file!.name; + + switch (file?.mimeType?.split('/').firstOrNull) { + case 'image': + type = PostWriteMediaType.image; + break; + case 'video': + type = PostWriteMediaType.video; + break; + case 'audio': + type = PostWriteMediaType.audio; + break; + default: + type = PostWriteMediaType.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(); + return UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid)); + } 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(); + + PostWriteController() { + titleController.addListener(() => notifyListeners()); + descriptionController.addListener(() => notifyListeners()); + } + + 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; + + List attachments = List.empty(growable: true); + DateTime? publishedAt, publishedUntil; + + Future fetchRelatedPost( + BuildContext context, { + int? editing, + int? reposting, + int? replying, + }) async { + final sn = context.read(); + final attach = context.read(); + + isLoading = true; + notifyListeners(); + + try { + if (editing != null) { + final resp = await sn.client.get('/cgi/co/posts/$editing'); + final post = SnPost.fromJson(resp.data); + final alts = await attach + .getMultiple(post.body['attachments']?.cast() ?? []); + publisher = post.publisher; + titleController.text = post.body['title'] ?? ''; + descriptionController.text = post.body['description'] ?? ''; + contentController.text = post.body['content'] ?? ''; + publishedAt = post.publishedAt; + publishedUntil = post.publishedUntil; + attachments.addAll(alts.map((ele) => PostWriteMedia(ele))); + + editingPost = post.copyWith( + preload: SnPostPreload( + attachments: alts, + ), + ); + } + + if (replying != null) { + final resp = await sn.client.get('/cgi/co/posts/$replying'); + final post = SnPost.fromJson(resp.data); + replyingPost = post.copyWith( + preload: SnPostPreload( + attachments: await attach + .getMultiple(post.body['attachments']?.cast() ?? []), + ), + ); + } + + if (reposting != null) { + final resp = await sn.client.get('/cgi/co/posts/$reposting'); + final post = SnPost.fromJson(resp.data); + repostingPost = post.copyWith( + preload: SnPostPreload( + attachments: await attach + .getMultiple(post.body['attachments']?.cast() ?? []), + ), + ); + } + } catch (err) { + if (!context.mounted) return; + context.showErrorDialog(err); + } finally { + isLoading = false; + notifyListeners(); + } + } + + void post(BuildContext context) async { + if (isBusy || publisher == null) return; + + final sn = context.read(); + final attach = context.read(); + + progress = 0; + isBusy = true; + notifyListeners(); + + // Uploading attachments + try { + 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, + ); + + final item = await attach.chunkedUploadParts( + media.toFile()!, + place.$1, + place.$2, + onProgress: (progress) { + // Calculate overall progress for attachments + progress = ((i + progress) / attachments.length) * + kAttachmentProgressWeight; + notifyListeners(); + }, + ); + + attachments[i] = PostWriteMedia(item); + } + } 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, + 'title': titleController.text, + 'description': descriptionController.text, + 'attachments': attachments + .where((e) => e.attachment != null) + .map((e) => e.attachment!.rid) + .toList(), + 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', + ), + ); + if (!context.mounted) return; + Navigator.pop(context, true); + } catch (err) { + if (!context.mounted) return; + context.showErrorDialog(err); + } finally { + isBusy = false; + notifyListeners(); + } + } + + void addAttachments(Iterable items) { + attachments.addAll(items); + notifyListeners(); + } + + void removeAttachmentAt(int idx) { + attachments.removeAt(idx); + notifyListeners(); + } + + void setPublisher(SnPublisher? item) { + publisher = item; + notifyListeners(); + } + + void setPublishedAt(DateTime? value) { + publishedAt = value; + notifyListeners(); + } + + void setPublishedUntil(DateTime? value) { + publishedUntil = value; + notifyListeners(); + } + + @override + void dispose() { + contentController.dispose(); + titleController.dispose(); + descriptionController.dispose(); + super.dispose(); + } +} diff --git a/lib/screens/post/post_editor.dart b/lib/screens/post/post_editor.dart index 42cd753..8910079 100644 --- a/lib/screens/post/post_editor.dart +++ b/lib/screens/post/post_editor.dart @@ -1,7 +1,6 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; -import 'package:dio/dio.dart'; import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/gestures.dart'; @@ -11,9 +10,8 @@ import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; -import 'package:surface/providers/sn_attachment.dart'; +import 'package:surface/controllers/post_write_controller.dart'; import 'package:surface/providers/sn_network.dart'; -import 'package:surface/types/attachment.dart'; import 'package:surface/types/post.dart'; import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/loading_indicator.dart'; @@ -42,203 +40,37 @@ class PostEditorScreen extends StatefulWidget { } class _PostEditorScreenState extends State { - static const Map _kTitleMap = { - 'stories': 'writePostTypeStory', - 'articles': 'writePostTypeArticle', - }; + final PostWriteController _writeController = PostWriteController(); - bool _isBusy = false; - bool _isLoading = false; + bool _isFetching = false; + bool get _isLoading => _isFetching || _writeController.isLoading; - SnPublisher? _publisher; List? _publishers; - final List _selectedMedia = List.empty(growable: true); - final List _attachments = List.empty(growable: true); - Future _fetchPublishers() async { - final sn = context.read(); - final resp = await sn.client.get('/cgi/co/publishers'); - _publishers = List.from( - resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], - ); - setState(() { - _publisher = _publishers?.first; - }); - } - - SnPost? _editingOg; - SnPost? _replyingTo; - SnPost? _repostingTo; - - Future _fetchRelatedPost() async { - final sn = context.read(); - final attach = context.read(); + setState(() => _isFetching = true); try { - setState(() => _isLoading = true); - - if (widget.postEditId != null) { - final resp = await sn.client.get('/cgi/co/posts/${widget.postEditId}'); - final post = SnPost.fromJson(resp.data); - final attachments = await attach - .getMultiple(post.body['attachments']?.cast() ?? []); - _title = post.body['title']; - _description = post.body['description']; - _contentController.text = post.body['content'] ?? ''; - _attachments.addAll(attachments); - - _editingOg = post.copyWith( - preload: SnPostPreload( - attachments: attachments, - ), - ); - } - - if (widget.postReplyId != null) { - final resp = await sn.client.get('/cgi/co/posts/${widget.postReplyId}'); - final post = SnPost.fromJson(resp.data); - _replyingTo = post.copyWith( - preload: SnPostPreload( - attachments: await attach - .getMultiple(post.body['attachments']?.cast() ?? []), - ), - ); - } - - if (widget.postRepostId != null) { - final resp = - await sn.client.get('/cgi/co/posts/${widget.postRepostId}'); - final post = SnPost.fromJson(resp.data); - _repostingTo = post.copyWith( - preload: SnPostPreload( - attachments: await attach - .getMultiple(post.body['attachments']?.cast() ?? []), - ), - ); - } - } catch (err) { - context.showErrorDialog(err); - } finally { - setState(() => _isLoading = false); - } - } - - String? _title; - String? _description; - - final TextEditingController _contentController = TextEditingController(); - - double? _progress; - - static const kAttachmentProgressWeight = 0.9; - static const kPostingProgressWeight = 0.1; - - void _performAction() async { - if (_isBusy || _publisher == null) return; - - final sn = context.read(); - final attach = context.read(); - - setState(() { - _progress = 0; - _isBusy = true; - }); - - // Uploading attachments - try { - for (int i = 0; i < _selectedMedia.length; i++) { - final media = _selectedMedia[i]; - final place = await attach.chunkedUploadInitialize( - await media.length(), - media.name, - 'interactive', - null, - ); - - final item = await attach.chunkedUploadParts( - media, - place.$1, - place.$2, - onProgress: (progress) { - // Calculate overall progress for attachments - setState(() { - _progress = ((i + progress) / _selectedMedia.length) * - kAttachmentProgressWeight; - }); - }, - ); - - _attachments.add(item); - } - } catch (err) { - if (!mounted) return; - context.showErrorDialog(err); - setState(() => _isBusy = false); - return; - } - - setState(() => _progress = kAttachmentProgressWeight); - - // Posting the content - try { - final baseProgressVal = _progress!; - await sn.client.request( - [ - '/cgi/co/${widget.mode}', - if (widget.postEditId != null) '${widget.postEditId}', - ].join('/'), - data: { - 'publisher': _publisher!.id, - 'content': _contentController.value.text, - 'title': _title, - 'description': _description, - 'attachments': _attachments.map((e) => e.rid).toList(), - if (_replyingTo != null) 'reply_to': _replyingTo!.id, - if (_repostingTo != null) 'repost_to': _repostingTo!.id, - }, - onSendProgress: (count, total) { - setState(() { - _progress = baseProgressVal + - (count / total) * (kPostingProgressWeight / 2); - }); - }, - onReceiveProgress: (count, total) { - setState(() { - _progress = baseProgressVal + - (kPostingProgressWeight / 2) + - (count / total) * (kPostingProgressWeight / 2); - }); - }, - options: Options( - method: widget.postEditId != null ? 'PUT' : 'POST', - ), + final sn = context.read(); + final resp = await sn.client.get('/cgi/co/publishers'); + _publishers = List.from( + resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], ); - if (!mounted) return; - Navigator.pop(context, true); + _writeController.setPublisher(_publishers?.firstOrNull); } catch (err) { if (!mounted) return; context.showErrorDialog(err); } finally { - setState(() => _isBusy = false); + setState(() => _isFetching = false); } } void _updateMeta() { - showModalBottomSheet( + showModalBottomSheet( context: context, - builder: (context) => PostMetaEditor( - initialTitle: _title, - initialDescription: _description, - ), + builder: (context) => PostMetaEditor(controller: _writeController), useRootNavigator: true, - ).then((value) { - if (value is PostMetaResult) { - _title = value.title; - _description = value.description; - setState(() {}); - } - }); + ); } final _imagePicker = ImagePicker(); @@ -246,308 +78,337 @@ class _PostEditorScreenState extends State { void _selectMedia() async { final result = await _imagePicker.pickMultipleMedia(); if (result.isEmpty) return; - _selectedMedia.addAll(result); + _writeController.addAttachments( + result.map((e) => PostWriteMedia.fromFile(e)), + ); setState(() {}); } @override void dispose() { - _contentController.dispose(); + _writeController.dispose(); super.dispose(); } @override void initState() { super.initState(); - if (!_kTitleMap.keys.contains(widget.mode)) { + if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) { context.showErrorDialog('Unknown post type'); Navigator.pop(context); } - _fetchRelatedPost(); _fetchPublishers(); + _writeController.fetchRelatedPost( + context, + editing: widget.postEditId, + replying: widget.postReplyId, + reposting: widget.postRepostId, + ); } @override Widget build(BuildContext context) { - return AppScaffold( - appBar: AppBar( - leading: BackButton( - onPressed: () { - Navigator.pop(context); - }, - ), - flexibleSpace: Column( - children: [ - Text(_title ?? 'untitled'.tr()) - .textStyle(Theme.of(context).textTheme.titleLarge!) - .textColor(Colors.white), - Text(_kTitleMap[widget.mode]!) - .tr() - .textColor(Colors.white.withAlpha((255 * 0.9).round())), - ], - ).padding(top: math.max(MediaQuery.of(context).padding.top, 8)), - actions: [ - IconButton( - icon: const Icon(Symbols.tune), - onPressed: _isBusy ? null : _updateMeta, + return ListenableBuilder( + listenable: _writeController, + builder: (context, _) { + return AppScaffold( + appBar: AppBar( + leading: BackButton( + onPressed: () { + Navigator.pop(context); + }, + ), + flexibleSpace: Column( + children: [ + Text(_writeController.title.isNotEmpty + ? _writeController.title + : 'untitled'.tr()) + .textStyle(Theme.of(context).textTheme.titleLarge!) + .textColor(Colors.white), + Text(PostWriteController.kTitleMap[widget.mode]!) + .tr() + .textColor(Colors.white.withAlpha((255 * 0.9).round())), + ], + ).padding(top: math.max(MediaQuery.of(context).padding.top, 8)), + actions: [ + IconButton( + icon: const Icon(Symbols.tune), + onPressed: _writeController.isBusy ? null : _updateMeta, + ), + ], ), - ], - ), - body: Column( - children: [ - DropdownButtonHideUnderline( - child: DropdownButton2( - isExpanded: true, - hint: Text( - 'fieldPostPublisher', - style: TextStyle( - fontSize: 14, - color: Theme.of(context).hintColor, + body: Column( + children: [ + DropdownButtonHideUnderline( + child: DropdownButton2( + isExpanded: true, + hint: Text( + 'fieldPostPublisher', + style: TextStyle( + fontSize: 14, + color: Theme.of(context).hintColor, + ), + ).tr(), + items: >[ + ...(_publishers?.map( + (item) => DropdownMenuItem( + enabled: _writeController.editingPost == null, + value: item, + child: Row( + children: [ + AccountImage(content: item.avatar, radius: 16), + const Gap(8), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text(item.nick).textStyle( + Theme.of(context) + .textTheme + .bodyMedium!), + Text('@${item.name}') + .textStyle(Theme.of(context) + .textTheme + .bodySmall!) + .fontSize(12), + ], + ), + ), + ], + ), + ), + ) ?? + []), + DropdownMenuItem( + value: null, + child: Row( + children: [ + CircleAvatar( + radius: 16, + backgroundColor: Colors.transparent, + foregroundColor: + Theme.of(context).colorScheme.onSurface, + child: const Icon(Symbols.add), + ), + const Gap(8), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('publishersNew').tr().textStyle( + Theme.of(context).textTheme.bodyMedium!), + ], + ), + ), + ], + ), + ), + ], + value: _writeController.publisher, + onChanged: (SnPublisher? value) { + if (value == null) { + GoRouter.of(context) + .pushNamed('accountPublisherNew') + .then((value) { + if (value == true) { + _publishers = null; + _fetchPublishers(); + } + }); + } else { + _writeController.setPublisher(value); + } + }, + buttonStyleData: const ButtonStyleData( + padding: EdgeInsets.only(right: 16), + height: 48, + ), + menuItemStyleData: const MenuItemStyleData( + height: 48, + ), ), - ).tr(), - items: >[ - ...(_publishers?.map( - (item) => DropdownMenuItem( - enabled: _editingOg == null, - value: item, - child: Row( + ), + const Divider(height: 1), + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.only(bottom: 8), + child: Column( + children: [ + // Replying Notice + if (_writeController.replyingPost != null) + Column( children: [ - AccountImage(content: item.avatar, radius: 16), - const Gap(8), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(item.nick).textStyle( - Theme.of(context).textTheme.bodyMedium!), - Text('@${item.name}') - .textStyle(Theme.of(context) - .textTheme - .bodySmall!) - .fontSize(12), + Theme( + data: Theme.of(context).copyWith( + dividerColor: Colors.transparent, + ), + child: ExpansionTile( + minTileHeight: 48, + leading: + const Icon(Symbols.reply).padding(left: 4), + title: Text('postReplyingNotice') + .fontSize(15) + .tr(args: [ + '@${_writeController.replyingPost!.publisher.name}' + ]), + children: [ + PostItem(data: _writeController.replyingPost!) ], ), ), + const Divider(height: 1), ], ), - ), - ) ?? - []), - DropdownMenuItem( - value: null, - child: Row( - children: [ - CircleAvatar( - radius: 16, - backgroundColor: Colors.transparent, - foregroundColor: - Theme.of(context).colorScheme.onSurface, - child: const Icon(Symbols.add), - ), - const Gap(8), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + // Reposting Notice + if (_writeController.repostingPost != null) + Column( children: [ - Text('publishersNew').tr().textStyle( - Theme.of(context).textTheme.bodyMedium!), + Theme( + data: Theme.of(context).copyWith( + dividerColor: Colors.transparent, + ), + child: ExpansionTile( + minTileHeight: 48, + leading: const Icon(Symbols.forward) + .padding(left: 4), + title: Text('postRepostingNotice') + .fontSize(15) + .tr(args: [ + '@${_writeController.repostingPost!.publisher.name}' + ]), + children: [ + PostItem( + data: _writeController.repostingPost!) + ], + ), + ), + const Divider(height: 1), ], ), - ), - ], + // Editing Notice + if (_writeController.editingPost != null) + Column( + children: [ + Theme( + data: Theme.of(context).copyWith( + dividerColor: Colors.transparent, + ), + child: ExpansionTile( + minTileHeight: 48, + leading: const Icon(Symbols.edit_note) + .padding(left: 4), + title: Text('postEditingNotice') + .fontSize(15) + .tr(args: [ + '@${_writeController.editingPost!.publisher.name}' + ]), + children: [ + PostItem(data: _writeController.editingPost!) + ], + ), + ), + const Divider(height: 1), + ], + ), + // Content Input Area + TextField( + controller: _writeController.contentController, + maxLines: null, + decoration: InputDecoration( + hintText: 'fieldPostContent'.tr(), + hintStyle: TextStyle(fontSize: 14), + isCollapsed: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + ), + border: InputBorder.none, + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ) + ] + .expandIndexed( + (idx, ele) => [ + if (idx != 0 || _writeController.isRelatedNull) + const Gap(8), + ele, + ], + ) + .toList(), ), ), - ], - value: _publisher, - onChanged: (SnPublisher? value) { - if (value == null) { - GoRouter.of(context) - .pushNamed('accountPublisherNew') - .then((value) { - if (value == true) { - _publisher = null; - _publishers = null; - _fetchPublishers(); - } - }); - } else { - setState(() { - _publisher = value; - }); - } - }, - buttonStyleData: const ButtonStyleData( - padding: EdgeInsets.only(right: 16), - height: 48, ), - menuItemStyleData: const MenuItemStyleData( - height: 48, - ), - ), - ), - const Divider(height: 1), - Expanded( - child: SingleChildScrollView( - padding: EdgeInsets.only(bottom: 8), - child: Column( - children: [ - // Replying Notice - if (_replyingTo != null) - Column( - children: [ - Theme( - data: Theme.of(context).copyWith( - dividerColor: Colors.transparent, - ), - child: ExpansionTile( - minTileHeight: 48, - leading: const Icon(Symbols.reply).padding(left: 4), - title: Text('postReplyingNotice') - .fontSize(15) - .tr(args: ['@${_replyingTo!.publisher.name}']), - children: [PostItem(data: _replyingTo!)], - ), - ), - const Divider(height: 1), - ], - ), - // Reposting Notice - if (_repostingTo != null) - Column( - children: [ - Theme( - data: Theme.of(context).copyWith( - dividerColor: Colors.transparent, - ), - child: ExpansionTile( - minTileHeight: 48, - leading: - const Icon(Symbols.forward).padding(left: 4), - title: Text('postRepostingNotice') - .fontSize(15) - .tr(args: ['@${_repostingTo!.publisher.name}']), - children: [PostItem(data: _repostingTo!)], - ), - ), - const Divider(height: 1), - ], - ), - // Editing Notice - if (_editingOg != null) - Column( - children: [ - Theme( - data: Theme.of(context).copyWith( - dividerColor: Colors.transparent, - ), - child: ExpansionTile( - minTileHeight: 48, - leading: - const Icon(Symbols.edit_note).padding(left: 4), - title: Text('postEditingNotice') - .fontSize(15) - .tr(args: ['@${_editingOg!.publisher.name}']), - children: [PostItem(data: _editingOg!)], - ), - ), - const Divider(height: 1), - ], - ), - // Content Input Area - TextField( - controller: _contentController, - maxLines: null, - decoration: InputDecoration( - hintText: 'fieldPostContent'.tr(), - hintStyle: TextStyle(fontSize: 14), - isCollapsed: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - ), - border: InputBorder.none, - ), - onTapOutside: (_) => - FocusManager.instance.primaryFocus?.unfocus(), - ) - ] - .expandIndexed( - (idx, ele) => [ - if (idx != 0 || - ![_editingOg, _replyingTo, _repostingTo] - .any((x) => x != null)) - const Gap(8), - ele, - ], - ) - .toList(), - ), - ), - ), - if (_selectedMedia.isNotEmpty) - PostMediaPendingList( - data: _selectedMedia, - onRemove: (idx) { - setState(() { - _selectedMedia.removeAt(idx); - }); - }, - ).padding(bottom: 8), - Material( - elevation: 2, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - LoadingIndicator(isActive: _isLoading), - if (_isBusy && _progress != null) - TweenAnimationBuilder( - tween: Tween(begin: 0, end: 1), - duration: Duration(milliseconds: 300), - builder: (context, value, _) => - LinearProgressIndicator(value: value, minHeight: 2), - ) - else if (_isBusy) - const LinearProgressIndicator(value: null, minHeight: 2), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + if (_writeController.attachments.isNotEmpty) + PostMediaPendingList( + data: _writeController.attachments, + onRemove: (idx) { + _writeController.removeAttachmentAt(idx); + }, + ).padding(bottom: 8), + Material( + elevation: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: ScrollConfiguration( - behavior: _PostEditorActionScrollBehavior(), - child: SingleChildScrollView( - scrollDirection: Axis.vertical, - child: Row( - children: [ - IconButton( - onPressed: _isBusy ? null : _selectMedia, - icon: Icon( - Symbols.add_photo_alternate, - color: Theme.of(context).colorScheme.primary, - ), + LoadingIndicator(isActive: _isLoading), + if (_writeController.isBusy && + _writeController.progress != null) + TweenAnimationBuilder( + tween: Tween(begin: 0, end: _writeController.progress), + duration: Duration(milliseconds: 300), + builder: (context, value, _) => + LinearProgressIndicator(value: value, minHeight: 2), + ) + else if (_writeController.isBusy) + const LinearProgressIndicator(value: null, minHeight: 2), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: ScrollConfiguration( + behavior: _PostEditorActionScrollBehavior(), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Row( + children: [ + IconButton( + onPressed: _writeController.isBusy + ? null + : _selectMedia, + icon: Icon( + Symbols.add_photo_alternate, + color: + Theme.of(context).colorScheme.primary, + ), + ), + ], ), - ], + ), ), ), - ), - ), - TextButton.icon( - onPressed: (_isBusy || _publisher == null) - ? null - : _performAction, - icon: const Icon(Symbols.send), - label: Text('postPublish').tr(), - ), + TextButton.icon( + onPressed: (_writeController.isBusy || + _writeController.publisher == null) + ? null + : () => _writeController.post(context), + icon: const Icon(Symbols.send), + label: Text('postPublish').tr(), + ), + ], + ).padding(horizontal: 16), ], - ).padding(horizontal: 16), - ], - ).padding( - bottom: MediaQuery.of(context).padding.bottom, - top: 4, - ), + ).padding( + bottom: MediaQuery.of(context).padding.bottom, + top: 4, + ), + ), + ], ), - ], - ), + ); + }, ); } } diff --git a/lib/types/post.dart b/lib/types/post.dart index b625bed..c7b65dc 100644 --- a/lib/types/post.dart +++ b/lib/types/post.dart @@ -34,7 +34,7 @@ class SnPost with _$SnPost { required DateTime? lockedAt, required bool isDraft, required DateTime? publishedAt, - required dynamic publishedUntil, + required DateTime? publishedUntil, required int totalUpvote, required int totalDownvote, required int? realmId, diff --git a/lib/types/post.freezed.dart b/lib/types/post.freezed.dart index 1c27ec2..888fae3 100644 --- a/lib/types/post.freezed.dart +++ b/lib/types/post.freezed.dart @@ -45,7 +45,7 @@ mixin _$SnPost { DateTime? get lockedAt => throw _privateConstructorUsedError; bool get isDraft => throw _privateConstructorUsedError; DateTime? get publishedAt => throw _privateConstructorUsedError; - dynamic get publishedUntil => throw _privateConstructorUsedError; + DateTime? get publishedUntil => throw _privateConstructorUsedError; int get totalUpvote => throw _privateConstructorUsedError; int get totalDownvote => throw _privateConstructorUsedError; int? get realmId => throw _privateConstructorUsedError; @@ -95,7 +95,7 @@ abstract class $SnPostCopyWith<$Res> { DateTime? lockedAt, bool isDraft, DateTime? publishedAt, - dynamic publishedUntil, + DateTime? publishedUntil, int totalUpvote, int totalDownvote, int? realmId, @@ -264,7 +264,7 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost> publishedUntil: freezed == publishedUntil ? _value.publishedUntil : publishedUntil // ignore: cast_nullable_to_non_nullable - as dynamic, + as DateTime?, totalUpvote: null == totalUpvote ? _value.totalUpvote : totalUpvote // ignore: cast_nullable_to_non_nullable @@ -368,7 +368,7 @@ abstract class _$$SnPostImplCopyWith<$Res> implements $SnPostCopyWith<$Res> { DateTime? lockedAt, bool isDraft, DateTime? publishedAt, - dynamic publishedUntil, + DateTime? publishedUntil, int totalUpvote, int totalDownvote, int? realmId, @@ -538,7 +538,7 @@ class __$$SnPostImplCopyWithImpl<$Res> publishedUntil: freezed == publishedUntil ? _value.publishedUntil : publishedUntil // ignore: cast_nullable_to_non_nullable - as dynamic, + as DateTime?, totalUpvote: null == totalUpvote ? _value.totalUpvote : totalUpvote // ignore: cast_nullable_to_non_nullable @@ -690,7 +690,7 @@ class _$SnPostImpl extends _SnPost { @override final DateTime? publishedAt; @override - final dynamic publishedUntil; + final DateTime? publishedUntil; @override final int totalUpvote; @override @@ -756,8 +756,8 @@ class _$SnPostImpl extends _SnPost { (identical(other.isDraft, isDraft) || other.isDraft == isDraft) && (identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt) && - const DeepCollectionEquality() - .equals(other.publishedUntil, publishedUntil) && + (identical(other.publishedUntil, publishedUntil) || + other.publishedUntil == publishedUntil) && (identical(other.totalUpvote, totalUpvote) || other.totalUpvote == totalUpvote) && (identical(other.totalDownvote, totalDownvote) || @@ -801,7 +801,7 @@ class _$SnPostImpl extends _SnPost { lockedAt, isDraft, publishedAt, - const DeepCollectionEquality().hash(publishedUntil), + publishedUntil, totalUpvote, totalDownvote, realmId, @@ -855,7 +855,7 @@ abstract class _SnPost extends SnPost { required final DateTime? lockedAt, required final bool isDraft, required final DateTime? publishedAt, - required final dynamic publishedUntil, + required final DateTime? publishedUntil, required final int totalUpvote, required final int totalDownvote, required final int? realmId, @@ -919,7 +919,7 @@ abstract class _SnPost extends SnPost { @override DateTime? get publishedAt; @override - dynamic get publishedUntil; + DateTime? get publishedUntil; @override int get totalUpvote; @override diff --git a/lib/types/post.g.dart b/lib/types/post.g.dart index 39fc426..3637687 100644 --- a/lib/types/post.g.dart +++ b/lib/types/post.g.dart @@ -42,7 +42,9 @@ _$SnPostImpl _$$SnPostImplFromJson(Map json) => _$SnPostImpl( publishedAt: json['published_at'] == null ? null : DateTime.parse(json['published_at'] as String), - publishedUntil: json['published_until'], + publishedUntil: json['published_until'] == null + ? null + : DateTime.parse(json['published_until'] as String), totalUpvote: (json['total_upvote'] as num).toInt(), totalDownvote: (json['total_downvote'] as num).toInt(), realmId: (json['realm_id'] as num?)?.toInt(), @@ -83,7 +85,7 @@ Map _$$SnPostImplToJson(_$SnPostImpl instance) => 'locked_at': instance.lockedAt?.toIso8601String(), 'is_draft': instance.isDraft, 'published_at': instance.publishedAt?.toIso8601String(), - 'published_until': instance.publishedUntil, + 'published_until': instance.publishedUntil?.toIso8601String(), 'total_upvote': instance.totalUpvote, 'total_downvote': instance.totalDownvote, 'realm_id': instance.realmId, diff --git a/lib/widgets/account/account_image.dart b/lib/widgets/account/account_image.dart index 342d381..5b1c948 100644 --- a/lib/widgets/account/account_image.dart +++ b/lib/widgets/account/account_image.dart @@ -35,6 +35,7 @@ class AccountImage extends StatelessWidget { UniversalImage.provider(url), width: ((radius ?? 20) * devicePixelRatio * 2).round(), height: ((radius ?? 20) * devicePixelRatio * 2).round(), + policy: ResizeImagePolicy.fit, ) : null, child: (content?.isEmpty ?? true) diff --git a/lib/widgets/post/post_media_pending_list.dart b/lib/widgets/post/post_media_pending_list.dart index cf0d8b2..ff4e5ce 100644 --- a/lib/widgets/post/post_media_pending_list.dart +++ b/lib/widgets/post/post_media_pending_list.dart @@ -1,15 +1,13 @@ -import 'dart:io'; - -import 'package:cross_file/cross_file.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_context_menu/flutter_context_menu.dart'; import 'package:gap/gap.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:surface/controllers/post_write_controller.dart'; class PostMediaPendingList extends StatelessWidget { - final List data; + final List data; final Function(int idx)? onRemove; const PostMediaPendingList({ super.key, @@ -19,6 +17,8 @@ class PostMediaPendingList extends StatelessWidget { @override Widget build(BuildContext context) { + final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + return Container( constraints: const BoxConstraints(maxHeight: 120), child: ListView.separated( @@ -53,9 +53,25 @@ class PostMediaPendingList extends StatelessWidget { borderRadius: const BorderRadius.all(Radius.circular(8)), child: AspectRatio( aspectRatio: 1, - child: kIsWeb - ? Image.network(file.path, fit: BoxFit.cover) - : Image.file(File(file.path), fit: BoxFit.cover), + child: switch (file.type) { + PostWriteMediaType.image => + LayoutBuilder(builder: (context, constraints) { + return Image( + image: file.getImageProvider( + context, + width: (constraints.maxWidth * devicePixelRatio) + .round(), + height: (constraints.maxHeight * devicePixelRatio) + .round(), + )!, + fit: BoxFit.cover, + ); + }), + _ => Container( + color: Theme.of(context).colorScheme.surface, + child: const Icon(Symbols.docs).center(), + ), + }, ), ), ), diff --git a/lib/widgets/post/post_meta_editor.dart b/lib/widgets/post/post_meta_editor.dart index 6ff8440..4bffdfa 100644 --- a/lib/widgets/post/post_meta_editor.dart +++ b/lib/widgets/post/post_meta_editor.dart @@ -1,87 +1,130 @@ import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; +import 'package:surface/controllers/post_write_controller.dart'; -class PostMetaResult { - final String title; - final String description; +class PostMetaEditor extends StatelessWidget { + final PostWriteController controller; + const PostMetaEditor({super.key, required this.controller}); - PostMetaResult({required this.title, required this.description}); -} - -class PostMetaEditor extends StatefulWidget { - final String? initialTitle; - final String? initialDescription; - const PostMetaEditor({super.key, this.initialTitle, this.initialDescription}); - - @override - State createState() => _PostMetaEditorState(); -} - -class _PostMetaEditorState extends State { - final TextEditingController _titleController = TextEditingController(); - final TextEditingController _descriptionController = TextEditingController(); - - void _applyChanges() { - Navigator.pop( - context, - PostMetaResult( - title: _titleController.text, - description: _descriptionController.text, + Future _selectDate( + BuildContext context, { + DateTime? initialDateTime, + }) async { + DateTime? picked; + await showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => Container( + height: 216, + padding: const EdgeInsets.only(top: 6.0), + margin: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + color: Theme.of(context).colorScheme.surface, + child: SafeArea( + top: false, + child: CupertinoDatePicker( + initialDateTime: initialDateTime, + mode: CupertinoDatePickerMode.dateAndTime, + use24hFormat: true, + onDateTimeChanged: (DateTime newDate) { + picked = newDate; + }, + ), + ), ), ); - } - - @override - void initState() { - super.initState(); - _titleController.text = widget.initialTitle ?? ''; - _descriptionController.text = widget.initialDescription ?? ''; - } - - @override - void dispose() { - _titleController.dispose(); - _descriptionController.dispose(); - super.dispose(); + return picked; } @override Widget build(BuildContext context) { - return Column( - children: [ - TextField( - controller: _titleController, - decoration: InputDecoration( - labelText: 'fieldPostTitle'.tr(), - border: UnderlineInputBorder(), - ), - onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), - ), - const Gap(4), - TextField( - controller: _descriptionController, - maxLines: null, - decoration: InputDecoration( - labelText: 'fieldPostDescription'.tr(), - border: UnderlineInputBorder(), - ), - onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), - ), - const Gap(12), - Row( - mainAxisAlignment: MainAxisAlignment.end, + final dateFormatter = DateFormat('y/M/d HH:mm:ss'); + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + return Column( children: [ - ElevatedButton.icon( - onPressed: _applyChanges, - icon: const Icon(Symbols.save), - label: Text('apply').tr(), + TextField( + controller: controller.titleController, + decoration: InputDecoration( + labelText: 'fieldPostTitle'.tr(), + border: UnderlineInputBorder(), + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ).padding(horizontal: 24), + if (controller.mode == 'article') const Gap(4), + if (controller.mode == 'article') + TextField( + controller: controller.descriptionController, + maxLines: null, + decoration: InputDecoration( + labelText: 'fieldPostDescription'.tr(), + border: UnderlineInputBorder(), + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ).padding(horizontal: 24), + const Gap(12), + ListTile( + leading: const Icon(Symbols.event_available), + title: Text('postPublishedAt').tr(), + subtitle: Text( + controller.publishedAt != null + ? dateFormatter.format(controller.publishedAt!) + : 'unset'.tr(), + ), + trailing: controller.publishedAt != null + ? IconButton( + icon: const Icon(Symbols.cancel), + onPressed: () { + controller.setPublishedAt(null); + }, + ) + : null, + contentPadding: const EdgeInsets.only(left: 24, right: 18), + onTap: () { + _selectDate( + context, + initialDateTime: controller.publishedAt, + ).then((value) { + controller.setPublishedAt(value); + }); + }, + ), + ListTile( + leading: const Icon(Symbols.event_busy), + title: Text('postPublishedUntil').tr(), + subtitle: Text( + controller.publishedUntil != null + ? dateFormatter.format(controller.publishedUntil!) + : 'unset'.tr(), + ), + trailing: controller.publishedUntil != null + ? IconButton( + icon: const Icon(Symbols.cancel), + onPressed: () { + controller.setPublishedUntil(null); + }, + ) + : null, + contentPadding: const EdgeInsets.only(left: 24, right: 18), + onTap: () { + _selectDate( + context, + initialDateTime: controller.publishedUntil, + ).then((value) { + controller.setPublishedUntil(value); + }); + }, ), ], - ) - ], - ).padding(horizontal: 24, vertical: 8); + ).padding(vertical: 8); + }, + ); } } diff --git a/lib/widgets/post/post_mini_editor.dart b/lib/widgets/post/post_mini_editor.dart new file mode 100644 index 0000000..3403fcb --- /dev/null +++ b/lib/widgets/post/post_mini_editor.dart @@ -0,0 +1,10 @@ +import 'package:flutter/widgets.dart'; + +class PostMiniEditor extends StatelessWidget { + const PostMiniEditor({super.key}); + + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} diff --git a/pubspec.lock b/pubspec.lock index 9bab929..188d160 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,23 +5,23 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" + sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 url: "https://pub.dev" source: hosted - version: "76.0.0" + version: "72.0.0" _macros: dependency: transitive description: dart source: sdk - version: "0.3.3" + version: "0.3.2" analyzer: dependency: transitive description: name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" + sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 url: "https://pub.dev" source: hosted - version: "6.11.0" + version: "6.7.0" animations: dependency: "direct main" description: @@ -202,10 +202,10 @@ packages: dependency: "direct main" description: name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.18.0" connectivity_plus: dependency: transitive description: @@ -790,18 +790,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.8" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -838,10 +838,10 @@ packages: dependency: transitive description: name: macros - sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" + sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" url: "https://pub.dev" source: hosted - version: "0.1.3-main.0" + version: "0.1.2-main.4" markdown: dependency: "direct main" description: @@ -1150,7 +1150,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.0" + version: "0.0.99" source_gen: dependency: transitive description: @@ -1227,10 +1227,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.11.1" stream_channel: dependency: transitive description: @@ -1251,10 +1251,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.2.0" styled_widget: dependency: "direct main" description: @@ -1291,10 +1291,10 @@ packages: dependency: transitive description: name: test_api - sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.3" + version: "0.7.2" timing: dependency: transitive description: @@ -1411,10 +1411,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.3.0" + version: "14.2.5" watcher: dependency: transitive description: