Compare commits

...

2 Commits

Author SHA1 Message Date
3395f3dbd0 Create auth factor 2025-01-28 00:52:44 +08:00
d258ba776e ♻️ Splitting up account page and settings 2025-01-27 20:14:02 +08:00
9 changed files with 406 additions and 92 deletions

View File

@ -17,6 +17,7 @@
"screenAccountProfileEdit": "Edit Profile",
"screenAbuseReport": "Abuse Reports",
"screenSettings": "Settings",
"screenAccountSettings": "Account Settings",
"screenNews": "News",
"screenAlbum": "Album",
"screenChat": "Chat",
@ -28,6 +29,7 @@
"screenNotification": "Notification",
"screenPostSearch": "Search Posts",
"screenFriend": "Friends",
"screenFactorSettings": "Auth Factors",
"dialogOkay": "Okay",
"dialogCancel": "Cancel",
"dialogConfirm": "Confirm",
@ -105,7 +107,15 @@
"loginEnterPassword": "Enter the code",
"loginSuccess": "Logged in as {}",
"authFactorPassword": "Password",
"authFactorPasswordDescription": "The password you set when you registered.",
"authFactorEmail": "Email verification code",
"authFactorEmailDescription": "An one-time code sent to the email address you set when you registered.",
"authFactorTOTP": "Time-based OTP",
"authFactorTOTPDescription": "A one-time code generated by a TOTP authenticator such as Google Authenticator or Authy.",
"authFactorInAppNotify": "In-app notification",
"authFactorInAppNotifyDescription": "A one-time code sent via in-app notification.",
"authFactorAdd": "Add a factor",
"authFactorAddSubtitle": "Provide another way to login your account.",
"accountIntroTitle": "Hello there!",
"accountIntroSubtitle": "Pick an option below to get started.",
"accountLogout": "Logout",
@ -114,8 +124,12 @@
"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.",
"accountSettings": "Account Settings",
"accountSettingsSubtitle": "Manage your account and make it yours.",
"accountProfileEdit": "Edit your profile",
"accountProfileEditSubtitle": "Make your Solarpass account more looks like you.",
"factorSettings": "Auth Factors",
"factorSettingsSubtitle": "Manage your authentication factors.",
"accountProfileEditApplied": "Profile modification applied.",
"publishersNew": "New Publisher",
"publisherNewSubtitle": "Create a new publisher identity.",

View File

@ -15,6 +15,7 @@
"screenAccountProfileEdit": "编辑资料",
"screenAbuseReport": "滥用检举",
"screenSettings": "设置",
"screenAccountSettings": "账号设置",
"screenNews": "新闻",
"screenAlbum": "相册",
"screenChat": "聊天",
@ -89,7 +90,15 @@
"loginEnterPassword": "验证代码",
"loginSuccess": "登录为 {}",
"authFactorPassword": "密码",
"authFactorPasswordDescription": "注册时选择设置的密码。",
"authFactorEmail": "电邮一次性验证码",
"authFactorEmailDescription": "由我们生成并发送到绑定的的电子邮箱的一次性验证码。",
"authFactorTOTP": "时序验证码",
"authFactorTOTPDescription": "使用 Google Authenticator 或 Authy 等验证器生成的一次性验证码。",
"authFactorInAppNotify": "应用内通知验证码",
"authFactorInAppNotifyDescription": "通过站内通知推送的一次性验证码。",
"authFactorAdd": "添加新验证因子",
"authFactorAddSubtitle": "给你的帐户登陆时提供另一个方案。",
"accountIntroTitle": "喜欢您来!",
"accountIntroSubtitle": "登陆以探索更广大的世界。",
"accountLogout": "退出登录",
@ -98,8 +107,12 @@
"accountLogoutConfirm": "您需要重新输入账号密码,甚至可能需要多步验证来再次登陆。",
"accountPublishers": "你的发布者",
"accountPublishersSubtitle": "管理你的公共形象。",
"accountSettings": "帐号设置",
"accountSettingsSubtitle": "管理你的帐号并让它更好的服务你。",
"accountProfileEdit": "编辑资料",
"accountProfileEditSubtitle": "使你的 Solarpass 账户更像你。",
"factorSettings": "验证因子",
"factorSettingsSubtitle": "管理你的登陆验证方式。",
"accountProfileEditApplied": "个人资料修改已被应用。",
"publishersNew": "新发布者",
"publisherNewSubtitle": "创建一个新的公共身份。",

