Brand new sign in flow

This commit is contained in:
LittleSheep 2024-09-16 02:37:20 +08:00
parent 73456fcff6
commit c18ce88993
8 changed files with 599 additions and 140 deletions

View File

@ -3,6 +3,7 @@
"hide": "Hide", "hide": "Hide",
"okay": "Okay", "okay": "Okay",
"next": "Next", "next": "Next",
"prev": "Previous",
"reset": "Reset", "reset": "Reset",
"page": "Page", "page": "Page",
"home": "Home", "home": "Home",
@ -70,8 +71,12 @@
"forgotPassword": "Forgot password", "forgotPassword": "Forgot password",
"email": "Email", "email": "Email",
"username": "Username", "username": "Username",
"usernameInputHint": "Also supports email and phone number",
"nickname": "Nickname", "nickname": "Nickname",
"password": "Password", "password": "Password",
"passwordOneTime": "One-time-password",
"passwordInputHint": "Forgot your password? Go back to the first step to reset your password",
"passwordOneTimeInputHint": "Check your inbox or authorizer for a verification code",
"title": "Title", "title": "Title",
"description": "Description", "description": "Description",
"birthday": "Birthday", "birthday": "Birthday",
@ -103,6 +108,11 @@
"signinRiskDetected": "Risk detected, click Next to open a webpage and signin through it to pass security check.", "signinRiskDetected": "Risk detected, click Next to open a webpage and signin through it to pass security check.",
"signinResetPasswordHint": "Please enter username to request reset password.", "signinResetPasswordHint": "Please enter username to request reset password.",
"signinResetPasswordSent": "Reset password request sent, check your inbox!", "signinResetPasswordSent": "Reset password request sent, check your inbox!",
"signinPickFactor": "Pick a way\nfor verification",
"signinEnterPassword": "Enter your\npassword",
"signinMultiFactor": "@n step(s) verifications",
"authFactorEmail": "Email One-time-password",
"authFactorPassword": "Password",
"signup": "Sign up", "signup": "Sign up",
"signupGreeting": "Welcome onboard", "signupGreeting": "Welcome onboard",
"signupCaption": "Create an account on Solarpass and then get the access of entire Solar Network!", "signupCaption": "Create an account on Solarpass and then get the access of entire Solar Network!",

View File

@ -4,6 +4,7 @@
"okay": "确认", "okay": "确认",
"home": "首页", "home": "首页",
"next": "下一步", "next": "下一步",
"prev": "上一步",
"reset": "重置", "reset": "重置",
"cancel": "取消", "cancel": "取消",
"confirm": "确认", "confirm": "确认",
@ -75,8 +76,12 @@
"forgotPassword": "忘记密码", "forgotPassword": "忘记密码",
"email": "邮件地址", "email": "邮件地址",
"username": "用户名", "username": "用户名",
"usernameInputHint": "同时支持邮箱 / 电话号码",
"nickname": "显示名", "nickname": "显示名",
"password": "密码", "password": "密码",
"passwordOneTime": "一次性验证码",
"passwordInputHint": "忘记密码了?回到第一步以重置密码",
"passwordOneTimeInputHint": "检查你的收件箱或是授权器获得以验证码",
"title": "标题", "title": "标题",
"description": "简介", "description": "简介",
"birthday": "生日", "birthday": "生日",
@ -108,6 +113,11 @@
"signinRiskDetected": "检测到风险,点击下一步按钮来打开一个网页,并通过在其上面登录来通过安全检查。", "signinRiskDetected": "检测到风险,点击下一步按钮来打开一个网页,并通过在其上面登录来通过安全检查。",
"signinResetPasswordHint": "请先填写用户名以发送重置密码请求。", "signinResetPasswordHint": "请先填写用户名以发送重置密码请求。",
"signinResetPasswordSent": "重置密码请求已发送,在绑定邮件收件箱可收取一份包含重置密码链接的邮件。", "signinResetPasswordSent": "重置密码请求已发送,在绑定邮件收件箱可收取一份包含重置密码链接的邮件。",
"signinPickFactor": "选择一个\n验证方式",
"signinEnterPassword": "输入密码\n或验证码",
"signinMultiFactor": "@n 步验证",
"authFactorEmail": "邮箱一次性密码",
"authFactorPassword": "账户密码",
"signup": "注册", "signup": "注册",
"signupGreeting": "欢迎加入\nSolar Network", "signupGreeting": "欢迎加入\nSolar Network",
"signupCaption": "在 Solarpass 注册一个账号以获得整个 Solar Network 的存取权!", "signupCaption": "在 Solarpass 注册一个账号以获得整个 Solar Network 的存取权!",

