diff --git a/assets/locales/en_us.json b/assets/locales/en_us.json index 88a93fc..347d09d 100644 --- a/assets/locales/en_us.json +++ b/assets/locales/en_us.json @@ -3,6 +3,7 @@ "hide": "Hide", "okay": "Okay", "next": "Next", + "prev": "Previous", "reset": "Reset", "page": "Page", "home": "Home", @@ -70,8 +71,12 @@ "forgotPassword": "Forgot password", "email": "Email", "username": "Username", + "usernameInputHint": "Also supports email and phone number", "nickname": "Nickname", "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", "description": "Description", "birthday": "Birthday", @@ -103,6 +108,11 @@ "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.", "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", "signupGreeting": "Welcome onboard", "signupCaption": "Create an account on Solarpass and then get the access of entire Solar Network!", diff --git a/assets/locales/zh_cn.json b/assets/locales/zh_cn.json index 88c3dfa..1d2e78a 100644 --- a/assets/locales/zh_cn.json +++ b/assets/locales/zh_cn.json @@ -4,6 +4,7 @@ "okay": "确认", "home": "首页", "next": "下一步", + "prev": "上一步", "reset": "重置", "cancel": "取消", "confirm": "确认", @@ -75,8 +76,12 @@ "forgotPassword": "忘记密码", "email": "邮件地址", "username": "用户名", + "usernameInputHint": "同时支持邮箱 / 电话号码", "nickname": "显示名", "password": "密码", + "passwordOneTime": "一次性验证码", + "passwordInputHint": "忘记密码了?回到第一步以重置密码", + "passwordOneTimeInputHint": "检查你的收件箱或是授权器获得以验证码", "title": "标题", "description": "简介", "birthday": "生日", @@ -108,6 +113,11 @@ "signinRiskDetected": "检测到风险,点击下一步按钮来打开一个网页,并通过在其上面登录来通过安全检查。", "signinResetPasswordHint": "请先填写用户名以发送重置密码请求。", "signinResetPasswordSent": "重置密码请求已发送,在绑定邮件收件箱可收取一份包含重置密码链接的邮件。", + "signinPickFactor": "选择一个\n验证方式", + "signinEnterPassword": "输入密码\n或验证码", + "signinMultiFactor": "@n 步验证", + "authFactorEmail": "邮箱一次性密码", + "authFactorPassword": "账户密码", "signup": "注册", "signupGreeting": "欢迎加入\nSolar Network", "signupCaption": "在 Solarpass 注册一个账号以获得整个 Solar Network 的存取权!", diff --git a/lib/models/auth.dart b/lib/models/auth.dart new file mode 100644 index 0000000..339736c --- /dev/null +++ b/lib/models/auth.dart @@ -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 json) => + _$AuthResultFromJson(json); + + Map toJson() => _$AuthResultToJson(this); +} + +@JsonSerializable() +class AuthTicket { + int id; + DateTime createdAt; + DateTime updatedAt; + DateTime? deletedAt; + String location; + String ipAddress; + String userAgent; + int stepRemain; + List claims; + List audiences; + @JsonKey(defaultValue: []) + List 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 json) => + _$AuthTicketFromJson(json); + + Map toJson() => _$AuthTicketToJson(this); +} + +@JsonSerializable() +class AuthFactor { + int id; + DateTime createdAt; + DateTime updatedAt; + DateTime? deletedAt; + int type; + Map? 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 json) => + _$AuthFactorFromJson(json); + + Map toJson() => _$AuthFactorToJson(this); +} diff --git a/lib/models/auth.g.dart b/lib/models/auth.g.dart new file mode 100644 index 0000000..7c305c6 --- /dev/null +++ b/lib/models/auth.g.dart @@ -0,0 +1,105 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +AuthResult _$AuthResultFromJson(Map json) => AuthResult( + isFinished: json['is_finished'] as bool, + ticket: AuthTicket.fromJson(json['ticket'] as Map), + ); + +Map _$AuthResultToJson(AuthResult instance) => + { + 'is_finished': instance.isFinished, + 'ticket': instance.ticket.toJson(), + }; + +AuthTicket _$AuthTicketFromJson(Map 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).map((e) => e as String).toList(), + audiences: + (json['audiences'] as List).map((e) => e as String).toList(), + factorTrail: (json['factor_trail'] as List?) + ?.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), + accountId: (json['account_id'] as num).toInt(), + ); + +Map _$AuthTicketToJson(AuthTicket instance) => + { + '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 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?, + account: Account.fromJson(json['account'] as Map), + accountId: (json['account_id'] as num).toInt(), + ); + +Map _$AuthFactorToJson(AuthFactor instance) => + { + '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, + }; diff --git a/lib/providers/auth.dart b/lib/providers/auth.dart index ebc07f6..8b1b0e8 100644 --- a/lib/providers/auth.dart +++ b/lib/providers/auth.dart @@ -9,6 +9,7 @@ import 'package:get/get_connect/http/src/request/request.dart'; import 'package:solian/background.dart'; import 'package:solian/exceptions/request.dart'; import 'package:solian/exceptions/unauthorized.dart'; +import 'package:solian/models/auth.dart'; import 'package:solian/providers/database/database.dart'; import 'package:solian/providers/websocket.dart'; import 'package:solian/services.dart'; @@ -148,27 +149,13 @@ class AuthProvider extends GetConnect { Future signin( BuildContext context, - String username, - String password, + AuthTicket ticket, ) async { 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 final tokenResp = await post('/auth/token', { - 'code': resp.body['ticket']['grant_token'], + 'code': ticket.grantToken!, 'grant_type': 'grant_token', }); if (tokenResp.statusCode != 200) { diff --git a/lib/screens/auth/signin.dart b/lib/screens/auth/signin.dart index ab9c20e..d4528f5 100644 --- a/lib/screens/auth/signin.dart +++ b/lib/screens/auth/signin.dart @@ -1,15 +1,15 @@ +import 'package:animations/animations.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:get/get.dart'; -import 'package:protocol_handler/protocol_handler.dart'; import 'package:solian/background.dart'; +import 'package:solian/exceptions/request.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/websocket.dart'; import 'package:solian/services.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 { const SignInScreen({super.key}); @@ -18,12 +18,28 @@ class SignInScreen extends StatefulWidget { State createState() => _SignInScreenState(); } -class _SignInScreenState extends State with ProtocolListener { +class _SignInScreenState extends State { bool _isBusy = false; + AuthTicket? _currentTicket; + + List? _factors; + int? _factorPicked; + int? _factorPickedType; + + int _period = 0; + final _usernameController = TextEditingController(); final _passwordController = TextEditingController(); + final Map _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 { final username = _usernameController.value.text; if (username.isEmpty) { @@ -54,78 +70,141 @@ class _SignInScreenState extends State with ProtocolListener { context.showModalDialog('done'.tr, 'signinResetPasswordSent'.tr); } - void _performAction() async { - final AuthProvider auth = Get.find(); - + void _performNewTicket() async { final username = _usernameController.value.text; - final password = _passwordController.value.text; - if (username.isEmpty || password.isEmpty) return; + if (username.isEmpty) return; + + final client = ServiceFinder.configureClient('auth'); setState(() => _isBusy = true); try { - await auth.signin(context, username, password); - await Future.delayed(const Duration(milliseconds: 250), () async { - await auth.refreshAuthorizeStatus(); - await auth.refreshUserProfile(); + // Create ticket + final resp = await client.post('/auth', { + 'username': username, }); - } on RiskyAuthenticateException catch (e) { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text('riskDetection'.tr), - content: Text('signinRiskDetected'.tr), - actions: [ - TextButton( - child: Text('next'.tr), - onPressed: () { - const redirect = 'solink://auth?status=done'; - launchUrlString( - ServiceFinder.buildUrl('capital', - '/auth/mfa?redirect_uri=$redirect&ticketId=${e.ticketId}'), - mode: LaunchMode.inAppWebView, - ); - Navigator.pop(context); - }, - ) - ], - ); - }, - ); - return; + if (resp.statusCode != 200) { + throw RequestException(resp); + } else { + final result = AuthResult.fromJson(resp.body); + _currentTicket = result.ticket; + } + + // Pull factors + final factorResp = await client.get('/auth/factors', + query: {'ticketId': _currentTicket!.id.toString()}); + if (factorResp.statusCode != 200) { + throw RequestException(factorResp); + } else { + final result = List.from( + factorResp.body.map((x) => AuthFactor.fromJson(x)), + ); + _factors = result; + } + + setState(() => _period++); } catch (e) { context.showErrorDialog(e); return; } finally { setState(() => _isBusy = false); } - - Get.find().registerPushNotifications(); - autoConfigureBackgroundNotificationService(); - autoStartBackgroundNotificationService(); - - Navigator.pop(context, true); } - @override - void initState() { - protocolHandler.addListener(this); - super.initState(); + void _performGetFactorCode() async { + if (_factorPicked == null) return; + + 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 dispose() { - protocolHandler.removeListener(this); - super.dispose(); + void _performCheckTicket() async { + final AuthProvider auth = Get.find(); + + 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().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 onProtocolUrlReceived(String url) { - final uri = url.replaceFirst('solink://', ''); - if (uri == 'auth?status=done') { - closeInAppWebView(); - _performAction(); + void _previousStep() { + assert(_period > 0); + switch (_period % 3) { + case 1: + _currentTicket = null; + _factors = null; + _factorPicked = null; + case 2: + _passwordController.clear(); + _factorPickedType = null; + default: + setState(() => _period--); } } @@ -135,72 +214,237 @@ class _SignInScreenState extends State with ProtocolListener { color: Theme.of(context).colorScheme.surface, child: CenteredContainer( maxWidth: 360, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: Image.asset('assets/logo.png', width: 64, height: 64), - ).paddingOnly(bottom: 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, - ), - 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 OutlineInputBorder(), - labelText: 'password'.tr, - ), - onTapOutside: (_) => - FocusManager.instance.primaryFocus?.unfocus(), - onSubmitted: (_) => _performAction(), - ), - 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 : () => _performAction(), - child: Row( - mainAxisSize: MainAxisSize.min, + child: PageTransitionSwitcher( + transitionBuilder: ( + Widget child, + Animation primaryAnimation, + Animation secondaryAnimation, + ) { + return SharedAxisTransition( + animation: primaryAnimation, + secondaryAnimation: secondaryAnimation, + transitionType: SharedAxisTransitionType.horizontal, + child: child, + ); + }, + child: switch (_period % 3) { + 1 => Column( + key: const ValueKey(1), + 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( + 'signinPickFactor'.tr, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w900, + ), + ).paddingOnly(left: 4, bottom: 16), + Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: Column( + children: _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: !_currentTicket!.factorTrail + .contains(x.id), + value: _factorPicked == x.id, + onChanged: (value) { + if (value == true) { + setState(() => _factorPicked = x.id); + } + }, + ), + ) + .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: [ - Text('next'.tr), - const Icon(Icons.chevron_right), + 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 : () => _performGetFactorCode(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('next'.tr), + const Icon(Icons.chevron_right), + ], + ), + ), ], ), - ), - ], - ), - ], + ], + ), + 2 => Column( + key: const ValueKey(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(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), + ], + ), + ), + ], + ), + ], + ), + }, ), ), ); diff --git a/lib/screens/auth/signup.dart b/lib/screens/auth/signup.dart index 61fae98..6dfb220 100644 --- a/lib/screens/auth/signup.dart +++ b/lib/screens/auth/signup.dart @@ -73,7 +73,7 @@ class _SignUpScreenState extends State { ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(8)), child: Image.asset('assets/logo.png', width: 64, height: 64), - ).paddingOnly(bottom: 4), + ).paddingOnly(bottom: 8, left: 4), Text( 'signupGreeting'.tr, style: const TextStyle( diff --git a/lib/services.dart b/lib/services.dart index 03826f0..8c60eb1 100644 --- a/lib/services.dart +++ b/lib/services.dart @@ -1,7 +1,7 @@ import 'package:get/get.dart'; abstract class ServiceFinder { - static const bool devFlag = false; + static const bool devFlag = true; static const String dealerUrl = devFlag ? 'http://localhost:8442' : 'https://api.sn.solsynth.dev';