View File

@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:surface/screens/abuse_report.dart';
import 'package:surface/screens/account.dart';
import 'package:surface/screens/account/account_settings.dart';
import 'package:surface/screens/account/factor_settings.dart';
import 'package:surface/screens/account/profile_page.dart';
import 'package:surface/screens/account/profile_edit.dart';
import 'package:surface/screens/account/publishers/publisher_edit.dart';
@ -96,11 +98,47 @@ final _appRoutes = [
),
],
),
GoRoute(
path: '/account',
name: 'account',
builder: (context, state) => const AccountScreen(),
),
GoRoute(path: '/account', name: 'account', builder: (context, state) => const AccountScreen(), routes: [
GoRoute(
path: '/settings',
name: 'accountSettings',
builder: (context, state) => AccountSettingsScreen(),
),
GoRoute(
path: '/settings/factors',
name: 'factorSettings',
builder: (context, state) => FactorSettingsScreen(),
),
GoRoute(
path: '/profile/edit',
name: 'accountProfileEdit',
builder: (context, state) => ProfileEditScreen(),
),
GoRoute(
path: '/publishers',
name: 'accountPublishers',
builder: (context, state) => PublisherScreen(),
),
GoRoute(
path: '/publishers/new',
name: 'accountPublisherNew',
builder: (context, state) => AccountPublisherNewScreen(),
),
GoRoute(
path: '/publishers/edit/:name',
name: 'accountPublisherEdit',
builder: (context, state) => AccountPublisherEditScreen(
name: state.pathParameters['name']!,
),
),
GoRoute(
path: '/:name',
name: 'accountProfilePage',
pageBuilder: (context, state) => NoTransitionPage(
child: UserScreen(name: state.pathParameters['name']!),
),
),
]),
GoRoute(
path: '/chat',
name: 'chat',
@ -161,20 +199,15 @@ final _appRoutes = [
),
],
),
GoRoute(
path: '/news',
name: 'news',
builder: (context, state) => const NewsScreen(),
routes: [
GoRoute(
path: '/:hash',
name: 'newsDetail',
builder: (context, state) => NewsDetailScreen(
hash: state.pathParameters['hash']!,
),
GoRoute(path: '/news', name: 'news', builder: (context, state) => const NewsScreen(), routes: [
GoRoute(
path: '/:hash',
name: 'newsDetail',
builder: (context, state) => NewsDetailScreen(
hash: state.pathParameters['hash']!,
),
]
),
),
]),
GoRoute(
path: '/album',
name: 'album',
@ -205,35 +238,6 @@ final _appRoutes = [
name: 'abuseReport',
builder: (context, state) => AbuseReportScreen(),
),
GoRoute(
path: '/account/profile/edit',
name: 'accountProfileEdit',
builder: (context, state) => ProfileEditScreen(),
),
GoRoute(
path: '/account/publishers',
name: 'accountPublishers',
builder: (context, state) => PublisherScreen(),
),
GoRoute(
path: '/account/publishers/new',
name: 'accountPublisherNew',
builder: (context, state) => AccountPublisherNewScreen(),
),
GoRoute(
path: '/account/publishers/edit/:name',
name: 'accountPublisherEdit',
builder: (context, state) => AccountPublisherEditScreen(
name: state.pathParameters['name']!,
),
),
GoRoute(
path: '/account/:name',
name: 'accountProfilePage',
pageBuilder: (context, state) => NoTransitionPage(
child: UserScreen(name: state.pathParameters['name']!),
),
),
GoRoute(
path: '/settings',
name: 'settings',

View File

@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
@ -13,6 +15,7 @@ import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/universal_image.dart';
class AccountScreen extends StatelessWidget {
const AccountScreen({super.key});
@ -20,11 +23,39 @@ class AccountScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final ua = context.watch<UserProvider>();
final sn = context.read<SnNetworkProvider>();
return AppScaffold(
appBar: AppBar(
leading: AutoAppBarLeading(),
title: Text("screenAccount").tr(),
flexibleSpace: ua.user != null && ua.user!.banner.isNotEmpty
? Stack(
fit: StackFit.expand,
children: [
AutoResizeUniversalImage(sn.getAttachmentUrl(ua.user!.banner), fit: BoxFit.cover),
Positioned(
top: 0,
left: 0,
right: 0,
height: 56 + MediaQuery.of(context).padding.top,
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 10,
sigmaY: 10,
),
child: Container(
color: Colors.black.withOpacity(
clampDouble(10 * 0.1, 0, 0.5),
),
),
),
),
),
],
)
: null,
actions: [
IconButton(
icon: const Icon(Symbols.settings, fill: 1),
@ -83,16 +114,6 @@ 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(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountProfileEdit');
},
),
ListTile(
title: Text('accountPublishers').tr(),
subtitle: Text('accountPublishersSubtitle').tr(),
@ -113,6 +134,26 @@ class _AuthorizedAccountScreen extends StatelessWidget {
GoRouter.of(context).pushNamed('abuseReport');
},
),
ListTile(
title: Text('factorSettings').tr(),
subtitle: Text('factorSettingsSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.lock),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('factorSettings');
},
),
ListTile(
title: Text('accountSettings').tr(),
subtitle: Text('accountSettingsSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.manage_accounts),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountSettings');
},
),
ListTile(
title: Text('accountLogout').tr(),
subtitle: Text('accountLogoutSubtitle').tr(),
@ -134,33 +175,6 @@ class _AuthorizedAccountScreen extends StatelessWidget {
await Hive.initFlutter();
},
),
ListTile(
title: Text('accountDeletion'.tr()),
subtitle: Text('accountDeletionActionDescription'.tr()),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.person_cancel),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
context
.showConfirmDialog(
'accountDeletion'.tr(),
'accountDeletionDescription'.tr(),
)
.then((value) {
if (!value || !context.mounted) return;
final sn = context.read<SnNetworkProvider>();
sn.client.post('/cgi/id/users/me/deletion').then((value) {
if (context.mounted) {
context.showSnackbar('accountDeletionSubmitted'.tr());
}
}).catchError((err) {
if (context.mounted) {
context.showErrorDialog(err);
}
});
});
},
),
],
);
}