103
lib/models/auth.dart Normal file
View File

@ -0,0 +1,103 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:solian/models/account.dart';
part 'auth.g.dart';
@JsonSerializable()
class AuthResult {
bool isFinished;
AuthTicket ticket;
AuthResult({
required this.isFinished,
required this.ticket,
});
factory AuthResult.fromJson(Map<String, dynamic> json) =>
_$AuthResultFromJson(json);
Map<String, dynamic> toJson() => _$AuthResultToJson(this);
}
@JsonSerializable()
class AuthTicket {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String location;
String ipAddress;
String userAgent;
int stepRemain;
List<String> claims;
List<String> audiences;
@JsonKey(defaultValue: [])
List<int> factorTrail;
String? grantToken;
String? accessToken;
String? refreshToken;
DateTime? expiredAt;
DateTime? availableAt;
DateTime? lastGrantAt;
String? nonce;
int? clientId;
Account account;
int accountId;
AuthTicket({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.location,
required this.ipAddress,
required this.userAgent,
required this.stepRemain,
required this.claims,
required this.audiences,
required this.factorTrail,
required this.grantToken,
required this.accessToken,
required this.refreshToken,
required this.expiredAt,
required this.availableAt,
required this.lastGrantAt,
required this.nonce,
required this.clientId,
required this.account,
required this.accountId,
});
factory AuthTicket.fromJson(Map<String, dynamic> json) =>
_$AuthTicketFromJson(json);
Map<String, dynamic> toJson() => _$AuthTicketToJson(this);
}
@JsonSerializable()
class AuthFactor {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
int type;
Map<String, dynamic>? config;
Account account;
int accountId;
AuthFactor({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.type,
required this.config,
required this.account,
required this.accountId,
});
factory AuthFactor.fromJson(Map<String, dynamic> json) =>
_$AuthFactorFromJson(json);
Map<String, dynamic> toJson() => _$AuthFactorToJson(this);
}

105
lib/models/auth.g.dart Normal file
View File

