diff --git a/lib/main.dart b/lib/main.dart index 0085899..120b324 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +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/providers/content/attachment.dart'; import 'package:solian/router.dart'; import 'package:solian/theme.dart'; import 'package:solian/translations.dart'; @@ -28,7 +28,7 @@ class SolianApp extends StatelessWidget { fallbackLocale: const Locale('en', 'US'), onInit: () { Get.lazyPut(() => AuthProvider()); - Get.lazyPut(() => AttachmentListProvider()); + Get.lazyPut(() => AttachmentProvider()); }, builder: (context, child) { return ScaffoldMessenger( diff --git a/lib/providers/content/attachment.dart b/lib/providers/content/attachment.dart new file mode 100644 index 0000000..427962a --- /dev/null +++ b/lib/providers/content/attachment.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:crypto/crypto.dart'; +import 'package:get/get.dart'; +import 'package:path/path.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/services.dart'; +import 'package:image/image.dart' as img; + +Future calculateFileSha256(File file) async { + final bytes = await Isolate.run(() => file.readAsBytesSync()); + final digest = await Isolate.run(() => sha256.convert(bytes)); + return digest.toString(); +} + +Future calculateFileAspectRatio(File file) async { + final bytes = await Isolate.run(() => file.readAsBytesSync()); + final decoder = await Isolate.run(() => img.findDecoderForData(bytes)); + if (decoder == null) return 1; + final image = await Isolate.run(() => decoder.decode(bytes)); + if (image == null) return 1; + return image.width / image.height; +} + +class AttachmentProvider extends GetConnect { + @override + void onInit() { + httpClient.baseUrl = ServiceFinder.services['paperclip']; + } + + Future getMetadata(int id) => get('/api/attachments/$id/meta'); + + Future createAttachment(File file, String hash, String usage, + {double? ratio}) async { + final AuthProvider auth = Get.find(); + if (!await auth.isAuthorized) throw Exception('unauthorized'); + + 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': usage, + 'metadata': jsonEncode({ + if (ratio != null) 'ratio': ratio, + }), + }), + ); + if (resp.statusCode == 200) { + return resp; + } + + throw Exception(resp.bodyString); + } + + Future updateAttachment( + int id, + String alt, + String usage, { + double? ratio, + bool isMature = false, + }) async { + final AuthProvider auth = Get.find(); + if (!await auth.isAuthorized) throw Exception('unauthorized'); + + final client = GetConnect(); + client.httpClient.baseUrl = ServiceFinder.services['paperclip']; + client.httpClient.addAuthenticator(auth.reqAuthenticator); + + var resp = await client.put('/api/attachments/$id', { + 'metadata': { + if (ratio != null) 'ratio': ratio, + }, + 'alt': alt, + 'usage': usage, + 'is_mature': isMature, + }); + + if (resp.statusCode != 200) { + throw Exception(resp.bodyString); + } + + return resp.body; + } + + Future deleteAttachment(int id) async { + final AuthProvider auth = Get.find(); + if (!await auth.isAuthorized) throw Exception('unauthorized'); + + final client = GetConnect(); + client.httpClient.baseUrl = ServiceFinder.services['paperclip']; + client.httpClient.addAuthenticator(auth.reqAuthenticator); + + var resp = await client.delete('/api/attachments/$id'); + if (resp.statusCode != 200) { + throw Exception(resp.bodyString); + } + + return resp; + } +} diff --git a/lib/providers/content/attachment_list.dart b/lib/providers/content/attachment_list.dart deleted file mode 100644 index 9de4872..0000000 --- a/lib/providers/content/attachment_list.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:get/get.dart'; -import 'package:solian/services.dart'; - -class AttachmentListProvider extends GetConnect { - @override - void onInit() { - httpClient.baseUrl = ServiceFinder.services['paperclip']; - } - - Future getMetadata(int id) => get('/api/attachments/$id/meta'); -} diff --git a/lib/router.dart b/lib/router.dart index e1d91a0..c0cf8bd 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -1,9 +1,11 @@ import 'package:go_router/go_router.dart'; import 'package:solian/screens/account.dart'; +import 'package:solian/screens/account/personalize.dart'; import 'package:solian/screens/auth/signin.dart'; import 'package:solian/screens/auth/signup.dart'; import 'package:solian/screens/home.dart'; import 'package:solian/screens/posts/publish.dart'; +import 'package:solian/shells/basic_shell.dart'; import 'package:solian/shells/nav_shell.dart'; abstract class AppRouter { @@ -14,30 +16,41 @@ abstract class AppRouter { NavShell(state: state, child: child), routes: [ GoRoute( - path: "/", - name: "home", + path: '/', + name: 'home', builder: (context, state) => const HomeScreen(), ), GoRoute( - path: "/account", - name: "account", + path: '/account', + name: 'account', builder: (context, state) => const AccountScreen(), ), + ], + ), + ShellRoute( + builder: (context, state, child) => + BasicShell(state: state, child: child), + routes: [ GoRoute( - path: "/auth/sign-in", - name: "signin", + path: '/account/personalize', + name: 'accountPersonalize', + builder: (context, state) => const PersonalizeScreen(), + ), + GoRoute( + path: '/auth/sign-in', + name: 'signin', builder: (context, state) => const SignInScreen(), ), GoRoute( - path: "/auth/sign-up", - name: "signup", + path: '/auth/sign-up', + name: 'signup', builder: (context, state) => const SignUpScreen(), ), ], ), GoRoute( - path: "/posts/publish", - name: "postPublishing", + path: '/posts/publish', + name: 'postPublishing', builder: (context, state) { final arguments = state.extra as PostPublishingArguments?; return PostPublishingScreen( diff --git a/lib/screens/account.dart b/lib/screens/account.dart index 1d08a83..cea3591 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/router.dart'; +import 'package:solian/theme.dart'; import 'package:solian/widgets/account/account_avatar.dart'; class AccountScreen extends StatefulWidget { @@ -15,8 +16,12 @@ class _AccountScreenState extends State { @override Widget build(BuildContext context) { final actionItems = [ - // (const Icon(Icons.color_lens), 'personalize'.tr, 'account.personalize'), - // (const Icon(Icons.diversity_1), 'friend'.tr, 'account.friend'), + ( + const Icon(Icons.color_lens), + 'accountPersonalize'.tr, + 'accountPersonalize' + ), + (const Icon(Icons.diversity_1), 'accountFriend'.tr, 'accountFriend'), ]; final AuthProvider provider = Get.find(); @@ -101,14 +106,19 @@ class AccountNameCard extends StatelessWidget { } return Material( - elevation: 2, - child: ListTile( - contentPadding: - const EdgeInsets.only(left: 22, right: 34, top: 4, bottom: 4), - leading: AccountAvatar( - content: snapshot.data!.body?['avatar'], radius: 24), - title: Text(snapshot.data!.body?['nick']), - subtitle: Text(snapshot.data!.body?['email']), + child: Card( + child: ListTile( + contentPadding: + const EdgeInsets.only(left: 22, right: 34, top: 4, bottom: 4), + leading: AccountAvatar( + content: snapshot.data!.body?['avatar'], radius: 24), + title: Text(snapshot.data!.body?['nick']), + subtitle: Text(snapshot.data!.body?['email']), + ), + ).paddingOnly( + left: 16, + right: 16, + top: SolianTheme.isLargeScreen(context) ? 8 : 0, ), ); }, diff --git a/lib/screens/account/personalize.dart b/lib/screens/account/personalize.dart new file mode 100644 index 0000000..a7edb9e --- /dev/null +++ b/lib/screens/account/personalize.dart @@ -0,0 +1,294 @@ +import 'dart:io'; + +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:intl/intl.dart'; +import 'package:solian/exts.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/providers/content/attachment.dart'; +import 'package:solian/services.dart'; +import 'package:solian/widgets/account/account_avatar.dart'; + +class PersonalizeScreen extends StatefulWidget { + const PersonalizeScreen({super.key}); + + @override + State createState() => _PersonalizeScreenState(); +} + +class _PersonalizeScreenState extends State { + final _imagePicker = ImagePicker(); + + final _usernameController = TextEditingController(); + final _nicknameController = TextEditingController(); + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + final _descriptionController = TextEditingController(); + final _birthdayController = TextEditingController(); + + int? _avatar; + int? _banner; + DateTime? _birthday; + + bool _isBusy = false; + + void selectBirthday() async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _birthday, + firstDate: DateTime(DateTime.now().year - 200), + lastDate: DateTime(DateTime.now().year + 200), + ); + if (picked != null && picked != _birthday) { + setState(() { + _birthday = picked; + _birthdayController.text = + DateFormat('yyyy-MM-dd hh:mm').format(_birthday!); + }); + } + } + + void syncWidget() async { + setState(() => _isBusy = true); + + final AuthProvider auth = Get.find(); + final prof = await auth.getProfile(noCache: true); + setState(() { + _usernameController.text = prof.body['name']; + _nicknameController.text = prof.body['nick']; + _descriptionController.text = prof.body['description']; + _firstNameController.text = prof.body['profile']['first_name']; + _lastNameController.text = prof.body['profile']['last_name']; + _avatar = prof.body['avatar']; + _banner = prof.body['banner']; + if (prof.body['profile']['birthday'] != null) { + _birthday = DateTime.parse(prof.body['profile']['birthday']); + _birthdayController.text = + DateFormat('yyyy-MM-dd hh:mm').format(_birthday!); + } + + _isBusy = false; + }); + } + + Future updateImage(String position) async { + final AuthProvider auth = Get.find(); + if (!await auth.isAuthorized) return; + + final image = await _imagePicker.pickImage(source: ImageSource.gallery); + if (image == null) return; + + setState(() => _isBusy = true); + + final AttachmentProvider provider = Get.find(); + + late Response attachResp; + try { + final file = File(image.path); + final hash = await calculateFileSha256(file); + attachResp = await provider.createAttachment( + file, + hash, + 'p.$position', + ratio: await calculateFileAspectRatio(file), + ); + } catch (e) { + setState(() => _isBusy = false); + context.showErrorDialog(e); + } + + final client = GetConnect(); + client.httpClient.baseUrl = ServiceFinder.services['passport']; + client.httpClient.addAuthenticator(auth.reqAuthenticator); + + final resp = await client.put( + '/api/users/me/$position', + {'attachment': attachResp.body['id']}, + ); + if (resp.statusCode == 200) { + syncWidget(); + + context.showSnackbar('accountPersonalizeApplied'.tr); + } else { + context.showErrorDialog(resp.bodyString); + } + + setState(() => _isBusy = false); + } + + @override + void initState() { + super.initState(); + + Future.delayed(Duration.zero, () => syncWidget()); + } + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.surface, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: ListView( + children: [ + if (_isBusy) const LinearProgressIndicator().animate().scaleX(), + const SizedBox(height: 24), + Stack( + children: [ + AccountAvatar(content: _avatar, radius: 40), + Positioned( + bottom: 0, + left: 40, + child: FloatingActionButton.small( + heroTag: const Key('avatar-editor'), + onPressed: () => updateImage('avatar'), + child: const Icon( + Icons.camera, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Stack( + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: AspectRatio( + aspectRatio: 16 / 9, + child: Container( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: _banner != null + ? Image.network( + '${ServiceFinder.services['paperclip']}/api/attachments/$_banner', + fit: BoxFit.cover, + loadingBuilder: (BuildContext context, + Widget child, + ImageChunkEvent? loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != + null + ? loadingProgress + .cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ); + }, + ) + : Container(), + ), + ), + ), + Positioned( + bottom: 16, + right: 16, + child: FloatingActionButton( + heroTag: const Key('banner-editor'), + onPressed: () => updateImage('banner'), + child: const Icon( + Icons.camera_alt, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + Row( + children: [ + Flexible( + flex: 1, + child: TextField( + readOnly: true, + controller: _usernameController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'username'.tr, + prefixText: '@', + ), + ), + ), + const SizedBox(width: 16), + Flexible( + flex: 1, + child: TextField( + controller: _nicknameController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'nickname'.tr, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Flexible( + flex: 1, + child: TextField( + controller: _firstNameController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'firstName'.tr, + ), + ), + ), + const SizedBox(width: 16), + Flexible( + flex: 1, + child: TextField( + controller: _lastNameController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'lastName'.tr, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + TextField( + controller: _descriptionController, + keyboardType: TextInputType.multiline, + maxLines: null, + minLines: 3, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'description'.tr, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _birthdayController, + readOnly: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'birthday'.tr, + ), + onTap: () => selectBirthday(), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: null, + child: Text('reset'.tr), + ), + ElevatedButton( + onPressed: null, + child: Text('apply'.tr), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/posts/publish.dart b/lib/screens/posts/publish.dart index 98469ec..5fbec60 100644 --- a/lib/screens/posts/publish.dart +++ b/lib/screens/posts/publish.dart @@ -7,9 +7,9 @@ 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'; import 'package:solian/widgets/posts/post_item.dart'; +import 'package:solian/widgets/prev_page.dart'; class PostPublishingArguments { final Post? edit; @@ -122,7 +122,7 @@ class _PostPublishingScreenState extends State { child: Scaffold( appBar: AppBar( title: Text('postPublishing'.tr), - leading: const shell.BackButton(), + leading: const PrevPageButton(), actions: [ TextButton( child: Text('postAction'.tr.toUpperCase()), diff --git a/lib/shells/basic_shell.dart b/lib/shells/basic_shell.dart new file mode 100644 index 0000000..dba9a4c --- /dev/null +++ b/lib/shells/basic_shell.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:go_router/go_router.dart'; +import 'package:solian/router.dart'; +import 'package:solian/theme.dart'; +import 'package:solian/widgets/prev_page.dart'; + +class BasicShell extends StatelessWidget { + final GoRouterState state; + final Widget child; + + const BasicShell({super.key, required this.child, required this.state}); + + @override + Widget build(BuildContext context) { + final canPop = AppRouter.instance.canPop(); + + return Scaffold( + appBar: AppBar( + title: Text(state.topRoute?.name?.tr ?? 'page'.tr), + centerTitle: false, + titleSpacing: canPop ? null : 24, + elevation: SolianTheme.isLargeScreen(context) ? 1 : 0, + leading: canPop ? const PrevPageButton() : null, + automaticallyImplyLeading: false, + ), + body: child, + ); + } +} diff --git a/lib/shells/nav_shell.dart b/lib/shells/nav_shell.dart index 545e455..9b72e2c 100644 --- a/lib/shells/nav_shell.dart +++ b/lib/shells/nav_shell.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import 'package:go_router/go_router.dart'; import 'package:solian/router.dart'; import 'package:solian/theme.dart'; +import 'package:solian/widgets/prev_page.dart'; import 'package:solian/widgets/navigation/app_navigation_bottom_bar.dart'; import 'package:solian/widgets/navigation/app_navigation_rail.dart'; @@ -22,9 +23,12 @@ class NavShell extends StatelessWidget { centerTitle: false, titleSpacing: canPop ? null : 24, elevation: SolianTheme.isLargeScreen(context) ? 1 : 0, - leading: canPop ? const BackButton() : null, + leading: canPop ? const PrevPageButton() : null, + automaticallyImplyLeading: false, ), - bottomNavigationBar: SolianTheme.isLargeScreen(context) ? null : const AppNavigationBottomBar(), + bottomNavigationBar: SolianTheme.isLargeScreen(context) + ? null + : const AppNavigationBottomBar(), body: SolianTheme.isLargeScreen(context) ? Row( children: [ @@ -37,20 +41,3 @@ class NavShell extends StatelessWidget { ); } } - -class BackButton extends StatelessWidget { - const BackButton({super.key}); - - @override - Widget build(BuildContext context) { - return IconButton( - icon: const Icon(Icons.arrow_back), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - onPressed: () { - if (AppRouter.instance.canPop()) { - AppRouter.instance.pop(); - } - }, - ); - } -} diff --git a/lib/translations.dart b/lib/translations.dart index 04277d7..3040162 100644 --- a/lib/translations.dart +++ b/lib/translations.dart @@ -7,6 +7,7 @@ class SolianMessages extends Translations { 'hide': 'Hide', 'okay': 'Okay', 'next': 'Next', + 'reset': 'Reset', 'page': 'Page', 'home': 'Home', 'apply': 'Apply', @@ -21,9 +22,15 @@ class SolianMessages extends Translations { 'username': 'Username', 'nickname': 'Nickname', 'password': 'Password', + 'description': 'Description', + 'birthday': 'Birthday', + 'firstName': 'First Name', + 'lastName': 'Last Name', 'account': 'Account', - 'personalize': 'Personalize', - 'friend': 'Friend', + 'accountPersonalize': 'Personalize', + 'accountPersonalizeApplied': + 'Account personalize settings has been saved.', + 'accountFriend': 'Friend', 'aspectRatio': 'Aspect Ratio', 'aspectRatioSquare': 'Square', 'aspectRatioPortrait': 'Portrait', @@ -71,6 +78,7 @@ class SolianMessages extends Translations { 'hide': '隐藏', 'okay': '确认', 'next': '下一步', + 'reset': '重置', 'cancel': '取消', 'confirm': '确认', 'edit': '编辑', @@ -85,9 +93,14 @@ class SolianMessages extends Translations { 'username': '用户名', 'nickname': '显示名', 'password': '密码', + 'description': '简介', + 'birthday': '生日', + 'firstName': '名称', + 'lastName': '姓氏', 'account': '账号', - 'personalize': '个性化', - 'friend': '好友', + 'accountPersonalize': '个性化', + 'accountPersonalizeApplied': '账户的个性化设置已保存。', + 'accountFriend': '好友', 'aspectRatio': '纵横比', 'aspectRatioSquare': '方型', 'aspectRatioPortrait': '竖型', diff --git a/lib/providers/content/attachment_item.dart b/lib/widgets/attachments/attachment_item.dart similarity index 100% rename from lib/providers/content/attachment_item.dart rename to lib/widgets/attachments/attachment_item.dart diff --git a/lib/widgets/attachments/attachment_list.dart b/lib/widgets/attachments/attachment_list.dart index 4238cb2..a4359b1 100644 --- a/lib/widgets/attachments/attachment_list.dart +++ b/lib/widgets/attachments/attachment_list.dart @@ -4,8 +4,8 @@ import 'package:carousel_slider/carousel_slider.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:solian/models/attachment.dart'; -import 'package:solian/providers/content/attachment_item.dart'; -import 'package:solian/providers/content/attachment_list.dart'; +import 'package:solian/widgets/attachments/attachment_item.dart'; +import 'package:solian/providers/content/attachment.dart'; import 'package:solian/widgets/attachments/attachment_list_fullscreen.dart'; class AttachmentList extends StatefulWidget { @@ -26,7 +26,7 @@ class _AttachmentListState extends State { List _attachmentsMeta = List.empty(); void getMetadataList() { - final AttachmentListProvider provider = Get.find(); + final AttachmentProvider provider = Get.find(); if (widget.attachmentsId.isEmpty) { return; diff --git a/lib/widgets/attachments/attachment_list_fullscreen.dart b/lib/widgets/attachments/attachment_list_fullscreen.dart index 2eb9e23..65a333f 100644 --- a/lib/widgets/attachments/attachment_list_fullscreen.dart +++ b/lib/widgets/attachments/attachment_list_fullscreen.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:solian/models/attachment.dart'; -import 'package:solian/providers/content/attachment_item.dart'; +import 'package:solian/widgets/attachments/attachment_item.dart'; class AttachmentListFullscreen extends StatefulWidget { final Attachment attachment; diff --git a/lib/widgets/attachments/attachment_publish.dart b/lib/widgets/attachments/attachment_publish.dart index 1e65034..66b3d7c 100644 --- a/lib/widgets/attachments/attachment_publish.dart +++ b/lib/widgets/attachments/attachment_publish.dart @@ -1,6 +1,4 @@ -import 'dart:convert'; import 'dart:io'; -import 'dart:isolate'; import 'dart:math' as math; import 'package:file_picker/file_picker.dart'; @@ -8,19 +6,10 @@ 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/providers/content/attachment_list.dart'; -import 'package:solian/services.dart'; - -Future calculateFileSha256(File file) async { - final bytes = await file.readAsBytes(); - final digest = await Isolate.run(() => sha256.convert(bytes)); - return digest.toString(); -} +import 'package:solian/providers/content/attachment.dart'; class AttachmentPublishingPopup extends StatefulWidget { final String usage; @@ -59,11 +48,13 @@ class _AttachmentPublishingPopupState extends State { 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); + await uploadAttachment( + file, + hash, + ratio: await calculateFileAspectRatio(file), + ); } catch (err) { this.context.showErrorDialog(err); } @@ -102,10 +93,10 @@ class _AttachmentPublishingPopupState extends State { await FilePicker.platform.pickFiles(allowMultiple: true); if (result == null) return; - List files = result.paths.map((path) => File(path!)).toList(); - setState(() => _isBusy = true); + List files = result.paths.map((path) => File(path!)).toList(); + for (final file in files) { final hash = await calculateFileSha256(file); try { @@ -139,8 +130,7 @@ class _AttachmentPublishingPopupState extends State { if (isVideo) { ratio = 16 / 9; } else { - final image = await decodeImageFromList(await file.readAsBytes()); - ratio = image.width / image.height; + ratio = await calculateFileAspectRatio(file); } try { @@ -153,36 +143,19 @@ class _AttachmentPublishingPopupState extends State { } Future 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': jsonEncode({ - if (ratio != null) 'ratio': ratio, - }), - }), - ); - if (resp.statusCode == 200) { + final AttachmentProvider provider = Get.find(); + try { + final resp = await provider.createAttachment( + file, + hash, + widget.usage, + ratio: ratio, + ); var result = Attachment.fromJson(resp.body); setState(() => _attachments.add(result)); widget.onUpdate(_attachments.map((e) => e!.id).toList()); - } else { - throw Exception(resp.bodyString); + } catch (e) { + rethrow; } } @@ -206,7 +179,7 @@ class _AttachmentPublishingPopupState extends State { } void revertMetadataList() { - final AttachmentListProvider provider = Get.find(); + final AttachmentProvider provider = Get.find(); if (widget.current.isEmpty) { _isFirstTimeBusy = false; @@ -299,7 +272,7 @@ class _AttachmentPublishingPopupState extends State { showDialog( context: context, builder: (context) { - return AttachmentEditingPopup( + return AttachmentEditingDialog( item: element, onDelete: () { setState( @@ -379,22 +352,23 @@ class _AttachmentPublishingPopupState extends State { } } -class AttachmentEditingPopup extends StatefulWidget { +class AttachmentEditingDialog extends StatefulWidget { final Attachment item; final Function onDelete; final Function(Attachment item) onUpdate; - const AttachmentEditingPopup( + const AttachmentEditingDialog( {super.key, required this.item, required this.onDelete, required this.onUpdate}); @override - State createState() => _AttachmentEditingPopupState(); + State createState() => + _AttachmentEditingDialogState(); } -class _AttachmentEditingPopupState extends State { +class _AttachmentEditingDialogState extends State { final _ratioController = TextEditingController(); final _altController = TextEditingController(); @@ -402,49 +376,40 @@ class _AttachmentEditingPopupState extends State { 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); + Future updateAttachment() async { + final AttachmentProvider provider = Get.find(); 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 { + try { + final resp = await provider.updateAttachment( + widget.item.id, + _altController.value.text, + widget.item.usage, + ratio: _hasAspectRatio + ? (double.tryParse(_ratioController.value.text) ?? 1) + : null, + isMature: _isMature, + ); return Attachment.fromJson(resp.body); + } catch (e) { + this.context.showErrorDialog(e); + return null; + } finally { + setState(() => _isBusy = false); } } 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) { + try { + final AttachmentProvider provider = Get.find(); + await provider.deleteAttachment(widget.item.id); widget.onDelete(); - } else { - this.context.showErrorDialog(resp.bodyString); + } catch (e) { + this.context.showErrorDialog(e); + } finally { + setState(() => _isBusy = false); } - setState(() => _isBusy = false); } void syncWidget() { @@ -586,7 +551,7 @@ class _AttachmentEditingPopupState extends State { TextButton( child: Text('apply'.tr), onPressed: () { - applyAttachment().then((value) { + updateAttachment().then((value) { if (value != null) { widget.onUpdate(value); Navigator.pop(context); diff --git a/lib/widgets/prev_page.dart b/lib/widgets/prev_page.dart new file mode 100644 index 0000000..e1b5f1f --- /dev/null +++ b/lib/widgets/prev_page.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:solian/router.dart'; + +class PrevPageButton extends StatelessWidget { + const PrevPageButton({super.key}); + + @override + Widget build(BuildContext context) { + return IconButton( + icon: const Icon(Icons.arrow_back), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () { + if (AppRouter.instance.canPop()) { + AppRouter.instance.pop(); + } + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 2b362e2..80eb171 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: "6bd38d335f0954f5fad9c79e614604fbf03a0e5b975923dd001b6ea965ef5b4b" + url: "https://pub.dev" + source: hosted + version: "3.6.0" args: dependency: transitive description: @@ -280,6 +288,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + image: + dependency: "direct main" + description: + name: image + sha256: "4c68bfd5ae83e700b5204c1e74451e7bf3cf750e6843c6e158289cf56bda018e" + url: "https://pub.dev" + source: hosted + version: "4.1.7" image_picker: dependency: "direct main" description: @@ -353,7 +369,7 @@ packages: source: hosted version: "4.0.0" intl: - dependency: transitive + dependency: "direct main" description: name: intl sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf @@ -512,6 +528,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" platform: dependency: transitive description: @@ -709,6 +733,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" sdks: dart: ">=3.3.4 <4.0.0" flutter: ">=3.19.0" diff --git a/pubspec.yaml b/pubspec.yaml index ed82834..1f512d9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,6 +49,8 @@ dependencies: file_picker: ^8.0.3 crypto: ^3.0.3 path: ^1.9.0 + intl: ^0.19.0 + image: ^4.1.7 dev_dependencies: flutter_test: