Login & register

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

View File

@ -33,11 +33,15 @@ class SolianApp extends StatelessWidget {
providers: [
Provider(create: (_) => SnNetworkProvider()),
Provider(create: (ctx) => SnAttachmentProvider(ctx)),
ChangeNotifierProvider(create: (_) => UserProvider()),
ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
ChangeNotifierProvider(create: (_) => ThemeProvider()),
],
child: Builder(builder: (context) {
// Initialize some providers
context.read<UserProvider>();
final th = context.watch<ThemeProvider>();
return MaterialApp.router(
theme: th.theme.light,
darkTheme: th.theme.dark,

View File

@ -1,15 +1,23 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio_smart_retry/dio_smart_retry.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:native_dio_adapter/native_dio_adapter.dart';
const kUseLocalNetwork = true;
const kAtkStoreKey = 'nex_user_atk';
const kRtkStoreKey = 'nex_user_rtk';
class SnNetworkProvider {
late final Dio client;
late final FlutterSecureStorage _storage = FlutterSecureStorage();
SnNetworkProvider() {
client = Dio();
@ -27,6 +35,56 @@ class SnNetworkProvider {
],
));
client.interceptors.add(
InterceptorsWrapper(
onRequest: (
RequestOptions options,
RequestInterceptorHandler handler,
) async {
try {
var atk = await _storage.read(key: kAtkStoreKey);
if (atk != null) {
final atkParts = atk.split('.');
if (atkParts.length != 3) {
throw Exception('invalid format of access token');
}
var rawPayload =
atkParts[1].replaceAll('-', '+').replaceAll('_', '/');
switch (rawPayload.length % 4) {
case 0:
break;
case 2:
rawPayload += '==';
break;
case 3:
rawPayload += '=';
break;
default:
throw Exception('illegal format of access token payload');
}
final b64 = utf8.fuse(base64Url);
final payload = b64.decode(rawPayload);
final exp = jsonDecode(payload)['exp'];
if (exp >= DateTime.now().millisecondsSinceEpoch) {
log('Access token need refresh, doing it at ${DateTime.now()}');
atk = await refreshToken();
}
if (atk != null) {
options.headers['Authorization'] = 'Bearer $atk';
} else {
log('Access token refresh failed...');
}
}
} finally {
handler.next(options);
}
},
),
);
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) {
// Switch to native implementation if possible
client.httpClientAdapter = NativeAdapter();
@ -37,4 +95,34 @@ class SnNetworkProvider {
if (ky.startsWith("http://")) return ky;
return '${client.options.baseUrl}/cgi/uc/attachments/$ky';
}
Future<void> setTokenPair(String atk, String rtk) async {
await Future.wait([
_storage.write(key: kAtkStoreKey, value: atk),
_storage.write(key: kRtkStoreKey, value: rtk),
]);
}
Future<void> clearTokenPair() async {
await Future.wait([
_storage.delete(key: kAtkStoreKey),
_storage.delete(key: kRtkStoreKey),
]);
}
Future<String?> refreshToken() async {
final rtk = await _storage.read(key: kRtkStoreKey);
if (rtk == null) return null;
final resp = await client.post('/cgi/id/auth/token', data: {
'grant_type': 'refresh_token',
'refresh_token': rtk,
});
final atk = resp.data['access_token'];
final nRtk = resp.data['refresh_token'];
await setTokenPair(atk, nRtk);
return atk;
}
}

View File

@ -1,3 +1,42 @@
import 'package:flutter/foundation.dart';
import 'dart:developer';
class UserProvider extends ChangeNotifier {}
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/account.dart';
class UserProvider extends ChangeNotifier {
bool isAuthorized = false;
SnAccount? user;
late final SnNetworkProvider sn;
late final FlutterSecureStorage _storage = FlutterSecureStorage();
UserProvider(BuildContext context) {
sn = context.read<SnNetworkProvider>();
_storage.read(key: kAtkStoreKey).then((value) {
isAuthorized = value != null;
notifyListeners();
refreshUser().then((value) {
if (value != null) {
log('Logged in as @${value.name}');
}
});
});
}
Future<SnAccount?> refreshUser() async {
if (!isAuthorized) return null;
final resp = await sn.client.get('/cgi/id/users/me');
final out = SnAccount.fromJson(resp.data);
user = out;
notifyListeners();
return out;
}
}

View File

@ -1,5 +1,7 @@
import 'package:go_router/go_router.dart';
import 'package:surface/screens/account.dart';
import 'package:surface/screens/auth/login.dart';
import 'package:surface/screens/auth/register.dart';
import 'package:surface/screens/explore.dart';
import 'package:surface/screens/home.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
@ -28,6 +30,24 @@ final appRouter = GoRouter(
builder: (context, state) => const AccountScreen(),
),
],
)
),
ShellRoute(
builder: (context, state, child) => AppScaffold(
body: child,
autoImplyAppBar: true,
),
routes: [
GoRoute(
path: '/auth/login',
name: 'authLogin',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/auth.register',
name: 'authRegister',
builder: (context, state) => const RegisterScreen(),
),
],
),
],
);

