From 52304a7633273291dc4f5bf64771e9c29685151f Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 24 Mar 2024 21:55:20 +0800 Subject: [PATCH] :sparkles: Upload photos --- lib/screens/publish/attachment_list.dart | 212 +++++++++++++++++++++++ lib/screens/publish/comment_editor.dart | 1 - lib/screens/publish/moment_editor.dart | 20 ++- pubspec.lock | 10 +- pubspec.yaml | 2 + 5 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 lib/screens/publish/attachment_list.dart diff --git a/lib/screens/publish/attachment_list.dart b/lib/screens/publish/attachment_list.dart new file mode 100644 index 0000000..f6afb23 --- /dev/null +++ b/lib/screens/publish/attachment_list.dart @@ -0,0 +1,212 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:solaragent/auth.dart'; +import 'package:solaragent/models/feed.dart'; + +class AttachmentList extends StatefulWidget { + final List current; + final void Function(List data) onUpdate; + + const AttachmentList( + {super.key, required this.current, required this.onUpdate}); + + @override + State createState() => _AttachmentListState(); +} + +class _AttachmentListState extends State { + final imagePicker = ImagePicker(); + + bool isSubmitting = false; + + List attachments = List.empty(growable: true); + + Future pickImageToUpload(BuildContext context) async { + if (!await authClient.isAuthorized()) return; + + final image = await imagePicker.pickImage(source: ImageSource.gallery); + if (image == null) return; + + final file = File(image.path); + final hashcode = await calculateSha256(file); + + try { + await uploadAttachment(file, hashcode); + } catch (err) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Something went wrong... $err")), + ); + } + } + + Future uploadAttachment(File file, String hashcode) async { + var req = MultipartRequest( + 'POST', + Uri.parse('https://co.solsynth.dev/api/attachments'), + ); + req.files.add(await MultipartFile.fromPath('attachment', file.path)); + req.fields['hashcode'] = hashcode; + + setState(() => isSubmitting = true); + var res = await authClient.client!.send(req); + if (res.statusCode == 200) { + var result = Attachment.fromJson( + jsonDecode(utf8.decode(await res.stream.toBytes()))["info"], + ); + setState(() => attachments.add(result)); + widget.onUpdate(attachments); + } else { + throw Exception(utf8.decode(await res.stream.toBytes())); + } + setState(() => isSubmitting = false); + } + + Future disposeAttachment( + BuildContext context, Attachment item, int index) async { + var req = MultipartRequest( + 'DELETE', + Uri.parse('https://co.solsynth.dev/api/attachments/${item.id}'), + ); + + setState(() => isSubmitting = true); + var res = await authClient.client!.send(req); + if (res.statusCode == 200) { + setState(() => attachments.removeAt(index)); + widget.onUpdate(attachments); + } else { + final err = utf8.decode(await res.stream.toBytes()); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Something went wrong... $err")), + ); + } + setState(() => isSubmitting = false); + } + + Future calculateSha256(File file) async { + final bytes = await file.readAsBytes(); + final digest = sha256.convert(bytes); + return digest.toString(); + } + + String getFileName(Attachment item) { + return item.filename.replaceAll(RegExp(r'\.[^/.]+$'), ''); + } + + String getFileType(Attachment item) { + switch (item.type) { + case 1: + return 'Photo'; + case 2: + return 'Video'; + case 3: + return 'Audio'; + default: + return 'Others'; + } + } + + 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 + void initState() { + attachments = widget.current; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + padding: const EdgeInsets.only(left: 10, right: 10, top: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 12.0, + ), + child: Text( + 'Attachments', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + FutureBuilder( + future: authClient.isAuthorized(), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data == true) { + return Tooltip( + message: "Add a photo", + child: TextButton( + onPressed: isSubmitting + ? null + : () => pickImageToUpload(context), + style: TextButton.styleFrom( + shape: const CircleBorder(), + ), + child: const Icon(Icons.add_photo_alternate), + ), + ); + } else { + return Container(); + } + }, + ), + ], + ), + ), + isSubmitting ? const LinearProgressIndicator() : Container(), + Expanded( + child: ListView.separated( + itemCount: attachments.length, + itemBuilder: (context, index) { + var element = attachments[index]; + return Container( + padding: const EdgeInsets.only(left: 8.0), + child: ListTile( + title: Text(getFileName(element)), + subtitle: Text( + "${getFileType(element)} ยท ${formatBytes(element.filesize)}", + ), + trailing: TextButton( + style: TextButton.styleFrom( + shape: const CircleBorder(), + foregroundColor: Colors.red, + ), + child: const Icon(Icons.delete), + onPressed: () => disposeAttachment(context, element, index), + ), + ), + ); + }, + separatorBuilder: (context, index) => const Divider(), + ), + ), + ], + ); + } +} diff --git a/lib/screens/publish/comment_editor.dart b/lib/screens/publish/comment_editor.dart index 6f44abc..91b5d6f 100644 --- a/lib/screens/publish/comment_editor.dart +++ b/lib/screens/publish/comment_editor.dart @@ -1,6 +1,5 @@ import 'dart:convert'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:solaragent/auth.dart'; diff --git a/lib/screens/publish/moment_editor.dart b/lib/screens/publish/moment_editor.dart index f82f97f..5f456ee 100644 --- a/lib/screens/publish/moment_editor.dart +++ b/lib/screens/publish/moment_editor.dart @@ -1,11 +1,11 @@ import 'dart:convert'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:image_picker/image_picker.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:solaragent/auth.dart'; +import 'package:solaragent/models/feed.dart'; import 'package:solaragent/router.dart'; +import 'package:solaragent/screens/publish/attachment_list.dart'; import 'package:url_launcher/url_launcher.dart'; class MomentEditorScreen extends StatefulWidget { @@ -16,13 +16,14 @@ class MomentEditorScreen extends StatefulWidget { } class _MomentEditorScreenState extends State { - final picker = ImagePicker(); final contentController = TextEditingController(); bool isSubmitting = false; bool showRecommendationBanner = true; + List attachments = List.empty(growable: true); + Future postMoment() async { if (!await authClient.isAuthorized()) return; @@ -34,6 +35,7 @@ class _MomentEditorScreenState extends State { }, body: jsonEncode({ 'content': contentController.value.text, + 'attachments': attachments, }), ); if (res.statusCode != 200) { @@ -49,6 +51,16 @@ class _MomentEditorScreenState extends State { setState(() => isSubmitting = false); } + void viewAttachments(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (context) => AttachmentList( + current: attachments, + onUpdate: (value) => attachments = value, + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -112,9 +124,9 @@ class _MomentEditorScreenState extends State { child: Row( children: [ TextButton( - onPressed: null, style: TextButton.styleFrom(shape: const CircleBorder()), child: const Icon(Icons.camera_alt), + onPressed: () => viewAttachments(context), ) ], ), diff --git a/pubspec.lock b/pubspec.lock index d8bead5..2519c0e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -90,7 +90,7 @@ packages: source: hosted version: "0.3.3+8" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab @@ -129,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: d1d0ac3966b36dc3e66eeefb40280c17feb87fa2099c6e22e6a1fc959327bd03 + url: "https://pub.dev" + source: hosted + version: "8.0.0+1" file_selector_linux: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2020771..7c026c1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,8 @@ dependencies: flutter_carousel_widget: ^2.2.0 image_picker: ^1.0.7 sentry_flutter: ^7.18.0 + crypto: ^3.0.3 + file_picker: ^8.0.0+1 dev_dependencies: flutter_test: