✨ Post reactions
This commit is contained in:
35
lib/exts.dart
Normal file
35
lib/exts.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
extension SolianExtenions on BuildContext {
|
||||
void showSnackbar(String content) {
|
||||
ScaffoldMessenger.of(this).showSnackBar(SnackBar(
|
||||
content: Text(content),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> showErrorDialog(dynamic exception) {
|
||||
String formatMessage(dynamic exception) {
|
||||
final message = exception.toString();
|
||||
if (message.trim().isEmpty) return '';
|
||||
return message
|
||||
.split(' ')
|
||||
.map((element) => '${element[0].toUpperCase()}${element.substring(1).toLowerCase()}')
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
return showDialog<void>(
|
||||
context: this,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text('errorHappened'.tr),
|
||||
content: Text(formatMessage(exception)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text('okay'.tr),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/services.dart';
|
||||
@@ -29,7 +30,7 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||
if (messages.last.contains('risk')) {
|
||||
final ticketId = RegExp(r'ticketId=(\d+)').firstMatch(messages.last);
|
||||
if (ticketId == null) {
|
||||
Get.snackbar('errorHappened'.tr, 'Requested to multi-factor authenticate, but the ticket id was not found');
|
||||
context.showErrorDialog('Requested to multi-factor authenticate, but the ticket id was not found');
|
||||
}
|
||||
showDialog(
|
||||
context: context,
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/services.dart';
|
||||
|
||||
@@ -54,7 +55,7 @@ class _SignUpScreenState extends State<SignUpScreen> {
|
||||
AppRouter.instance.replaceNamed('auth.sign-in');
|
||||
});
|
||||
} else {
|
||||
Get.snackbar('errorHappened'.tr, resp.bodyString!);
|
||||
context.showErrorDialog(resp.bodyString);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,11 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/services.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:solian/shells/nav_shell.dart' as shell;
|
||||
import 'package:solian/widgets/attachments/attachment_publish.dart';
|
||||
|
||||
class PostPublishingScreen extends StatefulWidget {
|
||||
const PostPublishingScreen({super.key});
|
||||
@@ -19,6 +21,19 @@ class _PostPublishingScreenState extends State<PostPublishingScreen> {
|
||||
|
||||
bool _isSubmitting = false;
|
||||
|
||||
List<String> _attachments = List.empty();
|
||||
|
||||
void showAttachments(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => AttachmentPublishingPopup(
|
||||
usage: 'i.attachment',
|
||||
current: _attachments,
|
||||
onUpdate: (value) => _attachments = value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void applyPost() async {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (!await auth.isAuthorized) return;
|
||||
@@ -32,9 +47,10 @@ class _PostPublishingScreenState extends State<PostPublishingScreen> {
|
||||
|
||||
final resp = await client.post('/api/posts', {
|
||||
'content': _contentController.value.text,
|
||||
'attachments': _attachments,
|
||||
});
|
||||
if (resp.statusCode != 200) {
|
||||
Get.snackbar('errorHappened'.tr, resp.bodyString!);
|
||||
context.showErrorDialog(resp.bodyString);
|
||||
} else {
|
||||
AppRouter.instance.pop(resp.body);
|
||||
}
|
||||
@@ -107,7 +123,7 @@ class _PostPublishingScreenState extends State<PostPublishingScreen> {
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(shape: const CircleBorder()),
|
||||
child: const Icon(Icons.camera_alt),
|
||||
onPressed: () {},
|
||||
onPressed: () => showAttachments(context),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
@@ -4,6 +4,7 @@ class SolianMessages extends Translations {
|
||||
@override
|
||||
Map<String, Map<String, String>> get keys => {
|
||||
'en_US': {
|
||||
'okay': 'Okay',
|
||||
'next': 'Next',
|
||||
'page': 'Page',
|
||||
'home': 'Home',
|
||||
@@ -32,9 +33,16 @@ class SolianMessages extends Translations {
|
||||
'postReaction': 'Reactions of the Post',
|
||||
'reactAdd': 'React',
|
||||
'reactCompleted': 'Your reaction has been added',
|
||||
'reactUncompleted': 'Your reaction has been removed'
|
||||
'reactUncompleted': 'Your reaction has been removed',
|
||||
'attachmentAdd': 'Attach attachments',
|
||||
'attachmentAddGalleryPhoto': 'Gallery photo',
|
||||
'attachmentAddGalleryVideo': 'Gallery video',
|
||||
'attachmentAddCameraPhoto': 'Capture photo',
|
||||
'attachmentAddCameraVideo': 'Capture video',
|
||||
'attachmentAddFile': 'Attach file',
|
||||
},
|
||||
'zh_CN': {
|
||||
'okay': '确认',
|
||||
'next': '下一步',
|
||||
'page': '页面',
|
||||
'home': '首页',
|
||||
@@ -62,7 +70,13 @@ class SolianMessages extends Translations {
|
||||
'postReaction': '帖子的反应',
|
||||
'reactAdd': '作出反应',
|
||||
'reactCompleted': '你的反应已被添加',
|
||||
'reactUncompleted': '你的反应已被移除'
|
||||
'reactUncompleted': '你的反应已被移除',
|
||||
'attachmentAdd': '附加附件',
|
||||
'attachmentAddGalleryPhoto': '相册照片',
|
||||
'attachmentAddGalleryVideo': '相册视频',
|
||||
'attachmentAddCameraPhoto': '拍摄图片',
|
||||
'attachmentAddCameraVideo': '拍摄视频',
|
||||
'attachmentAddFile': '附加文件',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
315
lib/widgets/attachments/attachment_publish.dart
Normal file
315
lib/widgets/attachments/attachment_publish.dart
Normal file
@@ -0,0 +1,315 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path/path.dart';
|
||||
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/services.dart';
|
||||
|
||||
Future<String> calculateFileSha256(File file) async {
|
||||
final bytes = await file.readAsBytes();
|
||||
final digest = sha256.convert(bytes);
|
||||
return digest.toString();
|
||||
}
|
||||
|
||||
class AttachmentPublishingPopup extends StatefulWidget {
|
||||
final String usage;
|
||||
final List<String> current;
|
||||
final void Function(List<String> data) onUpdate;
|
||||
|
||||
const AttachmentPublishingPopup({
|
||||
super.key,
|
||||
required this.usage,
|
||||
required this.current,
|
||||
required this.onUpdate,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AttachmentPublishingPopup> createState() => _AttachmentPublishingPopupState();
|
||||
}
|
||||
|
||||
class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> {
|
||||
final _imagePicker = ImagePicker();
|
||||
|
||||
bool _isSubmitting = false;
|
||||
|
||||
final List<Attachment> _attachments = List.empty(growable: true);
|
||||
|
||||
Future<void> pickPhotoToUpload() async {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (!await auth.isAuthorized) return;
|
||||
|
||||
final medias = await _imagePicker.pickMultiImage();
|
||||
if (medias.isEmpty) return;
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
for (final media in medias) {
|
||||
final file = File(media.path);
|
||||
final hash = await calculateFileSha256(file);
|
||||
final image = await decodeImageFromList(await file.readAsBytes());
|
||||
final ratio = image.width / image.height;
|
||||
|
||||
try {
|
||||
await uploadAttachment(file, hash, ratio: ratio);
|
||||
} catch (err) {
|
||||
this.context.showErrorDialog(err);
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
|
||||
Future<void> pickVideoToUpload() async {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (!await auth.isAuthorized) return;
|
||||
|
||||
final media = await _imagePicker.pickVideo(source: ImageSource.gallery);
|
||||
if (media == null) return;
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
final file = File(media.path);
|
||||
final hash = await calculateFileSha256(file);
|
||||
const ratio = 16 / 9; // TODO Calculate ratio of video
|
||||
|
||||
try {
|
||||
await uploadAttachment(file, hash, ratio: ratio);
|
||||
} catch (err) {
|
||||
this.context.showErrorDialog(err);
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
|
||||
Future<void> pickFileToUpload() async {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (!await auth.isAuthorized) return;
|
||||
|
||||
FilePickerResult? result = await FilePicker.platform.pickFiles(allowMultiple: true);
|
||||
if (result == null) return;
|
||||
|
||||
List<File> files = result.paths.map((path) => File(path!)).toList();
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
for (final file in files) {
|
||||
final hash = await calculateFileSha256(file);
|
||||
try {
|
||||
await uploadAttachment(file, hash);
|
||||
} catch (err) {
|
||||
this.context.showErrorDialog(err);
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
|
||||
Future<void> takeMediaToUpload(bool isVideo) async {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (!await auth.isAuthorized) return;
|
||||
|
||||
XFile? media;
|
||||
if (isVideo) {
|
||||
media = await _imagePicker.pickVideo(source: ImageSource.camera);
|
||||
} else {
|
||||
media = await _imagePicker.pickImage(source: ImageSource.camera);
|
||||
}
|
||||
if (media == null) return;
|
||||
|
||||
setState(() => _isSubmitting = 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());
|
||||
ratio = image.width / image.height;
|
||||
}
|
||||
|
||||
try {
|
||||
await uploadAttachment(file, hash, ratio: ratio);
|
||||
} catch (err) {
|
||||
this.context.showErrorDialog(err);
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
|
||||
Future<void> uploadAttachment(File file, String hash, {double? ratio}) async {
|
||||
final AuthProvider auth = Get.find();
|
||||
|
||||
final client = GetConnect();
|
||||
client.httpClient.baseUrl = ServiceFinder.services['paperclip'];
|
||||
client.httpClient.addAuthenticator(auth.reqAuthenticator);
|
||||
|
||||
final filePayload = MultipartFile(await file.readAsBytes(), filename: basename(file.path));
|
||||
final fileAlt = basename(file.path).contains('.')
|
||||
? basename(file.path).substring(0, basename(file.path).lastIndexOf('.'))
|
||||
: basename(file.path);
|
||||
|
||||
final resp = await client.post(
|
||||
'/api/attachments',
|
||||
FormData({
|
||||
'alt': fileAlt,
|
||||
'file': filePayload,
|
||||
'hash': hash,
|
||||
'usage': widget.usage,
|
||||
'metadata': {
|
||||
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());
|
||||
} else {
|
||||
throw Exception(resp.bodyString);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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;
|
||||
final dm = decimals < 0 ? 0 : decimals;
|
||||
final sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
|
||||
final i = (math.log(bytes) / math.log(k)).floor().toInt();
|
||||
return '${(bytes / math.pow(k, i)).toStringAsFixed(dm)} ${sizes[i]}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const density = VisualDensity(horizontal: 0, vertical: 0);
|
||||
|
||||
return SafeArea(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.85,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'attachmentAdd'.tr,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
|
||||
_isSubmitting ? 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(thickness: 0.3, height: 0.3),
|
||||
SizedBox(
|
||||
height: 64,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 0,
|
||||
alignment: WrapAlignment.center,
|
||||
runAlignment: WrapAlignment.center,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.add_photo_alternate),
|
||||
label: Text('attachmentAddGalleryPhoto'.tr),
|
||||
style: const ButtonStyle(visualDensity: density),
|
||||
onPressed: () => pickPhotoToUpload(),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.add_road),
|
||||
label: Text('attachmentAddGalleryVideo'.tr),
|
||||
style: const ButtonStyle(visualDensity: density),
|
||||
onPressed: () => pickVideoToUpload(),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.photo_camera_back),
|
||||
label: Text('attachmentAddCameraPhoto'.tr),
|
||||
style: const ButtonStyle(visualDensity: density),
|
||||
onPressed: () => takeMediaToUpload(false),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.video_camera_back_outlined),
|
||||
label: Text('attachmentAddCameraVideo'.tr),
|
||||
style: const ButtonStyle(visualDensity: density),
|
||||
onPressed: () => takeMediaToUpload(true),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.file_present_rounded),
|
||||
label: Text('attachmentAddFile'.tr),
|
||||
style: const ButtonStyle(visualDensity: density),
|
||||
onPressed: () => pickFileToUpload(),
|
||||
),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 12),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/models/post.dart';
|
||||
import 'package:solian/models/reaction.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
@@ -52,12 +53,12 @@ class _PostQuickActionState extends State<PostQuickAction> {
|
||||
});
|
||||
if (resp.statusCode == 201) {
|
||||
widget.onReact(symbol, 1);
|
||||
Get.snackbar('', 'reactCompleted'.tr);
|
||||
context.showSnackbar('reactCompleted'.tr);
|
||||
} else if (resp.statusCode == 204) {
|
||||
widget.onReact(symbol, -1);
|
||||
Get.snackbar('', 'reactUncompleted'.tr);
|
||||
context.showSnackbar('reactUncompleted'.tr);
|
||||
} else {
|
||||
Get.snackbar('errorHappened'.tr, resp.bodyString!);
|
||||
context.showErrorDialog(resp.bodyString);
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
|
Reference in New Issue
Block a user