View File

@ -1,5 +1,6 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class AccountScreen extends StatefulWidget {
@ -14,7 +15,29 @@ class _AccountScreenState extends State<AccountScreen> {
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
title: Text("screenHome").tr(),
title: Text("screenAccount").tr(),
),
body: ListView(
children: [
ListTile(
title: Text('screenAuthLogin').tr(),
subtitle: Text('screenAuthLoginSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
trailing: const Icon(Icons.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('authLogin');
},
),
ListTile(
title: Text('screenAuthRegister').tr(),
subtitle: Text('screenAuthRegisterSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
trailing: const Icon(Icons.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('authRegister');
},
)
],
),
);
}

556
lib/screens/auth/login.dart Normal file
View File

@ -0,0 +1,556 @@
import 'package:animations/animations.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/auth.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:url_launcher/url_launcher_string.dart';
final Map<int, (String label, IconData icon, bool isOtp)> _factorLabelMap = {
0: ('authFactorPassword'.tr(), Icons.password, false),
1: ('authFactorEmail'.tr(), Icons.email, true),
};
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
SnAuthTicket? _currentTicket;
List<SnAuthFactor>? _factors;
SnAuthFactor? _factorPicked;
int _period = 0;
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 280),
child: Theme(
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
child: SingleChildScrollView(
child: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
);
},
child: switch (_period % 3) {
1 => _LoginPickerScreen(
key: const ValueKey(1),
ticket: _currentTicket,
factors: _factors,
onTicket: (p0) => setState(() {
_currentTicket = p0;
}),
onPickFactor: (p0) => setState(() {
_factorPicked = p0;
}),
onNext: () => setState(() {
_period++;
}),
),
2 => _LoginCheckScreen(
key: const ValueKey(2),
ticket: _currentTicket,
factor: _factorPicked,
onTicket: (p0) => setState(() {
_currentTicket = p0;
}),
onNext: (p0) => setState(() {
_period = 1;
}),
),
_ => _LoginLookupScreen(
key: const ValueKey(0),
ticket: _currentTicket,
onTicket: (p0) => setState(() {
_currentTicket = p0;
}),
onFactor: (p0) => setState(() {
_factors = p0;
}),
onNext: () => setState(() {
_period++;
}),
),
},
).padding(all: 24),
).center(),
),
);
}
}
class _LoginCheckScreen extends StatefulWidget {
final SnAuthTicket? ticket;
final SnAuthFactor? factor;
final Function(SnAuthTicket?) onTicket;
final Function onNext;
const _LoginCheckScreen({
super.key,
required this.ticket,
required this.factor,
required this.onTicket,
required this.onNext,
});
@override
State<_LoginCheckScreen> createState() => _LoginCheckScreenState();
}
class _LoginCheckScreenState extends State<_LoginCheckScreen> {
bool _isBusy = false;
final _passwordController = TextEditingController();
void _performCheckTicket() async {
final password = _passwordController.value.text;
if (password.isEmpty) return;
final sn = context.read<SnNetworkProvider>();
setState(() => _isBusy = true);
try {
// Check ticket
final resp = await sn.client.patch('/cgi/id/auth', data: {
'ticket_id': widget.ticket!.id,
'factor_id': widget.factor!.id,
'code': password,
});
final result = SnAuthResult.fromJson(resp.data);
widget.onTicket(result.ticket);
if (!result.isFinished) {
widget.onNext();
return;
}
// Finish sign in if possible
final tokenResp = await sn.client.post('/cgi/id/auth/token', data: {
'grant_type': 'grant_token',
'code': result.ticket!.grantToken,
});
final atk = tokenResp.data['access_token'];
final rtk = tokenResp.data['refresh_token'];
await sn.setTokenPair(atk, rtk);
if (!mounted) return;
final user = context.read<UserProvider>();
final userinfo = await user.refreshUser();
context.showSnackbar('loginSuccess'.tr(args: [
'@${userinfo!.name} (${userinfo.nick})',
]));
Navigator.pop(context);
} catch (err) {
context.showErrorDialog(err);
return;
} finally {
setState(() => _isBusy = false);
}
}
@override
void dispose() {
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(
Symbols.password,
size: 28,
),
).padding(bottom: 8),
),
Text(
'loginEnterPassword'.tr(),
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).padding(left: 4, bottom: 16),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _passwordController,
obscureText: true,
autofillHints: [
(_factorLabelMap[widget.factor!.type]?.$3 ?? true)
? AutofillHints.password
: AutofillHints.oneTimeCode
],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'fieldPassword'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: _isBusy ? null : (_) => _performCheckTicket(),
).padding(horizontal: 7),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isBusy ? null : () => _performCheckTicket(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next').tr(),
const Icon(Icons.chevron_right),
],
),
),
],
),
],
);
}
}
class _LoginPickerScreen extends StatefulWidget {
final SnAuthTicket? ticket;
final List<SnAuthFactor>? factors;
final Function(SnAuthTicket?) onTicket;
final Function(SnAuthFactor) onPickFactor;
final Function onNext;
const _LoginPickerScreen({
super.key,
required this.ticket,
required this.factors,
required this.onTicket,
required this.onPickFactor,
required this.onNext,
});
@override
State<_LoginPickerScreen> createState() => _LoginPickerScreenState();
}
class _LoginPickerScreenState extends State<_LoginPickerScreen> {
bool _isBusy = false;
int? _factorPicked;
Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round());
void _performGetFactorCode() async {
if (_factorPicked == null) return;
final sn = context.read<SnNetworkProvider>();
setState(() => _isBusy = true);
try {
// Request one-time-password code
sn.client.post('/cgi/id/auth/factors/$_factorPicked');
widget.onPickFactor(
widget.factors!.where((x) => x.id == _factorPicked).first,
);
widget.onNext();
} catch (err) {
context.showErrorDialog(err);
return;
} finally {
setState(() => _isBusy = false);
}
}
@override
Widget build(BuildContext context) {
return Column(
key: const ValueKey<int>(1),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(
Symbols.security,
size: 28,
),
).padding(bottom: 8),
),
Text(
'loginPickFactor',
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).tr().padding(left: 4),
const Gap(8),
Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Column(
children: widget.factors
?.map(
(x) => CheckboxListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(8),
),
),
secondary: Icon(
_factorLabelMap[x.type]?.$2 ?? Icons.question_mark,
),
title: Text(
_factorLabelMap[x.type]?.$1 ?? 'unknown'.tr(),
),
enabled: !widget.ticket!.factorTrail.contains(x.id),
value: _factorPicked == x.id,
onChanged: (value) {
if (value == true) {
setState(() => _factorPicked = x.id);
}
},
),
)
.toList() ??
List.empty(),
),
),
const Gap(8),
Text(
'loginMultiFactor'.plural(
widget.ticket!.stepRemain,
),
style: TextStyle(color: _unFocusColor, fontSize: 13),
).padding(horizontal: 16),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isBusy ? null : () => _performGetFactorCode(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr()),
const Icon(Icons.chevron_right),
],
),
),
],
),
],
);
}
}
class _LoginLookupScreen extends StatefulWidget {
final SnAuthTicket? ticket;
final Function(SnAuthTicket?) onTicket;
final Function(List<SnAuthFactor>?) onFactor;
final Function onNext;
const _LoginLookupScreen({
super.key,
required this.ticket,
required this.onTicket,
required this.onFactor,
required this.onNext,
});
@override
State<_LoginLookupScreen> createState() => _LoginLookupScreenState();
}
class _LoginLookupScreenState extends State<_LoginLookupScreen> {
final _usernameController = TextEditingController();
bool _isBusy = false;
void _requestResetPassword() async {
final username = _usernameController.value.text;
if (username.isEmpty) {
context.showErrorDialog('signinResetPasswordHint'.tr());
return;
}
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final lookupResp =
await sn.client.get('/cgi/id/users/lookup?probe=$username');
await sn.client.post('/cgi/id/users/me/password-reset', data: {
'user_id': lookupResp.data['id'],
});
context.showModalDialog('done'.tr(), 'signinResetPasswordSent'.tr());
} catch (err) {
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
void _performNewTicket() async {
final username = _usernameController.value.text;
if (username.isEmpty) return;
final sn = context.read<SnNetworkProvider>();
setState(() => _isBusy = true);
try {
// Create ticket
final resp = await sn.client.post('/cgi/id/auth', data: {
'username': username,
});
final result = SnAuthResult.fromJson(resp.data);
widget.onTicket(result.ticket);
// Pull factors
final factorResp =
await sn.client.get('/cgi/id/auth/factors', queryParameters: {
'ticketId': result.ticket!.id.toString(),
});
widget.onFactor(
List<SnAuthFactor>.from(
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
),
);
widget.onNext();
} catch (err) {
context.showErrorDialog(err);
return;
} finally {
setState(() => _isBusy = false);
}
}
@override
void dispose() {
_usernameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(
Symbols.login,
size: 28,
),
).padding(bottom: 8),
),
Text(
'screenAuthLoginGreeting',
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).tr().padding(left: 4, bottom: 16),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _usernameController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'fieldUsername'.tr(),
helperText: 'fieldUsernameLookupHint'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: _isBusy ? null : (_) => _performNewTicket(),
).padding(horizontal: 7),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: _isBusy ? null : () => _requestResetPassword(),
style: TextButton.styleFrom(foregroundColor: Colors.grey),
child: Text('forgotPassword'.tr()),
),
TextButton(
onPressed: _isBusy ? null : () => _performNewTicket(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next').tr(),
const Icon(Icons.chevron_right),
],
),
),
],
),
const Gap(12),
Align(
alignment: Alignment.centerRight,
child: StyledWidget(
Container(
constraints: const BoxConstraints(maxWidth: 290),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'termAcceptNextWithAgree'.tr(),
textAlign: TextAlign.end,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha((255 * 0.75).round()),
),
),
Material(
color: Colors.transparent,
child: InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('termAcceptLink'.tr()),
const Gap(4),
const Icon(Icons.launch, size: 14),
],
),
onTap: () {
launchUrlString('https://solsynth.dev/terms');
},
),
),
],
),
),
).padding(horizontal: 16),
),
],
);
}
}

