Optimize

This commit is contained in:
LittleSheep 2024-05-22 23:18:01 +08:00
parent 57ff5e44ba
commit 1eb42fa351
17 changed files with 617 additions and 149 deletions

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/providers/auth.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/router.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/translations.dart'; import 'package:solian/translations.dart';
@ -28,7 +28,7 @@ class SolianApp extends StatelessWidget {
fallbackLocale: const Locale('en', 'US'), fallbackLocale: const Locale('en', 'US'),
onInit: () { onInit: () {
Get.lazyPut(() => AuthProvider()); Get.lazyPut(() => AuthProvider());
Get.lazyPut(() => AttachmentListProvider()); Get.lazyPut(() => AttachmentProvider());
}, },
builder: (context, child) { builder: (context, child) {
return ScaffoldMessenger( return ScaffoldMessenger(

View File

@ -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<String> calculateFileSha256(File file) async {
final bytes = await Isolate.run(() => file.readAsBytesSync());
final digest = await Isolate.run(() => sha256.convert(bytes));
return digest.toString();
}
Future<double> 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<Response> getMetadata(int id) => get('/api/attachments/$id/meta');
Future<Response> 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<Response> 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<Response> 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;
}
}

View File

@ -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<Response> getMetadata(int id) => get('/api/attachments/$id/meta');
}

View File

@ -1,9 +1,11 @@
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:solian/screens/account.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/signin.dart';
import 'package:solian/screens/auth/signup.dart'; import 'package:solian/screens/auth/signup.dart';
import 'package:solian/screens/home.dart'; import 'package:solian/screens/home.dart';
import 'package:solian/screens/posts/publish.dart'; import 'package:solian/screens/posts/publish.dart';
import 'package:solian/shells/basic_shell.dart';
import 'package:solian/shells/nav_shell.dart'; import 'package:solian/shells/nav_shell.dart';
abstract class AppRouter { abstract class AppRouter {
@ -14,30 +16,41 @@ abstract class AppRouter {
NavShell(state: state, child: child), NavShell(state: state, child: child),
routes: [ routes: [
GoRoute( GoRoute(
path: "/", path: '/',
name: "home", name: 'home',
builder: (context, state) => const HomeScreen(), builder: (context, state) => const HomeScreen(),
), ),
GoRoute( GoRoute(
path: "/account", path: '/account',
name: "account", name: 'account',
builder: (context, state) => const AccountScreen(), builder: (context, state) => const AccountScreen(),
), ),
],
),
ShellRoute(
builder: (context, state, child) =>
BasicShell(state: state, child: child),
routes: [
GoRoute( GoRoute(
path: "/auth/sign-in", path: '/account/personalize',
name: "signin", name: 'accountPersonalize',
builder: (context, state) => const PersonalizeScreen(),
),
GoRoute(
path: '/auth/sign-in',
name: 'signin',
builder: (context, state) => const SignInScreen(), builder: (context, state) => const SignInScreen(),
), ),
GoRoute( GoRoute(
path: "/auth/sign-up", path: '/auth/sign-up',
name: "signup", name: 'signup',
builder: (context, state) => const SignUpScreen(), builder: (context, state) => const SignUpScreen(),
), ),
], ],
), ),
GoRoute( GoRoute(
path: "/posts/publish", path: '/posts/publish',
name: "postPublishing", name: 'postPublishing',
builder: (context, state) { builder: (context, state) {
final arguments = state.extra as PostPublishingArguments?; final arguments = state.extra as PostPublishingArguments?;
return PostPublishingScreen( return PostPublishingScreen(

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
class AccountScreen extends StatefulWidget { class AccountScreen extends StatefulWidget {
@ -15,8 +16,12 @@ class _AccountScreenState extends State<AccountScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final actionItems = [ 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(); final AuthProvider provider = Get.find();
@ -101,14 +106,19 @@ class AccountNameCard extends StatelessWidget {
} }
return Material( return Material(
elevation: 2, child: Card(
child: ListTile( child: ListTile(
contentPadding: contentPadding:
const EdgeInsets.only(left: 22, right: 34, top: 4, bottom: 4), const EdgeInsets.only(left: 22, right: 34, top: 4, bottom: 4),
leading: AccountAvatar( leading: AccountAvatar(
content: snapshot.data!.body?['avatar'], radius: 24), content: snapshot.data!.body?['avatar'], radius: 24),
title: Text(snapshot.data!.body?['nick']), title: Text(snapshot.data!.body?['nick']),
subtitle: Text(snapshot.data!.body?['email']), subtitle: Text(snapshot.data!.body?['email']),
),
).paddingOnly(
left: 16,
right: 16,
top: SolianTheme.isLargeScreen(context) ? 8 : 0,
), ),
); );
}, },

View File

@ -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<PersonalizeScreen> createState() => _PersonalizeScreenState();
}
class _PersonalizeScreenState extends State<PersonalizeScreen> {
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<void> 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),
),
],
),
],
),
),
);
}
}

