From 4d6c65efef23e9f2d4331b17727ae4c37b1c8a76 Mon Sep 17 00:00:00 2001 From: jld3103 Date: Fri, 7 Jul 2023 10:52:37 +0200 Subject: [PATCH] neon: Refactor login flow --- packages/neon/neon/lib/l10n/en.arb | 29 +- .../neon/neon/lib/l10n/localizations.dart | 54 +++- .../neon/neon/lib/l10n/localizations_en.dart | 31 +- packages/neon/neon/lib/src/blocs/apps.dart | 4 +- packages/neon/neon/lib/src/blocs/login.dart | 120 -------- .../lib/src/blocs/login_check_account.dart | 71 +++++ .../src/blocs/login_check_server_status.dart | 56 ++++ .../neon/neon/lib/src/blocs/login_flow.dart | 82 +++++ .../neon/neon/lib/src/models/account.dart | 23 -- packages/neon/neon/lib/src/pages/home.dart | 2 +- packages/neon/neon/lib/src/pages/login.dart | 282 +++++------------- .../lib/src/pages/login_check_account.dart | 112 +++++++ .../src/pages/login_check_server_status.dart | 123 ++++++++ .../neon/neon/lib/src/pages/login_flow.dart | 88 ++++++ packages/neon/neon/lib/src/router.dart | 71 ++++- packages/neon/neon/lib/src/router.g.dart | 71 ++++- .../neon/lib/src/widgets/account_tile.dart | 3 + .../neon/neon/lib/src/widgets/exception.dart | 2 +- .../neon/lib/src/widgets/validation_tile.dart | 47 +++ packages/neon/neon/pubspec.yaml | 1 - 20 files changed, 891 insertions(+), 381 deletions(-) delete mode 100644 packages/neon/neon/lib/src/blocs/login.dart create mode 100644 packages/neon/neon/lib/src/blocs/login_check_account.dart create mode 100644 packages/neon/neon/lib/src/blocs/login_check_server_status.dart create mode 100644 packages/neon/neon/lib/src/blocs/login_flow.dart create mode 100644 packages/neon/neon/lib/src/pages/login_check_account.dart create mode 100644 packages/neon/neon/lib/src/pages/login_check_server_status.dart create mode 100644 packages/neon/neon/lib/src/pages/login_flow.dart create mode 100644 packages/neon/neon/lib/src/widgets/validation_tile.dart diff --git a/packages/neon/neon/lib/l10n/en.arb b/packages/neon/neon/lib/l10n/en.arb index 36557956..e9535306 100644 --- a/packages/neon/neon/lib/l10n/en.arb +++ b/packages/neon/neon/lib/l10n/en.arb @@ -11,7 +11,27 @@ "loginSwitchToBrowserWindow": "Please switch to the browser window that just opened and proceed there", "loginWorksWith": "works with", "loginRestart": "Restart login", - "errorAccountAlreadyExists": "The account you are trying to add already exists", + "loginCheckingServerVersion": "Checking server version", + "loginSupportedServerVersion": "Supported server version: {version}", + "@loginSupportedServerVersion": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "loginUnsupportedServerVersion": "Unsupported server version: {version}", + "@loginUnsupportedServerVersion": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "loginCheckingMaintenanceMode": "Checking maintenance mode", + "loginMaintenanceModeEnabled": "Maintenance mode enabled", + "loginMaintenanceModeDisabled": "Maintenance mode disabled", + "loginCheckingAccount": "Checking account", "errorCredentialsForAccountNoLongerMatch": "The credentials for this account no longer match", "errorServerHadAProblemProcessingYourRequest": "The server had a problem while processing your request. You might want to try again", "errorSomethingWentWrongTryAgainLater": "Something went wrong. Please try again later", @@ -36,10 +56,10 @@ } }, "errorUnableToOpenFile": "Unable to open the file", - "errorUnsupportedVersion": "Sorry, the version of the following apps on your Nextcloud instance are not supported. \n {unsupported} \n Please contact your administrator to resolve the issues.", - "@errorUnsupportedVersion" : { + "errorUnsupportedAppVersions": "Sorry, the version of the following apps on your Nextcloud instance are not supported. \n {names} \n Please contact your administrator to resolve the issues.", + "@errorUnsupportedAppVersions" : { "placeholders": { - "unsupported": { + "names": { "type": "String" } } @@ -52,6 +72,7 @@ "actionRetry": "Retry", "actionShowSlashHide": "Show/Hide", "actionExit": "Exit", + "actionContinue": "Continue", "firstLaunchGoToSettingsToEnablePushNotifications": "Go to the settings to enable push notifications", "nextPushSupported": "NextPush is supported!", "nextPushSupportedText": "NextPush is a FOSS way of receiving push notifications using the UnifiedPush protocol via a Nextcloud instance.\nYou can install NextPush from the F-Droid app store.", diff --git a/packages/neon/neon/lib/l10n/localizations.dart b/packages/neon/neon/lib/l10n/localizations.dart index 06cfaebe..4bcfa441 100644 --- a/packages/neon/neon/lib/l10n/localizations.dart +++ b/packages/neon/neon/lib/l10n/localizations.dart @@ -125,11 +125,47 @@ abstract class AppLocalizations { /// **'Restart login'** String get loginRestart; - /// No description provided for @errorAccountAlreadyExists. + /// No description provided for @loginCheckingServerVersion. /// /// In en, this message translates to: - /// **'The account you are trying to add already exists'** - String get errorAccountAlreadyExists; + /// **'Checking server version'** + String get loginCheckingServerVersion; + + /// No description provided for @loginSupportedServerVersion. + /// + /// In en, this message translates to: + /// **'Supported server version: {version}'** + String loginSupportedServerVersion(String version); + + /// No description provided for @loginUnsupportedServerVersion. + /// + /// In en, this message translates to: + /// **'Unsupported server version: {version}'** + String loginUnsupportedServerVersion(String version); + + /// No description provided for @loginCheckingMaintenanceMode. + /// + /// In en, this message translates to: + /// **'Checking maintenance mode'** + String get loginCheckingMaintenanceMode; + + /// No description provided for @loginMaintenanceModeEnabled. + /// + /// In en, this message translates to: + /// **'Maintenance mode enabled'** + String get loginMaintenanceModeEnabled; + + /// No description provided for @loginMaintenanceModeDisabled. + /// + /// In en, this message translates to: + /// **'Maintenance mode disabled'** + String get loginMaintenanceModeDisabled; + + /// No description provided for @loginCheckingAccount. + /// + /// In en, this message translates to: + /// **'Checking account'** + String get loginCheckingAccount; /// No description provided for @errorCredentialsForAccountNoLongerMatch. /// @@ -191,11 +227,11 @@ abstract class AppLocalizations { /// **'Unable to open the file'** String get errorUnableToOpenFile; - /// No description provided for @errorUnsupportedVersion. + /// No description provided for @errorUnsupportedAppVersions. /// /// In en, this message translates to: - /// **'Sorry, the version of the following apps on your Nextcloud instance are not supported. \n {unsupported} \n Please contact your administrator to resolve the issues.'** - String errorUnsupportedVersion(String unsupported); + /// **'Sorry, the version of the following apps on your Nextcloud instance are not supported. \n {names} \n Please contact your administrator to resolve the issues.'** + String errorUnsupportedAppVersions(String names); /// No description provided for @errorEmptyField. /// @@ -245,6 +281,12 @@ abstract class AppLocalizations { /// **'Exit'** String get actionExit; + /// No description provided for @actionContinue. + /// + /// In en, this message translates to: + /// **'Continue'** + String get actionContinue; + /// No description provided for @firstLaunchGoToSettingsToEnablePushNotifications. /// /// In en, this message translates to: diff --git a/packages/neon/neon/lib/l10n/localizations_en.dart b/packages/neon/neon/lib/l10n/localizations_en.dart index 12626573..5142f03b 100644 --- a/packages/neon/neon/lib/l10n/localizations_en.dart +++ b/packages/neon/neon/lib/l10n/localizations_en.dart @@ -39,7 +39,29 @@ class AppLocalizationsEn extends AppLocalizations { String get loginRestart => 'Restart login'; @override - String get errorAccountAlreadyExists => 'The account you are trying to add already exists'; + String get loginCheckingServerVersion => 'Checking server version'; + + @override + String loginSupportedServerVersion(String version) { + return 'Supported server version: $version'; + } + + @override + String loginUnsupportedServerVersion(String version) { + return 'Unsupported server version: $version'; + } + + @override + String get loginCheckingMaintenanceMode => 'Checking maintenance mode'; + + @override + String get loginMaintenanceModeEnabled => 'Maintenance mode enabled'; + + @override + String get loginMaintenanceModeDisabled => 'Maintenance mode disabled'; + + @override + String get loginCheckingAccount => 'Checking account'; @override String get errorCredentialsForAccountNoLongerMatch => 'The credentials for this account no longer match'; @@ -79,8 +101,8 @@ class AppLocalizationsEn extends AppLocalizations { String get errorUnableToOpenFile => 'Unable to open the file'; @override - String errorUnsupportedVersion(String unsupported) { - return 'Sorry, the version of the following apps on your Nextcloud instance are not supported. \n $unsupported \n Please contact your administrator to resolve the issues.'; + String errorUnsupportedAppVersions(String names) { + return 'Sorry, the version of the following apps on your Nextcloud instance are not supported. \n $names \n Please contact your administrator to resolve the issues.'; } @override @@ -107,6 +129,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get actionExit => 'Exit'; + @override + String get actionContinue => 'Continue'; + @override String get firstLaunchGoToSettingsToEnablePushNotifications => 'Go to the settings to enable push notifications'; diff --git a/packages/neon/neon/lib/src/blocs/apps.dart b/packages/neon/neon/lib/src/blocs/apps.dart index 383178dd..5d3b43a7 100644 --- a/packages/neon/neon/lib/src/blocs/apps.dart +++ b/packages/neon/neon/lib/src/blocs/apps.dart @@ -105,9 +105,9 @@ class AppsBloc extends InteractiveBloc implements AppsBlocEvents, AppsBlocStates for (final id in appIds) { try { final (supported, minVersion) = switch (id) { - 'core' => await _account.client.core.isSupported(capabilities.requireData), + 'core' => _account.client.core.isSupported(capabilities.requireData), 'news' => await _account.client.news.isSupported(), - 'notes' => await _account.client.notes.isSupported(capabilities.requireData), + 'notes' => _account.client.notes.isSupported(capabilities.requireData), _ => (true, null), }; diff --git a/packages/neon/neon/lib/src/blocs/login.dart b/packages/neon/neon/lib/src/blocs/login.dart deleted file mode 100644 index 9ce3dd6c..00000000 --- a/packages/neon/neon/lib/src/blocs/login.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:meta/meta.dart'; -import 'package:neon/src/bloc/bloc.dart'; -import 'package:neon/src/models/account.dart'; -import 'package:nextcloud/nextcloud.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:rxdart/rxdart.dart'; - -abstract class LoginBlocEvents { - void setServerURL(final String? url); -} - -abstract class LoginBlocStates { - BehaviorSubject get serverURL; - - BehaviorSubject get serverConnectionState; - - BehaviorSubject get loginFlowInit; - - BehaviorSubject get loginFlowResult; -} - -@internal -class LoginBloc extends InteractiveBloc implements LoginBlocEvents, LoginBlocStates { - LoginBloc(this._packageInfo); - - final PackageInfo _packageInfo; - - Timer? _pollTimer; - - @override - void dispose() { - _cancelPollTimer(); - unawaited(serverURL.close()); - unawaited(serverConnectionState.close()); - unawaited(loginFlowInit.close()); - unawaited(loginFlowResult.close()); - } - - @override - BehaviorSubject loginFlowInit = BehaviorSubject.seeded(null); - - @override - BehaviorSubject loginFlowResult = BehaviorSubject.seeded(null); - - @override - BehaviorSubject serverConnectionState = BehaviorSubject.seeded(null); - - @override - BehaviorSubject serverURL = BehaviorSubject.seeded(null); - - @override - Future refresh() async { - await _setServerURL(serverURL.valueOrNull); - } - - @override - void setServerURL(final String? url) { - unawaited(_setServerURL(url)); - } - - Future _setServerURL(final String? url) async { - serverURL.add(url); - loginFlowInit.add(null); - loginFlowResult.add(null); - serverConnectionState.add(url != null ? ServerConnectionState.loading : null); - - if (url != null) { - try { - final client = NextcloudClient( - url, - userAgentOverride: userAgent(_packageInfo), - ); - - final status = await client.core.getStatus(); - if (status.maintenance) { - serverConnectionState.add(ServerConnectionState.maintenanceMode); - return; - } - - serverConnectionState.add(ServerConnectionState.success); - - final init = await client.core.initLoginFlow(); - loginFlowInit.add(init); - - _cancelPollTimer(); - _pollTimer = Timer.periodic(const Duration(seconds: 2), (final _) async { - try { - final result = await client.core.getLoginFlowResult(token: init.poll.token); - _cancelPollTimer(); - loginFlowResult.add(result); - } catch (e, s) { - debugPrint(e.toString()); - debugPrint(s.toString()); - } - }); - } catch (e, s) { - debugPrint(e.toString()); - debugPrint(s.toString()); - serverConnectionState.add(ServerConnectionState.unreachable); - } - } - } - - void _cancelPollTimer() { - if (_pollTimer != null) { - _pollTimer!.cancel(); - _pollTimer = null; - } - } -} - -enum ServerConnectionState { - loading, - unreachable, - maintenanceMode, - success, -} diff --git a/packages/neon/neon/lib/src/blocs/login_check_account.dart b/packages/neon/neon/lib/src/blocs/login_check_account.dart new file mode 100644 index 00000000..b5f10bf5 --- /dev/null +++ b/packages/neon/neon/lib/src/blocs/login_check_account.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:neon/src/bloc/bloc.dart'; +import 'package:neon/src/bloc/result.dart'; +import 'package:neon/src/models/account.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:rxdart/rxdart.dart'; + +abstract interface class LoginCheckAccountBlocEvents {} + +abstract interface class LoginCheckAccountBlocStates { + /// Contains the account for the user + BehaviorSubject> get state; +} + +class LoginCheckAccountBloc extends InteractiveBloc + implements LoginCheckAccountBlocEvents, LoginCheckAccountBlocStates { + LoginCheckAccountBloc( + this._packageInfo, + this.serverURL, + this.loginName, + this.password, + ) { + unawaited(refresh()); + } + + final PackageInfo _packageInfo; + final String serverURL; + final String loginName; + final String password; + + @override + void dispose() { + unawaited(state.close()); + } + + @override + BehaviorSubject> state = BehaviorSubject(); + + @override + Future refresh() async { + state.add(Result.loading()); + + try { + final client = NextcloudClient( + serverURL, + loginName: loginName, + password: password, + userAgentOverride: userAgent(_packageInfo), + ); + + final response = await client.provisioningApi.getCurrentUser(); + + final account = Account( + serverURL: serverURL, + loginName: loginName, + username: response.ocs.data.id, + password: password, + userAgent: userAgent(_packageInfo), + ); + + state.add(Result.success(account)); + } catch (e, s) { + debugPrint(e.toString()); + debugPrint(s.toString()); + state.add(Result.error(e)); + } + } +} diff --git a/packages/neon/neon/lib/src/blocs/login_check_server_status.dart b/packages/neon/neon/lib/src/blocs/login_check_server_status.dart new file mode 100644 index 00000000..2996ddb7 --- /dev/null +++ b/packages/neon/neon/lib/src/blocs/login_check_server_status.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:neon/src/bloc/bloc.dart'; +import 'package:neon/src/bloc/result.dart'; +import 'package:neon/src/models/account.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:rxdart/rxdart.dart'; + +abstract interface class LoginCheckServerStatusBlocEvents {} + +abstract interface class LoginCheckServerStatusBlocStates { + /// Contains the current server connection state + BehaviorSubject> get state; +} + +class LoginCheckServerStatusBloc extends InteractiveBloc + implements LoginCheckServerStatusBlocEvents, LoginCheckServerStatusBlocStates { + LoginCheckServerStatusBloc( + this._packageInfo, + this.serverURL, + ) { + unawaited(refresh()); + } + + final PackageInfo _packageInfo; + final String serverURL; + + @override + void dispose() { + unawaited(state.close()); + } + + @override + BehaviorSubject> state = BehaviorSubject(); + + @override + Future refresh() async { + state.add(Result.loading()); + + try { + final client = NextcloudClient( + serverURL, + userAgentOverride: userAgent(_packageInfo), + ); + + final status = await client.core.getStatus(); + state.add(Result.success(status)); + } catch (e, s) { + debugPrint(e.toString()); + debugPrint(s.toString()); + state.add(Result.error(e)); + } + } +} diff --git a/packages/neon/neon/lib/src/blocs/login_flow.dart b/packages/neon/neon/lib/src/blocs/login_flow.dart new file mode 100644 index 00000000..4ac3ca76 --- /dev/null +++ b/packages/neon/neon/lib/src/blocs/login_flow.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:neon/src/bloc/bloc.dart'; +import 'package:neon/src/bloc/result.dart'; +import 'package:neon/src/models/account.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:rxdart/rxdart.dart'; + +abstract class LoginFlowBlocEvents {} + +abstract class LoginFlowBlocStates { + BehaviorSubject> get init; + + Stream get result; +} + +class LoginFlowBloc extends InteractiveBloc implements LoginFlowBlocEvents, LoginFlowBlocStates { + LoginFlowBloc( + this._packageInfo, + this.serverURL, + ) { + unawaited(refresh()); + } + + final PackageInfo _packageInfo; + final String serverURL; + late final _client = NextcloudClient( + serverURL, + userAgentOverride: userAgent(_packageInfo), + ); + final _resultController = StreamController(); + + Timer? _pollTimer; + + @override + void dispose() { + _cancelPollTimer(); + unawaited(init.close()); + unawaited(_resultController.close()); + } + + @override + BehaviorSubject> init = BehaviorSubject>(); + + @override + late Stream result = _resultController.stream.asBroadcastStream(); + + @override + Future refresh() async { + try { + init.add(Result.loading()); + + final initResponse = await _client.core.initLoginFlow(); + init.add(Result.success(initResponse)); + + _cancelPollTimer(); + _pollTimer = Timer.periodic(const Duration(seconds: 1), (final _) async { + try { + final resultResponse = await _client.core.getLoginFlowResult(token: initResponse.poll.token); + _cancelPollTimer(); + _resultController.add(resultResponse); + } catch (e, s) { + debugPrint(e.toString()); + debugPrint(s.toString()); + } + }); + } catch (e, s) { + debugPrint(e.toString()); + debugPrint(s.toString()); + init.add(Result.error(e)); + } + } + + void _cancelPollTimer() { + if (_pollTimer != null) { + _pollTimer!.cancel(); + _pollTimer = null; + } + } +} diff --git a/packages/neon/neon/lib/src/models/account.dart b/packages/neon/neon/lib/src/models/account.dart index 4effb840..5e336f72 100644 --- a/packages/neon/neon/lib/src/models/account.dart +++ b/packages/neon/neon/lib/src/models/account.dart @@ -9,29 +9,6 @@ import 'package:package_info_plus/package_info_plus.dart'; part 'account.g.dart'; -Future getAccount( - final PackageInfo packageInfo, - final String serverURL, - final String loginName, - final String password, -) async { - final username = (await NextcloudClient( - serverURL, - loginName: loginName, - password: password, - ).provisioningApi.getCurrentUser()) - .ocs - .data - .id; - return Account( - serverURL: serverURL, - loginName: loginName, - username: username, - password: password, - userAgent: userAgent(packageInfo), - ); -} - String userAgent(final PackageInfo packageInfo) { var buildNumber = packageInfo.buildNumber; if (buildNumber.isEmpty) { diff --git a/packages/neon/neon/lib/src/pages/home.dart b/packages/neon/neon/lib/src/pages/home.dart index db3c6419..144565ee 100644 --- a/packages/neon/neon/lib/src/pages/home.dart +++ b/packages/neon/neon/lib/src/pages/home.dart @@ -63,7 +63,7 @@ class _HomePageState extends State { } } - final message = l10n.errorUnsupportedVersion(buffer.toString()); + final message = l10n.errorUnsupportedAppVersions(buffer.toString()); unawaited(_showProblem(message)); }); diff --git a/packages/neon/neon/lib/src/pages/login.dart b/packages/neon/neon/lib/src/pages/login.dart index 637b622b..bdebeefd 100644 --- a/packages/neon/neon/lib/src/pages/login.dart +++ b/packages/neon/neon/lib/src/pages/login.dart @@ -1,19 +1,10 @@ import 'package:flutter/material.dart'; import 'package:neon/l10n/localizations.dart'; -import 'package:neon/src/blocs/accounts.dart'; -import 'package:neon/src/blocs/login.dart'; -import 'package:neon/src/models/account.dart'; import 'package:neon/src/models/branding.dart'; -import 'package:neon/src/platform/platform.dart'; import 'package:neon/src/router.dart'; import 'package:neon/src/utils/validators.dart'; -import 'package:neon/src/widgets/exception.dart'; -import 'package:neon/src/widgets/linear_progress_indicator.dart'; import 'package:neon/src/widgets/nextcloud_logo.dart'; -import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; -import 'package:url_launcher/url_launcher_string.dart'; -import 'package:webview_flutter/webview_flutter.dart'; class LoginPage extends StatefulWidget { const LoginPage({ @@ -28,231 +19,94 @@ class LoginPage extends StatefulWidget { } class _LoginPageState extends State { - late WebViewController? _webViewController; final _formKey = GlobalKey(); final _focusNode = FocusNode(); - late final PackageInfo _packageInfo; - late final AccountsBloc _accountsBloc; - late final LoginBloc _loginBloc; @override void initState() { super.initState(); - _packageInfo = Provider.of(context, listen: false); - _accountsBloc = Provider.of(context, listen: false); - _loginBloc = LoginBloc(_packageInfo); - if (widget.serverURL != null) { - _loginBloc.setServerURL(widget.serverURL); - } - - WidgetsBinding.instance.addPostFrameCallback((final _) { - if (Provider.of(context, listen: false).canUseWebView) { - _webViewController = WebViewController() - // ignore: discarded_futures - ..setJavaScriptMode(JavaScriptMode.unrestricted) - // ignore: discarded_futures - ..enableZoom(false) - // ignore: discarded_futures - ..setUserAgent(userAgent(_packageInfo)); - } - - _loginBloc.loginFlowInit.listen((final init) async { - if (init != null) { - if (Provider.of(context, listen: false).canUseWebView) { - await _webViewController!.loadRequest(Uri.parse(init.login)); - } else { - await launchUrlString( - init.login, - mode: LaunchMode.externalApplication, - ); - } - } + WidgetsBinding.instance.addPostFrameCallback((final _) async { + await _beginLoginFlow(widget.serverURL!); }); - }); - - _loginBloc.loginFlowResult.listen((final result) async { - if (result != null) { - try { - final account = await getAccount( - _packageInfo, - result.server, - result.loginName, - result.appPassword, - ); - - if (!mounted) { - return; - } - - if (widget.serverURL != null) { - _accountsBloc.updateAccount(account); - } else { - final existingAccount = _accountsBloc.accounts.value.tryFind(account.id); - if (existingAccount != null) { - NeonException.showSnackbar(context, AppLocalizations.of(context).errorAccountAlreadyExists); - await _loginBloc.refresh(); - return; - } - _accountsBloc - ..addAccount(account) - ..setActiveAccount(account); - } - - if (mounted) { - const HomeRoute().go(context); - } - } catch (e, s) { - debugPrint(e.toString()); - debugPrint(s.toString()); - NeonException.showSnackbar(context, e); - await _loginBloc.refresh(); - } - } - }); + } } @override void dispose() { - _loginBloc.dispose(); + _focusNode.dispose(); super.dispose(); } - @override - Widget build(final BuildContext context) => StreamBuilder>( - stream: _accountsBloc.accounts, - builder: (final context, final accountsSnapshot) => BackButtonListener( - onBackButtonPressed: () async { - if (accountsSnapshot.data?.isNotEmpty ?? false) { - return false; - } - - if ((await _loginBloc.serverURL.first) == null) { - return false; - } + Future _beginLoginFlow(final String serverURL) async { + final result = await LoginCheckServerStatusRoute(serverURL: serverURL).push(context); + if ((result ?? false) && mounted) { + // This needs be done, otherwise the context is dirty after returning from the previously pushed route + WidgetsBinding.instance.addPostFrameCallback((final _) async { + await LoginFlowRoute(serverURL: serverURL).push(context); + }); + } + } - _loginBloc.setServerURL(null); - return true; - }, - child: StreamBuilder( - stream: _loginBloc.serverURL, - builder: (final context, final serverURLSnapshot) => StreamBuilder( - stream: _loginBloc.serverConnectionState, - builder: (final context, final serverConnectionStateSnapshot) => Scaffold( - resizeToAvoidBottomInset: true, - appBar: serverConnectionStateSnapshot.data == ServerConnectionState.success || - (accountsSnapshot.data?.isNotEmpty ?? false) - ? AppBar( - leading: BackButton( - onPressed: () { - if (accountsSnapshot.data?.isNotEmpty ?? false) { - Navigator.of(context).pop(); - } else { - _loginBloc.setServerURL(null); - } - }, - ), - actions: [ - if (serverConnectionStateSnapshot.hasData) ...[ - IconButton( - onPressed: _loginBloc.refresh, - tooltip: AppLocalizations.of(context).loginRestart, - icon: const Icon(Icons.refresh), - ), - ], - ], - ) - : null, - body: serverConnectionStateSnapshot.data == ServerConnectionState.success - ? Provider.of(context).canUseWebView - ? WebViewWidget( - controller: _webViewController!, - ) - : Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(AppLocalizations.of(context).loginSwitchToBrowserWindow), - const SizedBox( - height: 10, - ), - ElevatedButton( - onPressed: _loginBloc.refresh, - child: Text(AppLocalizations.of(context).loginOpenAgain), - ), - ], - ), - ) - : Builder( - builder: (final context) { - final branding = Provider.of(context, listen: false); - return Center( - child: Scrollbar( - interactive: true, - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 20), - primary: true, - child: Column( - children: [ - branding.logo, - Text( - branding.name, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox( - height: 30, - ), - Text(AppLocalizations.of(context).loginWorksWith), - const SizedBox( - height: 20, - ), - const NextcloudLogo(), - Form( - key: _formKey, - child: TextFormField( - focusNode: _focusNode, - decoration: const InputDecoration( - hintText: 'https://...', - ), - keyboardType: TextInputType.url, - initialValue: widget.serverURL, - validator: (final input) => validateHttpUrl(context, input), - onFieldSubmitted: (final input) { - if (_formKey.currentState!.validate()) { - _loginBloc.setServerURL(input); - } else { - _focusNode.requestFocus(); - } - }, - ), - ), - NeonLinearProgressIndicator( - visible: serverConnectionStateSnapshot.data == ServerConnectionState.loading, - ), - if (serverConnectionStateSnapshot.data == ServerConnectionState.unreachable) ...[ - NeonException( - AppLocalizations.of(context).errorUnableToReachServer, - onRetry: _loginBloc.refresh, - ), - ], - if (serverConnectionStateSnapshot.data == - ServerConnectionState.maintenanceMode) ...[ - NeonException( - AppLocalizations.of(context).errorServerInMaintenanceMode, - onRetry: _loginBloc.refresh, - ), - ], - ], - ), - ), - ), - ); - }, + @override + Widget build(final BuildContext context) { + final branding = Provider.of(context, listen: false); + return Scaffold( + resizeToAvoidBottomInset: true, + appBar: AppBar( + leading: Navigator.of(context).canPop() ? const CloseButton() : null, + ), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 640, + ), + child: Scrollbar( + interactive: true, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 20), + primary: true, + child: Column( + children: [ + branding.logo, + Text( + branding.name, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox( + height: 30, + ), + Text(AppLocalizations.of(context).loginWorksWith), + const SizedBox( + height: 20, + ), + const NextcloudLogo(), + Form( + key: _formKey, + child: TextFormField( + focusNode: _focusNode, + decoration: const InputDecoration( + hintText: 'https://...', ), + keyboardType: TextInputType.url, + initialValue: widget.serverURL, + validator: (final input) => validateHttpUrl(context, input), + onFieldSubmitted: (final input) async { + if (_formKey.currentState!.validate()) { + await _beginLoginFlow(input); + } else { + _focusNode.requestFocus(); + } + }, + ), + ), + ], ), ), ), ), - ); + ), + ); + } } diff --git a/packages/neon/neon/lib/src/pages/login_check_account.dart b/packages/neon/neon/lib/src/pages/login_check_account.dart new file mode 100644 index 00000000..279abfcf --- /dev/null +++ b/packages/neon/neon/lib/src/pages/login_check_account.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/bloc/result.dart'; +import 'package:neon/src/bloc/result_builder.dart'; +import 'package:neon/src/blocs/accounts.dart'; +import 'package:neon/src/blocs/login_check_account.dart'; +import 'package:neon/src/models/account.dart'; +import 'package:neon/src/router.dart'; +import 'package:neon/src/widgets/account_tile.dart'; +import 'package:neon/src/widgets/exception.dart'; +import 'package:neon/src/widgets/linear_progress_indicator.dart'; +import 'package:neon/src/widgets/validation_tile.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:provider/provider.dart'; + +class LoginCheckAccountPage extends StatefulWidget { + const LoginCheckAccountPage({ + required this.serverURL, + required this.loginName, + required this.password, + super.key, + }); + + final String serverURL; + final String loginName; + final String password; + + @override + State createState() => _LoginCheckAccountPageState(); +} + +class _LoginCheckAccountPageState extends State { + late final LoginCheckAccountBloc bloc; + + @override + void initState() { + super.initState(); + + bloc = LoginCheckAccountBloc( + Provider.of(context, listen: false), + widget.serverURL, + widget.loginName, + widget.password, + ); + } + + @override + void dispose() { + bloc.dispose(); + super.dispose(); + } + + @override + Widget build(final BuildContext context) => Scaffold( + appBar: AppBar(), + body: Center( + child: Padding( + padding: const EdgeInsets.all(10), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 640, + ), + child: ResultBuilder.behaviorSubject( + stream: bloc.state, + builder: (final context, final state) => Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + NeonLinearProgressIndicator( + visible: state.isLoading, + ), + NeonException( + state.error, + onRetry: bloc.refresh, + ), + _buildAccountTile(state), + Align( + alignment: Alignment.bottomRight, + child: ElevatedButton( + onPressed: state.hasData + ? () { + Provider.of(context, listen: false) + ..updateAccount(state.requireData) + ..setActiveAccount(state.requireData); + + const HomeRoute().go(context); + } + : null, + child: Text(AppLocalizations.of(context).actionContinue), + ), + ), + ], + ), + ), + ), + ), + ), + ); + + Widget _buildAccountTile(final Result result) { + if (result.hasData) { + return NeonAccountTile( + account: result.requireData, + showStatus: false, + ); + } + + return NeonValidationTile( + title: AppLocalizations.of(context).loginCheckingAccount, + state: ValidationState.loading, + ); + } +} diff --git a/packages/neon/neon/lib/src/pages/login_check_server_status.dart b/packages/neon/neon/lib/src/pages/login_check_server_status.dart new file mode 100644 index 00000000..4e908948 --- /dev/null +++ b/packages/neon/neon/lib/src/pages/login_check_server_status.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/bloc/result.dart'; +import 'package:neon/src/bloc/result_builder.dart'; +import 'package:neon/src/blocs/login_check_server_status.dart'; +import 'package:neon/src/widgets/exception.dart'; +import 'package:neon/src/widgets/linear_progress_indicator.dart'; +import 'package:neon/src/widgets/validation_tile.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:provider/provider.dart'; + +class LoginCheckServerStatusPage extends StatefulWidget { + const LoginCheckServerStatusPage({ + required this.serverURL, + super.key, + }); + + final String serverURL; + + @override + State createState() => _LoginCheckServerStatusPageState(); +} + +class _LoginCheckServerStatusPageState extends State { + late final LoginCheckServerStatusBloc bloc; + + @override + void initState() { + super.initState(); + + bloc = LoginCheckServerStatusBloc(Provider.of(context, listen: false), widget.serverURL); + } + + @override + void dispose() { + bloc.dispose(); + super.dispose(); + } + + @override + Widget build(final BuildContext context) => Scaffold( + appBar: AppBar(), + body: Center( + child: Padding( + padding: const EdgeInsets.all(10), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 640, + ), + child: ResultBuilder.behaviorSubject( + stream: bloc.state, + builder: (final context, final state) => Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + NeonLinearProgressIndicator( + visible: state.isLoading, + ), + NeonException( + state.error, + onRetry: bloc.refresh, + ), + _buildServerVersionTile(state), + _buildMaintenanceModeTile(state), + Align( + alignment: Alignment.bottomRight, + child: ElevatedButton( + onPressed: state.hasData && state.requireData.isSupported && !state.requireData.maintenance + ? () => Navigator.of(context).pop(true) + : null, + child: Text(AppLocalizations.of(context).actionContinue), + ), + ), + ], + ), + ), + ), + ), + ), + ); + + Widget _buildServerVersionTile(final Result result) { + if (!result.hasData) { + return NeonValidationTile( + title: AppLocalizations.of(context).loginCheckingServerVersion, + state: ValidationState.loading, + ); + } + + if (result.requireData.isSupported) { + return NeonValidationTile( + title: AppLocalizations.of(context).loginSupportedServerVersion(result.requireData.versionstring), + state: ValidationState.success, + ); + } + + return NeonValidationTile( + title: AppLocalizations.of(context).loginUnsupportedServerVersion(result.requireData.versionstring), + state: ValidationState.failure, + ); + } + + Widget _buildMaintenanceModeTile(final Result result) { + if (!result.hasData) { + return NeonValidationTile( + title: AppLocalizations.of(context).loginCheckingMaintenanceMode, + state: ValidationState.loading, + ); + } + + if (result.requireData.maintenance) { + return NeonValidationTile( + title: AppLocalizations.of(context).loginMaintenanceModeEnabled, + state: ValidationState.failure, + ); + } + + return NeonValidationTile( + title: AppLocalizations.of(context).loginMaintenanceModeDisabled, + state: ValidationState.success, + ); + } +} diff --git a/packages/neon/neon/lib/src/pages/login_flow.dart b/packages/neon/neon/lib/src/pages/login_flow.dart new file mode 100644 index 00000000..585cf7dc --- /dev/null +++ b/packages/neon/neon/lib/src/pages/login_flow.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/bloc/result_builder.dart'; +import 'package:neon/src/blocs/login_flow.dart'; +import 'package:neon/src/router.dart'; +import 'package:neon/src/widgets/exception.dart'; +import 'package:neon/src/widgets/linear_progress_indicator.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class LoginFlowPage extends StatefulWidget { + const LoginFlowPage({ + required this.serverURL, + super.key, + }); + + final String serverURL; + + @override + State createState() => _LoginFlowPageState(); +} + +class _LoginFlowPageState extends State { + late final LoginFlowBloc bloc; + + @override + void initState() { + super.initState(); + + bloc = LoginFlowBloc(Provider.of(context, listen: false), widget.serverURL); + + bloc.init.listen((final result) async { + if (result.hasData) { + await launchUrlString( + result.requireData.login, + mode: LaunchMode.externalApplication, + ); + } + }); + + bloc.result.listen((final result) { + LoginCheckAccountRoute( + serverURL: result.server, + loginName: result.loginName, + password: result.appPassword, + ).pushReplacement(context); + }); + } + + @override + void dispose() { + bloc.dispose(); + super.dispose(); + } + + @override + Widget build(final BuildContext context) => Scaffold( + appBar: AppBar(), + body: Center( + child: ResultBuilder.behaviorSubject( + stream: bloc.init, + builder: (final context, final init) => Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + NeonLinearProgressIndicator( + visible: init.isLoading, + ), + NeonException( + init.error, + onRetry: bloc.refresh, + ), + if (init.hasData) ...[ + Text(AppLocalizations.of(context).loginSwitchToBrowserWindow), + const SizedBox( + height: 10, + ), + ElevatedButton( + onPressed: bloc.refresh, + child: Text(AppLocalizations.of(context).loginOpenAgain), + ), + ], + ], + ), + ), + ), + ); +} diff --git a/packages/neon/neon/lib/src/router.dart b/packages/neon/neon/lib/src/router.dart index 70f514d2..cad5b2af 100644 --- a/packages/neon/neon/lib/src/router.dart +++ b/packages/neon/neon/lib/src/router.dart @@ -7,6 +7,9 @@ import 'package:neon/src/models/app_implementation.dart'; import 'package:neon/src/pages/account_settings.dart'; import 'package:neon/src/pages/home.dart'; import 'package:neon/src/pages/login.dart'; +import 'package:neon/src/pages/login_check_account.dart'; +import 'package:neon/src/pages/login_check_server_status.dart'; +import 'package:neon/src/pages/login_flow.dart'; import 'package:neon/src/pages/nextcloud_app_settings.dart'; import 'package:neon/src/pages/settings.dart'; import 'package:neon/src/utils/stream_listenable.dart'; @@ -27,7 +30,7 @@ class AppRouter extends GoRouter { final account = accountsBloc.activeAccount.valueOrNull; // redirect to loginscreen when no account is logged in - if (account == null) { + if (account == null && !state.location.startsWith(const LoginRoute().location)) { return const LoginRoute().location; } @@ -97,15 +100,75 @@ class HomeRoute extends GoRouteData { @TypedGoRoute( path: '/login', name: 'login', + routes: [ + TypedGoRoute( + path: 'flow/:serverURL', + name: 'loginFlow', + ), + TypedGoRoute( + path: 'check/server/:serverURL', + name: 'checkServerStatus', + ), + TypedGoRoute( + path: 'check/account/:serverURL/:loginName/:password', + name: 'checkAccount', + ), + ], ) @immutable class LoginRoute extends GoRouteData { - const LoginRoute({this.server}); + const LoginRoute({this.serverURL}); + + final String? serverURL; + + @override + Widget build(final BuildContext context, final GoRouterState state) => LoginPage(serverURL: serverURL); +} + +@immutable +class LoginFlowRoute extends GoRouteData { + const LoginFlowRoute({ + required this.serverURL, + }); + + final String serverURL; + + @override + Widget build(final BuildContext context, final GoRouterState state) => LoginFlowPage(serverURL: serverURL); +} + +@immutable +class LoginCheckServerStatusRoute extends GoRouteData { + const LoginCheckServerStatusRoute({ + required this.serverURL, + }); + + final String serverURL; + + @override + Widget build(final BuildContext context, final GoRouterState state) => LoginCheckServerStatusPage( + serverURL: serverURL, + ); +} + +@immutable +class LoginCheckAccountRoute extends GoRouteData { + const LoginCheckAccountRoute({ + required this.serverURL, + required this.loginName, + required this.password, + }); - final String? server; + final String serverURL; + final String loginName; + final String password; @override - Widget build(final BuildContext context, final GoRouterState state) => LoginPage(serverURL: server); + Widget build(final BuildContext context, final GoRouterState state) => LoginCheckAccountPage( + serverURL: serverURL, + loginName: loginName, + password: password, + ); } @immutable diff --git a/packages/neon/neon/lib/src/router.g.dart b/packages/neon/neon/lib/src/router.g.dart index 8a9d98dc..bed7f49b 100644 --- a/packages/neon/neon/lib/src/router.g.dart +++ b/packages/neon/neon/lib/src/router.g.dart @@ -119,17 +119,34 @@ RouteBase get $loginRoute => GoRouteData.$route( path: '/login', name: 'login', factory: $LoginRouteExtension._fromState, + routes: [ + GoRouteData.$route( + path: 'flow/:serverURL', + name: 'loginFlow', + factory: $LoginFlowRouteExtension._fromState, + ), + GoRouteData.$route( + path: 'check/server/:serverURL', + name: 'checkServerStatus', + factory: $LoginCheckServerStatusRouteExtension._fromState, + ), + GoRouteData.$route( + path: 'check/account/:serverURL/:loginName/:password', + name: 'checkAccount', + factory: $LoginCheckAccountRouteExtension._fromState, + ), + ], ); extension $LoginRouteExtension on LoginRoute { static LoginRoute _fromState(GoRouterState state) => LoginRoute( - server: state.queryParameters['server'], + serverURL: state.queryParameters['server-u-r-l'], ); String get location => GoRouteData.$location( '/login', queryParams: { - if (server != null) 'server': server, + if (serverURL != null) 'server-u-r-l': serverURL, }, ); @@ -139,3 +156,53 @@ extension $LoginRouteExtension on LoginRoute { void pushReplacement(BuildContext context) => context.pushReplacement(location); } + +extension $LoginFlowRouteExtension on LoginFlowRoute { + static LoginFlowRoute _fromState(GoRouterState state) => LoginFlowRoute( + serverURL: state.pathParameters['serverURL']!, + ); + + String get location => GoRouteData.$location( + '/login/flow/${Uri.encodeComponent(serverURL)}', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => context.pushReplacement(location); +} + +extension $LoginCheckServerStatusRouteExtension on LoginCheckServerStatusRoute { + static LoginCheckServerStatusRoute _fromState(GoRouterState state) => LoginCheckServerStatusRoute( + serverURL: state.pathParameters['serverURL']!, + ); + + String get location => GoRouteData.$location( + '/login/check/server/${Uri.encodeComponent(serverURL)}', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => context.pushReplacement(location); +} + +extension $LoginCheckAccountRouteExtension on LoginCheckAccountRoute { + static LoginCheckAccountRoute _fromState(GoRouterState state) => LoginCheckAccountRoute( + serverURL: state.pathParameters['serverURL']!, + loginName: state.pathParameters['loginName']!, + password: state.pathParameters['password']!, + ); + + String get location => GoRouteData.$location( + '/login/check/account/${Uri.encodeComponent(serverURL)}/${Uri.encodeComponent(loginName)}/${Uri.encodeComponent(password)}', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => context.pushReplacement(location); +} diff --git a/packages/neon/neon/lib/src/widgets/account_tile.dart b/packages/neon/neon/lib/src/widgets/account_tile.dart index 136b484e..22aaad0a 100644 --- a/packages/neon/neon/lib/src/widgets/account_tile.dart +++ b/packages/neon/neon/lib/src/widgets/account_tile.dart @@ -18,6 +18,7 @@ class NeonAccountTile extends StatelessWidget { this.onTap, this.textColor, this.dense = false, + this.showStatus = true, super.key, }); @@ -27,6 +28,7 @@ class NeonAccountTile extends StatelessWidget { final VoidCallback? onTap; final Color? textColor; final bool dense; + final bool showStatus; @override Widget build(final BuildContext context) { @@ -45,6 +47,7 @@ class NeonAccountTile extends StatelessWidget { : null, leading: NeonUserAvatar( account: account, + showStatus: showStatus, ), title: ResultBuilder.behaviorSubject( stream: userDetailsBloc.userDetails, diff --git a/packages/neon/neon/lib/src/widgets/exception.dart b/packages/neon/neon/lib/src/widgets/exception.dart index e763e524..70fd348a 100644 --- a/packages/neon/neon/lib/src/widgets/exception.dart +++ b/packages/neon/neon/lib/src/widgets/exception.dart @@ -180,7 +180,7 @@ class NeonException extends StatelessWidget { static void _openLoginPage(final BuildContext context) { LoginRoute( - server: Provider.of(context, listen: false).activeAccount.value!.serverURL, + serverURL: Provider.of(context, listen: false).activeAccount.value!.serverURL, ).go(context); } } diff --git a/packages/neon/neon/lib/src/widgets/validation_tile.dart b/packages/neon/neon/lib/src/widgets/validation_tile.dart new file mode 100644 index 00000000..48c6c650 --- /dev/null +++ b/packages/neon/neon/lib/src/widgets/validation_tile.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +class NeonValidationTile extends StatelessWidget { + const NeonValidationTile({ + required this.title, + required this.state, + super.key, + }); + + final String title; + final ValidationState state; + + @override + Widget build(final BuildContext context) { + const size = 32.0; + + final leading = switch (state) { + ValidationState.loading => const SizedBox( + width: size, + height: size, + child: CircularProgressIndicator( + strokeWidth: 3, + ), + ), + ValidationState.failure => Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.error, + size: size, + ), + ValidationState.success => Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + size: size, + ), + }; + return ListTile( + leading: leading, + title: Text(title), + ); + } +} + +enum ValidationState { + loading, + failure, + success, +} diff --git a/packages/neon/neon/pubspec.yaml b/packages/neon/neon/pubspec.yaml index 2a90e42e..e8d7bdaf 100644 --- a/packages/neon/neon/pubspec.yaml +++ b/packages/neon/neon/pubspec.yaml @@ -49,7 +49,6 @@ dependencies: tray_manager: ^0.2.0 unifiedpush: ^5.0.0 url_launcher: ^6.1.11 - webview_flutter: ^4.2.0 window_manager: ^0.3.2 xdg_directories: ^1.0.0 xml: ^6.3.0