@ -0,0 +1,105 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AuthResult _$AuthResultFromJson(Map<String, dynamic> json) => AuthResult(
isFinished: json['is_finished'] as bool,
ticket: AuthTicket.fromJson(json['ticket'] as Map<String, dynamic>),
);
Map<String, dynamic> _$AuthResultToJson(AuthResult instance) =>
<String, dynamic>{
'is_finished': instance.isFinished,
'ticket': instance.ticket.toJson(),
};
AuthTicket _$AuthTicketFromJson(Map<String, dynamic> json) => AuthTicket(
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),
location: json['location'] as String,
ipAddress: json['ip_address'] as String,
userAgent: json['user_agent'] as String,
stepRemain: (json['step_remain'] as num).toInt(),
claims:
(json['claims'] as List<dynamic>).map((e) => e as String).toList(),
audiences:
(json['audiences'] as List<dynamic>).map((e) => e as String).toList(),
factorTrail: (json['factor_trail'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList() ??
[],
grantToken: json['grant_token'] as String?,
accessToken: json['access_token'] as String?,
refreshToken: json['refresh_token'] as String?,
expiredAt: json['expired_at'] == null
? null
: DateTime.parse(json['expired_at'] as String),
availableAt: json['available_at'] == null
? null
: DateTime.parse(json['available_at'] as String),
lastGrantAt: json['last_grant_at'] == null
? null
: DateTime.parse(json['last_grant_at'] as String),
nonce: json['nonce'] as String?,
clientId: (json['client_id'] as num?)?.toInt(),
account: Account.fromJson(json['account'] as Map<String, dynamic>),
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$AuthTicketToJson(AuthTicket instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'location': instance.location,
'ip_address': instance.ipAddress,
'user_agent': instance.userAgent,
'step_remain': instance.stepRemain,
'claims': instance.claims,
'audiences': instance.audiences,
'factor_trail': instance.factorTrail,
'grant_token': instance.grantToken,
'access_token': instance.accessToken,
'refresh_token': instance.refreshToken,
'expired_at': instance.expiredAt?.toIso8601String(),
'available_at': instance.availableAt?.toIso8601String(),
'last_grant_at': instance.lastGrantAt?.toIso8601String(),
'nonce': instance.nonce,
'client_id': instance.clientId,
'account': instance.account.toJson(),
'account_id': instance.accountId,
};
AuthFactor _$AuthFactorFromJson(Map<String, dynamic> json) => AuthFactor(
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>?,
account: Account.fromJson(json['account'] as Map<String, dynamic>),
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$AuthFactorToJson(AuthFactor 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': instance.account.toJson(),
'account_id': instance.accountId,
};

View File

@ -9,6 +9,7 @@ import 'package:get/get_connect/http/src/request/request.dart';
import 'package:solian/background.dart'; import 'package:solian/background.dart';
import 'package:solian/exceptions/request.dart'; import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart'; import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/models/auth.dart';
import 'package:solian/providers/database/database.dart'; import 'package:solian/providers/database/database.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
@ -148,27 +149,13 @@ class AuthProvider extends GetConnect {
Future<TokenSet> signin( Future<TokenSet> signin(
BuildContext context, BuildContext context,
String username, AuthTicket ticket,
String password,
) async { ) async {
userProfile.value = null; userProfile.value = null;
final client = ServiceFinder.configureClient('auth');
// Create ticket
final resp = await client.post('/auth', {
'username': username,
'password': password,
});
if (resp.statusCode != 200) {
throw RequestException(resp);
} else if (resp.body['is_finished'] == false) {
throw RiskyAuthenticateException(resp.body['ticket']['id']);
}
// Assign token // Assign token
final tokenResp = await post('/auth/token', { final tokenResp = await post('/auth/token', {
'code': resp.body['ticket']['grant_token'], 'code': ticket.grantToken!,
'grant_type': 'grant_token', 'grant_type': 'grant_token',
}); });
if (tokenResp.statusCode != 200) { if (tokenResp.statusCode != 200) {

View File

@ -1,15 +1,15 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:protocol_handler/protocol_handler.dart';
import 'package:solian/background.dart'; import 'package:solian/background.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/models/auth.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/widgets/sized_container.dart'; import 'package:solian/widgets/sized_container.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:url_launcher/url_launcher_string.dart';
class SignInScreen extends StatefulWidget { class SignInScreen extends StatefulWidget {
const SignInScreen({super.key}); const SignInScreen({super.key});
@ -18,12 +18,28 @@ class SignInScreen extends StatefulWidget {
State<SignInScreen> createState() => _SignInScreenState(); State<SignInScreen> createState() => _SignInScreenState();
} }
class _SignInScreenState extends State<SignInScreen> with ProtocolListener { class _SignInScreenState extends State<SignInScreen> {
bool _isBusy = false; bool _isBusy = false;
AuthTicket? _currentTicket;
List<AuthFactor>? _factors;
int? _factorPicked;
int? _factorPickedType;
int _period = 0;
final _usernameController = TextEditingController(); final _usernameController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final Map<int, (String label, IconData icon, bool isOtp)> _factorLabelMap = {
0: ('authFactorPassword'.tr, Icons.password, false),
1: ('authFactorEmail'.tr, Icons.email, true),
};
Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
void _requestResetPassword() async { void _requestResetPassword() async {
final username = _usernameController.value.text; final username = _usernameController.value.text;
if (username.isEmpty) { if (username.isEmpty) {
@ -54,78 +70,141 @@ class _SignInScreenState extends State<SignInScreen> with ProtocolListener {
context.showModalDialog('done'.tr, 'signinResetPasswordSent'.tr); context.showModalDialog('done'.tr, 'signinResetPasswordSent'.tr);
} }
void _performAction() async { void _performNewTicket() async {
final AuthProvider auth = Get.find();
final username = _usernameController.value.text; final username = _usernameController.value.text;
final password = _passwordController.value.text; if (username.isEmpty) return;
if (username.isEmpty || password.isEmpty) return;
final client = ServiceFinder.configureClient('auth');
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
await auth.signin(context, username, password); // Create ticket
await Future.delayed(const Duration(milliseconds: 250), () async { final resp = await client.post('/auth', {
await auth.refreshAuthorizeStatus(); 'username': username,
await auth.refreshUserProfile();
}); });
} on RiskyAuthenticateException catch (e) { if (resp.statusCode != 200) {
showDialog( throw RequestException(resp);
context: context, } else {
builder: (context) { final result = AuthResult.fromJson(resp.body);
return AlertDialog( _currentTicket = result.ticket;
title: Text('riskDetection'.tr), }
content: Text('signinRiskDetected'.tr),
actions: [ // Pull factors
TextButton( final factorResp = await client.get('/auth/factors',
child: Text('next'.tr), query: {'ticketId': _currentTicket!.id.toString()});
onPressed: () { if (factorResp.statusCode != 200) {
const redirect = 'solink://auth?status=done'; throw RequestException(factorResp);
launchUrlString( } else {
ServiceFinder.buildUrl('capital', final result = List<AuthFactor>.from(
'/auth/mfa?redirect_uri=$redirect&ticketId=${e.ticketId}'), factorResp.body.map((x) => AuthFactor.fromJson(x)),
mode: LaunchMode.inAppWebView, );
); _factors = result;
Navigator.pop(context); }
},
) setState(() => _period++);
],
);
},
);
return;
} catch (e) { } catch (e) {
context.showErrorDialog(e); context.showErrorDialog(e);
return; return;
} finally { } finally {
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
Get.find<WebSocketProvider>().registerPushNotifications();
autoConfigureBackgroundNotificationService();
autoStartBackgroundNotificationService();
Navigator.pop(context, true);
} }
@override void _performGetFactorCode() async {
void initState() { if (_factorPicked == null) return;
protocolHandler.addListener(this);
super.initState(); final client = ServiceFinder.configureClient('auth');
setState(() => _isBusy = true);
try {
// Request one-time-password code
final resp = await client.post('/auth/factors/$_factorPicked', {});
if (resp.statusCode != 200 && resp.statusCode != 204) {
throw RequestException(resp);
} else {
_factorPickedType = _factors!
.where(
(x) => x.id == _factorPicked,
)
.first
.type;
}
setState(() => _period++);
} catch (e) {
context.showErrorDialog(e);
return;
} finally {
setState(() => _isBusy = false);
}
} }
@override void _performCheckTicket() async {
void dispose() { final AuthProvider auth = Get.find();
protocolHandler.removeListener(this);
super.dispose(); final password = _passwordController.value.text;
if (password.isEmpty) return;
final client = ServiceFinder.configureClient('auth');
setState(() => _isBusy = true);
try {
// Check ticket
final resp = await client.patch('/auth', {
'ticket_id': _currentTicket!.id,
'factor_id': _factorPicked!,
'code': password,
});
if (resp.statusCode != 200) {
throw RequestException(resp);
}
final result = AuthResult.fromJson(resp.body);
_currentTicket = result.ticket;
// Finish sign in if possible
if (result.isFinished) {
await auth.signin(context, _currentTicket!);
await Future.delayed(const Duration(milliseconds: 250), () async {
await auth.refreshAuthorizeStatus();
await auth.refreshUserProfile();
Get.find<WebSocketProvider>().registerPushNotifications();
autoConfigureBackgroundNotificationService();
autoStartBackgroundNotificationService();
Navigator.pop(context, true);
});
} else {
// Skip the first step
_factorPicked = null;
_factorPickedType = null;
setState(() => _period += 2);
}
} catch (e) {
context.showErrorDialog(e);
return;
} finally {
setState(() => _isBusy = false);
}
} }
@override void _previousStep() {
void onProtocolUrlReceived(String url) { assert(_period > 0);
final uri = url.replaceFirst('solink://', ''); switch (_period % 3) {
if (uri == 'auth?status=done') { case 1:
closeInAppWebView(); _currentTicket = null;
_performAction(); _factors = null;
_factorPicked = null;
case 2:
_passwordController.clear();
_factorPickedType = null;
default:
setState(() => _period--);
} }
} }
@ -135,72 +214,237 @@ class _SignInScreenState extends State<SignInScreen> with ProtocolListener {
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: CenteredContainer( child: CenteredContainer(
maxWidth: 360, maxWidth: 360,
child: Column( child: PageTransitionSwitcher(
mainAxisSize: MainAxisSize.min, transitionBuilder: (
crossAxisAlignment: CrossAxisAlignment.start, Widget child,
children: [ Animation<double> primaryAnimation,
ClipRRect( Animation<double> secondaryAnimation,
borderRadius: const BorderRadius.all(Radius.circular(8)), ) {
child: Image.asset('assets/logo.png', width: 64, height: 64), return SharedAxisTransition(
).paddingOnly(bottom: 4), animation: primaryAnimation,
Text( secondaryAnimation: secondaryAnimation,
'signinGreeting'.tr, transitionType: SharedAxisTransitionType.horizontal,
style: const TextStyle( child: child,
fontSize: 28, );
fontWeight: FontWeight.w900, },
), child: switch (_period % 3) {
).paddingOnly(left: 4, bottom: 16), 1 => Column(
TextField( key: const ValueKey<int>(1),
autocorrect: false, mainAxisAlignment: MainAxisAlignment.center,
enableSuggestions: false, crossAxisAlignment: CrossAxisAlignment.start,
controller: _usernameController, children: [
autofillHints: const [AutofillHints.username], ClipRRect(
decoration: InputDecoration( borderRadius: const BorderRadius.all(Radius.circular(8)),
isDense: true, child:
border: const OutlineInputBorder(), Image.asset('assets/logo.png', width: 64, height: 64),
labelText: 'username'.tr, ).paddingOnly(bottom: 8, left: 4),
), Text(
onTapOutside: (_) => 'signinPickFactor'.tr,
FocusManager.instance.primaryFocus?.unfocus(), style: const TextStyle(
), fontSize: 28,
const Gap(12), fontWeight: FontWeight.w900,
TextField( ),
obscureText: true, ).paddingOnly(left: 4, bottom: 16),
autocorrect: false, Card(
enableSuggestions: false, margin: const EdgeInsets.symmetric(vertical: 4),
autofillHints: const [AutofillHints.password], child: Column(
controller: _passwordController, children: _factors
decoration: InputDecoration( ?.map(
isDense: true, (x) => CheckboxListTile(
border: const OutlineInputBorder(), shape: const RoundedRectangleBorder(
labelText: 'password'.tr, borderRadius: BorderRadius.all(
), Radius.circular(8),
onTapOutside: (_) => ),
FocusManager.instance.primaryFocus?.unfocus(), ),
onSubmitted: (_) => _performAction(), secondary: Icon(
), _factorLabelMap[x.type]?.$2 ??
const Gap(12), Icons.question_mark,
Row( ),
mainAxisAlignment: MainAxisAlignment.spaceBetween, title: Text(
children: [ _factorLabelMap[x.type]?.$1 ?? 'unknown'.tr,
TextButton( ),
onPressed: _isBusy ? null : () => _requestResetPassword(), enabled: !_currentTicket!.factorTrail
style: TextButton.styleFrom(foregroundColor: Colors.grey), .contains(x.id),
child: Text('forgotPassword'.tr), value: _factorPicked == x.id,
), onChanged: (value) {
TextButton( if (value == true) {
onPressed: _isBusy ? null : () => _performAction(), setState(() => _factorPicked = x.id);
child: Row( }
mainAxisSize: MainAxisSize.min, },
),
)
.toList() ??
List.empty(),
),
),
Text(
'signinMultiFactor'.trParams(
{'n': _currentTicket!.stepRemain.toString()},
),
style: TextStyle(color: _unFocusColor, fontSize: 12),
).paddingOnly(left: 16, right: 16),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text('next'.tr), TextButton(
const Icon(Icons.chevron_right), onPressed: _isBusy ? null : () => _previousStep(),
style:
TextButton.styleFrom(foregroundColor: Colors.grey),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.chevron_left),
Text('prev'.tr),
],
),
),
TextButton(
onPressed:
_isBusy ? null : () => _performGetFactorCode(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr),
const Icon(Icons.chevron_right),
],
),
),
], ],
), ),
), ],
], ),
), 2 => Column(
], key: const ValueKey<int>(2),
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child:
Image.asset('assets/logo.png', width: 64, height: 64),
).paddingOnly(bottom: 8, left: 4),
Text(
'signinEnterPassword'.tr,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).paddingOnly(left: 4, bottom: 16),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _passwordController,
obscureText: true,
autofillHints: [
(_factorLabelMap[_factorPickedType]?.$3 ?? true)
? AutofillHints.password
: AutofillHints.oneTimeCode
],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText:
(_factorLabelMap[_factorPickedType]?.$3 ?? true)
? 'passwordOneTime'.tr
: 'password'.tr,
helperText:
(_factorLabelMap[_factorPickedType]?.$3 ?? true)
? 'passwordOneTimeInputHint'.tr
: 'passwordInputHint'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: _isBusy ? null : (_) => _performCheckTicket(),
),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: _isBusy ? null : () => _previousStep(),
style:
TextButton.styleFrom(foregroundColor: Colors.grey),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.chevron_left),
Text('prev'.tr),
],
),
),
TextButton(
onPressed: _isBusy ? null : () => _performCheckTicket(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr),
const Icon(Icons.chevron_right),
],
),
),
],
),
],
),
_ => Column(
key: const ValueKey<int>(0),
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child:
Image.asset('assets/logo.png', width: 64, height: 64),
).paddingOnly(bottom: 8, left: 4),
Text(
'signinGreeting'.tr,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).paddingOnly(left: 4, bottom: 16),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _usernameController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'username'.tr,
helperText: 'usernameInputHint'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: _isBusy ? null : (_) => _performNewTicket(),
),
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),
],
),
),
],
),
],
),
},
), ),
), ),
); );

View File

@ -73,7 +73,7 @@ class _SignUpScreenState extends State<SignUpScreen> {
ClipRRect( ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Image.asset('assets/logo.png', width: 64, height: 64), child: Image.asset('assets/logo.png', width: 64, height: 64),
).paddingOnly(bottom: 4), ).paddingOnly(bottom: 8, left: 4),
Text( Text(
'signupGreeting'.tr, 'signupGreeting'.tr,
style: const TextStyle( style: const TextStyle(

View File

@ -1,7 +1,7 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
abstract class ServiceFinder { abstract class ServiceFinder {
static const bool devFlag = false; static const bool devFlag = true;
static const String dealerUrl = static const String dealerUrl =
devFlag ? 'http://localhost:8442' : 'https://api.sn.solsynth.dev'; devFlag ? 'http://localhost:8442' : 'https://api.sn.solsynth.dev';