View File

@ -0,0 +1,66 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.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/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class AccountSettingsScreen extends StatelessWidget {
const AccountSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
leading: PageBackButton(),
title: Text('screenAccountSettings').tr(),
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text('accountProfileEdit').tr(),
subtitle: Text('accountProfileEditSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.contact_page),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountProfileEdit');
},
),
ListTile(
title: Text('accountDeletion'.tr()),
subtitle: Text('accountDeletionActionDescription'.tr()),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.person_cancel),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
context
.showConfirmDialog(
'accountDeletion'.tr(),
'accountDeletionDescription'.tr(),
)
.then((value) {
if (!value || !context.mounted) return;
final sn = context.read<SnNetworkProvider>();
sn.client.post('/cgi/id/users/me/deletion').then((value) {
if (context.mounted) {
context.showSnackbar('accountDeletionSubmitted'.tr());
}
}).catchError((err) {
if (context.mounted) {
context.showErrorDialog(err);
}
});
});
},
),
],
),
),
);
}
}

View File

@ -0,0 +1,201 @@
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.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/types/auth.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
final Map<int, (String, String, IconData)> _kFactorTypes = {
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
2: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
3: ('authFactorInAppNotify', 'authFactorInAppNotifyDescription', Symbols.notifications_active),
};
class FactorSettingsScreen extends StatefulWidget {
const FactorSettingsScreen({super.key});
@override
State<FactorSettingsScreen> createState() => _FactorSettingsScreenState();
}
class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
List<SnAuthFactor>? _factors;
Future<void> _fetchFactors() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/me/factors');
_factors = List<SnAuthFactor>.from(
resp.data?.map((e) => SnAuthFactor.fromJson(e as Map<String, dynamic>)).toList() ?? [],
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() {});
}
}
@override
void initState() {
super.initState();
_fetchFactors();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
leading: PageBackButton(),
title: Text('screenFactorSettings').tr(),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LoadingIndicator(
isActive: _factors == null,
),
ListTile(
title: Text('authFactorAdd').tr(),
subtitle: Text('authFactorAddSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.add),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
showDialog(
context: context,
builder: (context) => _FactorNewDialog(
currentlyHave: _factors!,
),
).then((val) {
if (val == true) _fetchFactors();
});
},
),
const Divider(height: 1),
Expanded(
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: RefreshIndicator(
onRefresh: _fetchFactors,
child: ListView.builder(
itemCount: _factors?.length ?? 0,
itemBuilder: (context, idx) {
final ele = _factors![idx];
return ListTile(
title: Text(_kFactorTypes[ele.type]!.$1).tr(),
subtitle: Text(_kFactorTypes[ele.type]!.$2).tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(_kFactorTypes[ele.type]!.$3),
);
},
),
),
),
),
],
),
);
}
}
class _FactorNewDialog extends StatefulWidget {
final List<SnAuthFactor> currentlyHave;
const _FactorNewDialog({required this.currentlyHave});
@override
State<_FactorNewDialog> createState() => _FactorNewDialogState();
}
class _FactorNewDialogState extends State<_FactorNewDialog> {
int? _factorType;
bool _isBusy = false;
Future<void> _submit() async {
try {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.post('/cgi/id/users/me/factors', data: {
'type': _factorType,
});
// TODO show qrcode when creating totp factor
if (!mounted) return;
Navigator.of(context).pop(true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('authFactorAdd').tr(),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonHideUnderline(
child: DropdownButton2<int>(
hint: Text(
'Select Item',
style: TextStyle(
fontSize: 14,
),
overflow: TextOverflow.ellipsis,
),
value: _factorType,
items: _kFactorTypes.entries.map(
(ele) {
final contains = widget.currentlyHave.map((ele) => ele.type).contains(ele.key);
return DropdownMenuItem<int>(
enabled: !contains,
value: ele.key,
child: Text(
ele.value.$1.tr(),
style: const TextStyle(
fontSize: 14,
),
).opacity(contains ? 0.75 : 1),
);
},
).toList(),
onChanged: (val) => setState(() {
_factorType = val;
}),
buttonStyleData: ButtonStyleData(
height: 50,
padding: const EdgeInsets.only(left: 14, right: 14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Theme.of(context).dividerColor,
),
),
),
),
),
],
),
actions: [
TextButton(
onPressed: _isBusy ? null : () => Navigator.of(context).pop(),
child: Text('dialogCancel').tr(),
),
TextButton(
onPressed: _isBusy ? null : () => _submit(),
child: Text('dialogConfirm').tr(),
),
],
);
}
}