View File

@ -0,0 +1,161 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/widgets/dialog.dart';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override
State<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
final _emailController = TextEditingController();
final _usernameController = TextEditingController();
final _nicknameController = TextEditingController();
final _passwordController = TextEditingController();
void _performAction(BuildContext context) async {
final email = _emailController.value.text;
final username = _usernameController.value.text;
final nickname = _nicknameController.value.text;
final password = _passwordController.value.text;
if (email.isEmpty ||
username.isEmpty ||
nickname.isEmpty ||
password.isEmpty) {
return;
}
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.post('/cgi/id/users', data: {
'name': username,
'nick': nickname,
'email': email,
'password': password,
});
if (!mounted) return;
// TODO make celebration here
// ignore: use_build_context_synchronously
Navigator.pop(context);
} catch (err) {
context.showErrorDialog(err);
}
}
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 280),
child: StyledWidget(
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(
Symbols.person_add,
size: 28,
),
).padding(bottom: 8),
),
Text(
'screenAuthRegister',
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).tr().padding(left: 4, bottom: 16),
Column(
children: [
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _usernameController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'fieldUsername'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _nicknameController,
autofillHints: const [AutofillHints.nickname],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'fieldNickname'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _emailController,
autofillHints: const [AutofillHints.email],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'fieldEmail'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextField(
obscureText: true,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.password],
controller: _passwordController,
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'fieldPassword'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) => _performAction(context),
),
],
).padding(horizontal: 7),
const Gap(16),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => _performAction(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next').tr(),
const Icon(Icons.chevron_right),
],
),
),
)
],
),
),
).padding(all: 24).center(),
);
}
}

