✨ Login & register
This commit is contained in:
parent
5e12a8860c
commit
4d12d243b3
@ -1,5 +1,39 @@
|
||||
{
|
||||
"screenHome": "Home",
|
||||
"screenExplore": "Explore",
|
||||
"screenAccount": "Account"
|
||||
"screen": "Screen",
|
||||
"screenHome": "Home",
|
||||
"screenExplore": "Explore",
|
||||
"screenAccount": "Account",
|
||||
"screenAuthLogin": "Login",
|
||||
"screenAuthLoginSubtitle": "Login to Solar Network using Solarpass",
|
||||
"screenAuthLoginGreeting": "Welcome back",
|
||||
"screenAuthRegister": "Create an account",
|
||||
"screenAuthRegisterSubtitle": "Create a Solarpass account",
|
||||
"dialogOkay": "Okay",
|
||||
"dialogCancel": "Cancel",
|
||||
"dialogConfirm": "Confirm",
|
||||
"dialogDismiss": "Dismiss",
|
||||
"dialogError": "Something went wrong",
|
||||
"errorRequestBad": "Bad request, please check your input.",
|
||||
"errorRequestUnauthorized": "Unauthorized request, please login or try re-login.",
|
||||
"errorRequestForbidden": "Forbidden request, you have not enough permission to do that.",
|
||||
"errorRequestNotFound": "The resource that you looking for is not found.",
|
||||
"errorRequestConnection": "Network connection error, please check your network or the service status.",
|
||||
"errorRequestUnknown": "Unknown request error, maybe you want to take screenshot and report it to us.",
|
||||
"prev": "Next",
|
||||
"next": "Previous",
|
||||
"fieldUsername": "Username",
|
||||
"fieldNickname": "Nickname",
|
||||
"fieldEmail": "Email address",
|
||||
"fieldPassword": "Password",
|
||||
"fieldUsernameLookupHint": "You can use username, phone number or email to login",
|
||||
"forgotPassword": "Forgot password",
|
||||
"loginPickFactor": "Pick a factor",
|
||||
"loginMultiFactor": {
|
||||
"one": "{} step left",
|
||||
"other": "{} steps left"
|
||||
},
|
||||
"loginEnterPassword": "Enter the code",
|
||||
"loginSuccess": "Logged in as {}",
|
||||
"authFactorPassword": "Password",
|
||||
"authFactorEmail": "Email verification code"
|
||||
}
|
@ -1,5 +1,39 @@
|
||||
{
|
||||
"screenHome": "首页",
|
||||
"screenExplore": "探索" ,
|
||||
"screenAccount": "您"
|
||||
"screen": "页面",
|
||||
"screenHome": "首页",
|
||||
"screenExplore": "探索",
|
||||
"screenAccount": "您",
|
||||
"screenAuthLogin": "登陆",
|
||||
"screenAuthLoginSubtitle": "使用 Solarpass 登陆 Solar Network",
|
||||
"screenAuthLoginGreeting": "欢迎回来",
|
||||
"screenAuthRegister": "创建账号",
|
||||
"screenAuthRegisterSubtitle": "创建一个 Solarpass 账号",
|
||||
"dialogOkay": "好的",
|
||||
"dialogCancel": "取消",
|
||||
"dialogConfirm": "确认",
|
||||
"dialogDismiss": "忽略",
|
||||
"dialogError": "出了点问题",
|
||||
"errorRequestBad": "服务器拒绝了您的请求,请检查您的输入。",
|
||||
"errorRequestUnauthorized": "未授权的请求,请登录或者尝试重新登陆。",
|
||||
"errorRequestForbidden": "被禁止的请求,您没有足够的权限去做那件事。",
|
||||
"errorRequestNotFound": "您正查找的资源无法被找到。",
|
||||
"errorRequestConnection": "网络连接错误,请检查您的网络状态或者检查我们的服务状态。",
|
||||
"errorRequestUnknown": "位置请求错误,您可能想将此对话框截图并发送给我们。",
|
||||
"prev": "上一步",
|
||||
"next": "下一步",
|
||||
"fieldUsername": "用户名",
|
||||
"fieldNickname": "显示名",
|
||||
"fieldEmail": "电子邮箱地址",
|
||||
"fieldPassword": "密码",
|
||||
"fieldUsernameLookupHint": "支持用户名、电话号码或邮箱地址",
|
||||
"forgotPassword": "忘记密码",
|
||||
"loginPickFactor": "选择方式验证",
|
||||
"loginMultiFactor": {
|
||||
"one": "{} 步验证",
|
||||
"other": "{} 步验证"
|
||||
},
|
||||
"loginEnterPassword": "验证代码",
|
||||
"loginSuccess": "登录为 {}",
|
||||
"authFactorPassword": "密码",
|
||||
"authFactorEmail": "电邮一次性验证码"
|
||||
}
|
@ -7,6 +7,8 @@ PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_native_splash (0.0.1):
|
||||
- Flutter
|
||||
- flutter_secure_storage (3.3.1):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@ -24,6 +26,7 @@ DEPENDENCIES:
|
||||
- cupertino_http (from `.symlinks/plugins/cupertino_http/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/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`)
|
||||
@ -38,6 +41,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter
|
||||
flutter_native_splash:
|
||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||
flutter_secure_storage:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
shared_preferences_foundation:
|
||||
@ -52,6 +57,7 @@ SPEC CHECKSUMS:
|
||||
cupertino_http: 1a3a0f163c1b26e7f1a293b33d476e0fde7a64ec
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
|
||||
flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
|
@ -33,11 +33,15 @@ class SolianApp extends StatelessWidget {
|
||||
providers: [
|
||||
Provider(create: (_) => SnNetworkProvider()),
|
||||
Provider(create: (ctx) => SnAttachmentProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (_) => UserProvider()),
|
||||
ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (_) => ThemeProvider()),
|
||||
],
|
||||
child: Builder(builder: (context) {
|
||||
// Initialize some providers
|
||||
context.read<UserProvider>();
|
||||
|
||||
final th = context.watch<ThemeProvider>();
|
||||
|
||||
return MaterialApp.router(
|
||||
theme: th.theme.light,
|
||||
darkTheme: th.theme.dark,
|
||||
|
@ -1,15 +1,23 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio_smart_retry/dio_smart_retry.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:native_dio_adapter/native_dio_adapter.dart';
|
||||
|
||||
const kUseLocalNetwork = true;
|
||||
|
||||
const kAtkStoreKey = 'nex_user_atk';
|
||||
const kRtkStoreKey = 'nex_user_rtk';
|
||||
|
||||
class SnNetworkProvider {
|
||||
late final Dio client;
|
||||
|
||||
late final FlutterSecureStorage _storage = FlutterSecureStorage();
|
||||
|
||||
SnNetworkProvider() {
|
||||
client = Dio();
|
||||
|
||||
@ -27,6 +35,56 @@ class SnNetworkProvider {
|
||||
],
|
||||
));
|
||||
|
||||
client.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
try {
|
||||
var atk = await _storage.read(key: kAtkStoreKey);
|
||||
if (atk != null) {
|
||||
final atkParts = atk.split('.');
|
||||
if (atkParts.length != 3) {
|
||||
throw Exception('invalid format of access token');
|
||||
}
|
||||
|
||||
var rawPayload =
|
||||
atkParts[1].replaceAll('-', '+').replaceAll('_', '/');
|
||||
switch (rawPayload.length % 4) {
|
||||
case 0:
|
||||
break;
|
||||
case 2:
|
||||
rawPayload += '==';
|
||||
break;
|
||||
case 3:
|
||||
rawPayload += '=';
|
||||
break;
|
||||
default:
|
||||
throw Exception('illegal format of access token payload');
|
||||
}
|
||||
|
||||
final b64 = utf8.fuse(base64Url);
|
||||
final payload = b64.decode(rawPayload);
|
||||
final exp = jsonDecode(payload)['exp'];
|
||||
if (exp >= DateTime.now().millisecondsSinceEpoch) {
|
||||
log('Access token need refresh, doing it at ${DateTime.now()}');
|
||||
atk = await refreshToken();
|
||||
}
|
||||
|
||||
if (atk != null) {
|
||||
options.headers['Authorization'] = 'Bearer $atk';
|
||||
} else {
|
||||
log('Access token refresh failed...');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
handler.next(options);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) {
|
||||
// Switch to native implementation if possible
|
||||
client.httpClientAdapter = NativeAdapter();
|
||||
@ -37,4 +95,34 @@ class SnNetworkProvider {
|
||||
if (ky.startsWith("http://")) return ky;
|
||||
return '${client.options.baseUrl}/cgi/uc/attachments/$ky';
|
||||
}
|
||||
|
||||
Future<void> setTokenPair(String atk, String rtk) async {
|
||||
await Future.wait([
|
||||
_storage.write(key: kAtkStoreKey, value: atk),
|
||||
_storage.write(key: kRtkStoreKey, value: rtk),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> clearTokenPair() async {
|
||||
await Future.wait([
|
||||
_storage.delete(key: kAtkStoreKey),
|
||||
_storage.delete(key: kRtkStoreKey),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<String?> refreshToken() async {
|
||||
final rtk = await _storage.read(key: kRtkStoreKey);
|
||||
if (rtk == null) return null;
|
||||
|
||||
final resp = await client.post('/cgi/id/auth/token', data: {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': rtk,
|
||||
});
|
||||
|
||||
final atk = resp.data['access_token'];
|
||||
final nRtk = resp.data['refresh_token'];
|
||||
await setTokenPair(atk, nRtk);
|
||||
|
||||
return atk;
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,42 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dart:developer';
|
||||
|
||||
class UserProvider extends ChangeNotifier {}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/account.dart';
|
||||
|
||||
class UserProvider extends ChangeNotifier {
|
||||
bool isAuthorized = false;
|
||||
SnAccount? user;
|
||||
|
||||
late final SnNetworkProvider sn;
|
||||
|
||||
late final FlutterSecureStorage _storage = FlutterSecureStorage();
|
||||
|
||||
UserProvider(BuildContext context) {
|
||||
sn = context.read<SnNetworkProvider>();
|
||||
|
||||
_storage.read(key: kAtkStoreKey).then((value) {
|
||||
isAuthorized = value != null;
|
||||
notifyListeners();
|
||||
refreshUser().then((value) {
|
||||
if (value != null) {
|
||||
log('Logged in as @${value.name}');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<SnAccount?> refreshUser() async {
|
||||
if (!isAuthorized) return null;
|
||||
|
||||
final resp = await sn.client.get('/cgi/id/users/me');
|
||||
final out = SnAccount.fromJson(resp.data);
|
||||
|
||||
user = out;
|
||||
notifyListeners();
|
||||
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:surface/screens/account.dart';
|
||||
import 'package:surface/screens/auth/login.dart';
|
||||
import 'package:surface/screens/auth/register.dart';
|
||||
import 'package:surface/screens/explore.dart';
|
||||
import 'package:surface/screens/home.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
@ -28,6 +30,24 @@ final appRouter = GoRouter(
|
||||
builder: (context, state) => const AccountScreen(),
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => AppScaffold(
|
||||
body: child,
|
||||
autoImplyAppBar: true,
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/auth/login',
|
||||
name: 'authLogin',
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/auth.register',
|
||||
name: 'authRegister',
|
||||
builder: (context, state) => const RegisterScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
|
||||
class AccountScreen extends StatefulWidget {
|
||||
@ -14,7 +15,29 @@ class _AccountScreenState extends State<AccountScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("screenHome").tr(),
|
||||
title: Text("screenAccount").tr(),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text('screenAuthLogin').tr(),
|
||||
subtitle: Text('screenAuthLoginSubtitle').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('authLogin');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('screenAuthRegister').tr(),
|
||||
subtitle: Text('screenAuthRegisterSubtitle').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('authRegister');
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
556
lib/screens/auth/login.dart
Normal file
556
lib/screens/auth/login.dart
Normal file
@ -0,0 +1,556 @@
|
||||
import 'package:animations/animations.dart';
|
||||
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/auth.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
final Map<int, (String label, IconData icon, bool isOtp)> _factorLabelMap = {
|
||||
0: ('authFactorPassword'.tr(), Icons.password, false),
|
||||
1: ('authFactorEmail'.tr(), Icons.email, true),
|
||||
};
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
SnAuthTicket? _currentTicket;
|
||||
|
||||
List<SnAuthFactor>? _factors;
|
||||
SnAuthFactor? _factorPicked;
|
||||
|
||||
int _period = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
|
||||
child: SingleChildScrollView(
|
||||
child: PageTransitionSwitcher(
|
||||
transitionBuilder: (
|
||||
Widget child,
|
||||
Animation<double> primaryAnimation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
return SharedAxisTransition(
|
||||
animation: primaryAnimation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
transitionType: SharedAxisTransitionType.horizontal,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: switch (_period % 3) {
|
||||
1 => _LoginPickerScreen(
|
||||
key: const ValueKey(1),
|
||||
ticket: _currentTicket,
|
||||
factors: _factors,
|
||||
onTicket: (p0) => setState(() {
|
||||
_currentTicket = p0;
|
||||
}),
|
||||
onPickFactor: (p0) => setState(() {
|
||||
_factorPicked = p0;
|
||||
}),
|
||||
onNext: () => setState(() {
|
||||
_period++;
|
||||
}),
|
||||
),
|
||||
2 => _LoginCheckScreen(
|
||||
key: const ValueKey(2),
|
||||
ticket: _currentTicket,
|
||||
factor: _factorPicked,
|
||||
onTicket: (p0) => setState(() {
|
||||
_currentTicket = p0;
|
||||
}),
|
||||
onNext: (p0) => setState(() {
|
||||
_period = 1;
|
||||
}),
|
||||
),
|
||||
_ => _LoginLookupScreen(
|
||||
key: const ValueKey(0),
|
||||
ticket: _currentTicket,
|
||||
onTicket: (p0) => setState(() {
|
||||
_currentTicket = p0;
|
||||
}),
|
||||
onFactor: (p0) => setState(() {
|
||||
_factors = p0;
|
||||
}),
|
||||
onNext: () => setState(() {
|
||||
_period++;
|
||||
}),
|
||||
),
|
||||
},
|
||||
).padding(all: 24),
|
||||
).center(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginCheckScreen extends StatefulWidget {
|
||||
final SnAuthTicket? ticket;
|
||||
final SnAuthFactor? factor;
|
||||
final Function(SnAuthTicket?) onTicket;
|
||||
final Function onNext;
|
||||
const _LoginCheckScreen({
|
||||
super.key,
|
||||
required this.ticket,
|
||||
required this.factor,
|
||||
required this.onTicket,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_LoginCheckScreen> createState() => _LoginCheckScreenState();
|
||||
}
|
||||
|
||||
class _LoginCheckScreenState extends State<_LoginCheckScreen> {
|
||||
bool _isBusy = false;
|
||||
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
void _performCheckTicket() async {
|
||||
final password = _passwordController.value.text;
|
||||
if (password.isEmpty) return;
|
||||
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
// Check ticket
|
||||
final resp = await sn.client.patch('/cgi/id/auth', data: {
|
||||
'ticket_id': widget.ticket!.id,
|
||||
'factor_id': widget.factor!.id,
|
||||
'code': password,
|
||||
});
|
||||
|
||||
final result = SnAuthResult.fromJson(resp.data);
|
||||
widget.onTicket(result.ticket);
|
||||
|
||||
if (!result.isFinished) {
|
||||
widget.onNext();
|
||||
return;
|
||||
}
|
||||
|
||||
// Finish sign in if possible
|
||||
final tokenResp = await sn.client.post('/cgi/id/auth/token', data: {
|
||||
'grant_type': 'grant_token',
|
||||
'code': result.ticket!.grantToken,
|
||||
});
|
||||
final atk = tokenResp.data['access_token'];
|
||||
final rtk = tokenResp.data['refresh_token'];
|
||||
await sn.setTokenPair(atk, rtk);
|
||||
if (!mounted) return;
|
||||
final user = context.read<UserProvider>();
|
||||
final userinfo = await user.refreshUser();
|
||||
context.showSnackbar('loginSuccess'.tr(args: [
|
||||
'@${userinfo!.name} (${userinfo.nick})',
|
||||
]));
|
||||
|
||||
Navigator.pop(context);
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
return;
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(
|
||||
Symbols.password,
|
||||
size: 28,
|
||||
),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'loginEnterPassword'.tr(),
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
).padding(left: 4, bottom: 16),
|
||||
TextField(
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
autofillHints: [
|
||||
(_factorLabelMap[widget.factor!.type]?.$3 ?? true)
|
||||
? AutofillHints.password
|
||||
: AutofillHints.oneTimeCode
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldPassword'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onSubmitted: _isBusy ? null : (_) => _performCheckTicket(),
|
||||
).padding(horizontal: 7),
|
||||
const Gap(12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : () => _performCheckTicket(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('next').tr(),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginPickerScreen extends StatefulWidget {
|
||||
final SnAuthTicket? ticket;
|
||||
final List<SnAuthFactor>? factors;
|
||||
final Function(SnAuthTicket?) onTicket;
|
||||
final Function(SnAuthFactor) onPickFactor;
|
||||
final Function onNext;
|
||||
const _LoginPickerScreen({
|
||||
super.key,
|
||||
required this.ticket,
|
||||
required this.factors,
|
||||
required this.onTicket,
|
||||
required this.onPickFactor,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_LoginPickerScreen> createState() => _LoginPickerScreenState();
|
||||
}
|
||||
|
||||
class _LoginPickerScreenState extends State<_LoginPickerScreen> {
|
||||
bool _isBusy = false;
|
||||
int? _factorPicked;
|
||||
|
||||
Color get _unFocusColor =>
|
||||
Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round());
|
||||
|
||||
void _performGetFactorCode() async {
|
||||
if (_factorPicked == null) return;
|
||||
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
// Request one-time-password code
|
||||
sn.client.post('/cgi/id/auth/factors/$_factorPicked');
|
||||
widget.onPickFactor(
|
||||
widget.factors!.where((x) => x.id == _factorPicked).first,
|
||||
);
|
||||
widget.onNext();
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
return;
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
key: const ValueKey<int>(1),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(
|
||||
Symbols.security,
|
||||
size: 28,
|
||||
),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'loginPickFactor',
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
).tr().padding(left: 4),
|
||||
const Gap(8),
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
children: widget.factors
|
||||
?.map(
|
||||
(x) => CheckboxListTile(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
),
|
||||
secondary: Icon(
|
||||
_factorLabelMap[x.type]?.$2 ?? Icons.question_mark,
|
||||
),
|
||||
title: Text(
|
||||
_factorLabelMap[x.type]?.$1 ?? 'unknown'.tr(),
|
||||
),
|
||||
enabled: !widget.ticket!.factorTrail.contains(x.id),
|
||||
value: _factorPicked == x.id,
|
||||
onChanged: (value) {
|
||||
if (value == true) {
|
||||
setState(() => _factorPicked = x.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
List.empty(),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'loginMultiFactor'.plural(
|
||||
widget.ticket!.stepRemain,
|
||||
),
|
||||
style: TextStyle(color: _unFocusColor, fontSize: 13),
|
||||
).padding(horizontal: 16),
|
||||
const Gap(12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : () => _performGetFactorCode(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('next'.tr()),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginLookupScreen extends StatefulWidget {
|
||||
final SnAuthTicket? ticket;
|
||||
final Function(SnAuthTicket?) onTicket;
|
||||
final Function(List<SnAuthFactor>?) onFactor;
|
||||
final Function onNext;
|
||||
const _LoginLookupScreen({
|
||||
super.key,
|
||||
required this.ticket,
|
||||
required this.onTicket,
|
||||
required this.onFactor,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_LoginLookupScreen> createState() => _LoginLookupScreenState();
|
||||
}
|
||||
|
||||
class _LoginLookupScreenState extends State<_LoginLookupScreen> {
|
||||
final _usernameController = TextEditingController();
|
||||
|
||||
bool _isBusy = false;
|
||||
|
||||
void _requestResetPassword() async {
|
||||
final username = _usernameController.value.text;
|
||||
if (username.isEmpty) {
|
||||
context.showErrorDialog('signinResetPasswordHint'.tr());
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final lookupResp =
|
||||
await sn.client.get('/cgi/id/users/lookup?probe=$username');
|
||||
await sn.client.post('/cgi/id/users/me/password-reset', data: {
|
||||
'user_id': lookupResp.data['id'],
|
||||
});
|
||||
context.showModalDialog('done'.tr(), 'signinResetPasswordSent'.tr());
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _performNewTicket() async {
|
||||
final username = _usernameController.value.text;
|
||||
if (username.isEmpty) return;
|
||||
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
// Create ticket
|
||||
final resp = await sn.client.post('/cgi/id/auth', data: {
|
||||
'username': username,
|
||||
});
|
||||
final result = SnAuthResult.fromJson(resp.data);
|
||||
widget.onTicket(result.ticket);
|
||||
|
||||
// Pull factors
|
||||
final factorResp =
|
||||
await sn.client.get('/cgi/id/auth/factors', queryParameters: {
|
||||
'ticketId': result.ticket!.id.toString(),
|
||||
});
|
||||
widget.onFactor(
|
||||
List<SnAuthFactor>.from(
|
||||
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
||||
),
|
||||
);
|
||||
|
||||
widget.onNext();
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
return;
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(
|
||||
Symbols.login,
|
||||
size: 28,
|
||||
),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'screenAuthLoginGreeting',
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
).tr().padding(left: 4, bottom: 16),
|
||||
TextField(
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
controller: _usernameController,
|
||||
autofillHints: const [AutofillHints.username],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldUsername'.tr(),
|
||||
helperText: 'fieldUsernameLookupHint'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onSubmitted: _isBusy ? null : (_) => _performNewTicket(),
|
||||
).padding(horizontal: 7),
|
||||
const Gap(12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : () => _requestResetPassword(),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.grey),
|
||||
child: Text('forgotPassword'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : () => _performNewTicket(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('next').tr(),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: StyledWidget(
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 290),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'termAcceptNextWithAgree'.tr(),
|
||||
textAlign: TextAlign.end,
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withAlpha((255 * 0.75).round()),
|
||||
),
|
||||
),
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('termAcceptLink'.tr()),
|
||||
const Gap(4),
|
||||
const Icon(Icons.launch, size: 14),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
launchUrlString('https://solsynth.dev/terms');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).padding(horizontal: 16),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
161
lib/screens/auth/register.dart
Normal file
161
lib/screens/auth/register.dart
Normal file
@ -0,0 +1,161 @@
|
||||
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/widgets/dialog.dart';
|
||||
|
||||
class RegisterScreen extends StatefulWidget {
|
||||
const RegisterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RegisterScreen> createState() => _RegisterScreenState();
|
||||
}
|
||||
|
||||
class _RegisterScreenState extends State<RegisterScreen> {
|
||||
final _emailController = TextEditingController();
|
||||
final _usernameController = TextEditingController();
|
||||
final _nicknameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
void _performAction(BuildContext context) async {
|
||||
final email = _emailController.value.text;
|
||||
final username = _usernameController.value.text;
|
||||
final nickname = _nicknameController.value.text;
|
||||
final password = _passwordController.value.text;
|
||||
if (email.isEmpty ||
|
||||
username.isEmpty ||
|
||||
nickname.isEmpty ||
|
||||
password.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.post('/cgi/id/users', data: {
|
||||
'name': username,
|
||||
'nick': nickname,
|
||||
'email': email,
|
||||
'password': password,
|
||||
});
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// TODO make celebration here
|
||||
// ignore: use_build_context_synchronously
|
||||
Navigator.pop(context);
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: StyledWidget(
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(
|
||||
Symbols.person_add,
|
||||
size: 28,
|
||||
),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'screenAuthRegister',
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
).tr().padding(left: 4, bottom: 16),
|
||||
Column(
|
||||
children: [
|
||||
TextField(
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
controller: _usernameController,
|
||||
autofillHints: const [AutofillHints.username],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldUsername'.tr(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(12),
|
||||
TextField(
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
controller: _nicknameController,
|
||||
autofillHints: const [AutofillHints.nickname],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldNickname'.tr(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(12),
|
||||
TextField(
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
controller: _emailController,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldEmail'.tr(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(12),
|
||||
TextField(
|
||||
obscureText: true,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
controller: _passwordController,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldPassword'.tr(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onSubmitted: (_) => _performAction(context),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 7),
|
||||
const Gap(16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () => _performAction(context),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('next').tr(),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
).padding(all: 24).center(),
|
||||
);
|
||||
}
|
||||
}
|
69
lib/types/account.dart
Normal file
69
lib/types/account.dart
Normal file
@ -0,0 +1,69 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'account.freezed.dart';
|
||||
part 'account.g.dart';
|
||||
|
||||
@freezed
|
||||
class SnAccount with _$SnAccount {
|
||||
const factory SnAccount({
|
||||
required int id,
|
||||
required int? affiliatedId,
|
||||
required int? affiliatedTo,
|
||||
required int? automatedBy,
|
||||
required int? automatedId,
|
||||
required String avatar,
|
||||
required String banner,
|
||||
required DateTime? confirmedAt,
|
||||
required List<SnAccountContact> contacts,
|
||||
required DateTime createdAt,
|
||||
required DateTime? deletedAt,
|
||||
required String description,
|
||||
required String name,
|
||||
required String nick,
|
||||
required Map<String, dynamic> permNodes,
|
||||
required SnAccountProfile? profile,
|
||||
required DateTime? suspendedAt,
|
||||
required DateTime updatedAt,
|
||||
}) = _SnAccount;
|
||||
|
||||
factory SnAccount.fromJson(Map<String, Object?> json) =>
|
||||
_$SnAccountFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAccountContact with _$SnAccountContact {
|
||||
const factory SnAccountContact({
|
||||
required int accountId,
|
||||
required String content,
|
||||
required DateTime createdAt,
|
||||
required DateTime? deletedAt,
|
||||
required int id,
|
||||
required bool isPrimary,
|
||||
required bool isPublic,
|
||||
required int type,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? verifiedAt,
|
||||
}) = _SnAccountContact;
|
||||
|
||||
factory SnAccountContact.fromJson(Map<String, Object?> json) =>
|
||||
_$SnAccountContactFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAccountProfile with _$SnAccountProfile {
|
||||
const factory SnAccountProfile({
|
||||
required int id,
|
||||
required int accountId,
|
||||
required DateTime? birthday,
|
||||
required DateTime createdAt,
|
||||
required DateTime? deletedAt,
|
||||
required int experience,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required DateTime? lastSeenAt,
|
||||
required DateTime updatedAt,
|
||||
}) = _SnAccountProfile;
|
||||
|
||||
factory SnAccountProfile.fromJson(Map<String, Object?> json) =>
|
||||
_$SnAccountProfileFromJson(json);
|
||||
}
|
1266
lib/types/account.freezed.dart
Normal file
1266
lib/types/account.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
131
lib/types/account.g.dart
Normal file
131
lib/types/account.g.dart
Normal file
@ -0,0 +1,131 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'account.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnAccountImpl(
|
||||
id: (json['id'] as num).toInt(),
|
||||
affiliatedId: (json['affiliated_id'] as num?)?.toInt(),
|
||||
affiliatedTo: (json['affiliated_to'] as num?)?.toInt(),
|
||||
automatedBy: (json['automated_by'] as num?)?.toInt(),
|
||||
automatedId: (json['automated_id'] as num?)?.toInt(),
|
||||
avatar: json['avatar'] as String,
|
||||
banner: json['banner'] as String,
|
||||
confirmedAt: json['confirmed_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['confirmed_at'] as String),
|
||||
contacts: (json['contacts'] as List<dynamic>)
|
||||
.map((e) => SnAccountContact.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
description: json['description'] as String,
|
||||
name: json['name'] as String,
|
||||
nick: json['nick'] as String,
|
||||
permNodes: json['perm_nodes'] as Map<String, dynamic>,
|
||||
profile: json['profile'] == null
|
||||
? null
|
||||
: SnAccountProfile.fromJson(json['profile'] as Map<String, dynamic>),
|
||||
suspendedAt: json['suspended_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['suspended_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'affiliated_id': instance.affiliatedId,
|
||||
'affiliated_to': instance.affiliatedTo,
|
||||
'automated_by': instance.automatedBy,
|
||||
'automated_id': instance.automatedId,
|
||||
'avatar': instance.avatar,
|
||||
'banner': instance.banner,
|
||||
'confirmed_at': instance.confirmedAt?.toIso8601String(),
|
||||
'contacts': instance.contacts.map((e) => e.toJson()).toList(),
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'description': instance.description,
|
||||
'name': instance.name,
|
||||
'nick': instance.nick,
|
||||
'perm_nodes': instance.permNodes,
|
||||
'profile': instance.profile?.toJson(),
|
||||
'suspended_at': instance.suspendedAt?.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
_$SnAccountContactImpl _$$SnAccountContactImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnAccountContactImpl(
|
||||
accountId: (json['account_id'] as num).toInt(),
|
||||
content: json['content'] as String,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
id: (json['id'] as num).toInt(),
|
||||
isPrimary: json['is_primary'] as bool,
|
||||
isPublic: json['is_public'] as bool,
|
||||
type: (json['type'] as num).toInt(),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
verifiedAt: json['verified_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['verified_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAccountContactImplToJson(
|
||||
_$SnAccountContactImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'account_id': instance.accountId,
|
||||
'content': instance.content,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'id': instance.id,
|
||||
'is_primary': instance.isPrimary,
|
||||
'is_public': instance.isPublic,
|
||||
'type': instance.type,
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'verified_at': instance.verifiedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
_$SnAccountProfileImpl _$$SnAccountProfileImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnAccountProfileImpl(
|
||||
id: (json['id'] as num).toInt(),
|
||||
accountId: (json['account_id'] as num).toInt(),
|
||||
birthday: json['birthday'] == null
|
||||
? null
|
||||
: DateTime.parse(json['birthday'] as String),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
experience: (json['experience'] as num).toInt(),
|
||||
firstName: json['first_name'] as String,
|
||||
lastName: json['last_name'] as String,
|
||||
lastSeenAt: json['last_seen_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['last_seen_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAccountProfileImplToJson(
|
||||
_$SnAccountProfileImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'account_id': instance.accountId,
|
||||
'birthday': instance.birthday?.toIso8601String(),
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'experience': instance.experience,
|
||||
'first_name': instance.firstName,
|
||||
'last_name': instance.lastName,
|
||||
'last_seen_at': instance.lastSeenAt?.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
};
|
57
lib/types/auth.dart
Normal file
57
lib/types/auth.dart
Normal file
@ -0,0 +1,57 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'auth.freezed.dart';
|
||||
part 'auth.g.dart';
|
||||
|
||||
@freezed
|
||||
class SnAuthResult with _$SnAuthResult {
|
||||
const factory SnAuthResult({
|
||||
required bool isFinished,
|
||||
required SnAuthTicket? ticket,
|
||||
}) = _SnAuthResult;
|
||||
|
||||
factory SnAuthResult.fromJson(Map<String, Object?> json) =>
|
||||
_$SnAuthResultFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAuthTicket with _$SnAuthTicket {
|
||||
const factory SnAuthTicket({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
required int stepRemain,
|
||||
required String? grantToken,
|
||||
required String? accessToken,
|
||||
required String? refreshToken,
|
||||
required String ipAddress,
|
||||
required String location,
|
||||
required String userAgent,
|
||||
required DateTime? expiredAt,
|
||||
required DateTime? lastGrantAt,
|
||||
required DateTime? availableAt,
|
||||
required String? nonce,
|
||||
required int? accountId,
|
||||
@Default([]) List<int> factorTrail,
|
||||
}) = _SnAuthTicket;
|
||||
|
||||
factory SnAuthTicket.fromJson(Map<String, Object?> json) =>
|
||||
_$SnAuthTicketFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAuthFactor with _$SnAuthFactor {
|
||||
const factory SnAuthFactor({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
required int type,
|
||||
required Map<String, dynamic>? config,
|
||||
required int? accountId,
|
||||
}) = _SnAuthFactor;
|
||||
|
||||
factory SnAuthFactor.fromJson(Map<String, Object?> json) =>
|
||||
_$SnAuthFactorFromJson(json);
|
||||
}
|
1002
lib/types/auth.freezed.dart
Normal file
1002
lib/types/auth.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
98
lib/types/auth.g.dart
Normal file
98
lib/types/auth.g.dart
Normal file
@ -0,0 +1,98 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'auth.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$SnAuthResultImpl _$$SnAuthResultImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnAuthResultImpl(
|
||||
isFinished: json['is_finished'] as bool,
|
||||
ticket: json['ticket'] == null
|
||||
? null
|
||||
: SnAuthTicket.fromJson(json['ticket'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAuthResultImplToJson(_$SnAuthResultImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'is_finished': instance.isFinished,
|
||||
'ticket': instance.ticket?.toJson(),
|
||||
};
|
||||
|
||||
_$SnAuthTicketImpl _$$SnAuthTicketImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnAuthTicketImpl(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
stepRemain: (json['step_remain'] as num).toInt(),
|
||||
grantToken: json['grant_token'] as String?,
|
||||
accessToken: json['access_token'] as String?,
|
||||
refreshToken: json['refresh_token'] as String?,
|
||||
ipAddress: json['ip_address'] as String,
|
||||
location: json['location'] as String,
|
||||
userAgent: json['user_agent'] as String,
|
||||
expiredAt: json['expired_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['expired_at'] as String),
|
||||
lastGrantAt: json['last_grant_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['last_grant_at'] as String),
|
||||
availableAt: json['available_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['available_at'] as String),
|
||||
nonce: json['nonce'] as String?,
|
||||
accountId: (json['account_id'] as num?)?.toInt(),
|
||||
factorTrail: (json['factor_trail'] as List<dynamic>?)
|
||||
?.map((e) => (e as num).toInt())
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAuthTicketImplToJson(_$SnAuthTicketImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'step_remain': instance.stepRemain,
|
||||
'grant_token': instance.grantToken,
|
||||
'access_token': instance.accessToken,
|
||||
'refresh_token': instance.refreshToken,
|
||||
'ip_address': instance.ipAddress,
|
||||
'location': instance.location,
|
||||
'user_agent': instance.userAgent,
|
||||
'expired_at': instance.expiredAt?.toIso8601String(),
|
||||
'last_grant_at': instance.lastGrantAt?.toIso8601String(),
|
||||
'available_at': instance.availableAt?.toIso8601String(),
|
||||
'nonce': instance.nonce,
|
||||
'account_id': instance.accountId,
|
||||
'factor_trail': instance.factorTrail,
|
||||
};
|
||||
|
||||
_$SnAuthFactorImpl _$$SnAuthFactorImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnAuthFactorImpl(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
type: (json['type'] as num).toInt(),
|
||||
config: json['config'] as Map<String, dynamic>?,
|
||||
accountId: (json['account_id'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAuthFactorImplToJson(_$SnAuthFactorImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'type': instance.type,
|
||||
'config': instance.config,
|
||||
'account_id': instance.accountId,
|
||||
};
|
151
lib/widgets/dialog.dart
Normal file
151
lib/widgets/dialog.dart
Normal file
@ -0,0 +1,151 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension AppPromptExtension on BuildContext {
|
||||
void showSnackbar(String content, {SnackBarAction? action}) {
|
||||
ScaffoldMessenger.of(this).showSnackBar(SnackBar(
|
||||
content: Text(content),
|
||||
action: action,
|
||||
));
|
||||
}
|
||||
|
||||
void clearSnackbar() {
|
||||
ScaffoldMessenger.of(this).clearSnackBars();
|
||||
}
|
||||
|
||||
Future<void> showModalDialog(String title, desc) {
|
||||
return showDialog<void>(
|
||||
useRootNavigator: true,
|
||||
context: this,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(desc),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text('dialogDismiss').tr(),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showInfoDialog(String title, body) {
|
||||
return showDialog<void>(
|
||||
useRootNavigator: true,
|
||||
context: this,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(body),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text('dialogDismiss').tr(),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> showConfirmDialog(String title, body) async {
|
||||
return await showDialog<bool>(
|
||||
useRootNavigator: true,
|
||||
context: this,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(body),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text('dialogCancel').tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text('dialogConfirm').tr(),
|
||||
)
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
Future<void> showErrorDialog(dynamic exception) {
|
||||
Widget content = Text(exception.toString().capitalize());
|
||||
if (exception is DioException) {
|
||||
String preview;
|
||||
switch (exception.response?.statusCode) {
|
||||
case 400:
|
||||
preview = 'errorRequestBad'.tr();
|
||||
break;
|
||||
case 401:
|
||||
preview = 'errorRequestUnauthorized'.tr();
|
||||
break;
|
||||
case 403:
|
||||
preview = 'errorRequestForbidden'.tr();
|
||||
break;
|
||||
case 404:
|
||||
preview = 'errorRequestNotFound'.tr();
|
||||
break;
|
||||
case null:
|
||||
preview = 'errorRequestConnection'.tr();
|
||||
break;
|
||||
default:
|
||||
preview = 'errorRequestUnknown'.tr();
|
||||
break;
|
||||
}
|
||||
|
||||
if (exception.response != null) {
|
||||
content = Text(
|
||||
'$preview\n\n(${exception.response?.statusCode}) ${exception.response?.data}',
|
||||
);
|
||||
} else {
|
||||
content = Text(preview);
|
||||
}
|
||||
}
|
||||
|
||||
return showDialog<void>(
|
||||
useRootNavigator: true,
|
||||
context: this,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text('dialogError').tr(),
|
||||
content: content,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text('dialogDismiss').tr(),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension ByteFormatter on int {
|
||||
String formatBytes({int decimals = 2}) {
|
||||
if (this == 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
final dm = decimals < 0 ? 0 : decimals;
|
||||
final sizes = [
|
||||
'Bytes',
|
||||
'KiB',
|
||||
'MiB',
|
||||
'GiB',
|
||||
'TiB',
|
||||
'PiB',
|
||||
'EiB',
|
||||
'ZiB',
|
||||
'YiB'
|
||||
];
|
||||
final i = (math.log(this) / math.log(k)).floor().toInt();
|
||||
return '${(this / math.pow(k, i)).toStringAsFixed(dm)} ${sizes[i]}';
|
||||
}
|
||||
}
|
||||
|
||||
extension StringFormatter on String {
|
||||
String capitalize() {
|
||||
return "${this[0].toUpperCase()}${substring(1)}";
|
||||
}
|
||||
}
|
@ -1,24 +1,48 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:responsive_framework/responsive_framework.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/navigation/app_background.dart';
|
||||
import 'package:surface/widgets/navigation/app_bottom_navigation.dart';
|
||||
|
||||
class AppScaffold extends StatelessWidget {
|
||||
final PreferredSizeWidget? appBar;
|
||||
final String? title;
|
||||
final Widget? body;
|
||||
final bool? showBottomNavigation;
|
||||
const AppScaffold(
|
||||
{super.key, this.appBar, this.body, this.showBottomNavigation});
|
||||
final bool autoImplyAppBar;
|
||||
final bool showBottomNavigation;
|
||||
const AppScaffold({
|
||||
super.key,
|
||||
this.appBar,
|
||||
this.title,
|
||||
this.body,
|
||||
this.autoImplyAppBar = false,
|
||||
this.showBottomNavigation = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isShowBottomNavigation = (showBottomNavigation ?? false)
|
||||
final isShowBottomNavigation = (showBottomNavigation)
|
||||
? ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
|
||||
: false;
|
||||
|
||||
final state = GoRouter.maybeOf(context);
|
||||
|
||||
return AppBackground(
|
||||
child: Scaffold(
|
||||
appBar: appBar,
|
||||
appBar: appBar ??
|
||||
(autoImplyAppBar
|
||||
? AppBar(
|
||||
title: title != null
|
||||
? Text(title!)
|
||||
: state != null
|
||||
? Text(
|
||||
('screen${state.routerDelegate.currentConfiguration.last.route.name?.capitalize()}')
|
||||
.tr(),
|
||||
)
|
||||
: null)
|
||||
: null),
|
||||
body: body,
|
||||
bottomNavigationBar:
|
||||
isShowBottomNavigation ? AppBottomNavigationBar() : null,
|
||||
|
@ -6,9 +6,13 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage/flutter_secure_storage_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
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);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
|
@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
12
pubspec.lock
12
pubspec.lock
@ -416,6 +416,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9f3dd2ac3b6875b0fde5b04734789c3ef35ba3965c18e99dd564a7a2f8056df6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.1"
|
||||
flutter_shaders:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -886,10 +894,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "746e5369a43170c25816cc472ee016d3a66bc13fcf430c0bc41ad7b4b2922051"
|
||||
sha256: "95f9997ca1fb9799d494d0cb2a780fd7be075818d59f00c43832ed112b158a82"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
version: "2.3.3"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -58,6 +58,7 @@ dependencies:
|
||||
google_fonts: ^6.2.1
|
||||
path: ^1.9.0
|
||||
relative_time: ^5.0.0
|
||||
flutter_secure_storage: ^4.2.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@ -87,6 +88,7 @@ flutter:
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
assets:
|
||||
- assets/icon/icon.png
|
||||
- assets/translations/
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
|
Loading…
Reference in New Issue
Block a user