diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 0fac258..2a70256 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -11,6 +11,7 @@ "screenAccountPublishers": "Publishers", "screenAccountPublisherNew": "New Publisher", "screenAccountPublisherEdit": "Edit Publisher", + "screenAccountProfileEdit": "Edit Profile", "dialogOkay": "Okay", "dialogCancel": "Cancel", "dialogConfirm": "Confirm", @@ -36,6 +37,10 @@ "fieldDescription": "Description", "fieldUsernameCannotEditHint": "Username cannot be edited after created", "fieldUsernameLookupHint": "You can use username, phone number or email to login", + "fieldFirstName": "First name", + "fieldLastName": "Last name", + "fieldBirthday": "Birthday", + "fieldImageHint": "You can click those profile pictures to edit them.", "forgotPassword": "Forgot password", "loginPickFactor": "Pick a factor", "loginMultiFactor": { @@ -54,6 +59,9 @@ "accountLogoutConfirm": "You will need to re-enter your account password, even if you have already done so. This is required to login again.", "accountPublishers": "Your publishers", "accountPublishersSubtitle": "Manage your publish identities.", + "accountProfileEdit": "Edit your profile", + "accountProfileEditSubtitle": "Make your Solarpass account more looks like you.", + "accountProfileEditApplied": "Profile modification applied.", "publishersNew": "New Publisher", "publisherNewSubtitle": "Create a new publisher identity.", "publisherSyncWithAccount": "Sync with account" diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index e37e5ea..6c42db4 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -11,6 +11,7 @@ "screenAccountPublishers": "发布者", "screenAccountPublisherNew": "新建发布者", "screenAccountPublisherEdit": "编辑发布者", + "screenAccountProfileEdit": "编辑资料", "dialogOkay": "好的", "dialogCancel": "取消", "dialogConfirm": "确认", @@ -35,6 +36,10 @@ "fieldPassword": "密码", "fieldUsernameCannotEditHint": "用户名在创建后无法修改", "fieldUsernameLookupHint": "支持用户名、电话号码或邮箱地址", + "fieldFirstName": "名", + "fieldLastName": "姓", + "fieldBirthday": "生日", + "fieldImageHint": "你可以点击这些个人头像来编辑它们。", "fieldDescription": "简介", "forgotPassword": "忘记密码", "loginPickFactor": "选择方式验证", @@ -54,6 +59,9 @@ "accountLogoutConfirm": "您需要重新输入账号密码,甚至可能需要多步验证来再次登陆。", "accountPublishers": "你的发布者", "accountPublishersSubtitle": "管理你的公共形象。", + "accountProfileEdit": "编辑资料", + "accountProfileEditSubtitle": "使你的 Solarpass 账户更像你。", + "accountProfileEditApplied": "个人资料修改已被应用。", "publishersNew": "新发布者", "publisherNewSubtitle": "创建一个新的公共身份。", "publisherSyncWithAccount": "同步账户信息" diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0e88c5a..1f5c230 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2,47 +2,134 @@ PODS: - connectivity_plus (0.0.1): - Flutter - FlutterMacOS + - croppy (0.0.1): + - Flutter - cupertino_http (0.0.1): - Flutter + - DKImagePickerController/Core (4.3.9): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.9) + - DKImagePickerController/PhotoGallery (4.3.9): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.9) + - DKPhotoGallery (0.0.19): + - DKPhotoGallery/Core (= 0.0.19) + - DKPhotoGallery/Model (= 0.0.19) + - DKPhotoGallery/Preview (= 0.0.19) + - DKPhotoGallery/Resource (= 0.0.19) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.19): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.19): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter - Flutter (1.0.0) + - flutter_image_compress_common (1.0.0): + - Flutter + - Mantle + - SDWebImage + - SDWebImageWebPCoder - flutter_native_splash (0.0.1): - Flutter - flutter_secure_storage (3.3.1): - Flutter + - image_picker_ios (0.0.1): + - Flutter + - libwebp (1.3.2): + - libwebp/demux (= 1.3.2) + - libwebp/mux (= 1.3.2) + - libwebp/sharpyuv (= 1.3.2) + - libwebp/webp (= 1.3.2) + - libwebp/demux (1.3.2): + - libwebp/webp + - libwebp/mux (1.3.2): + - libwebp/demux + - libwebp/sharpyuv (1.3.2) + - libwebp/webp (1.3.2): + - libwebp/sharpyuv + - Mantle (2.2.0): + - Mantle/extobjc (= 2.2.0) + - Mantle/extobjc (2.2.0) - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - SDWebImage (5.19.7): + - SDWebImage/Core (= 5.19.7) + - SDWebImage/Core (5.19.7) + - SDWebImageWebPCoder (0.14.6): + - libwebp (~> 1.0) + - SDWebImage/Core (~> 5.17) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS + - SwiftyGif (5.4.5) - url_launcher_ios (0.0.1): - Flutter DEPENDENCIES: - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`) + - croppy (from `.symlinks/plugins/croppy/ios`) - cupertino_http (from `.symlinks/plugins/cupertino_http/ios`) + - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) + - flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - libwebp + - Mantle + - SDWebImage + - SDWebImageWebPCoder + - SwiftyGif + EXTERNAL SOURCES: connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/darwin" + croppy: + :path: ".symlinks/plugins/croppy/ios" cupertino_http: :path: ".symlinks/plugins/cupertino_http/ios" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter + flutter_image_compress_common: + :path: ".symlinks/plugins/flutter_image_compress_common/ios" flutter_native_splash: :path: ".symlinks/plugins/flutter_native_splash/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" shared_preferences_foundation: @@ -54,13 +141,24 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: connectivity_plus: 4c41c08fc6d7c91f63bc7aec70ffe3730b04f563 + croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321 cupertino_http: 1a3a0f163c1b26e7f1a293b33d476e0fde7a64ec + DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c + DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 + file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + flutter_image_compress_common: ec1d45c362c9d30a3f6a0426c297f47c52007e3e flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec + image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 + libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009 + Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3 + SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index e48f56e..2e1f684 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,56 +1,64 @@ - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Surface - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - surface - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - CFBundleLocalizations - - en - zh_CN - - UIStatusBarHidden - - + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Surface + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + surface + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + CFBundleLocalizations + + en + zh_CN + + NSPhotoLibraryUsageDescription + Grant access to Photo Library will allow Solian upload photo or video for your post. + NSCameraUsageDescription + Grant access to Photo Library will allow Solian take photo or video for your post. + NSMicrophoneUsageDescription + Grant access to Photo Library will allow Solian record audio for your post. + ITSAppUsesNonExemptEncryption + + UIStatusBarHidden + + diff --git a/lib/main.dart b/lib/main.dart index 637b18a..710fb32 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:croppy/croppy.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization_loader/easy_localization_loader.dart'; import 'package:flutter/material.dart'; @@ -48,6 +49,7 @@ class SolianApp extends StatelessWidget { locale: context.locale, supportedLocales: context.supportedLocales, localizationsDelegates: [ + CroppyLocalizations.delegate, RelativeTimeLocalizations.delegate, ...context.localizationDelegates, ], diff --git a/lib/providers/sn_attachment.dart b/lib/providers/sn_attachment.dart index 8a739e1..2323e07 100644 --- a/lib/providers/sn_attachment.dart +++ b/lib/providers/sn_attachment.dart @@ -1,8 +1,15 @@ +import 'dart:collection'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; import 'package:flutter/widgets.dart'; +import 'package:cross_file/cross_file.dart'; import 'package:provider/provider.dart'; import 'package:surface/providers/sn_network.dart'; import 'package:surface/types/attachment.dart'; +const kConcurrentUploadChunks = 5; + class SnAttachmentProvider { late final SnNetworkProvider _sn; final Map _cache = {}; @@ -43,4 +50,157 @@ class SnAttachmentProvider { } return rids.map((rid) => _cache[rid]!).toList(); } + + static Map mimetypeOverrides = { + 'mov': 'video/quicktime', + 'mp4': 'video/mp4' + }; + + Future directUploadOne( + Uint8List data, + String filename, + String pool, + Map? metadata, { + String? mimetype, + Function(double progress)? onProgress, + }) async { + final filePayload = MultipartFile.fromBytes(data, filename: filename); + final fileAlt = filename.contains('.') + ? filename.substring(0, filename.lastIndexOf('.')) + : filename; + final fileExt = + filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); + + String? mimetypeOverride; + if (mimetype != null) { + mimetypeOverride = mimetype; + } else if (mimetypeOverrides.keys.contains(fileExt)) { + mimetypeOverride = mimetypeOverrides[fileExt]; + } + + final formData = FormData.fromMap({ + 'alt': fileAlt, + 'file': filePayload, + 'pool': pool, + 'metadata': metadata, + if (mimetypeOverride != null) 'mimetype': mimetypeOverride, + }); + final resp = await _sn.client.post( + '/cgi/uc/attachments', + data: formData, + onSendProgress: (count, total) { + if (onProgress != null) { + onProgress(count / total); + } + }, + ); + + return SnAttachment.fromJson(resp.data); + } + + Future<(SnAttachment, int)> chunkedUploadInitialize( + int size, + String filename, + String pool, + Map? metadata, + ) async { + final fileAlt = filename.contains('.') + ? filename.substring(0, filename.lastIndexOf('.')) + : filename; + final fileExt = + filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); + + String? mimetypeOverride; + if (mimetypeOverrides.keys.contains(fileExt)) { + mimetypeOverride = mimetypeOverrides[fileExt]; + } + + final resp = await _sn.client.post('/cgi/uc/attachments/multipart', data: { + 'alt': fileAlt, + 'name': filename, + 'pool': pool, + 'metadata': metadata, + 'size': size, + if (mimetypeOverride != null) 'mimetype': mimetypeOverride, + }); + + return ( + SnAttachment.fromJson(resp.data['meta']), + resp.data['chunk_size'] as int + ); + } + + Future _chunkedUploadOnePart( + Uint8List data, + String rid, + String cid, { + Function(double progress)? onProgress, + }) async { + final resp = await _sn.client.post( + '/cgi/uc/attachments/multipart/$rid/$cid', + data: data, + options: Options(headers: {'Content-Type': 'application/octet-stream'}), + onSendProgress: (count, total) { + if (onProgress != null) { + onProgress(count / total); + } + }, + ); + + return SnAttachment.fromJson(resp.data); + } + + Future chunkedUploadParts( + XFile file, + SnAttachment place, + int chunkSize, { + Function(double progress)? onProgress, + }) async { + final Map chunks = place.fileChunks ?? {}; + var currentTask = 0; + + final queue = Queue>(); + final activeTasks = >[]; + + for (final entry in chunks.entries) { + queue.add(() async { + final beginCursor = entry.value * chunkSize; + final endCursor = (entry.value + 1) * chunkSize; + final data = Uint8List.fromList(await file + .openRead(beginCursor, endCursor) + .expand((chunk) => chunk) + .toList()); + + place = await _chunkedUploadOnePart( + data, + place.rid, + entry.key, + onProgress: (chunkProgress) { + final overallProgress = + (currentTask + chunkProgress) / chunks.length; + if (onProgress != null) { + onProgress(overallProgress); + } + }, + ); + + currentTask++; + }()); + } + + while (queue.isNotEmpty || activeTasks.isNotEmpty) { + while (activeTasks.length < kConcurrentUploadChunks && queue.isNotEmpty) { + final task = queue.removeFirst(); + activeTasks.add(task); + + task.then((_) => activeTasks.remove(task)); + } + + if (activeTasks.isNotEmpty) { + await Future.any(activeTasks); + } + } + + return place; + } } diff --git a/lib/router.dart b/lib/router.dart index fe1ab37..6ea6d7c 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -1,8 +1,9 @@ import 'package:go_router/go_router.dart'; import 'package:surface/screens/account.dart'; -import 'package:surface/screens/account/publisher_edit.dart'; -import 'package:surface/screens/account/publisher_new.dart'; -import 'package:surface/screens/account/publishers.dart'; +import 'package:surface/screens/account/profile_edit.dart'; +import 'package:surface/screens/account/publishers/publisher_edit.dart'; +import 'package:surface/screens/account/publishers/publisher_new.dart'; +import 'package:surface/screens/account/publishers/publishers.dart'; import 'package:surface/screens/auth/login.dart'; import 'package:surface/screens/auth/register.dart'; import 'package:surface/screens/explore.dart'; @@ -50,6 +51,11 @@ final appRouter = GoRouter( name: 'authRegister', builder: (context, state) => const RegisterScreen(), ), + GoRoute( + path: '/account/profile/edit', + name: 'accountProfileEdit', + builder: (context, state) => const ProfileEditScreen(), + ), GoRoute( path: '/account/publishers', name: 'accountPublishers', diff --git a/lib/screens/account.dart b/lib/screens/account.dart index 846c2c3..eac66c0 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -74,6 +74,16 @@ class _AuthorizedAccountScreen extends StatelessWidget { ); }).padding(all: 20), ).padding(horizontal: 8, top: 16, bottom: 4), + ListTile( + title: Text('accountProfileEdit').tr(), + subtitle: Text('accountProfileEditSubtitle').tr(), + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Symbols.contact_page), + trailing: const Icon(Icons.chevron_right), + onTap: () { + GoRouter.of(context).pushNamed('accountProfileEdit'); + }, + ), ListTile( title: Text('accountPublishers').tr(), subtitle: Text('accountPublishersSubtitle').tr(), diff --git a/lib/screens/account/profile_edit.dart b/lib/screens/account/profile_edit.dart new file mode 100644 index 0000000..695b2d7 --- /dev/null +++ b/lib/screens/account/profile_edit.dart @@ -0,0 +1,348 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:croppy/croppy.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:path/path.dart' show basename; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:surface/providers/sn_attachment.dart'; +import 'package:surface/providers/sn_network.dart'; +import 'package:surface/providers/userinfo.dart'; +import 'package:surface/widgets/account/account_image.dart'; +import 'package:surface/widgets/dialog.dart'; +import 'package:surface/widgets/loading_indicator.dart'; +import 'package:surface/widgets/universal_image.dart'; + +class ProfileEditScreen extends StatefulWidget { + const ProfileEditScreen({super.key}); + + @override + State createState() => _ProfileEditScreenState(); +} + +class _ProfileEditScreenState extends State { + final _imagePicker = ImagePicker(); + + final _usernameController = TextEditingController(); + final _nicknameController = TextEditingController(); + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + final _descriptionController = TextEditingController(); + final _birthdayController = TextEditingController(); + + String? _avatar; + String? _banner; + DateTime? _birthday; + + bool _isBusy = false; + + static const _kDateFormat = 'y/M/d'; + + void _syncWidget() async { + final ua = context.read(); + final prof = ua.user!; + _usernameController.text = prof.name; + _nicknameController.text = prof.nick; + _descriptionController.text = prof.description; + _firstNameController.text = prof.profile!.firstName; + _lastNameController.text = prof.profile!.lastName; + _avatar = prof.avatar; + _banner = prof.banner; + if (prof.profile!.birthday != null) { + _birthdayController.text = DateFormat(_kDateFormat).format( + prof.profile!.birthday!.toLocal(), + ); + } + } + + void _selectBirthday() async { + await showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => Container( + height: 216, + padding: const EdgeInsets.only(top: 6.0), + margin: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + color: Theme.of(context).colorScheme.surface, + child: SafeArea( + top: false, + child: CupertinoDatePicker( + initialDateTime: _birthday?.toLocal(), + mode: CupertinoDatePickerMode.date, + use24hFormat: true, + onDateTimeChanged: (DateTime newDate) { + setState(() { + _birthday = newDate; + _birthdayController.text = + DateFormat(_kDateFormat).format(_birthday!); + }); + }, + ), + ), + ), + ); + } + + Future _updateImage(String place) async { + final image = await _imagePicker.pickImage(source: ImageSource.gallery); + if (image == null) return; + if (!mounted) return; + + final ImageProvider imageProvider = + kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)); + final aspectRatios = place == 'banner' + ? [CropAspectRatio(width: 16, height: 7)] + : [CropAspectRatio(width: 1, height: 1)]; + final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) + ? await showCupertinoImageCropper( + // ignore: use_build_context_synchronously + context, + allowedAspectRatios: aspectRatios, + imageProvider: imageProvider, + ) + : await showMaterialImageCropper( + // ignore: use_build_context_synchronously + context, + allowedAspectRatios: aspectRatios, + imageProvider: imageProvider, + ); + + if (result == null) return; + + if (!mounted) return; + final attach = context.read(); + + setState(() => _isBusy = true); + + final rawBytes = + (await result.uiImage.toByteData(format: ImageByteFormat.png))! + .buffer + .asUint8List(); + + try { + final attachment = await attach.directUploadOne( + rawBytes, + basename(image.path), + 'avatar', + null, + mimetype: 'image/png', + ); + + if (!mounted) return; + final sn = context.read(); + await sn.client.put( + '/cgi/id/users/me/$place', + data: {'attachment': attachment.rid}, + ); + + if (!mounted) return; + final ua = context.read(); + await ua.refreshUser(); + + if (!mounted) return; + context.showSnackbar('accountProfileEditApplied'.tr()); + _syncWidget(); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } finally { + setState(() => _isBusy = false); + } + } + + void _updateUserInfo() async { + setState(() => _isBusy = true); + + final sn = context.read(); + + try { + await sn.client.put( + '/cgi/id/users/me', + data: { + 'nick': _nicknameController.value.text, + 'description': _descriptionController.value.text, + 'first_name': _firstNameController.value.text, + 'last_name': _lastNameController.value.text, + 'birthday': _birthday?.toUtc().toIso8601String(), + }, + ); + + if (!mounted) return; + final ua = context.read(); + await ua.refreshUser(); + + if (!mounted) return; + context.showSnackbar('accountProfileEditApplied'.tr()); + _syncWidget(); + } catch (err) { + context.showErrorDialog(err); + } finally { + setState(() => _isBusy = false); + } + } + + @override + void initState() { + super.initState(); + _syncWidget(); + } + + @override + void dispose() { + _usernameController.dispose(); + _nicknameController.dispose(); + _firstNameController.dispose(); + _lastNameController.dispose(); + _descriptionController.dispose(); + _birthdayController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const double padding = 24; + + final sn = context.read(); + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LoadingIndicator(isActive: _isBusy), + const Gap(24), + Stack( + clipBehavior: Clip.none, + children: [ + Material( + elevation: 0, + child: InkWell( + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: AspectRatio( + aspectRatio: 16 / 9, + child: Container( + color: + Theme.of(context).colorScheme.surfaceContainerHigh, + child: _banner != null + ? UniversalImage( + sn.getAttachmentUrl(_banner!), + fit: BoxFit.cover, + ) + : const SizedBox.shrink(), + ), + ), + ), + onTap: () { + _updateImage('banner'); + }, + ), + ), + Positioned( + bottom: -28, + left: 16, + child: Material( + elevation: 2, + borderRadius: const BorderRadius.all(Radius.circular(40)), + child: InkWell( + child: AccountImage(content: _avatar, radius: 40), + onTap: () { + _updateImage('avatar'); + }, + ), + ), + ), + ], + ).padding(horizontal: padding), + const Gap(8 + 28), + Column( + children: [ + TextField( + readOnly: true, + controller: _usernameController, + decoration: InputDecoration( + border: const UnderlineInputBorder(), + labelText: 'fieldUsername'.tr(), + helperText: 'fieldUsernameCannotEditHint'.tr(), + ), + ), + const Gap(4), + TextField( + controller: _nicknameController, + decoration: InputDecoration( + border: const UnderlineInputBorder(), + labelText: 'fieldNickname'.tr(), + ), + ), + const Gap(4), + Row( + children: [ + Flexible( + flex: 1, + child: TextField( + controller: _firstNameController, + decoration: InputDecoration( + border: const UnderlineInputBorder(), + labelText: 'fieldFirstName'.tr(), + ), + ), + ), + const Gap(8), + Flexible( + flex: 1, + child: TextField( + controller: _lastNameController, + decoration: InputDecoration( + border: const UnderlineInputBorder(), + labelText: 'fieldLastName'.tr(), + ), + ), + ), + ], + ), + const Gap(4), + TextField( + controller: _descriptionController, + keyboardType: TextInputType.multiline, + maxLines: null, + minLines: 3, + decoration: InputDecoration( + border: const UnderlineInputBorder(), + labelText: 'fieldDescription'.tr(), + ), + ), + const Gap(4), + TextField( + controller: _birthdayController, + readOnly: true, + decoration: InputDecoration( + border: const UnderlineInputBorder(), + labelText: 'fieldBirthday'.tr(), + ), + onTap: () => _selectBirthday(), + ), + ], + ).padding(horizontal: padding + 8), + const Gap(12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ElevatedButton.icon( + onPressed: _isBusy ? null : _updateUserInfo, + icon: const Icon(Symbols.save), + label: Text('apply').tr(), + ), + ], + ).padding(horizontal: padding), + ], + ), + ); + } +} diff --git a/lib/screens/account/publisher_edit.dart b/lib/screens/account/publishers/publisher_edit.dart similarity index 99% rename from lib/screens/account/publisher_edit.dart rename to lib/screens/account/publishers/publisher_edit.dart index 1c0095b..81d9b50 100644 --- a/lib/screens/account/publisher_edit.dart +++ b/lib/screens/account/publishers/publisher_edit.dart @@ -134,7 +134,7 @@ class _AccountPublisherEditScreenState const Gap(4), TextField( controller: _descriptionController, - maxLines: 3, + maxLines: null, minLines: 3, decoration: InputDecoration( labelText: 'fieldDescription'.tr(), diff --git a/lib/screens/account/publisher_new.dart b/lib/screens/account/publishers/publisher_new.dart similarity index 100% rename from lib/screens/account/publisher_new.dart rename to lib/screens/account/publishers/publisher_new.dart diff --git a/lib/screens/account/publishers.dart b/lib/screens/account/publishers/publishers.dart similarity index 100% rename from lib/screens/account/publishers.dart rename to lib/screens/account/publishers/publishers.dart diff --git a/lib/widgets/account/account_image.dart b/lib/widgets/account/account_image.dart index ccb62fe..d5aaa22 100644 --- a/lib/widgets/account/account_image.dart +++ b/lib/widgets/account/account_image.dart @@ -4,7 +4,7 @@ import 'package:surface/providers/sn_network.dart'; import 'package:surface/widgets/universal_image.dart'; class AccountImage extends StatelessWidget { - final String content; + final String? content; final Color? backgroundColor; final Color? foregroundColor; final double? radius; @@ -22,21 +22,21 @@ class AccountImage extends StatelessWidget { @override Widget build(BuildContext context) { final sn = context.read(); - final url = sn.getAttachmentUrl(content); + final url = sn.getAttachmentUrl(content ?? ''); final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; return CircleAvatar( key: Key('attachment-${content.hashCode}'), radius: radius, backgroundColor: backgroundColor, - backgroundImage: content.isNotEmpty + backgroundImage: (content?.isNotEmpty ?? false) ? ResizeImage( UniversalImage.provider(url), width: ((radius ?? 20) * devicePixelRatio * 2).round(), height: ((radius ?? 20) * devicePixelRatio * 2).round(), ) : null, - child: content.isEmpty + child: (content?.isEmpty ?? true) ? (fallbackWidget ?? Icon( Icons.account_circle, diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 80f0c37..0a1f571 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStoragePlugin"); flutter_secure_storage_plugin_register_with_registrar(flutter_secure_storage_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 3d4708b..aa8db8f 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,11 +3,13 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux flutter_secure_storage url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + croppy jni ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 311f992..de38c1e 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,8 @@ import FlutterMacOS import Foundation import connectivity_plus +import file_selector_macos +import flutter_image_compress_macos import path_provider_foundation import shared_preferences_foundation import sqflite_darwin @@ -13,6 +15,8 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/pubspec.lock b/pubspec.lock index b028ed2..c1d952c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -158,6 +158,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + cassowary: + dependency: transitive + description: + name: cassowary + sha256: f304452beaf93b9349daaeeda23f853578c9dd8674c06c6100fda0319c46b967 + url: "https://pub.dev" + source: hosted + version: "0.4.3" characters: dependency: transitive description: @@ -230,6 +238,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + croppy: + dependency: "direct main" + description: + name: croppy + sha256: "14bb40fd6c1771b093a907ddbf24df9aa49a4e6e379dd630602eb446e30ec629" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + cross_file: + dependency: "direct main" + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" crypto: dependency: transitive description: @@ -334,6 +358,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.2" + equatable: + dependency: transitive + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" fake_async: dependency: transitive description: @@ -358,6 +390,46 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: aac85f20436608e01a6ffd1fdd4e746a7f33c93a2c83752e626bdfaea139b877 + url: "https://pub.dev" + source: hosted + version: "8.1.3" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "712ce7fab537ba532c8febdb1a8f167b32441e74acd68c3ccb2e36dcb52c4ab2" + url: "https://pub.dev" + source: hosted + version: "0.9.3" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc" + url: "https://pub.dev" + source: hosted + version: "0.9.4+2" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "8f5d2f6590d51ecd9179ba39c64f722edc15226cc93dcc8698466ad36a4a85a4" + url: "https://pub.dev" + source: hosted + version: "0.9.3+3" fixnum: dependency: transitive description: @@ -387,6 +459,54 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" + flutter_image_compress: + dependency: "direct main" + description: + name: flutter_image_compress + sha256: "45a3071868092a61b11044c70422b04d39d4d9f2ef536f3c5b11fb65a1e7dd90" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + flutter_image_compress_common: + dependency: transitive + description: + name: flutter_image_compress_common + sha256: "7f79bc6c8a363063620b4e372fa86bc691e1cb28e58048cd38e030692fbd99ee" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + flutter_image_compress_macos: + dependency: transitive + description: + name: flutter_image_compress_macos + sha256: "26df6385512e92b3789dc76b613b54b55c457a7f1532e59078b04bf189782d47" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + flutter_image_compress_ohos: + dependency: transitive + description: + name: flutter_image_compress_ohos + sha256: e76b92bbc830ee08f5b05962fc78a532011fcd2041f620b5400a593e96da3f51 + url: "https://pub.dev" + source: hosted + version: "0.0.3" + flutter_image_compress_platform_interface: + dependency: transitive + description: + name: flutter_image_compress_platform_interface + sha256: "579cb3947fd4309103afe6442a01ca01e1e6f93dc53bb4cbd090e8ce34a41889" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + flutter_image_compress_web: + dependency: transitive + description: + name: flutter_image_compress_web + sha256: f02fe352b17f82b72f481de45add240db062a2585850bea1667e82cc4cd6c311 + url: "https://pub.dev" + source: hosted + version: "0.1.4+1" flutter_lints: dependency: "direct dev" description: @@ -416,6 +536,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.2" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "9b78450b89f059e96c9ebb355fa6b3df1d6b330436e0b885fb49594c41721398" + url: "https://pub.dev" + source: hosted + version: "2.0.23" flutter_secure_storage: dependency: "direct main" description: @@ -562,6 +690,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.3.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "8faba09ba361d4b246dc0a17cb4289b3324c2b9f6db7b3d457ee69106a86bd32" + url: "https://pub.dev" + source: hosted + version: "0.8.12+17" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "4f0568120c6fcc0aaa04511cb9f9f4d29fc3d0139884b1d06be88dcec7641d6b" + url: "https://pub.dev" + source: hosted + version: "0.8.12+1" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "9ec26d410ff46f483c5519c29c02ef0e02e13a543f882b152d4bfd2f06802f80" + url: "https://pub.dev" + source: hosted + version: "2.10.0" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" intl: dependency: transitive description: @@ -1263,6 +1455,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "84ba388638ed7a8cb3445a320c8273136ab2631cd5f2c57888335504ddab1bc2" + url: "https://pub.dev" + source: hosted + version: "5.8.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9ae35c2..de6553a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,11 @@ dependencies: path: ^1.9.0 relative_time: ^5.0.0 flutter_secure_storage: ^4.2.1 + image_picker: ^1.1.2 + cross_file: ^0.3.4+2 + file_picker: ^8.1.3 + flutter_image_compress: ^2.3.0 + croppy: ^1.3.1 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 5777988..384ea41 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,11 +7,14 @@ #include "generated_plugin_registrant.h" #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { ConnectivityPlusWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 0b0bda0..a8a1fe3 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,10 +4,12 @@ list(APPEND FLUTTER_PLUGIN_LIST connectivity_plus + file_selector_windows url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + croppy jni )