diff --git a/assets/locales/en_us.json b/assets/locales/en_us.json index 7195ce5..f5f0711 100644 --- a/assets/locales/en_us.json +++ b/assets/locales/en_us.json @@ -228,6 +228,8 @@ "realmDescription": "Description", "realmPublic": "Public Realm", "realmCommunity": "Community Realm", + "realmAvatar": "Realm avatar", + "realmBanner": "Realm banner", "realmDetail": "Realm detail", "realmMember": "Realm member", "realmMembers": "Realm members", diff --git a/assets/locales/zh_cn.json b/assets/locales/zh_cn.json index 844f8ec..5cb69fa 100644 --- a/assets/locales/zh_cn.json +++ b/assets/locales/zh_cn.json @@ -229,6 +229,8 @@ "realmDescription": "领域简介", "realmPublic": "公开领域", "realmCommunity": "社区领域", + "realmAvatar": "领域头像", + "realmBanner": "领域横幅", "realmDetail": "领域详情", "realmMember": "领域成员", "realmMembers": "领域成员", diff --git a/lib/router.dart b/lib/router.dart index d74594f..15d8849 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -5,7 +5,7 @@ import 'package:solian/models/realm.dart'; import 'package:solian/screens/about.dart'; import 'package:solian/screens/account.dart'; import 'package:solian/screens/account/friend.dart'; -import 'package:solian/screens/account/personalize.dart'; +import 'package:solian/screens/account/profile_edit.dart'; import 'package:solian/screens/account/profile_page.dart'; import 'package:solian/screens/auth/signin.dart'; import 'package:solian/screens/auth/signup.dart'; diff --git a/lib/screens/account/personalize.dart b/lib/screens/account/profile_edit.dart similarity index 100% rename from lib/screens/account/personalize.dart rename to lib/screens/account/profile_edit.dart diff --git a/lib/screens/channel/channel_detail.dart b/lib/screens/channel/channel_detail.dart index 375df02..df8a557 100644 --- a/lib/screens/channel/channel_detail.dart +++ b/lib/screens/channel/channel_detail.dart @@ -114,7 +114,7 @@ class _ChannelDetailScreenState extends State { ListTile( leading: const Icon(Icons.settings), trailing: const Icon(Icons.chevron_right), - title: Text('channelSettings'.tr.capitalize!), + title: Text('channelSettings'.tr), onTap: () async { AppRouter.instance .pushNamed( @@ -173,7 +173,7 @@ class _ChannelDetailScreenState extends State { children: [ ListTile( leading: const Icon(Icons.notifications_active), - title: Text('channelNotifyLevel'.tr.capitalize!), + title: Text('channelNotifyLevel'.tr), trailing: DropdownButtonHideUnderline( child: DropdownButton2( isExpanded: true, @@ -208,7 +208,7 @@ class _ChannelDetailScreenState extends State { ListTile( leading: const Icon(Icons.supervisor_account), trailing: const Icon(Icons.chevron_right), - title: Text('channelMembers'.tr.capitalize!), + title: Text('channelMembers'.tr), onTap: () => showMemberList(), ), ...(_isOwned ? ownerActions : List.empty()), diff --git a/lib/screens/channel/channel_organize.dart b/lib/screens/channel/channel_organize.dart index 36263be..0ecf9d9 100644 --- a/lib/screens/channel/channel_organize.dart +++ b/lib/screens/channel/channel_organize.dart @@ -97,6 +97,14 @@ class _ChannelOrganizeScreenState extends State { super.initState(); } + @override + void dispose() { + _aliasController.dispose(); + _nameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final notifyBannerActions = [ diff --git a/lib/screens/realms.dart b/lib/screens/realms.dart index 96a5e75..ab5d5eb 100644 --- a/lib/screens/realms.dart +++ b/lib/screens/realms.dart @@ -7,10 +7,13 @@ import 'package:solian/providers/auth.dart'; import 'package:solian/providers/content/realm.dart'; import 'package:solian/router.dart'; import 'package:solian/screens/account/notification.dart'; +import 'package:solian/services.dart'; import 'package:solian/theme.dart'; +import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/signin_required_overlay.dart'; import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_title.dart'; +import 'package:solian/widgets/auto_cache_image.dart'; import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/sized_container.dart'; @@ -128,19 +131,34 @@ class _RealmListScreenState extends State { children: [ Container( color: Theme.of(context).colorScheme.surfaceContainer, + child: (element.banner?.isEmpty ?? true) + ? const SizedBox.shrink() + : AutoCacheImage( + ServiceFinder.buildUrl( + 'uc', + '/attachments/${element.banner}', + ), + fit: BoxFit.cover, + ), ), - const Positioned( + Positioned( bottom: -30, left: 18, - child: CircleAvatar( - radius: 24, - backgroundColor: Colors.indigo, - child: FaIcon( - FontAwesomeIcons.globe, - color: Colors.white, - size: 18, - ), - ), + child: (element.avatar?.isEmpty ?? true) + ? CircleAvatar( + radius: 24, + backgroundColor: + Theme.of(context).colorScheme.primary, + child: const FaIcon( + FontAwesomeIcons.globe, + color: Colors.white, + size: 18, + ), + ) + : AccountAvatar( + content: element.avatar!, + bgColor: Theme.of(context).colorScheme.primary, + ), ), ], ), diff --git a/lib/screens/realms/realm_detail.dart b/lib/screens/realms/realm_detail.dart index ee5b899..7132e41 100644 --- a/lib/screens/realms/realm_detail.dart +++ b/lib/screens/realms/realm_detail.dart @@ -69,7 +69,8 @@ class _RealmDetailScreenState extends State { ListTile( leading: const Icon(Icons.settings), trailing: const Icon(Icons.chevron_right), - title: Text('realmSettings'.tr.capitalize!), + title: Text('realmSettings'.tr), + contentPadding: const EdgeInsets.symmetric(horizontal: 24), onTap: () async { AppRouter.instance .pushNamed( @@ -120,14 +121,16 @@ class _RealmDetailScreenState extends State { child: ListView( children: [ ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), leading: const Icon(Icons.supervisor_account), trailing: const Icon(Icons.chevron_right), - title: Text('realmMembers'.tr.capitalize!), + title: Text('realmMembers'.tr), onTap: () => showMemberList(), ), ...(_isOwned ? ownerActions : List.empty()), const Divider(thickness: 0.3), ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), leading: _isOwned ? const Icon(Icons.delete) : const Icon(Icons.exit_to_app), diff --git a/lib/screens/realms/realm_organize.dart b/lib/screens/realms/realm_organize.dart index eba450b..85f2786 100644 --- a/lib/screens/realms/realm_organize.dart +++ b/lib/screens/realms/realm_organize.dart @@ -1,9 +1,15 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:get/get.dart'; +import 'package:image_cropper/image_cropper.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:solian/exts.dart'; +import 'package:solian/models/attachment.dart'; import 'package:solian/models/realm.dart'; import 'package:solian/providers/auth.dart'; +import 'package:solian/providers/content/attachment.dart'; import 'package:solian/router.dart'; import 'package:solian/theme.dart'; import 'package:solian/widgets/app_bar_leading.dart'; @@ -29,17 +35,19 @@ class _RealmOrganizeScreenState extends State { bool _isBusy = false; final _aliasController = TextEditingController(); + final _avatarController = TextEditingController(); + final _bannerController = TextEditingController(); final _nameController = TextEditingController(); final _descriptionController = TextEditingController(); bool _isCommunity = false; bool _isPublic = false; - void applyRealm() async { + void _applyRealm() async { final AuthProvider auth = Get.find(); if (auth.isAuthorized.isFalse) return; - if (_aliasController.value.text.isEmpty) randomizeAlias(); + if (_aliasController.value.text.isEmpty) _randomizeAlias(); setState(() => _isBusy = true); @@ -49,6 +57,8 @@ class _RealmOrganizeScreenState extends State { 'alias': _aliasController.value.text.toLowerCase(), 'name': _nameController.value.text, 'description': _descriptionController.value.text, + 'avatar': _avatarController.value.text, + 'banner': _bannerController.value.text, 'is_public': _isPublic, 'is_community': _isCommunity, }; @@ -68,31 +78,110 @@ class _RealmOrganizeScreenState extends State { setState(() => _isBusy = false); } - void randomizeAlias() { + final _imagePicker = ImagePicker(); + + Future _editImage(String position) async { + final AuthProvider auth = Get.find(); + if (auth.isAuthorized.isFalse) return; + + final image = await _imagePicker.pickImage(source: ImageSource.gallery); + if (image == null) return; + + CroppedFile? croppedFile = await ImageCropper().cropImage( + sourcePath: image.path, + uiSettings: [ + AndroidUiSettings( + toolbarTitle: 'cropImage'.tr, + toolbarColor: Theme.of(context).colorScheme.primary, + toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary, + aspectRatioPresets: [ + if (position == 'avatar') CropAspectRatioPreset.square, + if (position == 'banner') _BannerCropAspectRatioPreset(), + ], + ), + IOSUiSettings( + title: 'cropImage'.tr, + aspectRatioPresets: [ + if (position == 'avatar') CropAspectRatioPreset.square, + if (position == 'banner') _BannerCropAspectRatioPreset(), + ], + ), + WebUiSettings( + context: context, + ), + ], + ); + + if (croppedFile == null) return; + final file = File(croppedFile.path); + + setState(() => _isBusy = true); + + final AttachmentProvider attach = Get.find(); + + Attachment? attachResult; + try { + attachResult = await attach.createAttachmentDirectly( + await file.readAsBytes(), + file.path, + 'avatar', + null, + ); + } catch (e) { + setState(() => _isBusy = false); + context.showErrorDialog(e); + return; + } + + switch (position) { + case 'avatar': + _avatarController.text = attachResult.rid; + break; + case 'banner': + _bannerController.text = attachResult.rid; + break; + } + + setState(() => _isBusy = false); + } + + void _randomizeAlias() { _aliasController.text = const Uuid().v4().replaceAll('-', '').substring(0, 12); } - void syncWidget() { + void _syncWidget() { if (widget.edit != null) { _aliasController.text = widget.edit!.alias; _nameController.text = widget.edit!.name; _descriptionController.text = widget.edit!.description; + _avatarController.text = widget.edit!.avatar ?? ''; + _bannerController.text = widget.edit!.banner ?? ''; _isPublic = widget.edit!.isPublic; _isCommunity = widget.edit!.isCommunity; } } - void cancelAction() { + void _cancelAction() { AppRouter.instance.pop(); } @override void initState() { - syncWidget(); + _syncWidget(); super.initState(); } + @override + void dispose() { + _aliasController.dispose(); + _avatarController.dispose(); + _bannerController.dispose(); + _nameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Material( @@ -105,7 +194,7 @@ class _RealmOrganizeScreenState extends State { toolbarHeight: AppTheme.toolbarHeight(context), actions: [ TextButton( - onPressed: _isBusy ? null : () => applyRealm(), + onPressed: _isBusy ? null : () => _applyRealm(), child: Text('apply'.tr.toUpperCase()), ) ], @@ -126,7 +215,7 @@ class _RealmOrganizeScreenState extends State { ), actions: [ TextButton( - onPressed: cancelAction, + onPressed: _cancelAction, child: Text('cancel'.tr), ), ], @@ -150,7 +239,7 @@ class _RealmOrganizeScreenState extends State { visualDensity: const VisualDensity(horizontal: -2, vertical: -2), ), - onPressed: () => randomizeAlias(), + onPressed: () => _randomizeAlias(), child: const Icon(Icons.refresh), ) ], @@ -166,6 +255,55 @@ class _RealmOrganizeScreenState extends State { FocusManager.instance.primaryFocus?.unfocus(), ).paddingSymmetric(horizontal: 16, vertical: 8), const Divider(thickness: 0.3), + Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + controller: _avatarController, + decoration: InputDecoration.collapsed( + hintText: 'realmAvatar'.tr, + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ), + ), + TextButton( + style: TextButton.styleFrom( + shape: const CircleBorder(), + visualDensity: + const VisualDensity(horizontal: -2, vertical: -2), + ), + onPressed: _isBusy ? null : () => _editImage('avatar'), + child: const Icon(Icons.upload), + ) + ], + ).paddingSymmetric(horizontal: 16, vertical: 2), + Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + controller: _bannerController, + decoration: InputDecoration.collapsed( + hintText: 'realmBanner'.tr, + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ), + ), + TextButton( + style: TextButton.styleFrom( + shape: const CircleBorder(), + visualDensity: + const VisualDensity(horizontal: -2, vertical: -2), + ), + onPressed: _isBusy ? null : () => _editImage('banner'), + child: const Icon(Icons.upload), + ) + ], + ).paddingSymmetric(horizontal: 16, vertical: 2), + const Divider(thickness: 0.3), Expanded( child: TextField( minLines: 5, @@ -202,3 +340,11 @@ class _RealmOrganizeScreenState extends State { ); } } + +class _BannerCropAspectRatioPreset extends CropAspectRatioPresetData { + @override + (int, int)? get data => (16, 7); + + @override + String get name => '16x7'; +} diff --git a/lib/widgets/navigation/app_navigation_region.dart b/lib/widgets/navigation/app_navigation_region.dart index 0a09db7..ef88360 100644 --- a/lib/widgets/navigation/app_navigation_region.dart +++ b/lib/widgets/navigation/app_navigation_region.dart @@ -6,7 +6,9 @@ import 'package:solian/providers/auth.dart'; import 'package:solian/providers/content/channel.dart'; import 'package:solian/providers/content/realm.dart'; import 'package:solian/providers/navigation.dart'; +import 'package:solian/services.dart'; import 'package:solian/widgets/account/account_avatar.dart'; +import 'package:solian/widgets/auto_cache_image.dart'; import 'package:solian/widgets/channel/channel_list.dart'; class AppNavigationRegion extends StatefulWidget { @@ -169,6 +171,19 @@ class _AppNavigationRegionState extends State { ) : Column( children: [ + if (!widget.isCollapsed && + (navState.focusedRealm.value!.banner?.isNotEmpty ?? + false)) + AspectRatio( + aspectRatio: 16 / 7, + child: AutoCacheImage( + ServiceFinder.buildUrl( + 'uc', + '/attachments/${navState.focusedRealm.value!.banner}', + ), + fit: BoxFit.cover, + ), + ), if (widget.isCollapsed) Tooltip( message: navState.focusedRealm.value!.name,