diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 64df944..0fac258 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -8,6 +8,9 @@ "screenAuthLoginGreeting": "Welcome back", "screenAuthRegister": "Create an account", "screenAuthRegisterSubtitle": "Create a Solarpass account", + "screenAccountPublishers": "Publishers", + "screenAccountPublisherNew": "New Publisher", + "screenAccountPublisherEdit": "Edit Publisher", "dialogOkay": "Okay", "dialogCancel": "Cancel", "dialogConfirm": "Confirm", @@ -21,10 +24,17 @@ "errorRequestUnknown": "Unknown request error, maybe you want to take screenshot and report it to us.", "prev": "Next", "next": "Previous", + "edit": "Edit", + "apply": "Apply", + "create": "Create", + "preview": "Preview", + "loading": "Loading...", "fieldUsername": "Username", "fieldNickname": "Nickname", "fieldEmail": "Email address", "fieldPassword": "Password", + "fieldDescription": "Description", + "fieldUsernameCannotEditHint": "Username cannot be edited after created", "fieldUsernameLookupHint": "You can use username, phone number or email to login", "forgotPassword": "Forgot password", "loginPickFactor": "Pick a factor", @@ -43,5 +53,8 @@ "accountLogoutConfirmTitle": "Are you sure you want to logout?", "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." + "accountPublishersSubtitle": "Manage your publish identities.", + "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 74cbce6..e37e5ea 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -8,6 +8,9 @@ "screenAuthLoginGreeting": "欢迎回来", "screenAuthRegister": "创建账号", "screenAuthRegisterSubtitle": "创建一个 Solarpass 账号", + "screenAccountPublishers": "发布者", + "screenAccountPublisherNew": "新建发布者", + "screenAccountPublisherEdit": "编辑发布者", "dialogOkay": "好的", "dialogCancel": "取消", "dialogConfirm": "确认", @@ -19,13 +22,20 @@ "errorRequestNotFound": "您正查找的资源无法被找到。", "errorRequestConnection": "网络连接错误,请检查您的网络状态或者检查我们的服务状态。", "errorRequestUnknown": "位置请求错误,您可能想将此对话框截图并发送给我们。", + "loading": "加载中…", "prev": "上一步", "next": "下一步", + "edit": "编辑", + "apply": "应用", + "create": "创建", + "preview": "预览", "fieldUsername": "用户名", "fieldNickname": "显示名", "fieldEmail": "电子邮箱地址", "fieldPassword": "密码", + "fieldUsernameCannotEditHint": "用户名在创建后无法修改", "fieldUsernameLookupHint": "支持用户名、电话号码或邮箱地址", + "fieldDescription": "简介", "forgotPassword": "忘记密码", "loginPickFactor": "选择方式验证", "loginMultiFactor": { @@ -43,5 +53,8 @@ "accountLogoutConfirmTitle": "您确定要退出登录吗?", "accountLogoutConfirm": "您需要重新输入账号密码,甚至可能需要多步验证来再次登陆。", "accountPublishers": "你的发布者", - "accountPublishersSubtitle": "管理你的公共形象。" + "accountPublishersSubtitle": "管理你的公共形象。", + "publishersNew": "新发布者", + "publisherNewSubtitle": "创建一个新的公共身份。", + "publisherSyncWithAccount": "同步账户信息" } diff --git a/lib/providers/userinfo.dart b/lib/providers/userinfo.dart index 5c3d6f8..607ec52 100644 --- a/lib/providers/userinfo.dart +++ b/lib/providers/userinfo.dart @@ -28,11 +28,10 @@ class UserProvider extends ChangeNotifier { } Future refreshUser() async { - if (!isAuthorized) return null; - final resp = await _sn.client.get('/cgi/id/users/me'); final out = SnAccount.fromJson(resp.data); + isAuthorized = true; user = out; notifyListeners(); @@ -40,7 +39,7 @@ class UserProvider extends ChangeNotifier { } void logoutUser() async { - _sn.clearTokenPair(); + await _sn.clearTokenPair(); isAuthorized = false; user = null; notifyListeners(); diff --git a/lib/router.dart b/lib/router.dart index fc956ae..fe1ab37 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -1,5 +1,8 @@ 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/auth/login.dart'; import 'package:surface/screens/auth/register.dart'; import 'package:surface/screens/explore.dart'; @@ -43,10 +46,27 @@ final appRouter = GoRouter( builder: (context, state) => const LoginScreen(), ), GoRoute( - path: '/auth.register', + path: '/auth/register', name: 'authRegister', builder: (context, state) => const RegisterScreen(), ), + GoRoute( + path: '/account/publishers', + name: 'accountPublishers', + builder: (context, state) => const PublisherScreen(), + ), + GoRoute( + path: '/account/publishers/new', + name: 'accountPublisherNew', + builder: (context, state) => const AccountPublisherNewScreen(), + ), + GoRoute( + path: '/account/publishers/edit/:name', + name: 'accountPublisherEdit', + builder: (context, state) => AccountPublisherEditScreen( + name: state.pathParameters['name']!, + ), + ), ], ), ], diff --git a/lib/screens/account.dart b/lib/screens/account.dart index 99914be..846c2c3 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -31,7 +31,7 @@ class AccountScreen extends StatelessWidget { } class _AuthorizedAccountScreen extends StatelessWidget { - const _AuthorizedAccountScreen({super.key}); + const _AuthorizedAccountScreen(); @override Widget build(BuildContext context) { @@ -80,7 +80,9 @@ class _AuthorizedAccountScreen extends StatelessWidget { contentPadding: const EdgeInsets.symmetric(horizontal: 24), leading: const Icon(Symbols.face), trailing: const Icon(Icons.chevron_right), - onTap: () {}, + onTap: () { + GoRouter.of(context).pushNamed('accountPublishers'); + }, ), ListTile( title: Text('accountLogout').tr(), @@ -105,7 +107,7 @@ class _AuthorizedAccountScreen extends StatelessWidget { } class _UnauthorizedAccountScreen extends StatelessWidget { - const _UnauthorizedAccountScreen({super.key}); + const _UnauthorizedAccountScreen(); @override Widget build(BuildContext context) { @@ -117,7 +119,10 @@ class _UnauthorizedAccountScreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Symbols.waving_hand, size: 32), + const CircleAvatar( + radius: 28, + child: Icon(Symbols.waving_hand, size: 28), + ), const Gap(8), Text('accountIntroTitle') .tr() diff --git a/lib/screens/account/publisher_edit.dart b/lib/screens/account/publisher_edit.dart new file mode 100644 index 0000000..1c0095b --- /dev/null +++ b/lib/screens/account/publisher_edit.dart @@ -0,0 +1,166 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:surface/providers/sn_network.dart'; +import 'package:surface/providers/userinfo.dart'; +import 'package:surface/types/post.dart'; +import 'package:surface/widgets/dialog.dart'; +import 'package:surface/widgets/loading_indicator.dart'; +import 'package:surface/widgets/navigation/app_scaffold.dart'; + +class AccountPublisherEditScreen extends StatefulWidget { + final String name; + const AccountPublisherEditScreen({super.key, required this.name}); + + @override + State createState() => + _AccountPublisherEditScreenState(); +} + +class _AccountPublisherEditScreenState + extends State { + bool _isBusy = false; + + SnPublisher? _publisher; + + String? _avatar; + String? _banner; + + final TextEditingController _nickController = TextEditingController(); + final TextEditingController _nameController = TextEditingController(); + final TextEditingController _descriptionController = TextEditingController(); + + Future _fetchPublisher() async { + final sn = context.read(); + final ua = context.read(); + if (!ua.isAuthorized) return; + + setState(() => _isBusy = true); + + try { + final resp = await sn.client.get('/cgi/co/publishers/${widget.name}'); + _publisher = SnPublisher.fromJson(resp.data); + _syncWidget(); + } catch (err) { + context.showErrorDialog(err); + } finally { + setState(() => _isBusy = false); + } + } + + Future _performAction() async { + final sn = context.read(); + final ua = context.read(); + if (!ua.isAuthorized) return; + + setState(() => _isBusy = true); + + try { + await sn.client.put('/cgi/co/publishers/${widget.name}', data: { + 'avatar': _avatar, + 'banner': _banner, + 'nick': _nickController.text, + 'name': _nameController.text, + 'description': _descriptionController.text, + }); + Navigator.pop(context, true); + } catch (err) { + context.showErrorDialog(err); + } finally { + setState(() => _isBusy = false); + } + } + + void _syncWidget() { + _avatar = _publisher!.avatar; + _banner = _publisher!.banner; + _nickController.text = _publisher!.nick; + _nameController.text = _publisher!.name; + _descriptionController.text = _publisher!.description; + } + + void _syncWithAccount() { + final ua = context.read(); + _avatar = ua.user!.avatar; + _banner = ua.user!.banner; + _nickController.text = ua.user!.nick; + _nameController.text = ua.user!.name; + _descriptionController.text = ua.user!.description; + } + + @override + void initState() { + super.initState(); + _fetchPublisher(); + } + + @override + void dispose() { + _nickController.dispose(); + _nameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AppScaffold( + body: SingleChildScrollView( + child: Column( + children: [ + LoadingIndicator(isActive: _isBusy), + TextField( + controller: _nameController, + readOnly: true, + decoration: InputDecoration( + labelText: 'fieldUsername'.tr(), + helperText: 'fieldUsernameCannotEditHint'.tr(), + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ), + const Gap(4), + TextField( + controller: _nickController, + decoration: InputDecoration( + labelText: 'fieldNickname'.tr(), + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ), + const Gap(4), + TextField( + controller: _descriptionController, + maxLines: 3, + minLines: 3, + decoration: InputDecoration( + labelText: 'fieldDescription'.tr(), + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ), + const Gap(12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton.icon( + onPressed: _syncWithAccount, + label: Text('publisherSyncWithAccount').tr(), + icon: const Icon(Symbols.sync), + ), + ElevatedButton.icon( + onPressed: _isBusy ? null : _performAction, + label: Text('apply').tr(), + icon: const Icon(Symbols.save), + ), + ], + ) + ], + ).padding(horizontal: 16, vertical: 12), + ), + ); + } +} diff --git a/lib/screens/account/publisher_new.dart b/lib/screens/account/publisher_new.dart new file mode 100644 index 0000000..9d52db2 --- /dev/null +++ b/lib/screens/account/publisher_new.dart @@ -0,0 +1,131 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.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/navigation/app_scaffold.dart'; + +class AccountPublisherNewScreen extends StatefulWidget { + const AccountPublisherNewScreen({super.key}); + + @override + State createState() => + _AccountPublisherNewScreenState(); +} + +class _AccountPublisherNewScreenState extends State { + String mode = 'personal'; + + @override + Widget build(BuildContext context) { + return AppScaffold( + body: SingleChildScrollView( + child: Column( + children: [ + SizedBox( + width: double.infinity, + child: SegmentedButton( + segments: const >[ + ButtonSegment( + value: 'personal', + label: Text('Personal'), + icon: Icon(Symbols.account_box)), + ButtonSegment( + value: 'organization', + label: Text('Organization'), + icon: Icon(Symbols.group)), + ], + selected: {mode}, + onSelectionChanged: (Set newSelection) { + setState(() => mode = newSelection.first); + }, + ), + ), + switch (mode) { + 'personal' => const _PublisherNewPersonal(), + _ => const Placeholder(), + }, + ], + ).padding(horizontal: 16, vertical: 12), + ), + ); + } +} + +class _PublisherNewPersonal extends StatefulWidget { + const _PublisherNewPersonal(); + + @override + State<_PublisherNewPersonal> createState() => _PublisherNewPersonalState(); +} + +class _PublisherNewPersonalState extends State<_PublisherNewPersonal> { + bool _isBusy = false; + + void _performAction() async { + final sn = context.read(); + final ua = context.read(); + if (!ua.isAuthorized) return; + + setState(() => _isBusy = true); + + try { + await sn.client.post('/cgi/co/publishers/personal'); + Navigator.pop(context, true); + } catch (err) { + context.showErrorDialog(err); + } finally { + setState(() => _isBusy = false); + } + } + + @override + Widget build(BuildContext context) { + final ua = context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('preview') + .tr() + .textStyle(Theme.of(context).textTheme.titleMedium!) + .padding(horizontal: 16, vertical: 4), + Card( + child: SizedBox( + width: double.infinity, + child: Row( + children: [ + AccountImage(content: ua.user!.avatar, radius: 24), + const Gap(16), + Column( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(ua.user!.nick) + .textStyle(Theme.of(context).textTheme.titleLarge!), + const Gap(4), + Text('@${ua.user!.name}') + .textStyle(Theme.of(context).textTheme.bodySmall!), + ], + ), + ], + ), + ).padding(all: 16), + ), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isBusy ? null : _performAction, + icon: const Icon(Icons.add), + label: Text('create').tr(), + ), + ).padding(horizontal: 2), + ], + ); + } +} diff --git a/lib/screens/account/publishers.dart b/lib/screens/account/publishers.dart new file mode 100644 index 0000000..7ebb1f0 --- /dev/null +++ b/lib/screens/account/publishers.dart @@ -0,0 +1,129 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; +import 'package:surface/providers/sn_network.dart'; +import 'package:surface/providers/userinfo.dart'; +import 'package:surface/types/post.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/navigation/app_scaffold.dart'; + +class PublisherScreen extends StatefulWidget { + const PublisherScreen({super.key}); + + @override + State createState() => _PublisherScreenState(); +} + +class _PublisherScreenState extends State { + bool _isBusy = false; + + final List _publishers = List.empty(growable: true); + + Future _fetchPublishers() async { + final sn = context.read(); + final ua = context.read(); + if (!ua.isAuthorized) return; + + setState(() => _isBusy = true); + + try { + final resp = await sn.client.get('/cgi/co/publishers'); + final List out = List.from( + resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []); + + if (!mounted) return; + + _publishers.addAll(out); + } catch (err) { + context.showErrorDialog(err); + } finally { + setState(() => _isBusy = false); + } + } + + @override + void initState() { + super.initState(); + _fetchPublishers(); + } + + @override + Widget build(BuildContext context) { + return AppScaffold( + body: Column( + children: [ + ListTile( + title: Text('publishersNew').tr(), + subtitle: Text('publisherNewSubtitle').tr(), + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Symbols.add_circle), + onTap: () { + GoRouter.of(context) + .pushNamed('accountPublisherNew') + .then((value) { + if (value == true) { + _publishers.clear(); + _fetchPublishers(); + } + }); + }, + ), + const Divider(height: 1), + LoadingIndicator(isActive: _isBusy), + Expanded( + child: RefreshIndicator( + onRefresh: () { + _publishers.clear(); + return _fetchPublishers(); + }, + child: ListView.builder( + itemCount: _publishers.length, + itemBuilder: (context, idx) { + final publisher = _publishers[idx]; + return ListTile( + title: Text(publisher.nick), + subtitle: Text('@${publisher.name}'), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + leading: AccountImage(content: publisher.avatar), + trailing: PopupMenuButton( + itemBuilder: (BuildContext context) => [ + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.edit), + const Gap(16), + Text('edit').tr(), + ], + ), + onTap: () { + GoRouter.of(context).pushNamed( + 'accountPublisherEdit', + pathParameters: { + 'name': publisher.name, + }, + ).then((value) { + if (value == true) { + _publishers.clear(); + _fetchPublishers(); + } + }); + }, + ), + ], + ), + ); + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/auth/login.dart b/lib/screens/auth/login.dart index b34f73a..ef0f51a 100644 --- a/lib/screens/auth/login.dart +++ b/lib/screens/auth/login.dart @@ -158,8 +158,9 @@ class _LoginCheckScreenState extends State<_LoginCheckScreen> { context.showSnackbar('loginSuccess'.tr(args: [ '@${userinfo!.name} (${userinfo.nick})', ])); - - Navigator.pop(context); + await Future.delayed(const Duration(milliseconds: 1850), () { + Navigator.pop(context); + }); } catch (err) { context.showErrorDialog(err); return; diff --git a/lib/screens/auth/register.dart b/lib/screens/auth/register.dart index bc70375..09ac1df 100644 --- a/lib/screens/auth/register.dart +++ b/lib/screens/auth/register.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -43,9 +44,7 @@ class _RegisterScreenState extends State { if (!mounted) return; - // TODO make celebration here - // ignore: use_build_context_synchronously - Navigator.pop(context); + GoRouter.of(context).replaceNamed("authLogin"); } catch (err) { context.showErrorDialog(err); } diff --git a/lib/theme.dart b/lib/theme.dart index 2818356..0086af2 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -21,5 +21,6 @@ ThemeData createAppTheme() { seedColor: Colors.indigo, brightness: Brightness.light, ), + iconTheme: const IconThemeData(fill: 0, weight: 400, opticalSize: 20), ); } diff --git a/lib/widgets/loading_indicator.dart b/lib/widgets/loading_indicator.dart new file mode 100644 index 0000000..c2f0499 --- /dev/null +++ b/lib/widgets/loading_indicator.dart @@ -0,0 +1,89 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; + +class LoadingIndicator extends StatefulWidget { + final bool isActive; + final Color? backgroundColor; + + const LoadingIndicator({ + super.key, + this.isActive = true, + this.backgroundColor, + }); + + @override + State createState() => _LoadingIndicatorState(); +} + +class _LoadingIndicatorState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + + _animation = CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ); + + if (widget.isActive) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + + @override + void didUpdateWidget(covariant LoadingIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.isActive != oldWidget.isActive) { + if (widget.isActive) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizeTransition( + sizeFactor: _animation, + axisAlignment: -1, // Align animation from the top + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), + child: widget.isActive + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + const Gap(16), + Text('loading').tr(), + ], + ) + : const SizedBox.shrink(), + ), + ); + } +} diff --git a/lib/widgets/navigation/app_destinations.dart b/lib/widgets/navigation/app_destinations.dart index f137daf..01e371b 100644 --- a/lib/widgets/navigation/app_destinations.dart +++ b/lib/widgets/navigation/app_destinations.dart @@ -16,17 +16,17 @@ class AppNavDestination { List appDestinations = [ AppNavDestination( - icon: Icon(Symbols.home), + icon: Icon(Symbols.home, weight: 400, opticalSize: 20), screen: 'home', label: tr('screenHome'), ), AppNavDestination( - icon: Icon(Symbols.explore), + icon: Icon(Symbols.explore, weight: 400, opticalSize: 20), screen: 'explore', label: tr('screenExplore'), ), AppNavDestination( - icon: Icon(Symbols.account_circle), + icon: Icon(Symbols.account_circle, weight: 400, opticalSize: 20), screen: 'account', label: tr('screenAccount'), ),