69
lib/types/account.dart Normal file
View File

@ -0,0 +1,69 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'account.freezed.dart';
part 'account.g.dart';
@freezed
class SnAccount with _$SnAccount {
const factory SnAccount({
required int id,
required int? affiliatedId,
required int? affiliatedTo,
required int? automatedBy,
required int? automatedId,
required String avatar,
required String banner,
required DateTime? confirmedAt,
required List<SnAccountContact> contacts,
required DateTime createdAt,
required DateTime? deletedAt,
required String description,
required String name,
required String nick,
required Map<String, dynamic> permNodes,
required SnAccountProfile? profile,
required DateTime? suspendedAt,
required DateTime updatedAt,
}) = _SnAccount;
factory SnAccount.fromJson(Map<String, Object?> json) =>
_$SnAccountFromJson(json);
}
@freezed
class SnAccountContact with _$SnAccountContact {
const factory SnAccountContact({
required int accountId,
required String content,
required DateTime createdAt,
required DateTime? deletedAt,
required int id,
required bool isPrimary,
required bool isPublic,
required int type,
required DateTime updatedAt,
required DateTime? verifiedAt,
}) = _SnAccountContact;
factory SnAccountContact.fromJson(Map<String, Object?> json) =>
_$SnAccountContactFromJson(json);
}
@freezed
class SnAccountProfile with _$SnAccountProfile {
const factory SnAccountProfile({
required int id,
required int accountId,
required DateTime? birthday,
required DateTime createdAt,
required DateTime? deletedAt,
required int experience,
required String firstName,
required String lastName,
required DateTime? lastSeenAt,
required DateTime updatedAt,
}) = _SnAccountProfile;
factory SnAccountProfile.fromJson(Map<String, Object?> json) =>
_$SnAccountProfileFromJson(json);
}

