Login & register

This commit is contained in:
LittleSheep 2024-11-09 18:28:45 +08:00
parent 5e12a8860c
commit 4d12d243b3
22 changed files with 3798 additions and 20 deletions

View File

@ -1,5 +1,39 @@
{
"screen": "Screen",
"screenHome": "Home",
"screenExplore": "Explore",
"screenAccount": "Account"
"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"
}

View File

@ -1,5 +1,39 @@
{
"screen": "页面",
"screenHome": "首页",
"screenExplore": "探索",
"screenAccount": "您"
"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": "电邮一次性验证码"
}

View File

@ -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

View File

@ -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,

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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(),
),
],
),
],
);

View File

@ -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
View 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),
),
],
);
}
}

View 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
View 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);
}

File diff suppressed because it is too large Load Diff

131
lib/types/account.g.dart Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

98
lib/types/auth.g.dart Normal file
View 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
View 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)}";
}
}

View File

@ -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,

View File

@ -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);

View File

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage
url_launcher_linux
)

View File

@ -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:

View File

@ -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