diff --git a/lib/main.dart b/lib/main.dart index 805f9fd..0085899 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:solian/providers/auth.dart'; +import 'package:solian/providers/content/attachment_list.dart'; import 'package:solian/router.dart'; import 'package:solian/theme.dart'; import 'package:solian/translations.dart'; @@ -27,6 +28,7 @@ class SolianApp extends StatelessWidget { fallbackLocale: const Locale('en', 'US'), onInit: () { Get.lazyPut(() => AuthProvider()); + Get.lazyPut(() => AttachmentListProvider()); }, builder: (context, child) { return ScaffoldMessenger( diff --git a/lib/models/account.dart b/lib/models/account.dart index cca8e1f..da2693e 100644 --- a/lib/models/account.dart +++ b/lib/models/account.dart @@ -5,8 +5,8 @@ class Account { DateTime? deletedAt; String name; String nick; - String avatar; - String banner; + dynamic avatar; + dynamic banner; String description; String? emailAddress; int? externalId; diff --git a/lib/models/post.dart b/lib/models/post.dart index 99cc706..377b2f1 100755 --- a/lib/models/post.dart +++ b/lib/models/post.dart @@ -12,7 +12,7 @@ class Post { dynamic categories; dynamic reactions; List? replies; - List? attachments; + List? attachments; int? replyId; int? repostId; int? realmId; @@ -63,7 +63,7 @@ class Post { categories: json["categories"], reactions: json["reactions"], replies: json["replies"], - attachments: json["attachments"] != null ? List.from(json["attachments"]) : null, + attachments: json["attachments"] != null ? List.from(json["attachments"]) : null, replyId: json["reply_id"], repostId: json["repost_id"], realmId: json["realm_id"], diff --git a/lib/providers/content/attachment_item.dart b/lib/providers/content/attachment_item.dart index a7df1ec..6a85311 100644 --- a/lib/providers/content/attachment_item.dart +++ b/lib/providers/content/attachment_item.dart @@ -12,7 +12,7 @@ class AttachmentItem extends StatelessWidget { return Hero( tag: Key('a${item.uuid}'), child: Image.network( - '${ServiceFinder.services['paperclip']}/api/attachments/${item.uuid}', + '${ServiceFinder.services['paperclip']}/api/attachments/${item.id}', fit: BoxFit.cover, ), ); diff --git a/lib/providers/content/attachment_list.dart b/lib/providers/content/attachment_list.dart index 79bba9a..9de4872 100644 --- a/lib/providers/content/attachment_list.dart +++ b/lib/providers/content/attachment_list.dart @@ -7,5 +7,5 @@ class AttachmentListProvider extends GetConnect { httpClient.baseUrl = ServiceFinder.services['paperclip']; } - Future getMetadata(String uuid) => get('/api/attachments/$uuid/meta'); + Future getMetadata(int id) => get('/api/attachments/$id/meta'); } diff --git a/lib/screens/posts/publish.dart b/lib/screens/posts/publish.dart index d337992..9ccca9c 100644 --- a/lib/screens/posts/publish.dart +++ b/lib/screens/posts/publish.dart @@ -21,7 +21,7 @@ class _PostPublishingScreenState extends State { bool _isSubmitting = false; - List _attachments = List.empty(); + List _attachments = List.empty(); void showAttachments(BuildContext context) { showModalBottomSheet( diff --git a/lib/translations.dart b/lib/translations.dart index a0925bf..831b98c 100644 --- a/lib/translations.dart +++ b/lib/translations.dart @@ -8,6 +8,10 @@ class SolianMessages extends Translations { 'next': 'Next', 'page': 'Page', 'home': 'Home', + 'apply': 'Apply', + 'cancel': 'Cancel', + 'confirm': 'Confirm', + 'delete': 'Delete', 'errorHappened': 'An error occurred', 'email': 'Email', 'username': 'Username', @@ -16,6 +20,10 @@ class SolianMessages extends Translations { 'account': 'Account', 'personalize': 'Personalize', 'friend': 'Friend', + 'aspectRatio': 'Aspect Ratio', + 'aspectRatioSquare': 'Square', + 'aspectRatioPortrait': 'Portrait', + 'aspectRatioLandscape': 'Landscape', 'signin': 'Sign in', 'signinCaption': 'Sign in to create post, start a realm, message your friend and more!', 'signinRiskDetected': @@ -40,12 +48,18 @@ class SolianMessages extends Translations { 'attachmentAddCameraPhoto': 'Capture photo', 'attachmentAddCameraVideo': 'Capture video', 'attachmentAddFile': 'Attach file', + 'attachmentSetting': 'Adjust attachment', + 'attachmentAlt': 'Alternative text', }, 'zh_CN': { 'okay': '确认', 'next': '下一步', + 'cancel': '取消', + 'confirm': '确认', + 'delete': '删除', 'page': '页面', 'home': '首页', + 'apply': '应用', 'errorHappened': '发生错误了', 'email': '邮件地址', 'username': '用户名', @@ -54,6 +68,10 @@ class SolianMessages extends Translations { 'account': '账号', 'personalize': '个性化', 'friend': '好友', + 'aspectRatio': '纵横比', + 'aspectRatioSquare': '方型', + 'aspectRatioPortrait': '竖型', + 'aspectRatioLandscape': '横型', 'signin': '登录', 'signinCaption': '登录以发表帖子、文章、创建领域、和你的朋友聊天,以及获取更多功能!', 'signinRiskDetected': '检测到风险,点击下一步按钮来打开一个网页,并通过在其上面登录来通过安全检查。', @@ -77,6 +95,8 @@ class SolianMessages extends Translations { 'attachmentAddCameraPhoto': '拍摄图片', 'attachmentAddCameraVideo': '拍摄视频', 'attachmentAddFile': '附加文件', + 'attachmentSetting': '调整附件', + 'attachmentAlt': '替代文字', } }; } diff --git a/lib/widgets/account/account_avatar.dart b/lib/widgets/account/account_avatar.dart index b83fd7b..c0ab0a3 100644 --- a/lib/widgets/account/account_avatar.dart +++ b/lib/widgets/account/account_avatar.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:solian/services.dart'; class AccountAvatar extends StatelessWidget { - final String content; + final dynamic content; final Color? color; final double? radius; @@ -10,13 +10,21 @@ class AccountAvatar extends StatelessWidget { @override Widget build(BuildContext context) { - final direct = content.startsWith('http'); + bool direct = false; + bool isEmpty = content == null; + if (content is String) { + direct = content.startsWith('http'); + isEmpty = content.endsWith('/api/attachments/0'); + } return CircleAvatar( key: Key('a$content'), radius: radius, backgroundColor: color, - backgroundImage: NetworkImage(direct ? content : '${ServiceFinder.services['paperclip']}/api/attachments/$content'), + backgroundImage: !isEmpty ? NetworkImage( + direct ? content : '${ServiceFinder.services['paperclip']}/api/attachments/$content', + ) : null, + child: isEmpty ? const Icon(Icons.account_circle) : null, ); } -} \ No newline at end of file +} diff --git a/lib/widgets/attachments/attachment_list.dart b/lib/widgets/attachments/attachment_list.dart index 7d130aa..81a2f67 100644 --- a/lib/widgets/attachments/attachment_list.dart +++ b/lib/widgets/attachments/attachment_list.dart @@ -8,7 +8,7 @@ import 'package:solian/providers/content/attachment_item.dart'; import 'package:solian/providers/content/attachment_list.dart'; class AttachmentList extends StatefulWidget { - final List attachmentsId; + final List attachmentsId; const AttachmentList({super.key, required this.attachmentsId}); @@ -49,9 +49,15 @@ class _AttachmentListState extends State { } void calculateAspectRatio() { + bool isConsistent = true; + double? consistentValue; int portrait = 0, square = 0, landscape = 0; for (var entry in _attachmentsMeta) { if (entry!.metadata?['ratio'] != null) { + consistentValue ??= entry.metadata?['ratio']; + if (isConsistent && entry.metadata?['ratio'] != consistentValue) { + isConsistent = false; + } if (entry.metadata!['ratio'] > 1) { landscape++; } else if (entry.metadata!['ratio'] == 1) { @@ -61,21 +67,23 @@ class _AttachmentListState extends State { } } } - if (portrait > square && portrait > landscape) { - _aspectRatio = 9 / 16; - } - if (landscape > square && landscape > portrait) { - _aspectRatio = 16 / 9; + if (isConsistent && consistentValue != null) { + _aspectRatio = consistentValue; } else { - _aspectRatio = 1; + if (portrait > square && portrait > landscape) { + _aspectRatio = 9 / 16; + } + if (landscape > square && landscape > portrait) { + _aspectRatio = 16 / 9; + } else { + _aspectRatio = 1; + } } } @override void initState() { - Get.lazyPut(() => AttachmentListProvider()); super.initState(); - getMetadataList(); } diff --git a/lib/widgets/attachments/attachment_publish.dart b/lib/widgets/attachments/attachment_publish.dart index 9c3f7bf..049ad4b 100644 --- a/lib/widgets/attachments/attachment_publish.dart +++ b/lib/widgets/attachments/attachment_publish.dart @@ -1,4 +1,6 @@ +import 'dart:convert'; import 'dart:io'; +import 'dart:isolate'; import 'dart:math' as math; import 'package:file_picker/file_picker.dart'; @@ -11,18 +13,19 @@ import 'package:solian/exts.dart'; import 'package:solian/models/attachment.dart'; import 'package:solian/providers/auth.dart'; import 'package:crypto/crypto.dart'; +import 'package:solian/providers/content/attachment_list.dart'; import 'package:solian/services.dart'; Future calculateFileSha256(File file) async { final bytes = await file.readAsBytes(); - final digest = sha256.convert(bytes); + final digest = await Isolate.run(() => sha256.convert(bytes)); return digest.toString(); } class AttachmentPublishingPopup extends StatefulWidget { final String usage; - final List current; - final void Function(List data) onUpdate; + final List current; + final void Function(List data) onUpdate; const AttachmentPublishingPopup({ super.key, @@ -38,9 +41,10 @@ class AttachmentPublishingPopup extends StatefulWidget { class _AttachmentPublishingPopupState extends State { final _imagePicker = ImagePicker(); - bool _isSubmitting = false; + bool _isBusy = false; + bool _isFirstTimeBusy = true; - final List _attachments = List.empty(growable: true); + List _attachments = List.empty(growable: true); Future pickPhotoToUpload() async { final AuthProvider auth = Get.find(); @@ -49,7 +53,7 @@ class _AttachmentPublishingPopupState extends State { final medias = await _imagePicker.pickMultiImage(); if (medias.isEmpty) return; - setState(() => _isSubmitting = true); + setState(() => _isBusy = true); for (final media in medias) { final file = File(media.path); @@ -64,7 +68,7 @@ class _AttachmentPublishingPopupState extends State { } } - setState(() => _isSubmitting = false); + setState(() => _isBusy = false); } Future pickVideoToUpload() async { @@ -74,11 +78,11 @@ class _AttachmentPublishingPopupState extends State { final media = await _imagePicker.pickVideo(source: ImageSource.gallery); if (media == null) return; - setState(() => _isSubmitting = true); + setState(() => _isBusy = true); final file = File(media.path); final hash = await calculateFileSha256(file); - const ratio = 16 / 9; // TODO Calculate ratio of video + const ratio = 16 / 9; try { await uploadAttachment(file, hash, ratio: ratio); @@ -86,7 +90,7 @@ class _AttachmentPublishingPopupState extends State { this.context.showErrorDialog(err); } - setState(() => _isSubmitting = false); + setState(() => _isBusy = false); } Future pickFileToUpload() async { @@ -98,7 +102,7 @@ class _AttachmentPublishingPopupState extends State { List files = result.paths.map((path) => File(path!)).toList(); - setState(() => _isSubmitting = true); + setState(() => _isBusy = true); for (final file in files) { final hash = await calculateFileSha256(file); @@ -109,7 +113,7 @@ class _AttachmentPublishingPopupState extends State { } } - setState(() => _isSubmitting = false); + setState(() => _isBusy = false); } Future takeMediaToUpload(bool isVideo) async { @@ -124,14 +128,13 @@ class _AttachmentPublishingPopupState extends State { } if (media == null) return; - setState(() => _isSubmitting = true); + setState(() => _isBusy = true); double? ratio; final file = File(media.path); final hash = await calculateFileSha256(file); if (isVideo) { - // TODO Calculate ratio of video ratio = 16 / 9; } else { final image = await decodeImageFromList(await file.readAsBytes()); @@ -144,7 +147,7 @@ class _AttachmentPublishingPopupState extends State { this.context.showErrorDialog(err); } - setState(() => _isSubmitting = false); + setState(() => _isBusy = false); } Future uploadAttachment(File file, String hash, {double? ratio}) async { @@ -166,38 +169,20 @@ class _AttachmentPublishingPopupState extends State { 'file': filePayload, 'hash': hash, 'usage': widget.usage, - 'metadata': { + 'metadata': jsonEncode({ if (ratio != null) 'ratio': ratio, - }, + }), }), ); if (resp.statusCode == 200) { var result = Attachment.fromJson(resp.body); setState(() => _attachments.add(result)); - widget.onUpdate(_attachments.map((e) => e.uuid).toList()); + widget.onUpdate(_attachments.map((e) => e!.id).toList()); } else { throw Exception(resp.bodyString); } } - Future disposeAttachment(Attachment item, int index) async { - final AuthProvider auth = Get.find(); - - final client = GetConnect(); - client.httpClient.baseUrl = ServiceFinder.services['paperclip']; - client.httpClient.addAuthenticator(auth.reqAuthenticator); - - setState(() => _isSubmitting = true); - var resp = await client.delete('/api/attachments/${item.id}'); - if (resp.statusCode == 200) { - setState(() => _attachments.removeAt(index)); - widget.onUpdate(_attachments.map((e) => e.uuid).toList()); - } else { - this.context.showErrorDialog(resp.bodyString); - } - setState(() => _isSubmitting = false); - } - String formatBytes(int bytes, {int decimals = 2}) { if (bytes == 0) return '0 Bytes'; const k = 1024; @@ -207,6 +192,40 @@ class _AttachmentPublishingPopupState extends State { return '${(bytes / math.pow(k, i)).toStringAsFixed(dm)} ${sizes[i]}'; } + void revertMetadataList() { + final AttachmentListProvider provider = Get.find(); + + if (widget.current.isEmpty) { + _isFirstTimeBusy = false; + return; + } else { + _attachments = List.filled(widget.current.length, null); + } + + setState(() => _isBusy = true); + + int progress = 0; + for (var idx = 0; idx < widget.current.length; idx++) { + provider.getMetadata(widget.current[idx]).then((resp) { + progress++; + _attachments[idx] = Attachment.fromJson(resp.body); + if (progress == widget.current.length) { + setState(() { + _isBusy = false; + _isFirstTimeBusy = false; + }); + } + }); + } + } + + @override + void initState() { + super.initState(); + + revertMetadataList(); + } + @override Widget build(BuildContext context) { const density = VisualDensity(horizontal: 0, vertical: 0); @@ -221,46 +240,71 @@ class _AttachmentPublishingPopupState extends State { 'attachmentAdd'.tr, style: Theme.of(context).textTheme.headlineSmall, ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), - _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(), + _isBusy ? const LinearProgressIndicator().animate().scaleX() : Container(), Expanded( - child: ListView.builder( - itemCount: _attachments.length, - itemBuilder: (context, index) { - final element = _attachments[index]; - final fileType = element.mimetype.split('/').first; - return Container( - padding: const EdgeInsets.only(left: 16, right: 8, bottom: 16), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - element.alt, - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - Text( - '${fileType[0].toUpperCase()}${fileType.substring(1)} · ${formatBytes(element.size)}', - ), - ], - ), - ), - TextButton( - style: TextButton.styleFrom( - shape: const CircleBorder(), - foregroundColor: Colors.red, - ), - child: const Icon(Icons.delete), - onPressed: () => disposeAttachment(element, index), - ), - ], - ), + child: Builder(builder: (context) { + if (_isFirstTimeBusy && _isBusy) { + return const Center( + child: CircularProgressIndicator(), ); - }, - ), + } + + return ListView.builder( + itemCount: _attachments.length, + itemBuilder: (context, index) { + final element = _attachments[index]; + final fileType = element!.mimetype.split('/').first; + return Container( + padding: const EdgeInsets.only(left: 16, right: 8, bottom: 16), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + element.alt, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Text( + '${fileType[0].toUpperCase()}${fileType.substring(1)} · ${formatBytes(element.size)}', + ), + ], + ), + ), + IconButton( + style: TextButton.styleFrom( + shape: const CircleBorder(), + foregroundColor: Theme.of(context).primaryColor, + ), + icon: const Icon(Icons.more_horiz), + onPressed: () { + showDialog( + context: context, + builder: (context) { + return AttachmentEditingPopup( + item: element, + onDelete: () { + setState(() => _attachments.removeAt(index)); + widget.onUpdate(_attachments.map((e) => e!.id).toList()); + }, + onUpdate: (item) { + setState(() => _attachments[index] = item); + widget.onUpdate(_attachments.map((e) => e!.id).toList()); + }, + ); + }, + ); + }, + ), + ], + ), + ); + }, + ); + }), ), const Divider(thickness: 0.3, height: 0.3), SizedBox( @@ -313,3 +357,209 @@ class _AttachmentPublishingPopupState extends State { ); } } + +class AttachmentEditingPopup extends StatefulWidget { + final Attachment item; + final Function onDelete; + final Function(Attachment item) onUpdate; + + const AttachmentEditingPopup({super.key, required this.item, required this.onDelete, required this.onUpdate}); + + @override + State createState() => _AttachmentEditingPopupState(); +} + +class _AttachmentEditingPopupState extends State { + final _ratioController = TextEditingController(); + final _altController = TextEditingController(); + + bool _isBusy = false; + bool _isMature = false; + bool _hasAspectRatio = false; + + Future applyAttachment() async { + final AuthProvider auth = Get.find(); + + final client = GetConnect(); + client.httpClient.baseUrl = ServiceFinder.services['paperclip']; + client.httpClient.addAuthenticator(auth.reqAuthenticator); + + setState(() => _isBusy = true); + var resp = await client.put('/api/attachments/${widget.item.id}', { + 'metadata': { + if (_hasAspectRatio) 'ratio': double.tryParse(_ratioController.value.text) ?? 1, + }, + 'alt': _altController.value.text, + 'usage': widget.item.usage, + 'is_mature': _isMature, + }); + + setState(() => _isBusy = false); + + if (resp.statusCode != 200) { + this.context.showErrorDialog(resp.bodyString); + return null; + } else { + return Attachment.fromJson(resp.body); + } + } + + Future deleteAttachment() async { + final AuthProvider auth = Get.find(); + + final client = GetConnect(); + client.httpClient.baseUrl = ServiceFinder.services['paperclip']; + client.httpClient.addAuthenticator(auth.reqAuthenticator); + + setState(() => _isBusy = true); + var resp = await client.delete('/api/attachments/${widget.item.id}'); + if (resp.statusCode == 200) { + widget.onDelete(); + } else { + this.context.showErrorDialog(resp.bodyString); + } + setState(() => _isBusy = false); + } + + void syncWidget() { + _isMature = widget.item.isMature; + _altController.text = widget.item.alt; + + if (['image', 'video'].contains(widget.item.mimetype.split('/').first)) { + _ratioController.text = widget.item.metadata?['ratio']?.toString() ?? 1.toString(); + _hasAspectRatio = true; + } + } + + @override + void initState() { + syncWidget(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('attachmentSetting'.tr), + content: Container( + constraints: const BoxConstraints(minWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _isBusy + ? ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: const LinearProgressIndicator().animate().scaleX(), + ) + : Container(), + const SizedBox(height: 18), + TextField( + controller: _altController, + decoration: InputDecoration( + isDense: true, + prefixIcon: const Icon(Icons.image_not_supported), + border: const OutlineInputBorder(), + labelText: 'attachmentAlt'.tr, + ), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + const SizedBox(height: 16), + TextField( + readOnly: !_hasAspectRatio, + controller: _ratioController, + decoration: InputDecoration( + isDense: true, + prefixIcon: const Icon(Icons.aspect_ratio), + border: const OutlineInputBorder(), + labelText: 'aspectRatio'.tr, + ), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + const SizedBox(height: 5), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Wrap( + spacing: 6, + runSpacing: 0, + children: [ + ActionChip( + avatar: Icon(Icons.square_rounded, color: Theme.of(context).colorScheme.onSurfaceVariant), + label: Text('aspectRatioSquare'.tr), + onPressed: () { + if (_hasAspectRatio) { + setState(() => _ratioController.text = '1'); + } + }, + ), + ActionChip( + avatar: Icon(Icons.portrait, color: Theme.of(context).colorScheme.onSurfaceVariant), + label: Text('aspectRatioPortrait'.tr), + onPressed: () { + if (_hasAspectRatio) { + setState(() => _ratioController.text = (9 / 16).toString()); + } + }, + ), + ActionChip( + avatar: Icon(Icons.landscape, color: Theme.of(context).colorScheme.onSurfaceVariant), + label: Text('aspectRatioLandscape'.tr), + onPressed: () { + if (_hasAspectRatio) { + setState(() => _ratioController.text = (16 / 9).toString()); + } + }, + ), + ], + ), + ), + Card( + child: CheckboxListTile( + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), + title: Text('matureContent'.tr), + secondary: const Icon(Icons.visibility_off), + value: _isMature, + onChanged: (newValue) { + setState(() => _isMature = newValue ?? false); + }, + controlAffinity: ListTileControlAffinity.leading, + ), + ), + ], + ), + ), + actionsAlignment: MainAxisAlignment.spaceBetween, + actions: [ + TextButton( + style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error), + onPressed: () { + deleteAttachment().then((_) { + Navigator.pop(context); + }); + }, + child: Text('delete'.tr), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant), + onPressed: () => Navigator.pop(context), + child: Text('cancel'.tr), + ), + TextButton( + child: Text('apply'.tr), + onPressed: () { + applyAttachment().then((value) { + if (value != null) { + widget.onUpdate(value); + Navigator.pop(context); + } + }); + }, + ), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/posts/post_item.dart b/lib/widgets/posts/post_item.dart index 2ab31be..63787fd 100644 --- a/lib/widgets/posts/post_item.dart +++ b/lib/widgets/posts/post_item.dart @@ -35,7 +35,7 @@ class _PostItemState extends State { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - AccountAvatar(content: item.author.avatar), + AccountAvatar(content: item.author.avatar.toString()), Expanded( child: Column( children: [