View File

@ -7,9 +7,9 @@ import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/widgets/account/account_avatar.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/attachments/attachment_publish.dart';
import 'package:solian/widgets/posts/post_item.dart'; import 'package:solian/widgets/posts/post_item.dart';
import 'package:solian/widgets/prev_page.dart';
class PostPublishingArguments { class PostPublishingArguments {
final Post? edit; final Post? edit;
@ -122,7 +122,7 @@ class _PostPublishingScreenState extends State<PostPublishingScreen> {
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('postPublishing'.tr), title: Text('postPublishing'.tr),
leading: const shell.BackButton(), leading: const PrevPageButton(),
actions: [ actions: [
TextButton( TextButton(
child: Text('postAction'.tr.toUpperCase()), child: Text('postAction'.tr.toUpperCase()),

View File

@ -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,
);
}
}

View File

@ -3,6 +3,7 @@ import 'package:get/get.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/theme.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_bottom_bar.dart';
import 'package:solian/widgets/navigation/app_navigation_rail.dart'; import 'package:solian/widgets/navigation/app_navigation_rail.dart';
@ -22,9 +23,12 @@ class NavShell extends StatelessWidget {
centerTitle: false, centerTitle: false,
titleSpacing: canPop ? null : 24, titleSpacing: canPop ? null : 24,
elevation: SolianTheme.isLargeScreen(context) ? 1 : 0, 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) body: SolianTheme.isLargeScreen(context)
? Row( ? Row(
children: [ 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();
}
},
);
}
}

View File

@ -7,6 +7,7 @@ class SolianMessages extends Translations {
'hide': 'Hide', 'hide': 'Hide',
'okay': 'Okay', 'okay': 'Okay',
'next': 'Next', 'next': 'Next',
'reset': 'Reset',
'page': 'Page', 'page': 'Page',
'home': 'Home', 'home': 'Home',
'apply': 'Apply', 'apply': 'Apply',
@ -21,9 +22,15 @@ class SolianMessages extends Translations {
'username': 'Username', 'username': 'Username',
'nickname': 'Nickname', 'nickname': 'Nickname',
'password': 'Password', 'password': 'Password',
'description': 'Description',
'birthday': 'Birthday',
'firstName': 'First Name',
'lastName': 'Last Name',
'account': 'Account', 'account': 'Account',
'personalize': 'Personalize', 'accountPersonalize': 'Personalize',
'friend': 'Friend', 'accountPersonalizeApplied':
'Account personalize settings has been saved.',
'accountFriend': 'Friend',
'aspectRatio': 'Aspect Ratio', 'aspectRatio': 'Aspect Ratio',
'aspectRatioSquare': 'Square', 'aspectRatioSquare': 'Square',
'aspectRatioPortrait': 'Portrait', 'aspectRatioPortrait': 'Portrait',
@ -71,6 +78,7 @@ class SolianMessages extends Translations {
'hide': '隐藏', 'hide': '隐藏',
'okay': '确认', 'okay': '确认',
'next': '下一步', 'next': '下一步',
'reset': '重置',
'cancel': '取消', 'cancel': '取消',
'confirm': '确认', 'confirm': '确认',
'edit': '编辑', 'edit': '编辑',
@ -85,9 +93,14 @@ class SolianMessages extends Translations {
'username': '用户名', 'username': '用户名',
'nickname': '显示名', 'nickname': '显示名',
'password': '密码', 'password': '密码',
'description': '简介',
'birthday': '生日',
'firstName': '名称',
'lastName': '姓氏',
'account': '账号', 'account': '账号',
'personalize': '个性化', 'accountPersonalize': '个性化',
'friend': '好友', 'accountPersonalizeApplied': '账户的个性化设置已保存。',
'accountFriend': '好友',
'aspectRatio': '纵横比', 'aspectRatio': '纵横比',
'aspectRatioSquare': '方型', 'aspectRatioSquare': '方型',
'aspectRatioPortrait': '竖型', 'aspectRatioPortrait': '竖型',

View File

@ -4,8 +4,8 @@ import 'package:carousel_slider/carousel_slider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/models/attachment.dart'; import 'package:solian/models/attachment.dart';
import 'package:solian/providers/content/attachment_item.dart'; import 'package:solian/widgets/attachments/attachment_item.dart';
import 'package:solian/providers/content/attachment_list.dart'; import 'package:solian/providers/content/attachment.dart';
import 'package:solian/widgets/attachments/attachment_list_fullscreen.dart'; import 'package:solian/widgets/attachments/attachment_list_fullscreen.dart';
class AttachmentList extends StatefulWidget { class AttachmentList extends StatefulWidget {
@ -26,7 +26,7 @@ class _AttachmentListState extends State<AttachmentList> {
List<Attachment?> _attachmentsMeta = List.empty(); List<Attachment?> _attachmentsMeta = List.empty();
void getMetadataList() { void getMetadataList() {
final AttachmentListProvider provider = Get.find(); final AttachmentProvider provider = Get.find();
if (widget.attachmentsId.isEmpty) { if (widget.attachmentsId.isEmpty) {
return; return;

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:solian/models/attachment.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 { class AttachmentListFullscreen extends StatefulWidget {
final Attachment attachment; final Attachment attachment;

View File

@ -1,6 +1,4 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:isolate';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:file_picker/file_picker.dart'; 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:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/attachment.dart'; import 'package:solian/models/attachment.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:crypto/crypto.dart'; import 'package:solian/providers/content/attachment.dart';
import 'package:solian/providers/content/attachment_list.dart';
import 'package:solian/services.dart';
Future<String> calculateFileSha256(File file) async {
final bytes = await file.readAsBytes();
final digest = await Isolate.run(() => sha256.convert(bytes));
return digest.toString();
}
class AttachmentPublishingPopup extends StatefulWidget { class AttachmentPublishingPopup extends StatefulWidget {
final String usage; final String usage;
@ -59,11 +48,13 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> {
for (final media in medias) { for (final media in medias) {
final file = File(media.path); final file = File(media.path);
final hash = await calculateFileSha256(file); final hash = await calculateFileSha256(file);
final image = await decodeImageFromList(await file.readAsBytes());
final ratio = image.width / image.height;
try { try {
await uploadAttachment(file, hash, ratio: ratio); await uploadAttachment(
file,
hash,
ratio: await calculateFileAspectRatio(file),
);
} catch (err) { } catch (err) {
this.context.showErrorDialog(err); this.context.showErrorDialog(err);
} }
@ -102,10 +93,10 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> {
await FilePicker.platform.pickFiles(allowMultiple: true); await FilePicker.platform.pickFiles(allowMultiple: true);
if (result == null) return; if (result == null) return;
List<File> files = result.paths.map((path) => File(path!)).toList();
setState(() => _isBusy = true); setState(() => _isBusy = true);
List<File> files = result.paths.map((path) => File(path!)).toList();
for (final file in files) { for (final file in files) {
final hash = await calculateFileSha256(file); final hash = await calculateFileSha256(file);
try { try {
@ -139,8 +130,7 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> {
if (isVideo) { if (isVideo) {
ratio = 16 / 9; ratio = 16 / 9;
} else { } else {
final image = await decodeImageFromList(await file.readAsBytes()); ratio = await calculateFileAspectRatio(file);
ratio = image.width / image.height;
} }
try { try {
@ -153,36 +143,19 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> {
} }
Future<void> uploadAttachment(File file, String hash, {double? ratio}) async { Future<void> uploadAttachment(File file, String hash, {double? ratio}) async {
final AuthProvider auth = Get.find(); final AttachmentProvider provider = Get.find();
try {
final client = GetConnect(); final resp = await provider.createAttachment(
client.httpClient.baseUrl = ServiceFinder.services['paperclip']; file,
client.httpClient.addAuthenticator(auth.reqAuthenticator); hash,
widget.usage,
final filePayload = ratio: ratio,
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) {
var result = Attachment.fromJson(resp.body); var result = Attachment.fromJson(resp.body);
setState(() => _attachments.add(result)); setState(() => _attachments.add(result));
widget.onUpdate(_attachments.map((e) => e!.id).toList()); widget.onUpdate(_attachments.map((e) => e!.id).toList());
} else { } catch (e) {
throw Exception(resp.bodyString); rethrow;
} }
} }
@ -206,7 +179,7 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> {
} }
void revertMetadataList() { void revertMetadataList() {
final AttachmentListProvider provider = Get.find(); final AttachmentProvider provider = Get.find();
if (widget.current.isEmpty) { if (widget.current.isEmpty) {
_isFirstTimeBusy = false; _isFirstTimeBusy = false;
@ -299,7 +272,7 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> {
showDialog( showDialog(
context: context, context: context,
builder: (context) { builder: (context) {
return AttachmentEditingPopup( return AttachmentEditingDialog(
item: element, item: element,
onDelete: () { onDelete: () {
setState( setState(
@ -379,22 +352,23 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> {
} }
} }
class AttachmentEditingPopup extends StatefulWidget { class AttachmentEditingDialog extends StatefulWidget {
final Attachment item; final Attachment item;
final Function onDelete; final Function onDelete;
final Function(Attachment item) onUpdate; final Function(Attachment item) onUpdate;
const AttachmentEditingPopup( const AttachmentEditingDialog(
{super.key, {super.key,
required this.item, required this.item,
required this.onDelete, required this.onDelete,
required this.onUpdate}); required this.onUpdate});
@override @override
State<AttachmentEditingPopup> createState() => _AttachmentEditingPopupState(); State<AttachmentEditingDialog> createState() =>
_AttachmentEditingDialogState();
} }
class _AttachmentEditingPopupState extends State<AttachmentEditingPopup> { class _AttachmentEditingDialogState extends State<AttachmentEditingDialog> {
final _ratioController = TextEditingController(); final _ratioController = TextEditingController();
final _altController = TextEditingController(); final _altController = TextEditingController();
@ -402,49 +376,40 @@ class _AttachmentEditingPopupState extends State<AttachmentEditingPopup> {
bool _isMature = false; bool _isMature = false;
bool _hasAspectRatio = false; bool _hasAspectRatio = false;
Future<Attachment?> applyAttachment() async { Future<Attachment?> updateAttachment() async {
final AuthProvider auth = Get.find(); final AttachmentProvider provider = Get.find();
final client = GetConnect();
client.httpClient.baseUrl = ServiceFinder.services['paperclip'];
client.httpClient.addAuthenticator(auth.reqAuthenticator);
setState(() => _isBusy = true); setState(() => _isBusy = true);
var resp = await client.put('/api/attachments/${widget.item.id}', { try {
'metadata': { final resp = await provider.updateAttachment(
if (_hasAspectRatio) widget.item.id,
'ratio': double.tryParse(_ratioController.value.text) ?? 1, _altController.value.text,
}, widget.item.usage,
'alt': _altController.value.text, ratio: _hasAspectRatio
'usage': widget.item.usage, ? (double.tryParse(_ratioController.value.text) ?? 1)
'is_mature': _isMature, : null,
}); isMature: _isMature,
);
setState(() => _isBusy = false);
if (resp.statusCode != 200) {
this.context.showErrorDialog(resp.bodyString);
return null;
} else {
return Attachment.fromJson(resp.body); return Attachment.fromJson(resp.body);
} catch (e) {
this.context.showErrorDialog(e);
return null;
} finally {
setState(() => _isBusy = false);
} }
} }
Future<void> deleteAttachment() async { Future<void> deleteAttachment() async {
final AuthProvider auth = Get.find();
final client = GetConnect();
client.httpClient.baseUrl = ServiceFinder.services['paperclip'];
client.httpClient.addAuthenticator(auth.reqAuthenticator);
setState(() => _isBusy = true); setState(() => _isBusy = true);
var resp = await client.delete('/api/attachments/${widget.item.id}'); try {
if (resp.statusCode == 200) { final AttachmentProvider provider = Get.find();
await provider.deleteAttachment(widget.item.id);
widget.onDelete(); widget.onDelete();
} else { } catch (e) {
this.context.showErrorDialog(resp.bodyString); this.context.showErrorDialog(e);
} finally {
setState(() => _isBusy = false);
} }
setState(() => _isBusy = false);
} }
void syncWidget() { void syncWidget() {
@ -586,7 +551,7 @@ class _AttachmentEditingPopupState extends State<AttachmentEditingPopup> {
TextButton( TextButton(
child: Text('apply'.tr), child: Text('apply'.tr),
onPressed: () { onPressed: () {
applyAttachment().then((value) { updateAttachment().then((value) {
if (value != null) { if (value != null) {
widget.onUpdate(value); widget.onUpdate(value);
Navigator.pop(context); Navigator.pop(context);

View File

@ -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();
}
},
);
}
}

View File

@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
archive:
dependency: transitive
description:
name: archive
sha256: "6bd38d335f0954f5fad9c79e614604fbf03a0e5b975923dd001b6ea965ef5b4b"
url: "https://pub.dev"
source: hosted
version: "3.6.0"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -280,6 +288,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.2" 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: image_picker:
dependency: "direct main" dependency: "direct main"
description: description:
@ -353,7 +369,7 @@ packages:
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
intl: intl:
dependency: transitive dependency: "direct main"
description: description:
name: intl name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
@ -512,6 +528,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.1" version: "2.2.1"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
url: "https://pub.dev"
source: hosted
version: "6.0.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -709,6 +733,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" version: "1.0.4"
xml:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.dev"
source: hosted
version: "6.5.0"
sdks: sdks:
dart: ">=3.3.4 <4.0.0" dart: ">=3.3.4 <4.0.0"
flutter: ">=3.19.0" flutter: ">=3.19.0"

View File

@ -49,6 +49,8 @@ dependencies:
file_picker: ^8.0.3 file_picker: ^8.0.3
crypto: ^3.0.3 crypto: ^3.0.3
path: ^1.9.0 path: ^1.9.0
intl: ^0.19.0
image: ^4.1.7
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: