✨ Login & register
This commit is contained in:
@ -33,11 +33,15 @@ class SolianApp extends StatelessWidget {
|
||||
providers: [
|
||||
Provider(create: (_) => SnNetworkProvider()),
|
||||
Provider(create: (ctx) => SnAttachmentProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (_) => UserProvider()),
|
||||
ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (_) => ThemeProvider()),
|
||||
],
|
||||
child: Builder(builder: (context) {
|
||||
// Initialize some providers
|
||||
context.read<UserProvider>();
|
||||
|
||||
final th = context.watch<ThemeProvider>();
|
||||
|
||||
return MaterialApp.router(
|
||||
theme: th.theme.light,
|
||||
darkTheme: th.theme.dark,
|
||||
|
@ -1,15 +1,23 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio_smart_retry/dio_smart_retry.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:native_dio_adapter/native_dio_adapter.dart';
|
||||
|
||||
const kUseLocalNetwork = true;
|
||||
|
||||
const kAtkStoreKey = 'nex_user_atk';
|
||||
const kRtkStoreKey = 'nex_user_rtk';
|
||||
|
||||
class SnNetworkProvider {
|
||||
late final Dio client;
|
||||
|
||||
late final FlutterSecureStorage _storage = FlutterSecureStorage();
|
||||
|
||||
SnNetworkProvider() {
|
||||
client = Dio();
|
||||
|
||||
@ -27,6 +35,56 @@ class SnNetworkProvider {
|
||||
],
|
||||
));
|
||||
|
||||
client.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
try {
|
||||
var atk = await _storage.read(key: kAtkStoreKey);
|
||||
if (atk != null) {
|
||||
final atkParts = atk.split('.');
|
||||
if (atkParts.length != 3) {
|
||||
throw Exception('invalid format of access token');
|
||||
}
|
||||
|
||||
var rawPayload =
|
||||
atkParts[1].replaceAll('-', '+').replaceAll('_', '/');
|
||||
switch (rawPayload.length % 4) {
|
||||
case 0:
|
||||
break;
|
||||
case 2:
|
||||
rawPayload += '==';
|
||||
break;
|
||||
case 3:
|
||||
rawPayload += '=';
|
||||
break;
|
||||
default:
|
||||
throw Exception('illegal format of access token payload');
|
||||
}
|
||||
|
||||
final b64 = utf8.fuse(base64Url);
|
||||
final payload = b64.decode(rawPayload);
|
||||
final exp = jsonDecode(payload)['exp'];
|
||||
if (exp >= DateTime.now().millisecondsSinceEpoch) {
|
||||
log('Access token need refresh, doing it at ${DateTime.now()}');
|
||||
atk = await refreshToken();
|
||||
}
|
||||
|
||||
if (atk != null) {
|
||||
options.headers['Authorization'] = 'Bearer $atk';
|
||||
} else {
|
||||
log('Access token refresh failed...');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
handler.next(options);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) {
|
||||
// Switch to native implementation if possible
|
||||
client.httpClientAdapter = NativeAdapter();
|
||||
@ -37,4 +95,34 @@ class SnNetworkProvider {
|
||||
if (ky.startsWith("http://")) return ky;
|
||||
return '${client.options.baseUrl}/cgi/uc/attachments/$ky';
|
||||
}
|
||||
|
||||
Future<void> setTokenPair(String atk, String rtk) async {
|
||||
await Future.wait([
|
||||
_storage.write(key: kAtkStoreKey, value: atk),
|
||||
_storage.write(key: kRtkStoreKey, value: rtk),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> clearTokenPair() async {
|
||||
await Future.wait([
|
||||
_storage.delete(key: kAtkStoreKey),
|
||||
_storage.delete(key: kRtkStoreKey),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<String?> refreshToken() async {
|
||||
final rtk = await _storage.read(key: kRtkStoreKey);
|
||||
if (rtk == null) return null;
|
||||
|
||||
final resp = await client.post('/cgi/id/auth/token', data: {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': rtk,
|
||||
});
|
||||
|
||||
final atk = resp.data['access_token'];
|
||||
final nRtk = resp.data['refresh_token'];
|
||||
await setTokenPair(atk, nRtk);
|
||||
|
||||
return atk;
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,42 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dart:developer';
|
||||
|
||||
class UserProvider extends ChangeNotifier {}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/account.dart';
|
||||
|
||||
class UserProvider extends ChangeNotifier {
|
||||
bool isAuthorized = false;
|
||||
SnAccount? user;
|
||||
|
||||
late final SnNetworkProvider sn;
|
||||
|
||||
late final FlutterSecureStorage _storage = FlutterSecureStorage();
|
||||
|
||||
UserProvider(BuildContext context) {
|
||||
sn = context.read<SnNetworkProvider>();
|
||||
|
||||
_storage.read(key: kAtkStoreKey).then((value) {
|
||||
isAuthorized = value != null;
|
||||
notifyListeners();
|
||||
refreshUser().then((value) {
|
||||
if (value != null) {
|
||||
log('Logged in as @${value.name}');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<SnAccount?> refreshUser() async {
|
||||
if (!isAuthorized) return null;
|
||||
|
||||
final resp = await sn.client.get('/cgi/id/users/me');
|
||||
final out = SnAccount.fromJson(resp.data);
|
||||
|
||||
user = out;
|
||||
notifyListeners();
|
||||
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:surface/screens/account.dart';
|
||||
import 'package:surface/screens/auth/login.dart';
|
||||
import 'package:surface/screens/auth/register.dart';
|
||||
import 'package:surface/screens/explore.dart';
|
||||
import 'package:surface/screens/home.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
@ -28,6 +30,24 @@ final appRouter = GoRouter(
|
||||
builder: (context, state) => const AccountScreen(),
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => AppScaffold(
|
||||
body: child,
|
||||
autoImplyAppBar: true,
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/auth/login',
|
||||
name: 'authLogin',
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/auth.register',
|
||||
name: 'authRegister',
|
||||
builder: (context, state) => const RegisterScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
|
||||
class AccountScreen extends StatefulWidget {
|
||||
@ -14,7 +15,29 @@ class _AccountScreenState extends State<AccountScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("screenHome").tr(),
|
||||
title: Text("screenAccount").tr(),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text('screenAuthLogin').tr(),
|
||||
subtitle: Text('screenAuthLoginSubtitle').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('authLogin');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('screenAuthRegister').tr(),
|
||||
subtitle: Text('screenAuthRegisterSubtitle').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('authRegister');
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
556
lib/screens/auth/login.dart
Normal file
556
lib/screens/auth/login.dart
Normal file
@ -0,0 +1,556 @@
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/types/auth.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
final Map<int, (String label, IconData icon, bool isOtp)> _factorLabelMap = {
|
||||
0: ('authFactorPassword'.tr(), Icons.password, false),
|
||||
1: ('authFactorEmail'.tr(), Icons.email, true),
|
||||
};
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
SnAuthTicket? _currentTicket;
|
||||
|
||||
List<SnAuthFactor>? _factors;
|
||||
SnAuthFactor? _factorPicked;
|
||||
|
||||
int _period = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
|
||||
child: SingleChildScrollView(
|
||||
child: PageTransitionSwitcher(
|
||||
transitionBuilder: (
|
||||
Widget child,
|
||||
Animation<double> primaryAnimation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
return SharedAxisTransition(
|
||||
animation: primaryAnimation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
transitionType: SharedAxisTransitionType.horizontal,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: switch (_period % 3) {
|
||||
1 => _LoginPickerScreen(
|
||||
key: const ValueKey(1),
|
||||
ticket: _currentTicket,
|
||||
factors: _factors,
|
||||
onTicket: (p0) => setState(() {
|
||||
_currentTicket = p0;
|
||||
}),
|
||||
onPickFactor: (p0) => setState(() {
|
||||
_factorPicked = p0;
|
||||
}),
|
||||
onNext: () => setState(() {
|
||||
_period++;
|
||||
}),
|
||||
),
|
||||
2 => _LoginCheckScreen(
|
||||
key: const ValueKey(2),
|
||||
ticket: _currentTicket,
|
||||
factor: _factorPicked,
|
||||
onTicket: (p0) => setState(() {
|
||||
_currentTicket = p0;
|
||||
}),
|
||||
onNext: (p0) => setState(() {
|
||||
_period = 1;
|
||||
}),
|
||||
),
|
||||
_ => _LoginLookupScreen(
|
||||
key: const ValueKey(0),
|
||||
ticket: _currentTicket,
|
||||
onTicket: (p0) => setState(() {
|
||||
_currentTicket = p0;
|
||||
}),
|
||||
onFactor: (p0) => setState(() {
|
||||
_factors = p0;
|
||||
}),
|
||||
onNext: () => setState(() {
|
||||
_period++;
|
||||
}),
|
||||
),
|
||||
},
|
||||
).padding(all: 24),
|
||||
).center(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginCheckScreen extends StatefulWidget {
|
||||
final SnAuthTicket? ticket;
|
||||
final SnAuthFactor? factor;
|
||||
final Function(SnAuthTicket?) onTicket;
|
||||
final Function onNext;
|
||||
const _LoginCheckScreen({
|
||||
super.key,
|
||||
required this.ticket,
|
||||
required this.factor,
|
||||
required this.onTicket,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_LoginCheckScreen> createState() => _LoginCheckScreenState();
|
||||
}
|
||||
|
||||
class _LoginCheckScreenState extends State<_LoginCheckScreen> {
|
||||
bool _isBusy = false;
|
||||
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
void _performCheckTicket() async {
|
||||
final password = _passwordController.value.text;
|
||||
if (password.isEmpty) return;
|
||||
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
// Check ticket
|
||||
final resp = await sn.client.patch('/cgi/id/auth', data: {
|
||||
'ticket_id': widget.ticket!.id,
|
||||
'factor_id': widget.factor!.id,
|
||||
'code': password,
|
||||
});
|
||||
|
||||
final result = SnAuthResult.fromJson(resp.data);
|
||||
widget.onTicket(result.ticket);
|
||||
|
||||
if (!result.isFinished) {
|
||||
widget.onNext();
|
||||
return;
|
||||
}
|
||||
|
||||
// Finish sign in if possible
|
||||
final tokenResp = await sn.client.post('/cgi/id/auth/token', data: {
|
||||
'grant_type': 'grant_token',
|
||||
'code': result.ticket!.grantToken,
|
||||
});
|
||||
final atk = tokenResp.data['access_token'];
|
||||
final rtk = tokenResp.data['refresh_token'];
|
||||
await sn.setTokenPair(atk, rtk);
|
||||
if (!mounted) return;
|
||||
final user = context.read<UserProvider>();
|
||||
final userinfo = await user.refreshUser();
|
||||
context.showSnackbar('loginSuccess'.tr(args: [
|
||||
'@${userinfo!.name} (${userinfo.nick})',
|
||||
]));
|
||||
|
||||
Navigator.pop(context);
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
return;
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(
|
||||
Symbols.password,
|
||||
size: 28,
|
||||
),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'loginEnterPassword'.tr(),
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
).padding(left: 4, bottom: 16),
|
||||
TextField(
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
autofillHints: [
|
||||
(_factorLabelMap[widget.factor!.type]?.$3 ?? true)
|
||||
? AutofillHints.password
|
||||
: AutofillHints.oneTimeCode
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldPassword'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onSubmitted: _isBusy ? null : (_) => _performCheckTicket(),
|
||||
).padding(horizontal: 7),
|
||||
const Gap(12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : () => _performCheckTicket(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('next').tr(),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginPickerScreen extends StatefulWidget {
|
||||
final SnAuthTicket? ticket;
|
||||
final List<SnAuthFactor>? factors;
|
||||
final Function(SnAuthTicket?) onTicket;
|
||||
final Function(SnAuthFactor) onPickFactor;
|
||||
final Function onNext;
|
||||
const _LoginPickerScreen({
|
||||
super.key,
|
||||
required this.ticket,
|
||||
required this.factors,
|
||||
required this.onTicket,
|
||||
required this.onPickFactor,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_LoginPickerScreen> createState() => _LoginPickerScreenState();
|
||||
}
|
||||
|
||||
class _LoginPickerScreenState extends State<_LoginPickerScreen> {
|
||||
bool _isBusy = false;
|
||||
int? _factorPicked;
|
||||
|
||||
Color get _unFocusColor =>
|
||||
Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round());
|
||||
|
||||
void _performGetFactorCode() async {
|
||||
if (_factorPicked == null) return;
|
||||
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
// Request one-time-password code
|
||||
sn.client.post('/cgi/id/auth/factors/$_factorPicked');
|
||||
widget.onPickFactor(
|
||||
widget.factors!.where((x) => x.id == _factorPicked).first,
|
||||
);
|
||||
widget.onNext();
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
return;
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
key: const ValueKey<int>(1),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(
|
||||
Symbols.security,
|
||||
size: 28,
|
||||
),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'loginPickFactor',
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
).tr().padding(left: 4),
|
||||
const Gap(8),
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
children: widget.factors
|
||||
?.map(
|
||||
(x) => CheckboxListTile(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
),
|
||||
secondary: Icon(
|
||||
_factorLabelMap[x.type]?.$2 ?? Icons.question_mark,
|
||||
),
|
||||
title: Text(
|
||||
_factorLabelMap[x.type]?.$1 ?? 'unknown'.tr(),
|
||||
),
|
||||
enabled: !widget.ticket!.factorTrail.contains(x.id),
|
||||
value: _factorPicked == x.id,
|
||||
onChanged: (value) {
|
||||
if (value == true) {
|
||||
setState(() => _factorPicked = x.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
List.empty(),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'loginMultiFactor'.plural(
|
||||
widget.ticket!.stepRemain,
|
||||
),
|
||||
style: TextStyle(color: _unFocusColor, fontSize: 13),
|
||||
).padding(horizontal: 16),
|
||||
const Gap(12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : () => _performGetFactorCode(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('next'.tr()),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginLookupScreen extends StatefulWidget {
|
||||
final SnAuthTicket? ticket;
|
||||
final Function(SnAuthTicket?) onTicket;
|
||||
final Function(List<SnAuthFactor>?) onFactor;
|
||||
final Function onNext;
|
||||
const _LoginLookupScreen({
|
||||
super.key,
|
||||
required this.ticket,
|
||||
required this.onTicket,
|
||||
required this.onFactor,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_LoginLookupScreen> createState() => _LoginLookupScreenState();
|
||||
}
|
||||
|
||||
class _LoginLookupScreenState extends State<_LoginLookupScreen> {
|
||||
final _usernameController = TextEditingController();
|
||||
|
||||
bool _isBusy = false;
|
||||
|
||||
void _requestResetPassword() async {
|
||||
final username = _usernameController.value.text;
|
||||
if (username.isEmpty) {
|
||||
context.showErrorDialog('signinResetPasswordHint'.tr());
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final lookupResp =
|
||||
await sn.client.get('/cgi/id/users/lookup?probe=$username');
|
||||
await sn.client.post('/cgi/id/users/me/password-reset', data: {
|
||||
'user_id': lookupResp.data['id'],
|
||||
});
|
||||
context.showModalDialog('done'.tr(), 'signinResetPasswordSent'.tr());
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _performNewTicket() async {
|
||||
final username = _usernameController.value.text;
|
||||
if (username.isEmpty) return;
|
||||
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
// Create ticket
|
||||
final resp = await sn.client.post('/cgi/id/auth', data: {
|
||||
'username': username,
|
||||
});
|
||||
final result = SnAuthResult.fromJson(resp.data);
|
||||
widget.onTicket(result.ticket);
|
||||
|
||||
// Pull factors
|
||||
final factorResp =
|
||||
await sn.client.get('/cgi/id/auth/factors', queryParameters: {
|
||||
'ticketId': result.ticket!.id.toString(),
|
||||
});
|
||||
widget.onFactor(
|
||||
List<SnAuthFactor>.from(
|
||||
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
||||
),
|
||||
);
|
||||
|
||||
widget.onNext();
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
return;
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(
|
||||
Symbols.login,
|
||||
size: 28,
|
||||
),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'screenAuthLoginGreeting',
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
).tr().padding(left: 4, bottom: 16),
|
||||
TextField(
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
controller: _usernameController,
|
||||
autofillHints: const [AutofillHints.username],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldUsername'.tr(),
|
||||
helperText: 'fieldUsernameLookupHint'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onSubmitted: _isBusy ? null : (_) => _performNewTicket(),
|
||||
).padding(horizontal: 7),
|
||||
const Gap(12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : () => _requestResetPassword(),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.grey),
|
||||
child: Text('forgotPassword'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : () => _performNewTicket(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('next').tr(),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: StyledWidget(
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 290),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'termAcceptNextWithAgree'.tr(),
|
||||
textAlign: TextAlign.end,
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withAlpha((255 * 0.75).round()),
|
||||
),
|
||||
),
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('termAcceptLink'.tr()),
|
||||
const Gap(4),
|
||||
const Icon(Icons.launch, size: 14),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
launchUrlString('https://solsynth.dev/terms');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).padding(horizontal: 16),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
161
lib/screens/auth/register.dart
Normal file
161
lib/screens/auth/register.dart
Normal file
@ -0,0 +1,161 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
|
||||
class RegisterScreen extends StatefulWidget {
|
||||
const RegisterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RegisterScreen> createState() => _RegisterScreenState();
|
||||
}
|
||||
|
||||
class _RegisterScreenState extends State<RegisterScreen> {
|
||||
final _emailController = TextEditingController();
|
||||
final _usernameController = TextEditingController();
|
||||
final _nicknameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
void _performAction(BuildContext context) async {
|
||||
final email = _emailController.value.text;
|
||||
final username = _usernameController.value.text;
|
||||
final nickname = _nicknameController.value.text;
|
||||
final password = _passwordController.value.text;
|
||||
if (email.isEmpty ||
|
||||
username.isEmpty ||
|
||||
nickname.isEmpty ||
|
||||
password.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.post('/cgi/id/users', data: {
|
||||
'name': username,
|
||||
'nick': nickname,
|
||||
'email': email,
|
||||
'password': password,
|
||||
});
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// TODO make celebration here
|
||||
// ignore: use_build_context_synchronously
|
||||
Navigator.pop(context);
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: StyledWidget(
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(
|
||||
Symbols.person_add,
|
||||
size: 28,
|
||||
),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'screenAuthRegister',
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
).tr().padding(left: 4, bottom: 16),
|
||||
Column(
|
||||
children: [
|
||||
TextField(
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
controller: _usernameController,
|
||||
autofillHints: const [AutofillHints.username],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldUsername'.tr(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(12),
|
||||
TextField(
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
controller: _nicknameController,
|
||||
autofillHints: const [AutofillHints.nickname],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldNickname'.tr(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(12),
|
||||
TextField(
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
controller: _emailController,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldEmail'.tr(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(12),
|
||||
TextField(
|
||||
obscureText: true,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
controller: _passwordController,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldPassword'.tr(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onSubmitted: (_) => _performAction(context),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 7),
|
||||
const Gap(16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () => _performAction(context),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('next').tr(),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
).padding(all: 24).center(),
|
||||
);
|
||||
}
|
||||
}
|
69
lib/types/account.dart
Normal file
69
lib/types/account.dart
Normal file
@ -0,0 +1,69 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'account.freezed.dart';
|
||||
part 'account.g.dart';
|
||||
|
||||
@freezed
|
||||
class SnAccount with _$SnAccount {
|
||||
const factory SnAccount({
|
||||
required int id,
|
||||
required int? affiliatedId,
|
||||
required int? affiliatedTo,
|
||||
required int? automatedBy,
|
||||
required int? automatedId,
|
||||
required String avatar,
|
||||
required String banner,
|
||||
required DateTime? confirmedAt,
|
||||
required List<SnAccountContact> contacts,
|
||||
required DateTime createdAt,
|
||||
required DateTime? deletedAt,
|
||||
required String description,
|
||||
required String name,
|
||||
required String nick,
|
||||
required Map<String, dynamic> permNodes,
|
||||
required SnAccountProfile? profile,
|
||||
required DateTime? suspendedAt,
|
||||
required DateTime updatedAt,
|
||||
}) = _SnAccount;
|
||||
|
||||
factory SnAccount.fromJson(Map<String, Object?> json) =>
|
||||
_$SnAccountFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAccountContact with _$SnAccountContact {
|
||||
const factory SnAccountContact({
|
||||
required int accountId,
|
||||
required String content,
|
||||
required DateTime createdAt,
|
||||
required DateTime? deletedAt,
|
||||
required int id,
|
||||
required bool isPrimary,
|
||||
required bool isPublic,
|
||||
required int type,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? verifiedAt,
|
||||
}) = _SnAccountContact;
|
||||
|
||||
factory SnAccountContact.fromJson(Map<String, Object?> json) =>
|
||||
_$SnAccountContactFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAccountProfile with _$SnAccountProfile {
|
||||
const factory SnAccountProfile({
|
||||
required int id,
|
||||
required int accountId,
|
||||
required DateTime? birthday,
|
||||
required DateTime createdAt,
|
||||
required DateTime? deletedAt,
|
||||
required int experience,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required DateTime? lastSeenAt,
|
||||
required DateTime updatedAt,
|
||||
}) = _SnAccountProfile;
|
||||
|
||||
factory SnAccountProfile.fromJson(Map<String, Object?> json) =>
|
||||
_$SnAccountProfileFromJson(json);
|
||||
}
|
1266
lib/types/account.freezed.dart
Normal file
1266
lib/types/account.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
131
lib/types/account.g.dart
Normal file
131
lib/types/account.g.dart
Normal file
@ -0,0 +1,131 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'account.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnAccountImpl(
|
||||
id: (json['id'] as num).toInt(),
|
||||
affiliatedId: (json['affiliated_id'] as num?)?.toInt(),
|
||||
affiliatedTo: (json['affiliated_to'] as num?)?.toInt(),
|
||||
automatedBy: (json['automated_by'] as num?)?.toInt(),
|
||||
automatedId: (json['automated_id'] as num?)?.toInt(),
|
||||
avatar: json['avatar'] as String,
|
||||
banner: json['banner'] as String,
|
||||
confirmedAt: json['confirmed_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['confirmed_at'] as String),
|
||||
contacts: (json['contacts'] as List<dynamic>)
|
||||
.map((e) => SnAccountContact.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
description: json['description'] as String,
|
||||
name: json['name'] as String,
|
||||
nick: json['nick'] as String,
|
||||
permNodes: json['perm_nodes'] as Map<String, dynamic>,
|
||||
profile: json['profile'] == null
|
||||
? null
|
||||
: SnAccountProfile.fromJson(json['profile'] as Map<String, dynamic>),
|
||||
suspendedAt: json['suspended_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['suspended_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'affiliated_id': instance.affiliatedId,
|
||||
'affiliated_to': instance.affiliatedTo,
|
||||
'automated_by': instance.automatedBy,
|
||||
'automated_id': instance.automatedId,
|
||||
'avatar': instance.avatar,
|
||||
'banner': instance.banner,
|
||||
'confirmed_at': instance.confirmedAt?.toIso8601String(),
|
||||
'contacts': instance.contacts.map((e) => e.toJson()).toList(),
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'description': instance.description,
|
||||
'name': instance.name,
|
||||
'nick': instance.nick,
|
||||
'perm_nodes': instance.permNodes,
|
||||
'profile': instance.profile?.toJson(),
|
||||
'suspended_at': instance.suspendedAt?.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
_$SnAccountContactImpl _$$SnAccountContactImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnAccountContactImpl(
|
||||
accountId: (json['account_id'] as num).toInt(),
|
||||
content: json['content'] as String,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
id: (json['id'] as num).toInt(),
|
||||
isPrimary: json['is_primary'] as bool,
|
||||
isPublic: json['is_public'] as bool,
|
||||
type: (json['type'] as num).toInt(),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
verifiedAt: json['verified_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['verified_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAccountContactImplToJson(
|
||||
_$SnAccountContactImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'account_id': instance.accountId,
|
||||
'content': instance.content,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'id': instance.id,
|
||||
'is_primary': instance.isPrimary,
|
||||
'is_public': instance.isPublic,
|
||||
'type': instance.type,
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'verified_at': instance.verifiedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
_$SnAccountProfileImpl _$$SnAccountProfileImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnAccountProfileImpl(
|
||||
id: (json['id'] as num).toInt(),
|
||||
accountId: (json['account_id'] as num).toInt(),
|
||||
birthday: json['birthday'] == null
|
||||
? null
|
||||
: DateTime.parse(json['birthday'] as String),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
experience: (json['experience'] as num).toInt(),
|
||||
firstName: json['first_name'] as String,
|
||||
lastName: json['last_name'] as String,
|
||||
lastSeenAt: json['last_seen_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['last_seen_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAccountProfileImplToJson(
|
||||
_$SnAccountProfileImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'account_id': instance.accountId,
|
||||
'birthday': instance.birthday?.toIso8601String(),
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'experience': instance.experience,
|
||||
'first_name': instance.firstName,
|
||||
'last_name': instance.lastName,
|
||||
'last_seen_at': instance.lastSeenAt?.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
};
|
57
lib/types/auth.dart
Normal file
57
lib/types/auth.dart
Normal file
@ -0,0 +1,57 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'auth.freezed.dart';
|
||||
part 'auth.g.dart';
|
||||
|
||||
@freezed
|
||||
class SnAuthResult with _$SnAuthResult {
|
||||
const factory SnAuthResult({
|
||||
required bool isFinished,
|
||||
required SnAuthTicket? ticket,
|
||||
}) = _SnAuthResult;
|
||||
|
||||
factory SnAuthResult.fromJson(Map<String, Object?> json) =>
|
||||
_$SnAuthResultFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAuthTicket with _$SnAuthTicket {
|
||||
const factory SnAuthTicket({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
required int stepRemain,
|
||||
required String? grantToken,
|
||||
required String? accessToken,
|
||||
required String? refreshToken,
|
||||
required String ipAddress,
|
||||
required String location,
|
||||
required String userAgent,
|
||||
required DateTime? expiredAt,
|
||||
required DateTime? lastGrantAt,
|
||||
required DateTime? availableAt,
|
||||
required String? nonce,
|
||||
required int? accountId,
|
||||
@Default([]) List<int> factorTrail,
|
||||
}) = _SnAuthTicket;
|
||||
|
||||
factory SnAuthTicket.fromJson(Map<String, Object?> json) =>
|
||||
_$SnAuthTicketFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAuthFactor with _$SnAuthFactor {
|
||||
const factory SnAuthFactor({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
required int type,
|
||||
required Map<String, dynamic>? config,
|
||||
required int? accountId,
|
||||
}) = _SnAuthFactor;
|
||||
|
||||
factory SnAuthFactor.fromJson(Map<String, Object?> json) =>
|
||||
_$SnAuthFactorFromJson(json);
|
||||
}
|
1002
lib/types/auth.freezed.dart
Normal file
1002
lib/types/auth.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
98
lib/types/auth.g.dart
Normal file
98
lib/types/auth.g.dart
Normal file
@ -0,0 +1,98 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'auth.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$SnAuthResultImpl _$$SnAuthResultImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnAuthResultImpl(
|
||||
isFinished: json['is_finished'] as bool,
|
||||
ticket: json['ticket'] == null
|
||||
? null
|
||||
: SnAuthTicket.fromJson(json['ticket'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAuthResultImplToJson(_$SnAuthResultImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'is_finished': instance.isFinished,
|
||||
'ticket': instance.ticket?.toJson(),
|
||||
};
|
||||
|
||||
_$SnAuthTicketImpl _$$SnAuthTicketImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnAuthTicketImpl(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
stepRemain: (json['step_remain'] as num).toInt(),
|
||||
grantToken: json['grant_token'] as String?,
|
||||
accessToken: json['access_token'] as String?,
|
||||
refreshToken: json['refresh_token'] as String?,
|
||||
ipAddress: json['ip_address'] as String,
|
||||
location: json['location'] as String,
|
||||
userAgent: json['user_agent'] as String,
|
||||
expiredAt: json['expired_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['expired_at'] as String),
|
||||
lastGrantAt: json['last_grant_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['last_grant_at'] as String),
|
||||
availableAt: json['available_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['available_at'] as String),
|
||||
nonce: json['nonce'] as String?,
|
||||
accountId: (json['account_id'] as num?)?.toInt(),
|
||||
factorTrail: (json['factor_trail'] as List<dynamic>?)
|
||||
?.map((e) => (e as num).toInt())
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAuthTicketImplToJson(_$SnAuthTicketImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'step_remain': instance.stepRemain,
|
||||
'grant_token': instance.grantToken,
|
||||
'access_token': instance.accessToken,
|
||||
'refresh_token': instance.refreshToken,
|
||||
'ip_address': instance.ipAddress,
|
||||
'location': instance.location,
|
||||
'user_agent': instance.userAgent,
|
||||
'expired_at': instance.expiredAt?.toIso8601String(),
|
||||
'last_grant_at': instance.lastGrantAt?.toIso8601String(),
|
||||
'available_at': instance.availableAt?.toIso8601String(),
|
||||
'nonce': instance.nonce,
|
||||
'account_id': instance.accountId,
|
||||
'factor_trail': instance.factorTrail,
|
||||
};
|
||||
|
||||
_$SnAuthFactorImpl _$$SnAuthFactorImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnAuthFactorImpl(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
type: (json['type'] as num).toInt(),
|
||||
config: json['config'] as Map<String, dynamic>?,
|
||||
accountId: (json['account_id'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAuthFactorImplToJson(_$SnAuthFactorImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'type': instance.type,
|
||||
'config': instance.config,
|
||||
'account_id': instance.accountId,
|
||||
};
|
151
lib/widgets/dialog.dart
Normal file
151
lib/widgets/dialog.dart
Normal file
@ -0,0 +1,151 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension AppPromptExtension on BuildContext {
|
||||
void showSnackbar(String content, {SnackBarAction? action}) {
|
||||
ScaffoldMessenger.of(this).showSnackBar(SnackBar(
|
||||
content: Text(content),
|
||||
action: action,
|
||||
));
|
||||
}
|
||||
|
||||
void clearSnackbar() {
|
||||
ScaffoldMessenger.of(this).clearSnackBars();
|
||||
}
|
||||
|
||||
Future<void> showModalDialog(String title, desc) {
|
||||
return showDialog<void>(
|
||||
useRootNavigator: true,
|
||||
context: this,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(desc),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text('dialogDismiss').tr(),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showInfoDialog(String title, body) {
|
||||
return showDialog<void>(
|
||||
useRootNavigator: true,
|
||||
context: this,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(body),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text('dialogDismiss').tr(),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> showConfirmDialog(String title, body) async {
|
||||
return await showDialog<bool>(
|
||||
useRootNavigator: true,
|
||||
context: this,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(body),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text('dialogCancel').tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text('dialogConfirm').tr(),
|
||||
)
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
Future<void> showErrorDialog(dynamic exception) {
|
||||
Widget content = Text(exception.toString().capitalize());
|
||||
if (exception is DioException) {
|
||||
String preview;
|
||||
switch (exception.response?.statusCode) {
|
||||
case 400:
|
||||
preview = 'errorRequestBad'.tr();
|
||||
break;
|
||||
case 401:
|
||||
preview = 'errorRequestUnauthorized'.tr();
|
||||
break;
|
||||
case 403:
|
||||
preview = 'errorRequestForbidden'.tr();
|
||||
break;
|
||||
case 404:
|
||||
preview = 'errorRequestNotFound'.tr();
|
||||
break;
|
||||
case null:
|
||||
preview = 'errorRequestConnection'.tr();
|
||||
break;
|
||||
default:
|
||||
preview = 'errorRequestUnknown'.tr();
|
||||
break;
|
||||
}
|
||||
|
||||
if (exception.response != null) {
|
||||
content = Text(
|
||||
'$preview\n\n(${exception.response?.statusCode}) ${exception.response?.data}',
|
||||
);
|
||||
} else {
|
||||
content = Text(preview);
|
||||
}
|
||||
}
|
||||
|
||||
return showDialog<void>(
|
||||
useRootNavigator: true,
|
||||
context: this,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text('dialogError').tr(),
|
||||
content: content,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text('dialogDismiss').tr(),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension ByteFormatter on int {
|
||||
String formatBytes({int decimals = 2}) {
|
||||
if (this == 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
final dm = decimals < 0 ? 0 : decimals;
|
||||
final sizes = [
|
||||
'Bytes',
|
||||
'KiB',
|
||||
'MiB',
|
||||
'GiB',
|
||||
'TiB',
|
||||
'PiB',
|
||||
'EiB',
|
||||
'ZiB',
|
||||
'YiB'
|
||||
];
|
||||
final i = (math.log(this) / math.log(k)).floor().toInt();
|
||||
return '${(this / math.pow(k, i)).toStringAsFixed(dm)} ${sizes[i]}';
|
||||
}
|
||||
}
|
||||
|
||||
extension StringFormatter on String {
|
||||
String capitalize() {
|
||||
return "${this[0].toUpperCase()}${substring(1)}";
|
||||
}
|
||||
}
|
@ -1,24 +1,48 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:responsive_framework/responsive_framework.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/navigation/app_background.dart';
|
||||
import 'package:surface/widgets/navigation/app_bottom_navigation.dart';
|
||||
|
||||
class AppScaffold extends StatelessWidget {
|
||||
final PreferredSizeWidget? appBar;
|
||||
final String? title;
|
||||
final Widget? body;
|
||||
final bool? showBottomNavigation;
|
||||
const AppScaffold(
|
||||
{super.key, this.appBar, this.body, this.showBottomNavigation});
|
||||
final bool autoImplyAppBar;
|
||||
final bool showBottomNavigation;
|
||||
const AppScaffold({
|
||||
super.key,
|
||||
this.appBar,
|
||||
this.title,
|
||||
this.body,
|
||||
this.autoImplyAppBar = false,
|
||||
this.showBottomNavigation = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isShowBottomNavigation = (showBottomNavigation ?? false)
|
||||
final isShowBottomNavigation = (showBottomNavigation)
|
||||
? ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
|
||||
: false;
|
||||
|
||||
final state = GoRouter.maybeOf(context);
|
||||
|
||||
return AppBackground(
|
||||
child: Scaffold(
|
||||
appBar: appBar,
|
||||
appBar: appBar ??
|
||||
(autoImplyAppBar
|
||||
? AppBar(
|
||||
title: title != null
|
||||
? Text(title!)
|
||||
: state != null
|
||||
? Text(
|
||||
('screen${state.routerDelegate.currentConfiguration.last.route.name?.capitalize()}')
|
||||
.tr(),
|
||||
)
|
||||
: null)
|
||||
: null),
|
||||
body: body,
|
||||
bottomNavigationBar:
|
||||
isShowBottomNavigation ? AppBottomNavigationBar() : null,
|
||||
|
Reference in New Issue
Block a user