diff --git a/lib/screens/auth/create_account.dart b/lib/screens/auth/create_account.dart index d090090f..57ff2674 100644 --- a/lib/screens/auth/create_account.dart +++ b/lib/screens/auth/create_account.dart @@ -1,315 +1,22 @@ 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:gap/gap.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: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 { const CreateAccountScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final formKey = useMemoized(GlobalKey.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( isNoBackground: false, appBar: AppBar( leading: const PageBackButton(), title: Text('createAccount').tr(), ), - body: - 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()), - ), - ], - ), - ), + body: CreateAccountContent(), ); } } diff --git a/lib/screens/auth/create_account_content.dart b/lib/screens/auth/create_account_content.dart new file mode 100644 index 00000000..fc2a9494 --- /dev/null +++ b/lib/screens/auth/create_account_content.dart @@ -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.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()), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/auth/create_account_modal.dart b/lib/screens/auth/create_account_modal.dart new file mode 100644 index 00000000..ba5f0303 --- /dev/null +++ b/lib/screens/auth/create_account_modal.dart @@ -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(), + ); + } +} diff --git a/lib/screens/auth/login.dart b/lib/screens/auth/login.dart index 689a6389..8a6a8b69 100644 --- a/lib/screens/auth/login.dart +++ b/lib/screens/auth/login.dart @@ -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: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:island/widgets/app_scaffold.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 kFactorTypes = { 0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password), @@ -44,743 +23,13 @@ class LoginScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isBusy = useState(false); - - final period = useState(0); - final currentTicket = useState(null); - final factors = useState>([]); - final factorPicked = useState(null); - return AppScaffold( isNoBackground: false, appBar: AppBar( leading: const PageBackButton(), title: Text('login').tr(), ), - body: 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 primaryAnimation, - Animation 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? 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 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 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? 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(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(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?) 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 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 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.from( - factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)), - ), - ); - onNext(); - } catch (err) { - showErrorAlert(err); - return; - } finally { - isBusy.value = false; - } - } - - Future 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.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 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.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: [ - 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), - ), - ], + body: LoginContent(), ); } } diff --git a/lib/screens/auth/login_content.dart b/lib/screens/auth/login_content.dart new file mode 100644 index 00000000..e5c1af05 --- /dev/null +++ b/lib/screens/auth/login_content.dart @@ -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 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 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 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(null); + final factors = useState>([]); + final factorPicked = useState(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 primaryAnimation, + Animation 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? 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? 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(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(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?) 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 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 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.from( + factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)), + ), + ); + onNext(); + } catch (err) { + showErrorAlert(err); + return; + } finally { + isBusy.value = false; + } + } + + Future 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.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 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.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: [ + 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), + ), + ], + ); + } +} diff --git a/lib/screens/auth/login_modal.dart b/lib/screens/auth/login_modal.dart new file mode 100644 index 00000000..334a4844 --- /dev/null +++ b/lib/screens/auth/login_modal.dart @@ -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(), + ); + } +} diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index 67696b7f..818355bc 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -11,6 +11,7 @@ import 'package:island/models/realm.dart'; import 'package:island/models/webfeed.dart'; import 'package:island/pods/event_calendar.dart'; import 'package:island/pods/userinfo.dart'; +import 'package:island/screens/auth/login_modal.dart'; import 'package:island/screens/notification.dart'; import 'package:island/services/responsive.dart'; import 'package:island/widgets/app_scaffold.dart'; @@ -348,21 +349,39 @@ class ExploreScreen extends HookConsumerWidget { else Flexible( flex: 2, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Welcome to\nthe Solar Network', - style: Theme.of(context).textTheme.titleLarge, - ).bold(), - const Gap(2), - Text( - 'Login to explore more!', - style: Theme.of(context).textTheme.bodyLarge, - ), - ], - ).padding(horizontal: 36, vertical: 16), + child: + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Symbols.emoji_people_rounded, size: 40), + const Gap(8), + Text( + 'Welcome to\nthe Solar Network', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ).bold(), + const Gap(2), + Text( + '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); diff --git a/lib/widgets/navigation/fab_menu.dart b/lib/widgets/navigation/fab_menu.dart index dfa72fd0..2319438a 100644 --- a/lib/widgets/navigation/fab_menu.dart +++ b/lib/widgets/navigation/fab_menu.dart @@ -4,6 +4,9 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:go_router/go_router.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/services/event_bus.dart'; import 'package:island/services/responsive.dart'; @@ -52,7 +55,36 @@ class FabMenu extends HookConsumerWidget { late final bool useRootNavigator; late final Widget menuContent; - final commonEntires = [ + final unauthorizedEntires = [ + 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 = [ if (!isWideScreen(context)) ListTile( 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) { case FabMenuType.compose: icon = Symbols.create; @@ -98,25 +134,28 @@ class FabMenu extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ const Gap(24), - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - leading: const Icon(Symbols.post_add_rounded), - title: Text('postCompose').tr(), - onTap: () async { - Navigator.of(context).pop(); - await PostComposeSheet.show(context); - }, - ), - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - leading: const Icon(Symbols.article), - title: Text('articleCompose').tr(), - onTap: () async { - Navigator.of(context).pop(); - GoRouter.of(context).pushNamed('articleCompose'); - }, - ), - const Divider(), + if (authorized) + ...([ + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Symbols.post_add_rounded), + title: Text('postCompose').tr(), + onTap: () async { + Navigator.of(context).pop(); + await PostComposeSheet.show(context); + }, + ), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Symbols.article), + title: Text('articleCompose').tr(), + onTap: () async { + Navigator.of(context).pop(); + GoRouter.of(context).pushNamed('articleCompose'); + }, + ), + const Divider(), + ]), ...commonEntires, Gap(MediaQuery.of(context).padding.bottom + 16), ], @@ -131,32 +170,35 @@ class FabMenu extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Gap(24), - ListTile( - title: const Text('createChatRoom').tr(), - leading: const Icon(Symbols.add), - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - onTap: () { - showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - builder: (context) => const EditChatScreen(), - ).then((value) { - if (value != null) { - eventBus.fire(const ChatRoomsRefreshEvent()); - } - }); - }, - ), - ListTile( - title: const Text('createDirectMessage').tr(), - leading: const Icon(Symbols.person), - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - onTap: () { - _createDirectMessage(context, ref); - }, - ), - const Divider(), + if (authorized) + ...([ + ListTile( + title: const Text('createChatRoom').tr(), + leading: const Icon(Symbols.add), + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + onTap: () { + showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + builder: (context) => const EditChatScreen(), + ).then((value) { + if (value != null) { + eventBus.fire(const ChatRoomsRefreshEvent()); + } + }); + }, + ), + ListTile( + title: const Text('createDirectMessage').tr(), + leading: const Icon(Symbols.person), + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + onTap: () { + _createDirectMessage(context, ref); + }, + ), + const Divider(), + ]), ...commonEntires, Gap(MediaQuery.of(context).padding.bottom + 16), ], @@ -170,21 +212,24 @@ class FabMenu extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ const Gap(24), - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - leading: const Icon(Symbols.group_add), - title: Text('createRealm').tr(), - onTap: () { - Navigator.of(context).pop(); - context.pushNamed('realmNew').then((value) { - if (value != null) { - // Fire realm refresh event if needed - // eventBus.fire(const RealmsRefreshEvent()); - } - }); - }, - ), - const Divider(), + if (authorized) + ...([ + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Symbols.group_add), + title: Text('createRealm').tr(), + onTap: () { + Navigator.of(context).pop(); + context.pushNamed('realmNew').then((value) { + if (value != null) { + // Fire realm refresh event if needed + // eventBus.fire(const RealmsRefreshEvent()); + } + }); + }, + ), + const Divider(), + ]), ...commonEntires, Gap(MediaQuery.of(context).padding.bottom + 16), ],