💄 Create account, login modal varaint and more auth check to prevent user from entering certain widget in unauthorized state
This commit is contained in:
@@ -1,315 +1,22 @@
|
|||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:email_validator/email_validator.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:gap/gap.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/pods/network.dart';
|
|
||||||
import 'package:island/screens/account/me/profile_update.dart';
|
|
||||||
import 'package:island/widgets/alert.dart';
|
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/app_scaffold.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
|
||||||
|
|
||||||
import 'captcha.dart';
|
import 'create_account_content.dart';
|
||||||
|
|
||||||
class CreateAccountScreen extends HookConsumerWidget {
|
class CreateAccountScreen extends HookConsumerWidget {
|
||||||
const CreateAccountScreen({super.key});
|
const CreateAccountScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final formKey = useMemoized(GlobalKey<FormState>.new, const []);
|
|
||||||
|
|
||||||
final emailController = useTextEditingController();
|
|
||||||
final usernameController = useTextEditingController();
|
|
||||||
final nicknameController = useTextEditingController();
|
|
||||||
final passwordController = useTextEditingController();
|
|
||||||
|
|
||||||
void showPostCreateModal() {
|
|
||||||
showModalBottomSheet(
|
|
||||||
isScrollControlled: true,
|
|
||||||
context: context,
|
|
||||||
builder: (context) => _PostCreateModal(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void performAction() async {
|
|
||||||
if (!formKey.currentState!.validate()) return;
|
|
||||||
|
|
||||||
final captchaTk = await CaptchaScreen.show(context);
|
|
||||||
if (captchaTk == null) return;
|
|
||||||
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
showLoadingModal(context);
|
|
||||||
final client = ref.watch(apiClientProvider);
|
|
||||||
await client.post(
|
|
||||||
'/pass/accounts',
|
|
||||||
data: {
|
|
||||||
'name': usernameController.text,
|
|
||||||
'nick': nicknameController.text,
|
|
||||||
'email': emailController.text,
|
|
||||||
'password': passwordController.text,
|
|
||||||
'language':
|
|
||||||
kServerSupportedLanguages[EasyLocalization.of(
|
|
||||||
context,
|
|
||||||
)!.currentLocale.toString()] ??
|
|
||||||
'en-us',
|
|
||||||
'captcha_token': captchaTk,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
hideLoadingModal(context);
|
|
||||||
showPostCreateModal();
|
|
||||||
} catch (err) {
|
|
||||||
if (context.mounted) hideLoadingModal(context);
|
|
||||||
showErrorAlert(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
isNoBackground: false,
|
isNoBackground: false,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: const PageBackButton(),
|
leading: const PageBackButton(),
|
||||||
title: Text('createAccount').tr(),
|
title: Text('createAccount').tr(),
|
||||||
),
|
),
|
||||||
body:
|
body: CreateAccountContent(),
|
||||||
StyledWidget(
|
|
||||||
Container(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 380),
|
|
||||||
child: 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(
|
|
||||||
'createAccount',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
),
|
|
||||||
).tr().padding(left: 4, bottom: 16),
|
|
||||||
Form(
|
|
||||||
key: formKey,
|
|
||||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
TextFormField(
|
|
||||||
controller: usernameController,
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'fieldCannotBeEmpty'.tr();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
autocorrect: false,
|
|
||||||
enableSuggestions: false,
|
|
||||||
autofillHints: const [AutofillHints.username],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
isDense: true,
|
|
||||||
border: const UnderlineInputBorder(),
|
|
||||||
labelText: 'username'.tr(),
|
|
||||||
helperText: 'usernameCannotChangeHint'.tr(),
|
|
||||||
),
|
|
||||||
onTapOutside:
|
|
||||||
(_) =>
|
|
||||||
FocusManager.instance.primaryFocus
|
|
||||||
?.unfocus(),
|
|
||||||
),
|
|
||||||
const Gap(12),
|
|
||||||
TextFormField(
|
|
||||||
controller: nicknameController,
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'fieldCannotBeEmpty'.tr();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
autocorrect: false,
|
|
||||||
autofillHints: const [AutofillHints.nickname],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
isDense: true,
|
|
||||||
border: const UnderlineInputBorder(),
|
|
||||||
labelText: 'nickname'.tr(),
|
|
||||||
),
|
|
||||||
onTapOutside:
|
|
||||||
(_) =>
|
|
||||||
FocusManager.instance.primaryFocus
|
|
||||||
?.unfocus(),
|
|
||||||
),
|
|
||||||
const Gap(12),
|
|
||||||
TextFormField(
|
|
||||||
controller: emailController,
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'fieldCannotBeEmpty'.tr();
|
|
||||||
}
|
|
||||||
if (!EmailValidator.validate(value)) {
|
|
||||||
return 'fieldEmailAddressMustBeValid'.tr();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
autocorrect: false,
|
|
||||||
enableSuggestions: false,
|
|
||||||
autofillHints: const [AutofillHints.email],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
isDense: true,
|
|
||||||
border: const UnderlineInputBorder(),
|
|
||||||
labelText: 'email'.tr(),
|
|
||||||
),
|
|
||||||
onTapOutside:
|
|
||||||
(_) =>
|
|
||||||
FocusManager.instance.primaryFocus
|
|
||||||
?.unfocus(),
|
|
||||||
),
|
|
||||||
const Gap(12),
|
|
||||||
TextFormField(
|
|
||||||
controller: passwordController,
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'fieldCannotBeEmpty'.tr();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
obscureText: true,
|
|
||||||
autocorrect: false,
|
|
||||||
enableSuggestions: false,
|
|
||||||
autofillHints: const [AutofillHints.password],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
isDense: true,
|
|
||||||
border: const UnderlineInputBorder(),
|
|
||||||
labelText: 'password'.tr(),
|
|
||||||
),
|
|
||||||
onTapOutside:
|
|
||||||
(_) =>
|
|
||||||
FocusManager.instance.primaryFocus
|
|
||||||
?.unfocus(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
).padding(horizontal: 7),
|
|
||||||
),
|
|
||||||
const Gap(16),
|
|
||||||
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(Symbols.launch, size: 14),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
launchUrlString(
|
|
||||||
'https://solsynth.dev/terms',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).padding(horizontal: 16),
|
|
||||||
),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
performAction();
|
|
||||||
},
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text("next").tr(),
|
|
||||||
const Icon(Symbols.chevron_right),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).padding(all: 24).center(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PostCreateModal extends HookConsumerWidget {
|
|
||||||
const _PostCreateModal();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return Center(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 280),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text('🎉').fontSize(32),
|
|
||||||
Text(
|
|
||||||
'postCreateAccountTitle'.tr(),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
).fontSize(17),
|
|
||||||
const Gap(18),
|
|
||||||
Text('postCreateAccountNext').tr().fontSize(19).bold(),
|
|
||||||
const Gap(4),
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
spacing: 6,
|
|
||||||
children: [
|
|
||||||
Text('\u2022'),
|
|
||||||
Expanded(child: Text('postCreateAccountNext1').tr()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
spacing: 6,
|
|
||||||
children: [
|
|
||||||
Text('\u2022'),
|
|
||||||
Expanded(child: Text('postCreateAccountNext2').tr()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Gap(6),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
context.pushReplacementNamed('login');
|
|
||||||
},
|
|
||||||
child: Text('login'.tr()),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
296
lib/screens/auth/create_account_content.dart
Normal file
296
lib/screens/auth/create_account_content.dart
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:email_validator/email_validator.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/screens/account/me/profile_update.dart';
|
||||||
|
import 'package:island/widgets/alert.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
|
import 'captcha.dart';
|
||||||
|
|
||||||
|
class CreateAccountContent extends HookConsumerWidget {
|
||||||
|
const CreateAccountContent({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final formKey = useMemoized(GlobalKey<FormState>.new, const []);
|
||||||
|
|
||||||
|
final emailController = useTextEditingController();
|
||||||
|
final usernameController = useTextEditingController();
|
||||||
|
final nicknameController = useTextEditingController();
|
||||||
|
final passwordController = useTextEditingController();
|
||||||
|
|
||||||
|
void showPostCreateModal() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
isScrollControlled: true,
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _PostCreateModal(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void performAction() async {
|
||||||
|
if (!formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
final captchaTk = await CaptchaScreen.show(context);
|
||||||
|
if (captchaTk == null) return;
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
showLoadingModal(context);
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
await client.post(
|
||||||
|
'/pass/accounts',
|
||||||
|
data: {
|
||||||
|
'name': usernameController.text,
|
||||||
|
'nick': nicknameController.text,
|
||||||
|
'email': emailController.text,
|
||||||
|
'password': passwordController.text,
|
||||||
|
'language':
|
||||||
|
kServerSupportedLanguages[EasyLocalization.of(
|
||||||
|
context,
|
||||||
|
)!.currentLocale.toString()] ??
|
||||||
|
'en-us',
|
||||||
|
'captcha_token': captchaTk,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
hideLoadingModal(context);
|
||||||
|
showPostCreateModal();
|
||||||
|
} catch (err) {
|
||||||
|
if (context.mounted) hideLoadingModal(context);
|
||||||
|
showErrorAlert(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return StyledWidget(
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 380),
|
||||||
|
child: 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(
|
||||||
|
'createAccount',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
).tr().padding(left: 4, bottom: 16),
|
||||||
|
Form(
|
||||||
|
key: formKey,
|
||||||
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: usernameController,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'fieldCannotBeEmpty'.tr();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
autofillHints: const [AutofillHints.username],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: const UnderlineInputBorder(),
|
||||||
|
labelText: 'username'.tr(),
|
||||||
|
helperText: 'usernameCannotChangeHint'.tr(),
|
||||||
|
),
|
||||||
|
onTapOutside:
|
||||||
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
TextFormField(
|
||||||
|
controller: nicknameController,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'fieldCannotBeEmpty'.tr();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
autocorrect: false,
|
||||||
|
autofillHints: const [AutofillHints.nickname],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: const UnderlineInputBorder(),
|
||||||
|
labelText: 'nickname'.tr(),
|
||||||
|
),
|
||||||
|
onTapOutside:
|
||||||
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
TextFormField(
|
||||||
|
controller: emailController,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'fieldCannotBeEmpty'.tr();
|
||||||
|
}
|
||||||
|
if (!EmailValidator.validate(value)) {
|
||||||
|
return 'fieldEmailAddressMustBeValid'.tr();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
autofillHints: const [AutofillHints.email],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: const UnderlineInputBorder(),
|
||||||
|
labelText: 'email'.tr(),
|
||||||
|
),
|
||||||
|
onTapOutside:
|
||||||
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
TextFormField(
|
||||||
|
controller: passwordController,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'fieldCannotBeEmpty'.tr();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
obscureText: true,
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: const UnderlineInputBorder(),
|
||||||
|
labelText: 'password'.tr(),
|
||||||
|
),
|
||||||
|
onTapOutside:
|
||||||
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 7),
|
||||||
|
),
|
||||||
|
const Gap(16),
|
||||||
|
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(Symbols.launch, size: 14),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
launchUrlString('https://solsynth.dev/terms');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).padding(horizontal: 16),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
performAction();
|
||||||
|
},
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text("next").tr(),
|
||||||
|
const Icon(Symbols.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).padding(all: 24).center();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PostCreateModal extends HookConsumerWidget {
|
||||||
|
const _PostCreateModal();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 280),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text('🎉').fontSize(32),
|
||||||
|
Text(
|
||||||
|
'postCreateAccountTitle'.tr(),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
).fontSize(17),
|
||||||
|
const Gap(18),
|
||||||
|
Text('postCreateAccountNext').tr().fontSize(19).bold(),
|
||||||
|
const Gap(4),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
spacing: 6,
|
||||||
|
children: [
|
||||||
|
Text('\u2022'),
|
||||||
|
Expanded(child: Text('postCreateAccountNext1').tr()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
spacing: 6,
|
||||||
|
children: [
|
||||||
|
Text('\u2022'),
|
||||||
|
Expanded(child: Text('postCreateAccountNext2').tr()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Gap(6),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.pushReplacementNamed('login');
|
||||||
|
},
|
||||||
|
child: Text('login'.tr()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
lib/screens/auth/create_account_modal.dart
Normal file
19
lib/screens/auth/create_account_modal.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:island/widgets/content/sheet.dart';
|
||||||
|
|
||||||
|
import 'create_account_content.dart';
|
||||||
|
|
||||||
|
class CreateAccountModal extends HookConsumerWidget {
|
||||||
|
const CreateAccountModal({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return SheetScaffold(
|
||||||
|
titleText: 'createAccount'.tr(),
|
||||||
|
heightFactor: 0.9,
|
||||||
|
child: CreateAccountContent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,10 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:math' as math;
|
|
||||||
import 'package:animations/animations.dart';
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:gap/gap.dart';
|
|
||||||
import 'package:island/models/auth.dart';
|
|
||||||
import 'package:island/pods/config.dart';
|
|
||||||
import 'package:island/pods/network.dart';
|
|
||||||
import 'package:island/pods/userinfo.dart';
|
|
||||||
import 'package:island/pods/websocket.dart';
|
|
||||||
import 'package:island/screens/account/me/settings_connections.dart';
|
|
||||||
import 'package:island/screens/auth/oidc.dart';
|
|
||||||
import 'package:island/services/notify.dart';
|
|
||||||
import 'package:island/services/udid.dart';
|
|
||||||
import 'package:island/widgets/alert.dart';
|
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/app_scaffold.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
|
||||||
|
|
||||||
import 'captcha.dart';
|
import 'login_content.dart';
|
||||||
|
|
||||||
final Map<int, (String, String, IconData)> kFactorTypes = {
|
final Map<int, (String, String, IconData)> kFactorTypes = {
|
||||||
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
|
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
|
||||||
@@ -44,743 +23,13 @@ class LoginScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isBusy = useState(false);
|
|
||||||
|
|
||||||
final period = useState(0);
|
|
||||||
final currentTicket = useState<SnAuthChallenge?>(null);
|
|
||||||
final factors = useState<List<SnAuthFactor>>([]);
|
|
||||||
final factorPicked = useState<SnAuthFactor?>(null);
|
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
isNoBackground: false,
|
isNoBackground: false,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: const PageBackButton(),
|
leading: const PageBackButton(),
|
||||||
title: Text('login').tr(),
|
title: Text('login').tr(),
|
||||||
),
|
),
|
||||||
body: Theme(
|
body: LoginContent(),
|
||||||
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
if (isBusy.value)
|
|
||||||
LinearProgressIndicator(
|
|
||||||
minHeight: 4,
|
|
||||||
borderRadius: BorderRadius.zero,
|
|
||||||
trackGap: 0,
|
|
||||||
stopIndicatorRadius: 0,
|
|
||||||
)
|
|
||||||
else if (currentTicket.value != null)
|
|
||||||
LinearProgressIndicator(
|
|
||||||
minHeight: 4,
|
|
||||||
borderRadius: BorderRadius.zero,
|
|
||||||
trackGap: 0,
|
|
||||||
stopIndicatorRadius: 0,
|
|
||||||
value:
|
|
||||||
1 -
|
|
||||||
(currentTicket.value!.stepRemain /
|
|
||||||
currentTicket.value!.stepTotal),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
const Gap(4),
|
|
||||||
Expanded(
|
|
||||||
child:
|
|
||||||
SingleChildScrollView(
|
|
||||||
child: PageTransitionSwitcher(
|
|
||||||
transitionBuilder: (
|
|
||||||
Widget child,
|
|
||||||
Animation<double> primaryAnimation,
|
|
||||||
Animation<double> secondaryAnimation,
|
|
||||||
) {
|
|
||||||
return SharedAxisTransition(
|
|
||||||
animation: primaryAnimation,
|
|
||||||
secondaryAnimation: secondaryAnimation,
|
|
||||||
transitionType: SharedAxisTransitionType.horizontal,
|
|
||||||
child: Container(
|
|
||||||
constraints: BoxConstraints(maxWidth: 380),
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: switch (period.value % 3) {
|
|
||||||
1 => _LoginPickerScreen(
|
|
||||||
key: const ValueKey(1),
|
|
||||||
challenge: currentTicket.value,
|
|
||||||
factors: factors.value,
|
|
||||||
onChallenge:
|
|
||||||
(SnAuthChallenge? p0) => currentTicket.value = p0,
|
|
||||||
onPickFactor:
|
|
||||||
(SnAuthFactor p0) => factorPicked.value = p0,
|
|
||||||
onNext: () => period.value++,
|
|
||||||
onBusy: (value) => isBusy.value = value,
|
|
||||||
),
|
|
||||||
2 => _LoginCheckScreen(
|
|
||||||
key: const ValueKey(2),
|
|
||||||
challenge: currentTicket.value,
|
|
||||||
factor: factorPicked.value,
|
|
||||||
onChallenge:
|
|
||||||
(SnAuthChallenge? p0) => currentTicket.value = p0,
|
|
||||||
onNext: () => period.value = 1,
|
|
||||||
onBusy: (value) => isBusy.value = value,
|
|
||||||
),
|
|
||||||
_ => _LoginLookupScreen(
|
|
||||||
key: const ValueKey(0),
|
|
||||||
ticket: currentTicket.value,
|
|
||||||
onChallenge:
|
|
||||||
(SnAuthChallenge? p0) => currentTicket.value = p0,
|
|
||||||
onFactor:
|
|
||||||
(List<SnAuthFactor>? p0) =>
|
|
||||||
factors.value = p0 ?? [],
|
|
||||||
onNext: () => period.value++,
|
|
||||||
onBusy: (value) => isBusy.value = value,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
).padding(all: 24),
|
|
||||||
).center(),
|
|
||||||
),
|
|
||||||
|
|
||||||
const Gap(4),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LoginCheckScreen extends HookConsumerWidget {
|
|
||||||
final SnAuthChallenge? challenge;
|
|
||||||
final SnAuthFactor? factor;
|
|
||||||
final Function(SnAuthChallenge?) onChallenge;
|
|
||||||
final VoidCallback onNext;
|
|
||||||
final Function(bool) onBusy;
|
|
||||||
|
|
||||||
const _LoginCheckScreen({
|
|
||||||
super.key,
|
|
||||||
required this.challenge,
|
|
||||||
required this.factor,
|
|
||||||
required this.onChallenge,
|
|
||||||
required this.onNext,
|
|
||||||
required this.onBusy,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final isBusy = useState(false);
|
|
||||||
final passwordController = useTextEditingController();
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
onBusy.call(isBusy.value);
|
|
||||||
return null;
|
|
||||||
}, [isBusy]);
|
|
||||||
|
|
||||||
Future<void> getToken({String? code}) async {
|
|
||||||
// Get token if challenge is completed
|
|
||||||
final client = ref.watch(apiClientProvider);
|
|
||||||
final tokenResp = await client.post(
|
|
||||||
'/pass/auth/token',
|
|
||||||
data: {
|
|
||||||
'grant_type': 'authorization_code',
|
|
||||||
'code': code ?? challenge!.id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
final token = tokenResp.data['token'];
|
|
||||||
setToken(ref.watch(sharedPreferencesProvider), token);
|
|
||||||
ref.invalidate(tokenProvider);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
// Do post login tasks
|
|
||||||
final userNotifier = ref.read(userInfoProvider.notifier);
|
|
||||||
userNotifier.fetchUser().then((_) {
|
|
||||||
final apiClient = ref.read(apiClientProvider);
|
|
||||||
subscribePushNotification(apiClient);
|
|
||||||
final wsNotifier = ref.read(websocketStateProvider.notifier);
|
|
||||||
wsNotifier.connect();
|
|
||||||
if (context.mounted) Navigator.pop(context, true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
if (challenge != null && challenge?.stepRemain == 0) {
|
|
||||||
Future(() {
|
|
||||||
if (isBusy.value) return;
|
|
||||||
isBusy.value = true;
|
|
||||||
getToken().catchError((err) {
|
|
||||||
showErrorAlert(err);
|
|
||||||
isBusy.value = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [challenge]);
|
|
||||||
|
|
||||||
if (factor == null) {
|
|
||||||
// Logging in by third parties
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 26,
|
|
||||||
child: const Icon(Symbols.asterisk, size: 28),
|
|
||||||
).padding(bottom: 8),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'loginInProgress'.tr(),
|
|
||||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
|
||||||
).padding(left: 4, bottom: 16),
|
|
||||||
const Gap(16),
|
|
||||||
CircularProgressIndicator().alignment(Alignment.centerLeft),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> performCheckTicket() async {
|
|
||||||
final pwd = passwordController.value.text;
|
|
||||||
if (pwd.isEmpty) return;
|
|
||||||
isBusy.value = true;
|
|
||||||
try {
|
|
||||||
// Pass challenge
|
|
||||||
final client = ref.watch(apiClientProvider);
|
|
||||||
final resp = await client.patch(
|
|
||||||
'/pass/auth/challenge/${challenge!.id}',
|
|
||||||
data: {'factor_id': factor!.id, 'password': pwd},
|
|
||||||
);
|
|
||||||
final result = SnAuthChallenge.fromJson(resp.data);
|
|
||||||
onChallenge(result);
|
|
||||||
if (result.stepRemain > 0) {
|
|
||||||
onNext();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await getToken(code: result.id);
|
|
||||||
} catch (err) {
|
|
||||||
showErrorAlert(err);
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
isBusy.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final width = math.min(380, MediaQuery.of(context).size.width);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 26,
|
|
||||||
child: const Icon(Symbols.asterisk, size: 28),
|
|
||||||
).padding(bottom: 8),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'loginEnterPassword'.tr(),
|
|
||||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
|
||||||
).padding(left: 4, bottom: 16),
|
|
||||||
if ([0].contains(factor!.type))
|
|
||||||
TextField(
|
|
||||||
autocorrect: false,
|
|
||||||
enableSuggestions: false,
|
|
||||||
controller: passwordController,
|
|
||||||
obscureText: true,
|
|
||||||
autofillHints: [
|
|
||||||
factor!.type == 0
|
|
||||||
? AutofillHints.password
|
|
||||||
: AutofillHints.oneTimeCode,
|
|
||||||
],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
isDense: true,
|
|
||||||
labelText: 'password'.tr(),
|
|
||||||
),
|
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
|
||||||
onSubmitted: isBusy.value ? null : (_) => performCheckTicket(),
|
|
||||||
).padding(horizontal: 7)
|
|
||||||
else
|
|
||||||
OtpTextField(
|
|
||||||
showCursor: false,
|
|
||||||
numberOfFields: 6,
|
|
||||||
obscureText: false,
|
|
||||||
showFieldAsBox: true,
|
|
||||||
focusedBorderColor: Theme.of(context).colorScheme.primary,
|
|
||||||
fieldWidth: (width / 6) - 10,
|
|
||||||
onSubmit: (value) {
|
|
||||||
passwordController.text = value;
|
|
||||||
performCheckTicket();
|
|
||||||
},
|
|
||||||
textStyle: Theme.of(context).textTheme.titleLarge!,
|
|
||||||
),
|
|
||||||
const Gap(12),
|
|
||||||
ListTile(
|
|
||||||
leading: Icon(
|
|
||||||
kFactorTypes[factor!.type]?.$3 ?? Symbols.question_mark,
|
|
||||||
),
|
|
||||||
title: Text(kFactorTypes[factor!.type]?.$1 ?? 'unknown').tr(),
|
|
||||||
subtitle: Text(kFactorTypes[factor!.type]?.$2 ?? 'unknown').tr(),
|
|
||||||
),
|
|
||||||
const Gap(12),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: isBusy.value ? null : () => performCheckTicket(),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text('next').tr(),
|
|
||||||
const Icon(Symbols.chevron_right),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LoginPickerScreen extends HookConsumerWidget {
|
|
||||||
final SnAuthChallenge? challenge;
|
|
||||||
final List<SnAuthFactor>? factors;
|
|
||||||
final Function(SnAuthChallenge?) onChallenge;
|
|
||||||
final Function(SnAuthFactor) onPickFactor;
|
|
||||||
final VoidCallback onNext;
|
|
||||||
final Function(bool) onBusy;
|
|
||||||
|
|
||||||
const _LoginPickerScreen({
|
|
||||||
super.key,
|
|
||||||
required this.challenge,
|
|
||||||
required this.factors,
|
|
||||||
required this.onChallenge,
|
|
||||||
required this.onPickFactor,
|
|
||||||
required this.onNext,
|
|
||||||
required this.onBusy,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final isBusy = useState(false);
|
|
||||||
final factorPicked = useState<SnAuthFactor?>(null);
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
onBusy.call(isBusy.value);
|
|
||||||
return null;
|
|
||||||
}, [isBusy]);
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
if (challenge != null && challenge?.stepRemain == 0) {
|
|
||||||
Future(() {
|
|
||||||
onNext();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [challenge]);
|
|
||||||
|
|
||||||
final unfocusColor = Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurface.withAlpha((255 * 0.75).round());
|
|
||||||
|
|
||||||
final hintController = useTextEditingController();
|
|
||||||
|
|
||||||
void performGetFactorCode() async {
|
|
||||||
if (factorPicked.value == null) return;
|
|
||||||
|
|
||||||
isBusy.value = true;
|
|
||||||
final client = ref.watch(apiClientProvider);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.post(
|
|
||||||
'/pass/auth/challenge/${challenge!.id}/factors/${factorPicked.value!.id}',
|
|
||||||
data:
|
|
||||||
hintController.text.isNotEmpty
|
|
||||||
? jsonEncode(hintController.text)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
|
|
||||||
onNext();
|
|
||||||
} catch (err) {
|
|
||||||
if (err is DioException && err.response?.statusCode == 400) {
|
|
||||||
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
|
|
||||||
onNext();
|
|
||||||
if (context.mounted) {
|
|
||||||
showSnackBar(err.response!.data.toString());
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showErrorAlert(err);
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
isBusy.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
key: const ValueKey<int>(1),
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 26,
|
|
||||||
child: const Icon(Symbols.lock, 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:
|
|
||||||
factors
|
|
||||||
?.map(
|
|
||||||
(x) => CheckboxListTile(
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
|
||||||
),
|
|
||||||
secondary: Icon(
|
|
||||||
kFactorTypes[x.type]?.$3 ?? Symbols.question_mark,
|
|
||||||
),
|
|
||||||
title: Text(kFactorTypes[x.type]?.$1 ?? 'unknown').tr(),
|
|
||||||
enabled: !challenge!.blacklistFactors.contains(x.id),
|
|
||||||
value: factorPicked.value == x,
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value == true) {
|
|
||||||
factorPicked.value = x;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList() ??
|
|
||||||
List.empty(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if ([1].contains(factorPicked.value?.type))
|
|
||||||
TextField(
|
|
||||||
controller: hintController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
isDense: true,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
labelText: 'authFactorHint'.tr(),
|
|
||||||
helperText: 'authFactorHintHelper'.tr(),
|
|
||||||
),
|
|
||||||
).padding(top: 12, bottom: 4, horizontal: 4),
|
|
||||||
const Gap(8),
|
|
||||||
Text(
|
|
||||||
'loginMultiFactor'.plural(challenge!.stepRemain),
|
|
||||||
style: TextStyle(color: unfocusColor, fontSize: 13),
|
|
||||||
).padding(horizontal: 16),
|
|
||||||
const Gap(12),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: isBusy.value ? null : () => performGetFactorCode(),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text('next'.tr()),
|
|
||||||
const Icon(Symbols.chevron_right),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LoginLookupScreen extends HookConsumerWidget {
|
|
||||||
final SnAuthChallenge? ticket;
|
|
||||||
final Function(SnAuthChallenge?) onChallenge;
|
|
||||||
final Function(List<SnAuthFactor>?) onFactor;
|
|
||||||
final VoidCallback onNext;
|
|
||||||
final Function(bool) onBusy;
|
|
||||||
|
|
||||||
const _LoginLookupScreen({
|
|
||||||
super.key,
|
|
||||||
required this.ticket,
|
|
||||||
required this.onChallenge,
|
|
||||||
required this.onFactor,
|
|
||||||
required this.onNext,
|
|
||||||
required this.onBusy,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final isBusy = useState(false);
|
|
||||||
final usernameController = useTextEditingController();
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
onBusy.call(isBusy.value);
|
|
||||||
return null;
|
|
||||||
}, [isBusy]);
|
|
||||||
|
|
||||||
Future<void> requestResetPassword() async {
|
|
||||||
final uname = usernameController.value.text;
|
|
||||||
if (uname.isEmpty) {
|
|
||||||
showErrorAlert('loginResetPasswordHint'.tr());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final captchaTk = await CaptchaScreen.show(context);
|
|
||||||
if (captchaTk == null) return;
|
|
||||||
isBusy.value = true;
|
|
||||||
try {
|
|
||||||
final client = ref.watch(apiClientProvider);
|
|
||||||
await client.post(
|
|
||||||
'/pass/accounts/recovery/password',
|
|
||||||
data: {'account': uname, 'captcha_token': captchaTk},
|
|
||||||
);
|
|
||||||
showInfoAlert('loginResetPasswordSent'.tr(), 'done'.tr());
|
|
||||||
} catch (err) {
|
|
||||||
showErrorAlert(err);
|
|
||||||
} finally {
|
|
||||||
isBusy.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> performNewTicket() async {
|
|
||||||
final uname = usernameController.value.text;
|
|
||||||
if (uname.isEmpty) return;
|
|
||||||
isBusy.value = true;
|
|
||||||
try {
|
|
||||||
final client = ref.watch(apiClientProvider);
|
|
||||||
final resp = await client.post(
|
|
||||||
'/pass/auth/challenge',
|
|
||||||
data: {
|
|
||||||
'account': uname,
|
|
||||||
'device_id': await getUdid(),
|
|
||||||
'device_name': await getDeviceName(),
|
|
||||||
'platform':
|
|
||||||
kIsWeb
|
|
||||||
? 1
|
|
||||||
: switch (defaultTargetPlatform) {
|
|
||||||
TargetPlatform.iOS => 2,
|
|
||||||
TargetPlatform.android => 3,
|
|
||||||
TargetPlatform.macOS => 4,
|
|
||||||
TargetPlatform.windows => 5,
|
|
||||||
TargetPlatform.linux => 6,
|
|
||||||
_ => 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
final result = SnAuthChallenge.fromJson(resp.data);
|
|
||||||
onChallenge(result);
|
|
||||||
final factorResp = await client.get(
|
|
||||||
'/pass/auth/challenge/${result.id}/factors',
|
|
||||||
);
|
|
||||||
onFactor(
|
|
||||||
List<SnAuthFactor>.from(
|
|
||||||
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
onNext();
|
|
||||||
} catch (err) {
|
|
||||||
showErrorAlert(err);
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
isBusy.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> withApple() async {
|
|
||||||
final client = ref.watch(apiClientProvider);
|
|
||||||
try {
|
|
||||||
final credential = await SignInWithApple.getAppleIDCredential(
|
|
||||||
scopes: [AppleIDAuthorizationScopes.email],
|
|
||||||
webAuthenticationOptions: WebAuthenticationOptions(
|
|
||||||
clientId: 'dev.solsynth.solarpass',
|
|
||||||
redirectUri: Uri.parse('https://nt.solian.app/auth/callback/apple'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (context.mounted) showLoadingModal(context);
|
|
||||||
final resp = await client.post(
|
|
||||||
'/pass/auth/login/apple/mobile',
|
|
||||||
data: {
|
|
||||||
'identity_token': credential.identityToken!,
|
|
||||||
'authorization_code': credential.authorizationCode,
|
|
||||||
'device_id': await getUdid(),
|
|
||||||
'device_name': await getDeviceName(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final challenge = SnAuthChallenge.fromJson(resp.data);
|
|
||||||
onChallenge(challenge);
|
|
||||||
final factorResp = await client.get(
|
|
||||||
'/pass/auth/challenge/${challenge.id}/factors',
|
|
||||||
);
|
|
||||||
onFactor(
|
|
||||||
List<SnAuthFactor>.from(
|
|
||||||
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
onNext();
|
|
||||||
} catch (err) {
|
|
||||||
if (err is SignInWithAppleAuthorizationException) return;
|
|
||||||
showErrorAlert(err);
|
|
||||||
} finally {
|
|
||||||
if (context.mounted) hideLoadingModal(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> withOidc(String provider) async {
|
|
||||||
final challengeId = await Navigator.of(context, rootNavigator: true).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => OidcScreen(provider: provider.toLowerCase()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final client = ref.watch(apiClientProvider);
|
|
||||||
try {
|
|
||||||
final resp = await client.get('/pass/auth/challenge/$challengeId');
|
|
||||||
final challenge = SnAuthChallenge.fromJson(resp.data);
|
|
||||||
onChallenge(challenge);
|
|
||||||
final factorResp = await client.get(
|
|
||||||
'/pass/auth/challenge/${challenge.id}/factors',
|
|
||||||
);
|
|
||||||
onFactor(
|
|
||||||
List<SnAuthFactor>.from(
|
|
||||||
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
onNext();
|
|
||||||
} catch (err) {
|
|
||||||
showErrorAlert(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
'loginGreeting',
|
|
||||||
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: 'username'.tr(),
|
|
||||||
helperText: 'usernameLookupHint'.tr(),
|
|
||||||
),
|
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
|
||||||
onSubmitted: isBusy.value ? null : (_) => performNewTicket(),
|
|
||||||
).padding(horizontal: 7),
|
|
||||||
if (!kIsWeb)
|
|
||||||
Row(
|
|
||||||
spacing: 6,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: <Widget>[
|
|
||||||
Text("loginOr").tr().fontSize(11).opacity(0.85),
|
|
||||||
const Gap(8),
|
|
||||||
Spacer(),
|
|
||||||
IconButton.filledTonal(
|
|
||||||
onPressed: () => withOidc('github'),
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
icon: getProviderIcon(
|
|
||||||
"github",
|
|
||||||
size: 16,
|
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
tooltip: 'GitHub',
|
|
||||||
),
|
|
||||||
IconButton.filledTonal(
|
|
||||||
onPressed: () => withOidc('google'),
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
icon: getProviderIcon(
|
|
||||||
"google",
|
|
||||||
size: 16,
|
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
tooltip: 'Google',
|
|
||||||
),
|
|
||||||
IconButton.filledTonal(
|
|
||||||
onPressed: withApple,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
icon: getProviderIcon(
|
|
||||||
"apple",
|
|
||||||
size: 16,
|
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
tooltip: 'Apple Account',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
).padding(horizontal: 8, vertical: 8)
|
|
||||||
else
|
|
||||||
const Gap(12),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: isBusy.value ? null : () => requestResetPassword(),
|
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.grey),
|
|
||||||
child: Text('forgotPassword'.tr()),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: isBusy.value ? null : () => performNewTicket(),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text('next').tr(),
|
|
||||||
const Icon(Symbols.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(Symbols.launch, size: 14),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
launchUrlString('https://solsynth.dev/terms');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).padding(horizontal: 16),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
778
lib/screens/auth/login_content.dart
Normal file
778
lib/screens/auth/login_content.dart
Normal file
@@ -0,0 +1,778 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'package:animations/animations.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:island/models/auth.dart';
|
||||||
|
import 'package:island/pods/config.dart';
|
||||||
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/pods/userinfo.dart';
|
||||||
|
import 'package:island/pods/websocket.dart';
|
||||||
|
import 'package:island/screens/account/me/settings_connections.dart';
|
||||||
|
import 'package:island/screens/auth/oidc.dart';
|
||||||
|
import 'package:island/services/notify.dart';
|
||||||
|
import 'package:island/services/udid.dart';
|
||||||
|
import 'package:island/widgets/alert.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
|
import 'captcha.dart';
|
||||||
|
|
||||||
|
final Map<int, (String, String, IconData)> kFactorTypes = {
|
||||||
|
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
|
||||||
|
1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
|
||||||
|
2: (
|
||||||
|
'authFactorInAppNotify',
|
||||||
|
'authFactorInAppNotifyDescription',
|
||||||
|
Symbols.notifications_active,
|
||||||
|
),
|
||||||
|
3: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
|
||||||
|
4: ('authFactorPin', 'authFactorPinDescription', Symbols.nest_secure_alarm),
|
||||||
|
};
|
||||||
|
|
||||||
|
class _LoginCheckScreen extends HookConsumerWidget {
|
||||||
|
final SnAuthChallenge? challenge;
|
||||||
|
final SnAuthFactor? factor;
|
||||||
|
final Function(SnAuthChallenge?) onChallenge;
|
||||||
|
final VoidCallback onNext;
|
||||||
|
final Function(bool) onBusy;
|
||||||
|
|
||||||
|
const _LoginCheckScreen({
|
||||||
|
super.key,
|
||||||
|
required this.challenge,
|
||||||
|
required this.factor,
|
||||||
|
required this.onChallenge,
|
||||||
|
required this.onNext,
|
||||||
|
required this.onBusy,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isBusy = useState(false);
|
||||||
|
final passwordController = useTextEditingController();
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
onBusy.call(isBusy.value);
|
||||||
|
return null;
|
||||||
|
}, [isBusy]);
|
||||||
|
|
||||||
|
Future<void> getToken({String? code}) async {
|
||||||
|
// Get token if challenge is completed
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
final tokenResp = await client.post(
|
||||||
|
'/pass/auth/token',
|
||||||
|
data: {
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
'code': code ?? challenge!.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final token = tokenResp.data['token'];
|
||||||
|
setToken(ref.watch(sharedPreferencesProvider), token);
|
||||||
|
ref.invalidate(tokenProvider);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
// Do post login tasks
|
||||||
|
final userNotifier = ref.read(userInfoProvider.notifier);
|
||||||
|
userNotifier.fetchUser().then((_) {
|
||||||
|
final apiClient = ref.read(apiClientProvider);
|
||||||
|
subscribePushNotification(apiClient);
|
||||||
|
final wsNotifier = ref.read(websocketStateProvider.notifier);
|
||||||
|
wsNotifier.connect();
|
||||||
|
if (context.mounted) Navigator.pop(context, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
if (challenge != null && challenge?.stepRemain == 0) {
|
||||||
|
Future(() {
|
||||||
|
if (isBusy.value) return;
|
||||||
|
isBusy.value = true;
|
||||||
|
getToken().catchError((err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
isBusy.value = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [challenge]);
|
||||||
|
|
||||||
|
if (factor == null) {
|
||||||
|
// Logging in by third parties
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 26,
|
||||||
|
child: const Icon(Symbols.asterisk, size: 28),
|
||||||
|
).padding(bottom: 8),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'loginInProgress'.tr(),
|
||||||
|
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||||
|
).padding(left: 4, bottom: 16),
|
||||||
|
const Gap(16),
|
||||||
|
CircularProgressIndicator().alignment(Alignment.centerLeft),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performCheckTicket() async {
|
||||||
|
final pwd = passwordController.value.text;
|
||||||
|
if (pwd.isEmpty) return;
|
||||||
|
isBusy.value = true;
|
||||||
|
try {
|
||||||
|
// Pass challenge
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
final resp = await client.patch(
|
||||||
|
'/pass/auth/challenge/${challenge!.id}',
|
||||||
|
data: {'factor_id': factor!.id, 'password': pwd},
|
||||||
|
);
|
||||||
|
final result = SnAuthChallenge.fromJson(resp.data);
|
||||||
|
onChallenge(result);
|
||||||
|
if (result.stepRemain > 0) {
|
||||||
|
onNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await getToken(code: result.id);
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
isBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final width = math.min(380, MediaQuery.of(context).size.width);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 26,
|
||||||
|
child: const Icon(Symbols.asterisk, size: 28),
|
||||||
|
).padding(bottom: 8),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'loginEnterPassword'.tr(),
|
||||||
|
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||||
|
).padding(left: 4, bottom: 16),
|
||||||
|
if ([0].contains(factor!.type))
|
||||||
|
TextField(
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
controller: passwordController,
|
||||||
|
obscureText: true,
|
||||||
|
autofillHints: [
|
||||||
|
factor!.type == 0
|
||||||
|
? AutofillHints.password
|
||||||
|
: AutofillHints.oneTimeCode,
|
||||||
|
],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
labelText: 'password'.tr(),
|
||||||
|
),
|
||||||
|
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
onSubmitted: isBusy.value ? null : (_) => performCheckTicket(),
|
||||||
|
).padding(horizontal: 7)
|
||||||
|
else
|
||||||
|
OtpTextField(
|
||||||
|
showCursor: false,
|
||||||
|
numberOfFields: 6,
|
||||||
|
obscureText: false,
|
||||||
|
showFieldAsBox: true,
|
||||||
|
focusedBorderColor: Theme.of(context).colorScheme.primary,
|
||||||
|
fieldWidth: (width / 6) - 10,
|
||||||
|
onSubmit: (value) {
|
||||||
|
passwordController.text = value;
|
||||||
|
performCheckTicket();
|
||||||
|
},
|
||||||
|
textStyle: Theme.of(context).textTheme.titleLarge!,
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
kFactorTypes[factor!.type]?.$3 ?? Symbols.question_mark,
|
||||||
|
),
|
||||||
|
title: Text(kFactorTypes[factor!.type]?.$1 ?? 'unknown').tr(),
|
||||||
|
subtitle: Text(kFactorTypes[factor!.type]?.$2 ?? 'unknown').tr(),
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: isBusy.value ? null : () => performCheckTicket(),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('next').tr(),
|
||||||
|
const Icon(Symbols.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoginContent extends HookConsumerWidget {
|
||||||
|
const LoginContent({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isBusy = useState(false);
|
||||||
|
|
||||||
|
final period = useState(0);
|
||||||
|
final currentTicket = useState<SnAuthChallenge?>(null);
|
||||||
|
final factors = useState<List<SnAuthFactor>>([]);
|
||||||
|
final factorPicked = useState<SnAuthFactor?>(null);
|
||||||
|
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (isBusy.value)
|
||||||
|
LinearProgressIndicator(
|
||||||
|
minHeight: 4,
|
||||||
|
borderRadius: BorderRadius.zero,
|
||||||
|
trackGap: 0,
|
||||||
|
stopIndicatorRadius: 0,
|
||||||
|
)
|
||||||
|
else if (currentTicket.value != null)
|
||||||
|
LinearProgressIndicator(
|
||||||
|
minHeight: 4,
|
||||||
|
borderRadius: BorderRadius.zero,
|
||||||
|
trackGap: 0,
|
||||||
|
stopIndicatorRadius: 0,
|
||||||
|
value:
|
||||||
|
1 -
|
||||||
|
(currentTicket.value!.stepRemain /
|
||||||
|
currentTicket.value!.stepTotal),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const Gap(4),
|
||||||
|
Expanded(
|
||||||
|
child:
|
||||||
|
SingleChildScrollView(
|
||||||
|
child: PageTransitionSwitcher(
|
||||||
|
transitionBuilder: (
|
||||||
|
Widget child,
|
||||||
|
Animation<double> primaryAnimation,
|
||||||
|
Animation<double> secondaryAnimation,
|
||||||
|
) {
|
||||||
|
return SharedAxisTransition(
|
||||||
|
animation: primaryAnimation,
|
||||||
|
secondaryAnimation: secondaryAnimation,
|
||||||
|
transitionType: SharedAxisTransitionType.horizontal,
|
||||||
|
child: Container(
|
||||||
|
constraints: BoxConstraints(maxWidth: 380),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: switch (period.value % 3) {
|
||||||
|
1 => _LoginPickerScreen(
|
||||||
|
key: const ValueKey(1),
|
||||||
|
challenge: currentTicket.value,
|
||||||
|
factors: factors.value,
|
||||||
|
onChallenge:
|
||||||
|
(SnAuthChallenge? p0) => currentTicket.value = p0,
|
||||||
|
onPickFactor:
|
||||||
|
(SnAuthFactor p0) => factorPicked.value = p0,
|
||||||
|
onNext: () => period.value++,
|
||||||
|
onBusy: (value) => isBusy.value = value,
|
||||||
|
),
|
||||||
|
2 => _LoginCheckScreen(
|
||||||
|
key: const ValueKey(2),
|
||||||
|
challenge: currentTicket.value,
|
||||||
|
factor: factorPicked.value,
|
||||||
|
onChallenge:
|
||||||
|
(SnAuthChallenge? p0) => currentTicket.value = p0,
|
||||||
|
onNext: () => period.value = 1,
|
||||||
|
onBusy: (value) => isBusy.value = value,
|
||||||
|
),
|
||||||
|
_ => _LoginLookupScreen(
|
||||||
|
key: const ValueKey(0),
|
||||||
|
ticket: currentTicket.value,
|
||||||
|
onChallenge:
|
||||||
|
(SnAuthChallenge? p0) => currentTicket.value = p0,
|
||||||
|
onFactor:
|
||||||
|
(List<SnAuthFactor>? p0) =>
|
||||||
|
factors.value = p0 ?? [],
|
||||||
|
onNext: () => period.value++,
|
||||||
|
onBusy: (value) => isBusy.value = value,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
).padding(all: 24),
|
||||||
|
).center(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Gap(4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginPickerScreen extends HookConsumerWidget {
|
||||||
|
final SnAuthChallenge? challenge;
|
||||||
|
final List<SnAuthFactor>? factors;
|
||||||
|
final Function(SnAuthChallenge?) onChallenge;
|
||||||
|
final Function(SnAuthFactor) onPickFactor;
|
||||||
|
final VoidCallback onNext;
|
||||||
|
final Function(bool) onBusy;
|
||||||
|
|
||||||
|
const _LoginPickerScreen({
|
||||||
|
super.key,
|
||||||
|
required this.challenge,
|
||||||
|
required this.factors,
|
||||||
|
required this.onChallenge,
|
||||||
|
required this.onPickFactor,
|
||||||
|
required this.onNext,
|
||||||
|
required this.onBusy,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isBusy = useState(false);
|
||||||
|
final factorPicked = useState<SnAuthFactor?>(null);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
onBusy.call(isBusy.value);
|
||||||
|
return null;
|
||||||
|
}, [isBusy]);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
if (challenge != null && challenge?.stepRemain == 0) {
|
||||||
|
Future(() {
|
||||||
|
onNext();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [challenge]);
|
||||||
|
|
||||||
|
final unfocusColor = Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface.withAlpha((255 * 0.75).round());
|
||||||
|
|
||||||
|
final hintController = useTextEditingController();
|
||||||
|
|
||||||
|
void performGetFactorCode() async {
|
||||||
|
if (factorPicked.value == null) return;
|
||||||
|
|
||||||
|
isBusy.value = true;
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.post(
|
||||||
|
'/pass/auth/challenge/${challenge!.id}/factors/${factorPicked.value!.id}',
|
||||||
|
data:
|
||||||
|
hintController.text.isNotEmpty
|
||||||
|
? jsonEncode(hintController.text)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
|
||||||
|
onNext();
|
||||||
|
} catch (err) {
|
||||||
|
if (err is DioException && err.response?.statusCode == 400) {
|
||||||
|
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
|
||||||
|
onNext();
|
||||||
|
if (context.mounted) {
|
||||||
|
showSnackBar(err.response!.data.toString());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showErrorAlert(err);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
isBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
key: const ValueKey<int>(1),
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 26,
|
||||||
|
child: const Icon(Symbols.lock, size: 28),
|
||||||
|
).padding(bottom: 8),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'loginPickFactor'.tr(),
|
||||||
|
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||||
|
).padding(left: 4),
|
||||||
|
const Gap(8),
|
||||||
|
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(
|
||||||
|
kFactorTypes[x.type]?.$3 ?? Symbols.question_mark,
|
||||||
|
),
|
||||||
|
title: Text(kFactorTypes[x.type]?.$1 ?? 'unknown').tr(),
|
||||||
|
enabled: !challenge!.blacklistFactors.contains(x.id),
|
||||||
|
value: factorPicked.value == x,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == true) {
|
||||||
|
factorPicked.value = x;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
List.empty(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if ([1].contains(factorPicked.value?.type))
|
||||||
|
TextField(
|
||||||
|
controller: hintController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: 'authFactorHint'.tr(),
|
||||||
|
helperText: 'authFactorHintHelper'.tr(),
|
||||||
|
),
|
||||||
|
).padding(top: 12, bottom: 4, horizontal: 4),
|
||||||
|
const Gap(8),
|
||||||
|
Text(
|
||||||
|
'loginMultiFactor'.plural(challenge!.stepRemain),
|
||||||
|
style: TextStyle(color: unfocusColor, fontSize: 13),
|
||||||
|
).padding(horizontal: 16),
|
||||||
|
const Gap(12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: isBusy.value ? null : () => performGetFactorCode(),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('next'.tr()),
|
||||||
|
const Icon(Symbols.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginLookupScreen extends HookConsumerWidget {
|
||||||
|
final SnAuthChallenge? ticket;
|
||||||
|
final Function(SnAuthChallenge?) onChallenge;
|
||||||
|
final Function(List<SnAuthFactor>?) onFactor;
|
||||||
|
final VoidCallback onNext;
|
||||||
|
final Function(bool) onBusy;
|
||||||
|
|
||||||
|
const _LoginLookupScreen({
|
||||||
|
super.key,
|
||||||
|
required this.ticket,
|
||||||
|
required this.onChallenge,
|
||||||
|
required this.onFactor,
|
||||||
|
required this.onNext,
|
||||||
|
required this.onBusy,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isBusy = useState(false);
|
||||||
|
final usernameController = useTextEditingController();
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
onBusy.call(isBusy.value);
|
||||||
|
return null;
|
||||||
|
}, [isBusy]);
|
||||||
|
|
||||||
|
Future<void> requestResetPassword() async {
|
||||||
|
final uname = usernameController.value.text;
|
||||||
|
if (uname.isEmpty) {
|
||||||
|
showErrorAlert('loginResetPasswordHint'.tr());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final captchaTk = await CaptchaScreen.show(context);
|
||||||
|
if (captchaTk == null) return;
|
||||||
|
isBusy.value = true;
|
||||||
|
try {
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
await client.post(
|
||||||
|
'/pass/accounts/recovery/password',
|
||||||
|
data: {'account': uname, 'captcha_token': captchaTk},
|
||||||
|
);
|
||||||
|
showInfoAlert('loginResetPasswordSent'.tr(), 'done'.tr());
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
} finally {
|
||||||
|
isBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performNewTicket() async {
|
||||||
|
final uname = usernameController.value.text;
|
||||||
|
if (uname.isEmpty) return;
|
||||||
|
isBusy.value = true;
|
||||||
|
try {
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
final resp = await client.post(
|
||||||
|
'/pass/auth/challenge',
|
||||||
|
data: {
|
||||||
|
'account': uname,
|
||||||
|
'device_id': await getUdid(),
|
||||||
|
'device_name': await getDeviceName(),
|
||||||
|
'platform':
|
||||||
|
kIsWeb
|
||||||
|
? 1
|
||||||
|
: switch (defaultTargetPlatform) {
|
||||||
|
TargetPlatform.iOS => 2,
|
||||||
|
TargetPlatform.android => 3,
|
||||||
|
TargetPlatform.macOS => 4,
|
||||||
|
TargetPlatform.windows => 5,
|
||||||
|
TargetPlatform.linux => 6,
|
||||||
|
_ => 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final result = SnAuthChallenge.fromJson(resp.data);
|
||||||
|
onChallenge(result);
|
||||||
|
final factorResp = await client.get(
|
||||||
|
'/pass/auth/challenge/${result.id}/factors',
|
||||||
|
);
|
||||||
|
onFactor(
|
||||||
|
List<SnAuthFactor>.from(
|
||||||
|
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
onNext();
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
isBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> withApple() async {
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
try {
|
||||||
|
final credential = await SignInWithApple.getAppleIDCredential(
|
||||||
|
scopes: [AppleIDAuthorizationScopes.email],
|
||||||
|
webAuthenticationOptions: WebAuthenticationOptions(
|
||||||
|
clientId: 'dev.solsynth.solarpass',
|
||||||
|
redirectUri: Uri.parse('https://nt.solian.app/auth/callback/apple'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted) showLoadingModal(context);
|
||||||
|
final resp = await client.post(
|
||||||
|
'/pass/auth/login/apple/mobile',
|
||||||
|
data: {
|
||||||
|
'identity_token': credential.identityToken!,
|
||||||
|
'authorization_code': credential.authorizationCode,
|
||||||
|
'device_id': await getUdid(),
|
||||||
|
'device_name': await getDeviceName(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final challenge = SnAuthChallenge.fromJson(resp.data);
|
||||||
|
onChallenge(challenge);
|
||||||
|
final factorResp = await client.get(
|
||||||
|
'/pass/auth/challenge/${challenge.id}/factors',
|
||||||
|
);
|
||||||
|
onFactor(
|
||||||
|
List<SnAuthFactor>.from(
|
||||||
|
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
onNext();
|
||||||
|
} catch (err) {
|
||||||
|
if (err is SignInWithAppleAuthorizationException) return;
|
||||||
|
showErrorAlert(err);
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) hideLoadingModal(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> withOidc(String provider) async {
|
||||||
|
final challengeId = await Navigator.of(context, rootNavigator: true).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => OidcScreen(provider: provider.toLowerCase()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
try {
|
||||||
|
final resp = await client.get('/pass/auth/challenge/$challengeId');
|
||||||
|
final challenge = SnAuthChallenge.fromJson(resp.data);
|
||||||
|
onChallenge(challenge);
|
||||||
|
final factorResp = await client.get(
|
||||||
|
'/pass/auth/challenge/${challenge.id}/factors',
|
||||||
|
);
|
||||||
|
onFactor(
|
||||||
|
List<SnAuthFactor>.from(
|
||||||
|
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
onNext();
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
'loginGreeting'.tr(),
|
||||||
|
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||||
|
).padding(left: 4, bottom: 16),
|
||||||
|
TextField(
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
controller: usernameController,
|
||||||
|
autofillHints: const [AutofillHints.username],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: const UnderlineInputBorder(),
|
||||||
|
labelText: 'username'.tr(),
|
||||||
|
helperText: 'usernameLookupHint'.tr(),
|
||||||
|
),
|
||||||
|
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
onSubmitted: isBusy.value ? null : (_) => performNewTicket(),
|
||||||
|
).padding(horizontal: 7),
|
||||||
|
if (!kIsWeb)
|
||||||
|
Row(
|
||||||
|
spacing: 6,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
Text("loginOr").tr().fontSize(11).opacity(0.85),
|
||||||
|
const Gap(8),
|
||||||
|
Spacer(),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: () => withOidc('github'),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: getProviderIcon(
|
||||||
|
"github",
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
tooltip: 'GitHub',
|
||||||
|
),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: () => withOidc('google'),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: getProviderIcon(
|
||||||
|
"google",
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
tooltip: 'Google',
|
||||||
|
),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: withApple,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: getProviderIcon(
|
||||||
|
"apple",
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
tooltip: 'Apple Account',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 8, vertical: 8)
|
||||||
|
else
|
||||||
|
const Gap(12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: isBusy.value ? null : () => requestResetPassword(),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.grey),
|
||||||
|
child: Text('forgotPassword'.tr()),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: isBusy.value ? null : () => performNewTicket(),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('next').tr(),
|
||||||
|
const Icon(Symbols.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(Symbols.launch, size: 14),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
launchUrlString('https://solsynth.dev/terms');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).padding(horizontal: 16),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
lib/screens/auth/login_modal.dart
Normal file
19
lib/screens/auth/login_modal.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:island/widgets/content/sheet.dart';
|
||||||
|
|
||||||
|
import 'login_content.dart';
|
||||||
|
|
||||||
|
class LoginModal extends HookConsumerWidget {
|
||||||
|
const LoginModal({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return SheetScaffold(
|
||||||
|
titleText: 'login'.tr(),
|
||||||
|
heightFactor: 0.9,
|
||||||
|
child: LoginContent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import 'package:island/models/realm.dart';
|
|||||||
import 'package:island/models/webfeed.dart';
|
import 'package:island/models/webfeed.dart';
|
||||||
import 'package:island/pods/event_calendar.dart';
|
import 'package:island/pods/event_calendar.dart';
|
||||||
import 'package:island/pods/userinfo.dart';
|
import 'package:island/pods/userinfo.dart';
|
||||||
|
import 'package:island/screens/auth/login_modal.dart';
|
||||||
import 'package:island/screens/notification.dart';
|
import 'package:island/screens/notification.dart';
|
||||||
import 'package:island/services/responsive.dart';
|
import 'package:island/services/responsive.dart';
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/app_scaffold.dart';
|
||||||
@@ -348,21 +349,39 @@ class ExploreScreen extends HookConsumerWidget {
|
|||||||
else
|
else
|
||||||
Flexible(
|
Flexible(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: Column(
|
child:
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
Text(
|
children: [
|
||||||
'Welcome to\nthe Solar Network',
|
const Icon(Symbols.emoji_people_rounded, size: 40),
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
const Gap(8),
|
||||||
).bold(),
|
Text(
|
||||||
const Gap(2),
|
'Welcome to\nthe Solar Network',
|
||||||
Text(
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
'Login to explore more!',
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
).bold(),
|
||||||
),
|
const Gap(2),
|
||||||
],
|
Text(
|
||||||
).padding(horizontal: 36, vertical: 16),
|
'Login to explore more!',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => LoginModal(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Symbols.login),
|
||||||
|
label: Text('login').tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 36, vertical: 16).center(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
).padding(horizontal: 12);
|
).padding(horizontal: 12);
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import 'package:gap/gap.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/pods/userinfo.dart';
|
||||||
|
import 'package:island/screens/auth/create_account_modal.dart';
|
||||||
|
import 'package:island/screens/auth/login_modal.dart';
|
||||||
import 'package:island/screens/notification.dart';
|
import 'package:island/screens/notification.dart';
|
||||||
import 'package:island/services/event_bus.dart';
|
import 'package:island/services/event_bus.dart';
|
||||||
import 'package:island/services/responsive.dart';
|
import 'package:island/services/responsive.dart';
|
||||||
@@ -52,7 +55,36 @@ class FabMenu extends HookConsumerWidget {
|
|||||||
late final bool useRootNavigator;
|
late final bool useRootNavigator;
|
||||||
late final Widget menuContent;
|
late final Widget menuContent;
|
||||||
|
|
||||||
final commonEntires = <Widget>[
|
final unauthorizedEntires = <Widget>[
|
||||||
|
ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: const Icon(Symbols.login),
|
||||||
|
title: Text('login').tr(),
|
||||||
|
onTap: () async {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => LoginModal(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: const Icon(Symbols.person_add),
|
||||||
|
title: Text('createAccount').tr(),
|
||||||
|
onTap: () async {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => CreateAccountModal(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
final authorizedEntires = <Widget>[
|
||||||
if (!isWideScreen(context))
|
if (!isWideScreen(context))
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
@@ -90,6 +122,10 @@ class FabMenu extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
final userInfo = ref.watch(userInfoProvider);
|
||||||
|
final authorized = userInfo.value != null;
|
||||||
|
final commonEntires = authorized ? authorizedEntires : unauthorizedEntires;
|
||||||
|
|
||||||
switch (fabType) {
|
switch (fabType) {
|
||||||
case FabMenuType.compose:
|
case FabMenuType.compose:
|
||||||
icon = Symbols.create;
|
icon = Symbols.create;
|
||||||
@@ -98,25 +134,28 @@ class FabMenu extends HookConsumerWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Gap(24),
|
const Gap(24),
|
||||||
ListTile(
|
if (authorized)
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
...([
|
||||||
leading: const Icon(Symbols.post_add_rounded),
|
ListTile(
|
||||||
title: Text('postCompose').tr(),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
onTap: () async {
|
leading: const Icon(Symbols.post_add_rounded),
|
||||||
Navigator.of(context).pop();
|
title: Text('postCompose').tr(),
|
||||||
await PostComposeSheet.show(context);
|
onTap: () async {
|
||||||
},
|
Navigator.of(context).pop();
|
||||||
),
|
await PostComposeSheet.show(context);
|
||||||
ListTile(
|
},
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
),
|
||||||
leading: const Icon(Symbols.article),
|
ListTile(
|
||||||
title: Text('articleCompose').tr(),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
onTap: () async {
|
leading: const Icon(Symbols.article),
|
||||||
Navigator.of(context).pop();
|
title: Text('articleCompose').tr(),
|
||||||
GoRouter.of(context).pushNamed('articleCompose');
|
onTap: () async {
|
||||||
},
|
Navigator.of(context).pop();
|
||||||
),
|
GoRouter.of(context).pushNamed('articleCompose');
|
||||||
const Divider(),
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
]),
|
||||||
...commonEntires,
|
...commonEntires,
|
||||||
Gap(MediaQuery.of(context).padding.bottom + 16),
|
Gap(MediaQuery.of(context).padding.bottom + 16),
|
||||||
],
|
],
|
||||||
@@ -131,32 +170,35 @@ class FabMenu extends HookConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const Gap(24),
|
const Gap(24),
|
||||||
ListTile(
|
if (authorized)
|
||||||
title: const Text('createChatRoom').tr(),
|
...([
|
||||||
leading: const Icon(Symbols.add),
|
ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
title: const Text('createChatRoom').tr(),
|
||||||
onTap: () {
|
leading: const Icon(Symbols.add),
|
||||||
showModalBottomSheet(
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
context: context,
|
onTap: () {
|
||||||
useRootNavigator: true,
|
showModalBottomSheet(
|
||||||
isScrollControlled: true,
|
context: context,
|
||||||
builder: (context) => const EditChatScreen(),
|
useRootNavigator: true,
|
||||||
).then((value) {
|
isScrollControlled: true,
|
||||||
if (value != null) {
|
builder: (context) => const EditChatScreen(),
|
||||||
eventBus.fire(const ChatRoomsRefreshEvent());
|
).then((value) {
|
||||||
}
|
if (value != null) {
|
||||||
});
|
eventBus.fire(const ChatRoomsRefreshEvent());
|
||||||
},
|
}
|
||||||
),
|
});
|
||||||
ListTile(
|
},
|
||||||
title: const Text('createDirectMessage').tr(),
|
),
|
||||||
leading: const Icon(Symbols.person),
|
ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
title: const Text('createDirectMessage').tr(),
|
||||||
onTap: () {
|
leading: const Icon(Symbols.person),
|
||||||
_createDirectMessage(context, ref);
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
},
|
onTap: () {
|
||||||
),
|
_createDirectMessage(context, ref);
|
||||||
const Divider(),
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
]),
|
||||||
...commonEntires,
|
...commonEntires,
|
||||||
Gap(MediaQuery.of(context).padding.bottom + 16),
|
Gap(MediaQuery.of(context).padding.bottom + 16),
|
||||||
],
|
],
|
||||||
@@ -170,21 +212,24 @@ class FabMenu extends HookConsumerWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Gap(24),
|
const Gap(24),
|
||||||
ListTile(
|
if (authorized)
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
...([
|
||||||
leading: const Icon(Symbols.group_add),
|
ListTile(
|
||||||
title: Text('createRealm').tr(),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
onTap: () {
|
leading: const Icon(Symbols.group_add),
|
||||||
Navigator.of(context).pop();
|
title: Text('createRealm').tr(),
|
||||||
context.pushNamed('realmNew').then((value) {
|
onTap: () {
|
||||||
if (value != null) {
|
Navigator.of(context).pop();
|
||||||
// Fire realm refresh event if needed
|
context.pushNamed('realmNew').then((value) {
|
||||||
// eventBus.fire(const RealmsRefreshEvent());
|
if (value != null) {
|
||||||
}
|
// Fire realm refresh event if needed
|
||||||
});
|
// eventBus.fire(const RealmsRefreshEvent());
|
||||||
},
|
}
|
||||||
),
|
});
|
||||||
const Divider(),
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
]),
|
||||||
...commonEntires,
|
...commonEntires,
|
||||||
Gap(MediaQuery.of(context).padding.bottom + 16),
|
Gap(MediaQuery.of(context).padding.bottom + 16),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user