File diff suppressed because it is too large Load Diff

131
lib/types/account.g.dart Normal file
View File

@ -0,0 +1,131 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'account.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
_$SnAccountImpl(
id: (json['id'] as num).toInt(),
affiliatedId: (json['affiliated_id'] as num?)?.toInt(),
affiliatedTo: (json['affiliated_to'] as num?)?.toInt(),
automatedBy: (json['automated_by'] as num?)?.toInt(),
automatedId: (json['automated_id'] as num?)?.toInt(),
avatar: json['avatar'] as String,
banner: json['banner'] as String,
confirmedAt: json['confirmed_at'] == null
? null
: DateTime.parse(json['confirmed_at'] as String),
contacts: (json['contacts'] as List<dynamic>)
.map((e) => SnAccountContact.fromJson(e as Map<String, dynamic>))
.toList(),
createdAt: DateTime.parse(json['created_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
description: json['description'] as String,
name: json['name'] as String,
nick: json['nick'] as String,
permNodes: json['perm_nodes'] as Map<String, dynamic>,
profile: json['profile'] == null
? null
: SnAccountProfile.fromJson(json['profile'] as Map<String, dynamic>),
suspendedAt: json['suspended_at'] == null
? null
: DateTime.parse(json['suspended_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
<String, dynamic>{
'id': instance.id,
'affiliated_id': instance.affiliatedId,
'affiliated_to': instance.affiliatedTo,
'automated_by': instance.automatedBy,
'automated_id': instance.automatedId,
'avatar': instance.avatar,
'banner': instance.banner,
'confirmed_at': instance.confirmedAt?.toIso8601String(),
'contacts': instance.contacts.map((e) => e.toJson()).toList(),
'created_at': instance.createdAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'description': instance.description,
'name': instance.name,
'nick': instance.nick,
'perm_nodes': instance.permNodes,
'profile': instance.profile?.toJson(),
'suspended_at': instance.suspendedAt?.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
};
_$SnAccountContactImpl _$$SnAccountContactImplFromJson(
Map<String, dynamic> json) =>
_$SnAccountContactImpl(
accountId: (json['account_id'] as num).toInt(),
content: json['content'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
id: (json['id'] as num).toInt(),
isPrimary: json['is_primary'] as bool,
isPublic: json['is_public'] as bool,
type: (json['type'] as num).toInt(),
updatedAt: DateTime.parse(json['updated_at'] as String),
verifiedAt: json['verified_at'] == null
? null
: DateTime.parse(json['verified_at'] as String),
);
Map<String, dynamic> _$$SnAccountContactImplToJson(
_$SnAccountContactImpl instance) =>
<String, dynamic>{
'account_id': instance.accountId,
'content': instance.content,
'created_at': instance.createdAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'id': instance.id,
'is_primary': instance.isPrimary,
'is_public': instance.isPublic,
'type': instance.type,
'updated_at': instance.updatedAt.toIso8601String(),
'verified_at': instance.verifiedAt?.toIso8601String(),
};
_$SnAccountProfileImpl _$$SnAccountProfileImplFromJson(
Map<String, dynamic> json) =>
_$SnAccountProfileImpl(
id: (json['id'] as num).toInt(),
accountId: (json['account_id'] as num).toInt(),
birthday: json['birthday'] == null
? null
: DateTime.parse(json['birthday'] as String),
createdAt: DateTime.parse(json['created_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
experience: (json['experience'] as num).toInt(),
firstName: json['first_name'] as String,
lastName: json['last_name'] as String,
lastSeenAt: json['last_seen_at'] == null
? null
: DateTime.parse(json['last_seen_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
Map<String, dynamic> _$$SnAccountProfileImplToJson(
_$SnAccountProfileImpl instance) =>
<String, dynamic>{
'id': instance.id,
'account_id': instance.accountId,
'birthday': instance.birthday?.toIso8601String(),
'created_at': instance.createdAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'experience': instance.experience,
'first_name': instance.firstName,
'last_name': instance.lastName,
'last_seen_at': instance.lastSeenAt?.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
};

57
lib/types/auth.dart Normal file
View File

@ -0,0 +1,57 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'auth.freezed.dart';
part 'auth.g.dart';
@freezed
class SnAuthResult with _$SnAuthResult {
const factory SnAuthResult({
required bool isFinished,
required SnAuthTicket? ticket,
}) = _SnAuthResult;
factory SnAuthResult.fromJson(Map<String, Object?> json) =>
_$SnAuthResultFromJson(json);
}
@freezed
class SnAuthTicket with _$SnAuthTicket {
const factory SnAuthTicket({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required int stepRemain,
required String? grantToken,
required String? accessToken,
required String? refreshToken,
required String ipAddress,
required String location,
required String userAgent,
required DateTime? expiredAt,
required DateTime? lastGrantAt,
required DateTime? availableAt,
required String? nonce,
required int? accountId,
@Default([]) List<int> factorTrail,
}) = _SnAuthTicket;
factory SnAuthTicket.fromJson(Map<String, Object?> json) =>
_$SnAuthTicketFromJson(json);
}
@freezed
class SnAuthFactor with _$SnAuthFactor {
const factory SnAuthFactor({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required int type,
required Map<String, dynamic>? config,
required int? accountId,
}) = _SnAuthFactor;
factory SnAuthFactor.fromJson(Map<String, Object?> json) =>
_$SnAuthFactorFromJson(json);
}

1002
lib/types/auth.freezed.dart Normal file

File diff suppressed because it is too large Load Diff

98
lib/types/auth.g.dart Normal file
View File

@ -0,0 +1,98 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SnAuthResultImpl _$$SnAuthResultImplFromJson(Map<String, dynamic> json) =>
_$SnAuthResultImpl(
isFinished: json['is_finished'] as bool,
ticket: json['ticket'] == null
? null
: SnAuthTicket.fromJson(json['ticket'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$SnAuthResultImplToJson(_$SnAuthResultImpl instance) =>
<String, dynamic>{
'is_finished': instance.isFinished,
'ticket': instance.ticket?.toJson(),
};
_$SnAuthTicketImpl _$$SnAuthTicketImplFromJson(Map<String, dynamic> json) =>
_$SnAuthTicketImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
stepRemain: (json['step_remain'] as num).toInt(),
grantToken: json['grant_token'] as String?,
accessToken: json['access_token'] as String?,
refreshToken: json['refresh_token'] as String?,
ipAddress: json['ip_address'] as String,
location: json['location'] as String,
userAgent: json['user_agent'] as String,
expiredAt: json['expired_at'] == null
? null
: DateTime.parse(json['expired_at'] as String),
lastGrantAt: json['last_grant_at'] == null
? null
: DateTime.parse(json['last_grant_at'] as String),
availableAt: json['available_at'] == null
? null
: DateTime.parse(json['available_at'] as String),
nonce: json['nonce'] as String?,
accountId: (json['account_id'] as num?)?.toInt(),
factorTrail: (json['factor_trail'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList() ??
const [],
);
Map<String, dynamic> _$$SnAuthTicketImplToJson(_$SnAuthTicketImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'step_remain': instance.stepRemain,
'grant_token': instance.grantToken,
'access_token': instance.accessToken,
'refresh_token': instance.refreshToken,
'ip_address': instance.ipAddress,
'location': instance.location,
'user_agent': instance.userAgent,
'expired_at': instance.expiredAt?.toIso8601String(),
'last_grant_at': instance.lastGrantAt?.toIso8601String(),
'available_at': instance.availableAt?.toIso8601String(),
'nonce': instance.nonce,
'account_id': instance.accountId,
'factor_trail': instance.factorTrail,
};
_$SnAuthFactorImpl _$$SnAuthFactorImplFromJson(Map<String, dynamic> json) =>
_$SnAuthFactorImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
type: (json['type'] as num).toInt(),
config: json['config'] as Map<String, dynamic>?,
accountId: (json['account_id'] as num?)?.toInt(),
);
Map<String, dynamic> _$$SnAuthFactorImplToJson(_$SnAuthFactorImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'type': instance.type,
'config': instance.config,
'account_id': instance.accountId,
};

151
lib/widgets/dialog.dart Normal file
View File

@ -0,0 +1,151 @@
import 'dart:math' as math;
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
extension AppPromptExtension on BuildContext {
void showSnackbar(String content, {SnackBarAction? action}) {
ScaffoldMessenger.of(this).showSnackBar(SnackBar(
content: Text(content),
action: action,
));
}
void clearSnackbar() {
ScaffoldMessenger.of(this).clearSnackBars();
}
Future<void> showModalDialog(String title, desc) {
return showDialog<void>(
useRootNavigator: true,
context: this,
builder: (ctx) => AlertDialog(
title: Text(title),
content: Text(desc),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text('dialogDismiss').tr(),
)
],
),
);
}
Future<void> showInfoDialog(String title, body) {
return showDialog<void>(
useRootNavigator: true,
context: this,
builder: (ctx) => AlertDialog(
title: Text(title),
content: Text(body),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text('dialogDismiss').tr(),
)
],
),
);
}
Future<bool> showConfirmDialog(String title, body) async {
return await showDialog<bool>(
useRootNavigator: true,
context: this,
builder: (ctx) => AlertDialog(
title: Text(title),
content: Text(body),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('dialogCancel').tr(),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text('dialogConfirm').tr(),
)
],
),
) ??
false;
}
Future<void> showErrorDialog(dynamic exception) {
Widget content = Text(exception.toString().capitalize());
if (exception is DioException) {
String preview;
switch (exception.response?.statusCode) {
case 400:
preview = 'errorRequestBad'.tr();
break;
case 401:
preview = 'errorRequestUnauthorized'.tr();
break;
case 403:
preview = 'errorRequestForbidden'.tr();
break;
case 404:
preview = 'errorRequestNotFound'.tr();
break;
case null:
preview = 'errorRequestConnection'.tr();
break;
default:
preview = 'errorRequestUnknown'.tr();
break;
}
if (exception.response != null) {
content = Text(
'$preview\n\n(${exception.response?.statusCode}) ${exception.response?.data}',
);
} else {
content = Text(preview);
}
}
return showDialog<void>(
useRootNavigator: true,
context: this,
builder: (ctx) => AlertDialog(
title: Text('dialogError').tr(),
content: content,
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text('dialogDismiss').tr(),
)
],
),
);
}
}
extension ByteFormatter on int {
String formatBytes({int decimals = 2}) {
if (this == 0) return '0 Bytes';
const k = 1024;
final dm = decimals < 0 ? 0 : decimals;
final sizes = [
'Bytes',
'KiB',
'MiB',
'GiB',
'TiB',
'PiB',
'EiB',
'ZiB',
'YiB'
];
final i = (math.log(this) / math.log(k)).floor().toInt();
return '${(this / math.pow(k, i)).toStringAsFixed(dm)} ${sizes[i]}';
}
}
extension StringFormatter on String {
String capitalize() {
return "${this[0].toUpperCase()}${substring(1)}";
}
}

View File

@ -1,24 +1,48 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_background.dart';
import 'package:surface/widgets/navigation/app_bottom_navigation.dart';
class AppScaffold extends StatelessWidget {
final PreferredSizeWidget? appBar;
final String? title;
final Widget? body;
final bool? showBottomNavigation;
const AppScaffold(
{super.key, this.appBar, this.body, this.showBottomNavigation});
final bool autoImplyAppBar;
final bool showBottomNavigation;
const AppScaffold({
super.key,
this.appBar,
this.title,
this.body,
this.autoImplyAppBar = false,
this.showBottomNavigation = false,
});
@override
Widget build(BuildContext context) {
final isShowBottomNavigation = (showBottomNavigation ?? false)
final isShowBottomNavigation = (showBottomNavigation)
? ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
: false;
final state = GoRouter.maybeOf(context);
return AppBackground(
child: Scaffold(
appBar: appBar,
appBar: appBar ??
(autoImplyAppBar
? AppBar(
title: title != null
? Text(title!)
: state != null
? Text(
('screen${state.routerDelegate.currentConfiguration.last.route.name?.capitalize()}')
.tr(),
)
: null)
: null),
body: body,
bottomNavigationBar:
isShowBottomNavigation ? AppBottomNavigationBar() : null,