View File

@ -15,8 +15,8 @@ class SnAccount with _$SnAccount {
required DateTime? deletedAt,
required DateTime? confirmedAt,
required List<SnAccountContact>? contacts,
required String avatar,
required String banner,
@Default("") String avatar,
@Default("") String banner,
required String description,
required String name,
required String nick,

View File

@ -367,8 +367,8 @@ class _$SnAccountImpl extends _SnAccount {
required this.deletedAt,
required this.confirmedAt,
required final List<SnAccountContact>? contacts,
required this.avatar,
required this.banner,
this.avatar = "",
this.banner = "",
required this.description,
required this.name,
required this.nick,
@ -410,8 +410,10 @@ class _$SnAccountImpl extends _SnAccount {
}
@override
@JsonKey()
final String avatar;
@override
@JsonKey()
final String banner;
@override
final String description;
@ -540,8 +542,8 @@ abstract class _SnAccount extends SnAccount {
required final DateTime? deletedAt,
required final DateTime? confirmedAt,
required final List<SnAccountContact>? contacts,
required final String avatar,
required final String banner,
final String avatar,
final String banner,
required final String description,
required final String name,
required final String nick,

View File

@ -20,8 +20,8 @@ _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
contacts: (json['contacts'] as List<dynamic>?)
?.map((e) => SnAccountContact.fromJson(e as Map<String, dynamic>))
.toList(),
avatar: json['avatar'] as String,
banner: json['banner'] as String,
avatar: json['avatar'] as String? ?? "",
banner: json['banner'] as String? ?? "",
description: json['description'] as String,
name: json['name'] as String,
nick: json['nick'] as String,