From 61aeaf429fbd7ea9b62b6c9b9c16581aec70542e Mon Sep 17 00:00:00 2001 From: Dr-Blank <64108942+Dr-Blank@users.noreply.github.com> Date: Mon, 16 Sep 2024 23:51:50 -0400 Subject: [PATCH] feat: add deeplinking support for oauth login --- .vscode/launch.json | 8 +- .vscode/settings.json | 1 + android/app/src/main/AndroidManifest.xml | 15 +- .../view/library_item_actions.dart | 4 +- lib/features/onboarding/models/flow.dart | 17 + .../onboarding/models/flow.freezed.dart | 249 ++++++++++++++ .../onboarding/providers/oauth_provider.dart | 94 +++++ .../providers/oauth_provider.g.dart | 224 ++++++++++++ .../onboarding/view/callback_page.dart | 121 +++++++ lib/features/onboarding/view/user_login.dart | 323 ++---------------- .../view/user_login_with_open_id.dart | 116 +++++++ .../view/user_login_with_password.dart | 224 ++++++++++++ .../view/user_login_with_token.dart | 110 ++++++ .../providers/currently_playing_provider.dart | 8 +- .../currently_playing_provider.g.dart | 2 +- lib/features/you/view/server_manager.dart | 2 +- lib/main.dart | 26 +- lib/models/error_response.dart | 6 +- lib/pages/home_page.dart | 25 +- lib/router/constants.dart | 11 +- lib/router/router.dart | 61 +++- lib/settings/constants.dart | 3 + lib/shared/widgets/add_new_server.dart | 3 +- 23 files changed, 1310 insertions(+), 343 deletions(-) create mode 100644 lib/features/onboarding/models/flow.dart create mode 100644 lib/features/onboarding/models/flow.freezed.dart create mode 100644 lib/features/onboarding/providers/oauth_provider.dart create mode 100644 lib/features/onboarding/providers/oauth_provider.g.dart create mode 100644 lib/features/onboarding/view/callback_page.dart create mode 100644 lib/features/onboarding/view/user_login_with_open_id.dart create mode 100644 lib/features/onboarding/view/user_login_with_password.dart create mode 100644 lib/features/onboarding/view/user_login_with_token.dart diff --git a/.vscode/launch.json b/.vscode/launch.json index 2745f3e..18ecea1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -20,6 +20,12 @@ "request": "launch", "type": "dart", "flutterMode": "release" + }, + { + "name": "debug debug.dart", + "request": "launch", + "type": "dart", + "program": "${workspaceFolder}/shelfsdk/playground/debug.dart" } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index d49462f..6b19190 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,6 +11,7 @@ "cSpell.words": [ "audioplayers", "Autovalidate", + "deeplinking", "fullscreen", "Lerp", "miniplayer", diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 62d44d0..41b686e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,7 +12,7 @@ android:icon="@mipmap/ic_launcher"> + + + + + + + + + + + diff --git a/lib/features/item_viewer/view/library_item_actions.dart b/lib/features/item_viewer/view/library_item_actions.dart index 2b021e5..7be83f4 100644 --- a/lib/features/item_viewer/view/library_item_actions.dart +++ b/lib/features/item_viewer/view/library_item_actions.dart @@ -82,11 +82,11 @@ class LibraryItemActions extends HookConsumerWidget { Uri.parse( currentServerUrl.toString() + (Routes.libraryItem.pathParamName != null - ? Routes.libraryItem.path.replaceAll( + ? Routes.libraryItem.fullPath.replaceAll( ':${Routes.libraryItem.pathParamName!}', item.id, ) - : Routes.libraryItem.path), + : Routes.libraryItem.fullPath), ), ); }, diff --git a/lib/features/onboarding/models/flow.dart b/lib/features/onboarding/models/flow.dart new file mode 100644 index 0000000..80e8514 --- /dev/null +++ b/lib/features/onboarding/models/flow.dart @@ -0,0 +1,17 @@ +import 'dart:io'; + +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'flow.freezed.dart'; + +@freezed +class Flow with _$Flow { + const factory Flow({ + required Uri serverUri, + required String state, + required String verifier, + required Cookie cookie, + @Default(false) bool isFlowComplete, + String? authToken, + }) = _Flow; +} diff --git a/lib/features/onboarding/models/flow.freezed.dart b/lib/features/onboarding/models/flow.freezed.dart new file mode 100644 index 0000000..1437f2e --- /dev/null +++ b/lib/features/onboarding/models/flow.freezed.dart @@ -0,0 +1,249 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'flow.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$Flow { + Uri get serverUri => throw _privateConstructorUsedError; + String get state => throw _privateConstructorUsedError; + String get verifier => throw _privateConstructorUsedError; + Cookie get cookie => throw _privateConstructorUsedError; + bool get isFlowComplete => throw _privateConstructorUsedError; + String? get authToken => throw _privateConstructorUsedError; + + /// Create a copy of Flow + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $FlowCopyWith get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $FlowCopyWith<$Res> { + factory $FlowCopyWith(Flow value, $Res Function(Flow) then) = + _$FlowCopyWithImpl<$Res, Flow>; + @useResult + $Res call( + {Uri serverUri, + String state, + String verifier, + Cookie cookie, + bool isFlowComplete, + String? authToken}); +} + +/// @nodoc +class _$FlowCopyWithImpl<$Res, $Val extends Flow> + implements $FlowCopyWith<$Res> { + _$FlowCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of Flow + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? serverUri = null, + Object? state = null, + Object? verifier = null, + Object? cookie = null, + Object? isFlowComplete = null, + Object? authToken = freezed, + }) { + return _then(_value.copyWith( + serverUri: null == serverUri + ? _value.serverUri + : serverUri // ignore: cast_nullable_to_non_nullable + as Uri, + state: null == state + ? _value.state + : state // ignore: cast_nullable_to_non_nullable + as String, + verifier: null == verifier + ? _value.verifier + : verifier // ignore: cast_nullable_to_non_nullable + as String, + cookie: null == cookie + ? _value.cookie + : cookie // ignore: cast_nullable_to_non_nullable + as Cookie, + isFlowComplete: null == isFlowComplete + ? _value.isFlowComplete + : isFlowComplete // ignore: cast_nullable_to_non_nullable + as bool, + authToken: freezed == authToken + ? _value.authToken + : authToken // ignore: cast_nullable_to_non_nullable + as String?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$FlowImplCopyWith<$Res> implements $FlowCopyWith<$Res> { + factory _$$FlowImplCopyWith( + _$FlowImpl value, $Res Function(_$FlowImpl) then) = + __$$FlowImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {Uri serverUri, + String state, + String verifier, + Cookie cookie, + bool isFlowComplete, + String? authToken}); +} + +/// @nodoc +class __$$FlowImplCopyWithImpl<$Res> + extends _$FlowCopyWithImpl<$Res, _$FlowImpl> + implements _$$FlowImplCopyWith<$Res> { + __$$FlowImplCopyWithImpl(_$FlowImpl _value, $Res Function(_$FlowImpl) _then) + : super(_value, _then); + + /// Create a copy of Flow + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? serverUri = null, + Object? state = null, + Object? verifier = null, + Object? cookie = null, + Object? isFlowComplete = null, + Object? authToken = freezed, + }) { + return _then(_$FlowImpl( + serverUri: null == serverUri + ? _value.serverUri + : serverUri // ignore: cast_nullable_to_non_nullable + as Uri, + state: null == state + ? _value.state + : state // ignore: cast_nullable_to_non_nullable + as String, + verifier: null == verifier + ? _value.verifier + : verifier // ignore: cast_nullable_to_non_nullable + as String, + cookie: null == cookie + ? _value.cookie + : cookie // ignore: cast_nullable_to_non_nullable + as Cookie, + isFlowComplete: null == isFlowComplete + ? _value.isFlowComplete + : isFlowComplete // ignore: cast_nullable_to_non_nullable + as bool, + authToken: freezed == authToken + ? _value.authToken + : authToken // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc + +class _$FlowImpl implements _Flow { + const _$FlowImpl( + {required this.serverUri, + required this.state, + required this.verifier, + required this.cookie, + this.isFlowComplete = false, + this.authToken}); + + @override + final Uri serverUri; + @override + final String state; + @override + final String verifier; + @override + final Cookie cookie; + @override + @JsonKey() + final bool isFlowComplete; + @override + final String? authToken; + + @override + String toString() { + return 'Flow(serverUri: $serverUri, state: $state, verifier: $verifier, cookie: $cookie, isFlowComplete: $isFlowComplete, authToken: $authToken)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$FlowImpl && + (identical(other.serverUri, serverUri) || + other.serverUri == serverUri) && + (identical(other.state, state) || other.state == state) && + (identical(other.verifier, verifier) || + other.verifier == verifier) && + (identical(other.cookie, cookie) || other.cookie == cookie) && + (identical(other.isFlowComplete, isFlowComplete) || + other.isFlowComplete == isFlowComplete) && + (identical(other.authToken, authToken) || + other.authToken == authToken)); + } + + @override + int get hashCode => Object.hash(runtimeType, serverUri, state, verifier, + cookie, isFlowComplete, authToken); + + /// Create a copy of Flow + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$FlowImplCopyWith<_$FlowImpl> get copyWith => + __$$FlowImplCopyWithImpl<_$FlowImpl>(this, _$identity); +} + +abstract class _Flow implements Flow { + const factory _Flow( + {required final Uri serverUri, + required final String state, + required final String verifier, + required final Cookie cookie, + final bool isFlowComplete, + final String? authToken}) = _$FlowImpl; + + @override + Uri get serverUri; + @override + String get state; + @override + String get verifier; + @override + Cookie get cookie; + @override + bool get isFlowComplete; + @override + String? get authToken; + + /// Create a copy of Flow + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$FlowImplCopyWith<_$FlowImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/onboarding/providers/oauth_provider.dart b/lib/features/onboarding/providers/oauth_provider.dart new file mode 100644 index 0000000..79445d9 --- /dev/null +++ b/lib/features/onboarding/providers/oauth_provider.dart @@ -0,0 +1,94 @@ +import 'dart:io'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:vaani/api/api_provider.dart'; +import 'package:vaani/models/error_response.dart'; + +import '../models/flow.dart'; + +part 'oauth_provider.g.dart'; + +/// the string state of a flow started by user +typedef State = String; + +/// the verifier string of a flow started by user +typedef Verifier = String; + +/// the code returned by the oauth provider +typedef Code = String; + +@Riverpod(keepAlive: true) +class OauthFlows extends _$OauthFlows { + @override + Map build() { + return {}; + } + + void addFlow( + State oauthState, { + required Verifier verifier, + required Uri serverUri, + required Cookie cookie, + bool replaceExisting = false, + }) { + if (state.containsKey(oauthState) && !replaceExisting) { + return; + } + state = { + ...state, + oauthState: Flow( + state: oauthState, + verifier: verifier, + serverUri: serverUri, + cookie: cookie, + isFlowComplete: false, + ), + }; + } + + void markComplete(State oauthState, String? authToken) { + if (!state.containsKey(oauthState)) { + return; + } + state = { + ...state, + oauthState: state[oauthState]! + .copyWith(isFlowComplete: true, authToken: authToken), + }; + } +} + +/// the code returned by the server in exchange for the verifier +@riverpod +Future loginInExchangeForCode( + LoginInExchangeForCodeRef ref, { + required State oauthState, + required Code code, + ErrorResponseHandler? responseHandler, +}) async { + final flows = ref.watch(oauthFlowsProvider); + final flow = flows[oauthState]; + if (flow == null) { + throw StateError('No flow active for state: $oauthState'); + } + + if (flow.authToken != null) { + return flow.authToken; + } + + final api = ref.read(audiobookshelfApiProvider(flow.serverUri)); + final response = await api.server.oauth2Callback( + code: code, + codeVerifier: flow.verifier, + state: oauthState, + cookie: flow.cookie, + responseErrorHandler: responseHandler?.storeError, + ); + + if (response == null) { + return null; + } + + ref.read(oauthFlowsProvider.notifier).markComplete(oauthState, api.token); + return api.token; +} diff --git a/lib/features/onboarding/providers/oauth_provider.g.dart b/lib/features/onboarding/providers/oauth_provider.g.dart new file mode 100644 index 0000000..9b7c4f5 --- /dev/null +++ b/lib/features/onboarding/providers/oauth_provider.g.dart @@ -0,0 +1,224 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'oauth_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$loginInExchangeForCodeHash() => + r'e931254959d9eb8196439c6b0c884c26cbe17c2f'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// the code returned by the server in exchange for the verifier +/// +/// Copied from [loginInExchangeForCode]. +@ProviderFor(loginInExchangeForCode) +const loginInExchangeForCodeProvider = LoginInExchangeForCodeFamily(); + +/// the code returned by the server in exchange for the verifier +/// +/// Copied from [loginInExchangeForCode]. +class LoginInExchangeForCodeFamily extends Family> { + /// the code returned by the server in exchange for the verifier + /// + /// Copied from [loginInExchangeForCode]. + const LoginInExchangeForCodeFamily(); + + /// the code returned by the server in exchange for the verifier + /// + /// Copied from [loginInExchangeForCode]. + LoginInExchangeForCodeProvider call({ + required String oauthState, + required String code, + ErrorResponseHandler? responseHandler, + }) { + return LoginInExchangeForCodeProvider( + oauthState: oauthState, + code: code, + responseHandler: responseHandler, + ); + } + + @override + LoginInExchangeForCodeProvider getProviderOverride( + covariant LoginInExchangeForCodeProvider provider, + ) { + return call( + oauthState: provider.oauthState, + code: provider.code, + responseHandler: provider.responseHandler, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'loginInExchangeForCodeProvider'; +} + +/// the code returned by the server in exchange for the verifier +/// +/// Copied from [loginInExchangeForCode]. +class LoginInExchangeForCodeProvider + extends AutoDisposeFutureProvider { + /// the code returned by the server in exchange for the verifier + /// + /// Copied from [loginInExchangeForCode]. + LoginInExchangeForCodeProvider({ + required String oauthState, + required String code, + ErrorResponseHandler? responseHandler, + }) : this._internal( + (ref) => loginInExchangeForCode( + ref as LoginInExchangeForCodeRef, + oauthState: oauthState, + code: code, + responseHandler: responseHandler, + ), + from: loginInExchangeForCodeProvider, + name: r'loginInExchangeForCodeProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$loginInExchangeForCodeHash, + dependencies: LoginInExchangeForCodeFamily._dependencies, + allTransitiveDependencies: + LoginInExchangeForCodeFamily._allTransitiveDependencies, + oauthState: oauthState, + code: code, + responseHandler: responseHandler, + ); + + LoginInExchangeForCodeProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.oauthState, + required this.code, + required this.responseHandler, + }) : super.internal(); + + final String oauthState; + final String code; + final ErrorResponseHandler? responseHandler; + + @override + Override overrideWith( + FutureOr Function(LoginInExchangeForCodeRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: LoginInExchangeForCodeProvider._internal( + (ref) => create(ref as LoginInExchangeForCodeRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + oauthState: oauthState, + code: code, + responseHandler: responseHandler, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _LoginInExchangeForCodeProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is LoginInExchangeForCodeProvider && + other.oauthState == oauthState && + other.code == code && + other.responseHandler == responseHandler; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, oauthState.hashCode); + hash = _SystemHash.combine(hash, code.hashCode); + hash = _SystemHash.combine(hash, responseHandler.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin LoginInExchangeForCodeRef on AutoDisposeFutureProviderRef { + /// The parameter `oauthState` of this provider. + String get oauthState; + + /// The parameter `code` of this provider. + String get code; + + /// The parameter `responseHandler` of this provider. + ErrorResponseHandler? get responseHandler; +} + +class _LoginInExchangeForCodeProviderElement + extends AutoDisposeFutureProviderElement + with LoginInExchangeForCodeRef { + _LoginInExchangeForCodeProviderElement(super.provider); + + @override + String get oauthState => + (origin as LoginInExchangeForCodeProvider).oauthState; + @override + String get code => (origin as LoginInExchangeForCodeProvider).code; + @override + ErrorResponseHandler? get responseHandler => + (origin as LoginInExchangeForCodeProvider).responseHandler; +} + +String _$oauthFlowsHash() => r'4e278baa0bf26f2a10694ca2caadb68dd5b6156f'; + +/// See also [OauthFlows]. +@ProviderFor(OauthFlows) +final oauthFlowsProvider = + NotifierProvider>.internal( + OauthFlows.new, + name: r'oauthFlowsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$oauthFlowsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$OauthFlows = Notifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/features/onboarding/view/callback_page.dart b/lib/features/onboarding/view/callback_page.dart new file mode 100644 index 0000000..dd3de86 --- /dev/null +++ b/lib/features/onboarding/view/callback_page.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:vaani/features/onboarding/providers/oauth_provider.dart'; +import 'package:vaani/features/onboarding/view/user_login_with_password.dart'; +import 'package:vaani/models/error_response.dart'; +import 'package:vaani/router/router.dart'; + +class CallbackPage extends HookConsumerWidget { + CallbackPage({super.key, this.state, this.code}); + + final String? state; + final String? code; + + final serverErrorResponse = ErrorResponseHandler(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (state == null || code == null) { + return _SomethingWentWrong( + message: + 'OAuth callback missing state or code\nGot: State: $state, Code: $code', + ); + } + + final flows = ref.read(oauthFlowsProvider); + + // check if the state is in the flows + if (!flows.containsKey(state)) { + return const _SomethingWentWrong( + message: 'State not found', + ); + } + + // get the token + final loginAuthToken = ref.watch( + loginInExchangeForCodeProvider( + oauthState: state!, + code: code!, + responseHandler: serverErrorResponse, + ), + ); + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Contacting server...\nPlease wait\n\nGot:' + '\nState: $state\nCode: $code'), + loginAuthToken.when( + data: (authenticationToken) { + if (authenticationToken == null) { + handleServerError( + context, + serverErrorResponse, + ); + return const BackToLoginButton(); + } + return Text('Token: $authenticationToken'); + }, + loading: () => const CircularProgressIndicator(), + error: (error, _) { + handleServerError( + context, + serverErrorResponse, + e: error, + ); + return Column( + children: [ + Text('Error with OAuth flow: $error'), + const BackToLoginButton(), + ], + ); + }, + ), + ], + ), + ), + ); + } +} + +class BackToLoginButton extends StatelessWidget { + const BackToLoginButton({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: () { + GoRouter.of(context).goNamed(Routes.onboarding.name); + }, + child: const Text('Return to login'), + ); + } +} + +class _SomethingWentWrong extends StatelessWidget { + const _SomethingWentWrong({ + super.key, + this.message = 'Error with OAuth flow', + }); + + final String message; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(message), + const BackToLoginButton(), + ], + ), + ), + ); + } +} diff --git a/lib/features/onboarding/view/user_login.dart b/lib/features/onboarding/view/user_login.dart index 3df652d..3e2f0d5 100644 --- a/lib/features/onboarding/view/user_login.dart +++ b/lib/features/onboarding/view/user_login.dart @@ -1,15 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lottie/lottie.dart'; import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:vaani/api/api_provider.dart'; -import 'package:vaani/api/authenticated_user_provider.dart'; import 'package:vaani/api/server_provider.dart'; +import 'package:vaani/features/onboarding/view/user_login_with_open_id.dart'; +import 'package:vaani/features/onboarding/view/user_login_with_password.dart'; +import 'package:vaani/features/onboarding/view/user_login_with_token.dart'; import 'package:vaani/hacks/fix_autofill_losing_focus.dart'; import 'package:vaani/models/error_response.dart'; -import 'package:vaani/router/router.dart'; import 'package:vaani/settings/api_settings_provider.dart'; import 'package:vaani/settings/models/models.dart' as model; @@ -20,7 +19,7 @@ class UserLoginWidget extends HookConsumerWidget { }); final Uri server; - final serverStatusError = ErrorResponse(); + final serverStatusError = ErrorResponseHandler(); @override Widget build(BuildContext context, WidgetRef ref) { @@ -40,8 +39,9 @@ class UserLoginWidget extends HookConsumerWidget { server: server, localAvailable: value.authMethods?.contains(AuthMethod.local) ?? false, - openidAvailable: + openIDAvailable: value.authMethods?.contains(AuthMethod.openid) ?? false, + openIDButtonText: value.authFormData?.authOpenIDButtonText, ); }, loading: () { @@ -85,21 +85,24 @@ class UserLoginMultipleAuth extends HookConsumerWidget { super.key, required this.server, this.localAvailable = false, - this.openidAvailable = false, + this.openIDAvailable = false, this.onPressed, + this.openIDButtonText, }); final Uri server; final bool localAvailable; - final bool openidAvailable; + final bool openIDAvailable; final void Function()? onPressed; + final String? openIDButtonText; @override Widget build(BuildContext context, WidgetRef ref) { // will show choice chips for the available authentication methods // authToken method is always available final methodChoice = useState( - localAvailable ? AuthMethodChoice.local : AuthMethodChoice.authToken, + // ! TODO revert to local when openID debugging is done + localAvailable ? AuthMethodChoice.openid : AuthMethodChoice.authToken, ); final apiSettings = ref.watch(apiSettingsProvider); @@ -150,7 +153,7 @@ class UserLoginMultipleAuth extends HookConsumerWidget { } }, ), - if (openidAvailable) + if (openIDAvailable) ChoiceChip( label: const Text('OpenID'), selected: methodChoice.value == AuthMethodChoice.openid, @@ -176,17 +179,18 @@ class UserLoginMultipleAuth extends HookConsumerWidget { dimension: 8, ), switch (methodChoice.value) { + AuthMethodChoice.authToken => UserLoginWithToken( + server: server, + addServer: addServer, + ), AuthMethodChoice.local => UserLoginWithPassword( server: server, addServer: addServer, ), - AuthMethodChoice.openid => _UserLoginWithOpenID( - server: server, - addServer: addServer, - ), - AuthMethodChoice.authToken => UserLoginWithToken( + AuthMethodChoice.openid => UserLoginWithOpenID( server: server, addServer: addServer, + openIDButtonText: openIDButtonText, ), }, ], @@ -197,292 +201,3 @@ class UserLoginMultipleAuth extends HookConsumerWidget { ); } } - -class _UserLoginWithOpenID extends HookConsumerWidget { - const _UserLoginWithOpenID({ - super.key, - required this.server, - required this.addServer, - }); - - final Uri server; - final model.AudiobookShelfServer Function() addServer; - - @override - Widget build(BuildContext context, WidgetRef ref) { - // TODO: implement build - return const Text('OpenID'); - } -} - -class UserLoginWithToken extends HookConsumerWidget { - UserLoginWithToken({ - super.key, - required this.server, - required this.addServer, - }); - - final Uri server; - final model.AudiobookShelfServer Function() addServer; - final serverErrorResponse = ErrorResponse(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final authTokensController = useTextEditingController(); - - final api = ref.watch(audiobookshelfApiProvider(server)); - Future loginAndSave() async { - api.token = authTokensController.text; - model.AuthenticatedUser? authenticatedUser; - LoginResponse? success; - try { - success = await api.misc.authorize( - responseErrorHandler: serverErrorResponse.storeError, - ); - if (success == null) { - throw StateError('No response from server'); - } - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Login failed. Got response: ${serverErrorResponse.response.body} (${serverErrorResponse.response.statusCode})', - ), - action: SnackBarAction( - label: 'See Error', - onPressed: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Error'), - content: Text(e.toString()), - ), - ); - }, - ), - ), - ); - return; - } - authenticatedUser = model.AuthenticatedUser( - server: addServer(), - id: success.user.id, - username: success.user.username, - authToken: api.token!, - ); - - ref - .read(authenticatedUserProvider.notifier) - .addUser(authenticatedUser, setActive: true); - - context.goNamed(Routes.home.name); - } - - return Form( - child: Column( - children: [ - TextFormField( - controller: authTokensController, - autofocus: true, - textInputAction: TextInputAction.done, - maxLines: 10, - minLines: 1, - decoration: InputDecoration( - labelText: 'API Token', - labelStyle: TextStyle( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8), - ), - border: const OutlineInputBorder(), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter an API token'; - } - return null; - }, - onFieldSubmitted: (_) async { - await loginAndSave(); - }, - ), - const SizedBox(height: 10), - ElevatedButton( - onPressed: loginAndSave, - child: const Text('Login'), - ), - ], - ), - ); - } -} - -// class _UserLoginWithToken extends HookConsumerWidget { - -class UserLoginWithPassword extends HookConsumerWidget { - UserLoginWithPassword({ - super.key, - required this.server, - required this.addServer, - }); - - final Uri server; - final model.AudiobookShelfServer Function() addServer; - final serverErrorResponse = ErrorResponse(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final usernameController = useTextEditingController(); - final passwordController = useTextEditingController(); - final isPasswordVisibleAnimationController = useAnimationController( - duration: const Duration(milliseconds: 500), - ); - - var isPasswordVisible = useState(false); - final api = ref.watch(audiobookshelfApiProvider(server)); - - // forward animation when the password visibility changes - useEffect( - () { - if (isPasswordVisible.value) { - isPasswordVisibleAnimationController.forward(); - } else { - isPasswordVisibleAnimationController.reverse(); - } - return null; - }, - [isPasswordVisible.value], - ); - - /// Login to the server and save the user - Future loginAndSave() async { - final username = usernameController.text; - final password = passwordController.text; - - LoginResponse? success; - try { - success = await api.login( - username: username, - password: password, - responseErrorHandler: serverErrorResponse.storeError, - ); - if (success == null) { - throw StateError('No response from server'); - } - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Login failed. Got response: ${serverErrorResponse.response.body} (${serverErrorResponse.response.statusCode})', - ), - action: SnackBarAction( - label: 'See Error', - onPressed: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Error'), - content: Text(e.toString()), - ), - ); - }, - ), - ), - ); - - return; - } - // save the server - final authenticatedUser = model.AuthenticatedUser( - server: addServer(), - id: success.user.id, - password: password, - username: username, - authToken: api.token!, - ); - // add the user to the list of users - ref - .read(authenticatedUserProvider.notifier) - .addUser(authenticatedUser, setActive: true); - - // redirect to the library page - GoRouter.of(context).goNamed(Routes.home.name); - } - - return Center( - child: InactiveFocusScopeObserver( - child: AutofillGroup( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextFormField( - controller: usernameController, - autofocus: true, - autofillHints: const [AutofillHints.username], - textInputAction: TextInputAction.next, - decoration: InputDecoration( - labelText: 'Username', - labelStyle: TextStyle( - color: Theme.of(context) - .colorScheme - .onSurface - .withOpacity(0.8), - ), - border: const OutlineInputBorder(), - ), - ), - const SizedBox(height: 10), - TextFormField( - controller: passwordController, - autofillHints: const [AutofillHints.password], - textInputAction: TextInputAction.done, - obscureText: !isPasswordVisible.value, - onFieldSubmitted: (_) { - loginAndSave(); - }, - decoration: InputDecoration( - labelText: 'Password', - labelStyle: TextStyle( - color: Theme.of(context) - .colorScheme - .onSurface - .withOpacity(0.8), - ), - border: const OutlineInputBorder(), - suffixIcon: ColorFiltered( - colorFilter: ColorFilter.mode( - Theme.of(context).colorScheme.primary.withOpacity(0.8), - BlendMode.srcIn, - ), - child: InkWell( - borderRadius: BorderRadius.circular(50), - onTap: () { - isPasswordVisible.value = !isPasswordVisible.value; - }, - child: Container( - margin: const EdgeInsets.only(left: 8, right: 8), - child: Lottie.asset( - 'assets/animations/Animation - 1714930099660.json', - controller: isPasswordVisibleAnimationController, - ), - ), - ), - ), - suffixIconConstraints: const BoxConstraints( - maxHeight: 45, - ), - ), - ), - const SizedBox(height: 30), - ElevatedButton( - onPressed: loginAndSave, - child: const Text('Login'), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/features/onboarding/view/user_login_with_open_id.dart b/lib/features/onboarding/view/user_login_with_open_id.dart new file mode 100644 index 0000000..e403c81 --- /dev/null +++ b/lib/features/onboarding/view/user_login_with_open_id.dart @@ -0,0 +1,116 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shelfsdk/audiobookshelf_api.dart'; +import 'package:vaani/api/api_provider.dart'; +import 'package:vaani/features/onboarding/providers/oauth_provider.dart'; +import 'package:vaani/features/onboarding/view/user_login_with_password.dart'; +import 'package:vaani/main.dart'; +import 'package:vaani/models/error_response.dart'; +import 'package:vaani/router/router.dart'; +import 'package:vaani/settings/constants.dart'; +import 'package:vaani/settings/models/models.dart' as model; +import 'package:vaani/shared/utils.dart'; + +class UserLoginWithOpenID extends HookConsumerWidget { + UserLoginWithOpenID({ + super.key, + required this.server, + required this.addServer, + this.openIDButtonText, + }); + + final Uri server; + final model.AudiobookShelfServer Function() addServer; + final String? openIDButtonText; + final responseErrorHandler = ErrorResponseHandler(name: 'OpenID'); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final serverStatus = ref.watch(serverStatusProvider(server)); + void openIDLoginFlow() async { + appLogger.fine('Clicked Login with OpenID'); + + final api = ref.read(audiobookshelfApiProvider(server)); + final (verifier, challenge) = generateVerifierAndChallenge(); + + appLogger.fine('Generated verifier: $verifier\nchallenge: $challenge'); + final appRedirectUri = + '${AppMetadata.appScheme}://${Routes.openIDCallback.fullPath.substring(1)}'; + final (openIDLoginEndpoint, authCookie) = await api.server.oauth2Request( + clientId: AppMetadata.appName, + codeChallenge: challenge, + // redirectUri: Uri( + // scheme: AppMetadata.appScheme, + // host: Routes.openIDCallback.path.substring(1), + // ).toString(), + redirectUri: appRedirectUri, + responseErrorHandler: responseErrorHandler.storeError, + ); + + if (openIDLoginEndpoint == null) { + if (responseErrorHandler.response.statusCode == 400 && + responseErrorHandler.response.body + .toLowerCase() + .contains(RegExp(r'invalid.*redirect.*uri'))) { + // show error + handleServerError( + context, + responseErrorHandler, + title: 'Failed to get OpenID login endpoint\n', + body: + 'Please check that the redirect URI: "$appRedirectUri" is registered with the server.', + outLink: server.replace(path: '${Routes.settings.fullPath}/auth'), + outLinkText: 'Server settings', + ); + return; + } + + // show error + handleServerError( + context, + responseErrorHandler, + title: 'Failed to get OpenID login endpoint', + ); + return; + } + + // extract the state parameter + final oauthState = openIDLoginEndpoint.queryParameters['state']; + + if (oauthState == null) { + handleServerError( + context, + responseErrorHandler, + title: 'Failed to get OpenID login endpoint', + body: 'No state parameter found in the response', + ); + return; + } + + appLogger.fine('Got OpenID login endpoint: $openIDLoginEndpoint'); + + // add the flow to the provider + ref.read(oauthFlowsProvider.notifier).addFlow( + oauthState, + verifier: verifier, + serverUri: server, + cookie: Cookie.fromSetCookieValue(authCookie!), + ); + + await handleLaunchUrl( + openIDLoginEndpoint, + ); + } + + return Column( + children: [ + ElevatedButton( + onPressed: openIDLoginFlow, + child: Text(openIDButtonText ?? 'Login with OpenID'), + ), + ], + ); + } +} diff --git a/lib/features/onboarding/view/user_login_with_password.dart b/lib/features/onboarding/view/user_login_with_password.dart new file mode 100644 index 0000000..91eeea0 --- /dev/null +++ b/lib/features/onboarding/view/user_login_with_password.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lottie/lottie.dart'; +import 'package:shelfsdk/audiobookshelf_api.dart'; +import 'package:vaani/api/api_provider.dart'; +import 'package:vaani/api/authenticated_user_provider.dart'; +import 'package:vaani/hacks/fix_autofill_losing_focus.dart'; +import 'package:vaani/models/error_response.dart'; +import 'package:vaani/router/router.dart'; +import 'package:vaani/settings/models/models.dart' as model; +import 'package:vaani/shared/utils.dart'; + +class UserLoginWithPassword extends HookConsumerWidget { + UserLoginWithPassword({ + super.key, + required this.server, + required this.addServer, + }); + + final Uri server; + final model.AudiobookShelfServer Function() addServer; + final serverErrorResponse = ErrorResponseHandler(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final usernameController = useTextEditingController(); + final passwordController = useTextEditingController(); + final isPasswordVisibleAnimationController = useAnimationController( + duration: const Duration(milliseconds: 500), + ); + + var isPasswordVisible = useState(false); + final api = ref.watch(audiobookshelfApiProvider(server)); + + // forward animation when the password visibility changes + useEffect( + () { + if (isPasswordVisible.value) { + isPasswordVisibleAnimationController.forward(); + } else { + isPasswordVisibleAnimationController.reverse(); + } + return null; + }, + [isPasswordVisible.value], + ); + + /// Login to the server and save the user + Future loginAndSave() async { + final username = usernameController.text; + final password = passwordController.text; + + LoginResponse? success; + try { + success = await api.login( + username: username, + password: password, + responseErrorHandler: serverErrorResponse.storeError, + ); + if (success == null) { + throw StateError('No response from server'); + } + } catch (e) { + handleServerError( + context, + serverErrorResponse, + title: 'Login failed', + e: e, + ); + + return; + } + // save the server + final authenticatedUser = model.AuthenticatedUser( + server: addServer(), + id: success.user.id, + password: password, + username: username, + authToken: api.token!, + ); + // add the user to the list of users + ref + .read(authenticatedUserProvider.notifier) + .addUser(authenticatedUser, setActive: true); + + // redirect to the library page + GoRouter.of(context).goNamed(Routes.home.name); + } + + return Center( + child: InactiveFocusScopeObserver( + child: AutofillGroup( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextFormField( + controller: usernameController, + autofocus: true, + autofillHints: const [AutofillHints.username], + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: 'Username', + labelStyle: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.8), + ), + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 10), + TextFormField( + controller: passwordController, + autofillHints: const [AutofillHints.password], + textInputAction: TextInputAction.done, + obscureText: !isPasswordVisible.value, + onFieldSubmitted: (_) { + loginAndSave(); + }, + decoration: InputDecoration( + labelText: 'Password', + labelStyle: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.8), + ), + border: const OutlineInputBorder(), + suffixIcon: ColorFiltered( + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.primary.withOpacity(0.8), + BlendMode.srcIn, + ), + child: InkWell( + borderRadius: BorderRadius.circular(50), + onTap: () { + isPasswordVisible.value = !isPasswordVisible.value; + }, + child: Container( + margin: const EdgeInsets.only(left: 8, right: 8), + child: Lottie.asset( + 'assets/animations/Animation - 1714930099660.json', + controller: isPasswordVisibleAnimationController, + ), + ), + ), + ), + suffixIconConstraints: const BoxConstraints( + maxHeight: 45, + ), + ), + ), + const SizedBox(height: 30), + ElevatedButton( + onPressed: loginAndSave, + child: const Text('Login'), + ), + ], + ), + ), + ), + ), + ); + } +} + +Future handleServerError( + BuildContext context, + ErrorResponseHandler responseErrorHandler, { + String title = 'Something went wrong', + String? body, + Uri? outLink, + String? outLinkText, + Object? e, +}) async { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '$title\n' + 'Got response: ${responseErrorHandler.response.body} (${responseErrorHandler.response.statusCode})', + ), + action: SnackBarAction( + label: 'See Error', + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Error'), + content: SelectableText('$title\n' + 'Got response: ${responseErrorHandler.response.body} (${responseErrorHandler.response.statusCode})\n' + 'Stacktrace: $e\n\n' + '$body\n\n'), + actions: [ + if (outLink != null) + TextButton( + onPressed: () async { + await handleLaunchUrl(outLink); + }, + child: Text(outLinkText ?? 'Open link'), + ), + TextButton( + onPressed: () { + // open an issue on the github page + handleLaunchUrl( + Uri.parse( + 'https://github.com/Dr-Blank/Vaani/issues', + ), + ); + }, + child: const Text('Open issue'), + ), + ], + ), + ); + }, + ), + ), + ); +} diff --git a/lib/features/onboarding/view/user_login_with_token.dart b/lib/features/onboarding/view/user_login_with_token.dart new file mode 100644 index 0000000..35cdf55 --- /dev/null +++ b/lib/features/onboarding/view/user_login_with_token.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shelfsdk/audiobookshelf_api.dart'; +import 'package:vaani/api/api_provider.dart'; +import 'package:vaani/api/authenticated_user_provider.dart'; +import 'package:vaani/models/error_response.dart'; +import 'package:vaani/router/router.dart'; +import 'package:vaani/settings/models/models.dart' as model; + +class UserLoginWithToken extends HookConsumerWidget { + UserLoginWithToken({ + super.key, + required this.server, + required this.addServer, + }); + + final Uri server; + final model.AudiobookShelfServer Function() addServer; + final serverErrorResponse = ErrorResponseHandler(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final authTokensController = useTextEditingController(); + + final api = ref.watch(audiobookshelfApiProvider(server)); + Future loginAndSave() async { + api.token = authTokensController.text; + model.AuthenticatedUser? authenticatedUser; + LoginResponse? success; + try { + success = await api.misc.authorize( + responseErrorHandler: serverErrorResponse.storeError, + ); + if (success == null) { + throw StateError('No response from server'); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Login failed. Got response: ${serverErrorResponse.response.body} (${serverErrorResponse.response.statusCode})', + ), + action: SnackBarAction( + label: 'See Error', + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Error'), + content: Text(e.toString()), + ), + ); + }, + ), + ), + ); + return; + } + authenticatedUser = model.AuthenticatedUser( + server: addServer(), + id: success.user.id, + username: success.user.username, + authToken: api.token!, + ); + + ref + .read(authenticatedUserProvider.notifier) + .addUser(authenticatedUser, setActive: true); + + context.goNamed(Routes.home.name); + } + + return Form( + child: Column( + children: [ + TextFormField( + controller: authTokensController, + autofocus: true, + textInputAction: TextInputAction.done, + maxLines: 10, + minLines: 1, + decoration: InputDecoration( + labelText: 'API Token', + labelStyle: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8), + ), + border: const OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter an API token'; + } + return null; + }, + onFieldSubmitted: (_) async { + await loginAndSave(); + }, + ), + const SizedBox(height: 10), + ElevatedButton( + onPressed: loginAndSave, + child: const Text('Login'), + ), + ], + ), + ); + } +} diff --git a/lib/features/player/providers/currently_playing_provider.dart b/lib/features/player/providers/currently_playing_provider.dart index 68642a5..67e0a35 100644 --- a/lib/features/player/providers/currently_playing_provider.dart +++ b/lib/features/player/providers/currently_playing_provider.dart @@ -7,8 +7,12 @@ part 'currently_playing_provider.g.dart'; @riverpod BookExpanded? currentlyPlayingBook(CurrentlyPlayingBookRef ref) { - final player = ref.watch(audiobookPlayerProvider); - return player.book; + try { + final player = ref.watch(audiobookPlayerProvider); + return player.book; + } catch (e) { + return null; + } } /// provided the current chapter of the book being played diff --git a/lib/features/player/providers/currently_playing_provider.g.dart b/lib/features/player/providers/currently_playing_provider.g.dart index e6b8871..fb22028 100644 --- a/lib/features/player/providers/currently_playing_provider.g.dart +++ b/lib/features/player/providers/currently_playing_provider.g.dart @@ -7,7 +7,7 @@ part of 'currently_playing_provider.dart'; // ************************************************************************** String _$currentlyPlayingBookHash() => - r'c777ea8b463d8441a0da5e08b4c41b501ce68aad'; + r'52334c7b4d68fd498a2a00208d8d7f1ba0085237'; /// See also [currentlyPlayingBook]. @ProviderFor(currentlyPlayingBook) diff --git a/lib/features/you/view/server_manager.dart b/lib/features/you/view/server_manager.dart index 3ac254b..2ab70ea 100644 --- a/lib/features/you/view/server_manager.dart +++ b/lib/features/you/view/server_manager.dart @@ -286,7 +286,7 @@ class _AddUserDialog extends HookConsumerWidget { final formKey = GlobalKey(); - final serverErrorResponse = ErrorResponse(); + final serverErrorResponse = ErrorResponseHandler(); /// Login to the server and save the user Future loginAndSave() async { diff --git a/lib/main.dart b/lib/main.dart index afec94d..2630802 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -76,15 +76,23 @@ class MyApp extends ConsumerWidget { routerConfig.goNamed(Routes.onboarding.name); } - return MaterialApp.router( - // debugShowCheckedModeBanner: false, - theme: lightTheme, - darkTheme: darkTheme, - themeMode: ref.watch(appSettingsProvider).themeSettings.isDarkMode - ? ThemeMode.dark - : ThemeMode.light, - routerConfig: routerConfig, - ); + try { + return MaterialApp.router( + // debugShowCheckedModeBanner: false, + theme: lightTheme, + darkTheme: darkTheme, + themeMode: ref.watch(appSettingsProvider).themeSettings.isDarkMode + ? ThemeMode.dark + : ThemeMode.light, + routerConfig: routerConfig, + ); + } catch (e) { + debugPrintStack(stackTrace: StackTrace.current, label: e.toString()); + if (needOnboarding) { + routerConfig.goNamed(Routes.onboarding.name); + } + return const SizedBox.shrink(); + } } } diff --git a/lib/models/error_response.dart b/lib/models/error_response.dart index d6021c5..2e4c926 100644 --- a/lib/models/error_response.dart +++ b/lib/models/error_response.dart @@ -3,17 +3,17 @@ import 'package:logging/logging.dart'; final _logger = Logger('ErrorResponse'); -class ErrorResponse { +class ErrorResponseHandler { String? name; http.Response _response; - ErrorResponse({ + ErrorResponseHandler({ this.name, http.Response? response, }) : _response = response ?? http.Response('', 418); void storeError(http.Response response, [Object? error]) { - _logger.warning('for $name got response: $response'); + _logger.fine('for $name got response: $response'); _response = response; } diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index ce78886..5b3ab41 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:vaani/api/api_provider.dart'; -import 'package:vaani/settings/app_settings_provider.dart'; +import 'package:vaani/router/router.dart'; +import 'package:vaani/settings/api_settings_provider.dart'; import '../shared/widgets/shelves/home_shelf.dart'; @@ -11,10 +13,8 @@ class HomePage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final settings = ref.watch(appSettingsProvider); - final api = ref.watch(authenticatedApiProvider); - final me = ref.watch(meProvider); final views = ref.watch(personalizedViewProvider); + final apiSettings = ref.watch(apiSettingsProvider); final scrollController = useScrollController(); return Scaffold( appBar: AppBar( @@ -83,6 +83,23 @@ class HomePage extends HookConsumerWidget { }, loading: () => const HomePageSkeleton(), error: (error, stack) { + if (apiSettings.activeUser == null || + apiSettings.activeServer == null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Error: $error'), + ElevatedButton( + onPressed: () { + GoRouter.of(context).goNamed(Routes.onboarding.name); + }, + child: const Text('Go to login'), + ), + ], + ), + ); + } return Text('Error: $error'); }, ), diff --git a/lib/router/constants.dart b/lib/router/constants.dart index deaeba9..5073e60 100644 --- a/lib/router/constants.dart +++ b/lib/router/constants.dart @@ -68,6 +68,13 @@ class Routes { pathName: 'users', name: 'userManagement', ); + + // openID callback + static const openIDCallback = _SimpleRoute( + pathName: 'callback', + name: 'openIDCallback', + parentRoute: onboarding, + ); } // a class to store path @@ -86,8 +93,8 @@ class _SimpleRoute { final _SimpleRoute? parentRoute; /// the full path of the route - String get path { - return '${parentRoute?.path ?? ''}$localPath'; + String get fullPath { + return '${parentRoute?.fullPath ?? ''}$localPath'; } /// the local path of the route diff --git a/lib/router/router.dart b/lib/router/router.dart index 62e0ebb..9d6ec42 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -5,6 +5,7 @@ import 'package:vaani/features/explore/view/explore_page.dart'; import 'package:vaani/features/explore/view/search_result_page.dart'; import 'package:vaani/features/item_viewer/view/library_item_page.dart'; import 'package:vaani/features/library_browser/view/library_browser_page.dart'; +import 'package:vaani/features/onboarding/view/callback_page.dart'; import 'package:vaani/features/onboarding/view/onboarding_single_page.dart'; import 'package:vaani/features/you/view/server_manager.dart'; import 'package:vaani/features/you/view/you_page.dart'; @@ -27,13 +28,30 @@ class MyAppRouter { const MyAppRouter(); GoRouter get config => GoRouter( - initialLocation: Routes.home.path, + initialLocation: Routes.home.localPath, + debugLogDiagnostics: true, routes: [ // sign in page GoRoute( - path: Routes.onboarding.path, + path: Routes.onboarding.localPath, name: Routes.onboarding.name, builder: (context, state) => const OnboardingSinglePage(), + routes: [ + // open id callback + GoRoute( + path: Routes.openIDCallback.pathName, + name: Routes.openIDCallback.name, + pageBuilder: handleCallback, + ), + ], + ), + // callback for open id + // need to duplicate because of https://github.com/flutter/flutter/issues/100624 + GoRoute( + path: Routes.openIDCallback.localPath, + // name: Routes.openIDCallback.name, + // builder: handleCallback, + pageBuilder: handleCallback, ), // The main app shell StatefulShellRoute.indexedStack( @@ -54,13 +72,13 @@ class MyAppRouter { navigatorKey: sectionHomeNavigatorKey, routes: [ GoRoute( - path: Routes.home.path, + path: Routes.home.localPath, name: Routes.home.name, // builder: (context, state) => const HomePage(), pageBuilder: defaultPageBuilder(const HomePage()), ), GoRoute( - path: Routes.libraryItem.path, + path: Routes.libraryItem.localPath, name: Routes.libraryItem.name, // builder: (context, state) { // final itemId = state @@ -82,7 +100,7 @@ class MyAppRouter { ), // downloads page GoRoute( - path: Routes.downloads.path, + path: Routes.downloads.localPath, name: Routes.downloads.name, pageBuilder: defaultPageBuilder(const DownloadsPage()), ), @@ -93,7 +111,7 @@ class MyAppRouter { StatefulShellBranch( routes: [ GoRoute( - path: Routes.libraryBrowser.path, + path: Routes.libraryBrowser.localPath, name: Routes.libraryBrowser.name, pageBuilder: defaultPageBuilder(const LibraryBrowserPage()), ), @@ -103,14 +121,14 @@ class MyAppRouter { StatefulShellBranch( routes: [ GoRoute( - path: Routes.explore.path, + path: Routes.explore.localPath, name: Routes.explore.name, // builder: (context, state) => const ExplorePage(), pageBuilder: defaultPageBuilder(const ExplorePage()), ), // search page GoRoute( - path: Routes.search.path, + path: Routes.search.localPath, name: Routes.search.name, // builder: (context, state) { // final libraryId = state @@ -145,18 +163,18 @@ class MyAppRouter { StatefulShellBranch( routes: [ GoRoute( - path: Routes.you.path, + path: Routes.you.localPath, name: Routes.you.name, pageBuilder: defaultPageBuilder(const YouPage()), ), GoRoute( - path: Routes.settings.path, + path: Routes.settings.localPath, name: Routes.settings.name, // builder: (context, state) => const AppSettingsPage(), pageBuilder: defaultPageBuilder(const AppSettingsPage()), ), GoRoute( - path: Routes.autoSleepTimerSettings.path, + path: Routes.autoSleepTimerSettings.localPath, name: Routes.autoSleepTimerSettings.name, // builder: (context, state) => // const AutoSleepTimerSettingsPage(), @@ -165,7 +183,7 @@ class MyAppRouter { ), ), GoRoute( - path: Routes.userManagement.path, + path: Routes.userManagement.localPath, name: Routes.userManagement.name, // builder: (context, state) => const UserManagementPage(), pageBuilder: defaultPageBuilder(const ServerManagerPage()), @@ -176,4 +194,23 @@ class MyAppRouter { ), ], ); + + Page handleCallback( + BuildContext context, + GoRouterState state, + ) { + // TODO: handle the open id callback + // extract the code and state from the uri + final code = state.uri.queryParameters['code']; + final stateParam = state.uri.queryParameters['state']; + debugPrint('deep linking callback: code: $code, state: $stateParam'); + + var callbackPage = + CallbackPage(code: code, state: stateParam, key: ValueKey(stateParam)); + return buildPageWithDefaultTransition( + context: context, + state: state, + child: callbackPage, + ); + } } diff --git a/lib/settings/constants.dart b/lib/settings/constants.dart index 16247b0..2429545 100644 --- a/lib/settings/constants.dart +++ b/lib/settings/constants.dart @@ -7,5 +7,8 @@ class AppMetadata { // TODO: use the packageinfo package to get the app name static const String appName = 'Vaani'; + // for deeplinking + static const String appScheme = 'vaani'; + static get appNameLowerCase => appName.toLowerCase().replaceAll(' ', '_'); } diff --git a/lib/shared/widgets/add_new_server.dart b/lib/shared/widgets/add_new_server.dart index c388763..c04d8fd 100644 --- a/lib/shared/widgets/add_new_server.dart +++ b/lib/shared/widgets/add_new_server.dart @@ -46,7 +46,8 @@ class AddNewServer extends HookConsumerWidget { color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8), ), border: const OutlineInputBorder(), - prefixText: 'https://', + prefixText: + myController.text.startsWith(RegExp('https?://')) ? '' : 'https://', prefixIcon: ServerAliveIcon(server: Uri.parse(newServerURI.text)), // add server button