diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Open In Thunder.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Open In Thunder.xcscheme index 6a6b36b47..4819c8a51 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Open In Thunder.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Open In Thunder.xcscheme @@ -73,7 +73,6 @@ savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES" - askForAppToLaunch = "Yes" launchAutomaticallySubstyle = "2"> diff --git a/lib/account/pages/login_page.dart b/lib/account/pages/login_page.dart index 22f548c75..d87eb24f8 100644 --- a/lib/account/pages/login_page.dart +++ b/lib/account/pages/login_page.dart @@ -13,6 +13,7 @@ import 'package:thunder/core/auth/bloc/auth_bloc.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; import 'package:thunder/instances.dart'; import 'package:thunder/shared/dialogs.dart'; +import 'package:thunder/shared/input_dialogs.dart'; import 'package:thunder/shared/snackbar.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; import 'package:thunder/utils/instance.dart'; @@ -47,6 +48,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix bool instanceValidated = true; bool instanceAwaitingValidation = true; String? instanceError; + List oauthProviders = []; bool isLoading = false; @@ -77,6 +79,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix _instanceTextEditingController.addListener(() async { if (currentInstance != _instanceTextEditingController.text) { setState(() => instanceIcon = null); + setState(() => oauthProviders = []); currentInstance = _instanceTextEditingController.text; } @@ -94,6 +97,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix await getInstanceInfo(_instanceTextEditingController.text).then((value) { // Make sure the icon we looked up still matches the text if (currentInstance == _instanceTextEditingController.text) { + setState(() => oauthProviders = value.oauthProviders ?? []); setState(() => instanceIcon = value.icon); } }); @@ -152,7 +156,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix } else if (state.status == AuthStatus.success && context.read().state.isLoggedIn) { widget.popModal(); showSnackbar(AppLocalizations.of(context)!.loginSucceeded); - } else if (state.status == AuthStatus.contentWarning) { + } else if (state.status == AuthStatus.contentWarning || state.status == AuthStatus.oauthContentWarning) { bool acceptedContentWarning = false; await showThunderDialog( @@ -170,8 +174,37 @@ class _LoginPageState extends State with SingleTickerProviderStateMix if (context.mounted) { if (acceptedContentWarning) { - // Do another login attempt, this time without the content warning - _handleLogin(showContentWarning: false); + if (state.status == AuthStatus.oauthContentWarning) { + context.read().add(const AddAccount()); + } else { + // Do another login attempt, this time without the content warning + // TODO: This can be updated to use AddAccount instead of starting the login process over. + _handleLogin(showContentWarning: false); + } + } else { + // Cancel the login + context.read().add(const CancelLoginAttempt()); + } + } + } else if (state.status == AuthStatus.oauthSignUp) { + bool completedSignUp = false; + String? username; + + await showBlockingInputDialog( + context: context, + title: l10n.signUp, + inputLabel: l10n.username, + getSuggestions: (_) => [], + suggestionBuilder: (payload) => Container(), + onSubmitted: ({payload, value}) { + completedSignUp = true; + username = value; + return Future.value(null); + }); + + if (context.mounted) { + if (completedSignUp) { + context.read().add(OAuthLoginAttempt(username: username)); } else { // Cancel the login context.read().add(const CancelLoginAttempt()); @@ -435,6 +468,34 @@ class _LoginPageState extends State with SingleTickerProviderStateMix child: Text(widget.anonymous ? AppLocalizations.of(context)!.add : AppLocalizations.of(context)!.login, style: theme.textTheme.titleMedium?.copyWith(color: !isLoading && fieldsFilledIn ? theme.colorScheme.onPrimary : theme.colorScheme.primary)), ), + if (oauthProviders.isNotEmpty) ...[ + const SizedBox(height: 20.0), + Text( + l10n.orLogInWithSso, + style: theme.textTheme.titleMedium, + ), + ], + for (final provider in oauthProviders) ...[ + const SizedBox(height: 12.0), + ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(60), + backgroundColor: theme.colorScheme.primary, + textStyle: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), + onPressed: (!isLoading && _instanceTextEditingController.text.isNotEmpty) + ? () { + _handleOAuthLogin(provider: provider); + } + : (_instanceTextEditingController.text.isNotEmpty && widget.anonymous) + ? () => _addAnonymousInstance(context) + : null, + child: Text(provider.displayName, + style: theme.textTheme.titleMedium?.copyWith(color: !isLoading && _instanceTextEditingController.text.isNotEmpty ? theme.colorScheme.onPrimary : theme.colorScheme.primary)), + ), + ], const SizedBox(height: 12.0), TextButton( style: ElevatedButton.styleFrom(minimumSize: const Size.fromHeight(60)), @@ -465,6 +526,17 @@ class _LoginPageState extends State with SingleTickerProviderStateMix ); } + void _handleOAuthLogin({required ProviderView provider}) { + TextInput.finishAutofillContext(); + // Perform oauth login authentication. + context.read().add( + OAuthLoginAttempt( + instance: _instanceTextEditingController.text.trim(), + provider: provider, + ), + ); + } + void _addAnonymousInstance(BuildContext context) async { final AppLocalizations l10n = AppLocalizations.of(context)!; diff --git a/lib/core/auth/bloc/auth_bloc.dart b/lib/core/auth/bloc/auth_bloc.dart index 07f336792..8e165388f 100644 --- a/lib/core/auth/bloc/auth_bloc.dart +++ b/lib/core/auth/bloc/auth_bloc.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:lemmy_api_client/v3.dart'; @@ -12,11 +14,14 @@ import 'package:thunder/account/models/account.dart'; import 'package:thunder/core/singletons/preferences.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; import 'package:thunder/utils/global_context.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:uuid/uuid.dart'; part 'auth_event.dart'; part 'auth_state.dart'; const throttleDuration = Duration(milliseconds: 100); +const String redirectUri = "https://thunderapp.dev/oauth/callback"; // This must end in /oauth/callback. EventTransformer throttleDroppable(Duration duration) { return (events, mapper) { @@ -48,11 +53,11 @@ class AuthBloc extends Bloc { prefs.setString('active_profile_id', event.accountId); // Check to see the instance settings (for checking if downvotes are enabled) - LemmyClient.instance.changeBaseUrl(account.instance.replaceAll('https://', '')); + LemmyClient.instance.changeBaseUrl(account.instance.replaceFirst('https://', '')); LemmyApiV3 lemmy = LemmyClient.instance.lemmyApiV3; GetSiteResponse getSiteResponse = await lemmy.run(GetSite(auth: account.jwt)); - bool downvotesEnabled = getSiteResponse.siteView.localSite.enableDownvotes; + bool downvotesEnabled = getSiteResponse.siteView.localSite.enableDownvotes ?? false; return emit(state.copyWith( status: AuthStatus.success, @@ -99,7 +104,7 @@ class AuthBloc extends Bloc { if (activeAccount.username != null && activeAccount.jwt != null) { // Set lemmy client to use the instance - LemmyClient.instance.changeBaseUrl(activeAccount.instance.replaceAll('https://', '')); + LemmyClient.instance.changeBaseUrl(activeAccount.instance.replaceFirst('https://', '')); // Check to see the instance settings (for checking if downvotes are enabled) LemmyApiV3 lemmy = LemmyClient.instance.lemmyApiV3; @@ -109,7 +114,7 @@ class AuthBloc extends Bloc { try { getSiteResponse = await lemmy.run(GetSite(auth: activeAccount.jwt)).timeout(const Duration(seconds: 15)); - downvotesEnabled = getSiteResponse.siteView.localSite.enableDownvotes; + downvotesEnabled = getSiteResponse.siteView.localSite.enableDownvotes ?? false; } catch (e) { return emit(state.copyWith(status: AuthStatus.failureCheckingInstance, errorMessage: getExceptionErrorMessage(e))); } @@ -127,7 +132,7 @@ class AuthBloc extends Bloc { emit(state.copyWith(status: AuthStatus.loading, account: null, isLoggedIn: false)); String instance = event.instance; - if (instance.startsWith('https://')) instance = instance.replaceAll('https://', ''); + if (instance.startsWith('https://')) instance = instance.replaceFirst('https://', ''); lemmyClient.changeBaseUrl(instance); LemmyApiV3 lemmy = LemmyClient.instance.lemmyApiV3; @@ -168,7 +173,7 @@ class AuthBloc extends Bloc { SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; prefs.setString('active_profile_id', account.id); - bool downvotesEnabled = getSiteResponse.siteView.localSite.enableDownvotes; + bool downvotesEnabled = getSiteResponse.siteView.localSite.enableDownvotes ?? false; return emit(state.copyWith(status: AuthStatus.success, account: account, isLoggedIn: true, downvotesEnabled: downvotesEnabled, getSiteResponse: getSiteResponse)); } on LemmyApiException catch (e) { @@ -184,8 +189,229 @@ class AuthBloc extends Bloc { } }); + /// Log in with OAuth Provider to get a code. + on((event, emit) async { + LemmyClient lemmyClient = LemmyClient.instance; + String originalBaseUrl = lemmyClient.lemmyApiV3.host; + String instance = event.instance ?? state.oauthInstance!; + ProviderView provider = event.provider ?? state.oauthProvider!; + + try { + emit(state.copyWith(status: AuthStatus.loading, account: null, isLoggedIn: false)); + + if (instance.startsWith('https://')) instance = instance.replaceFirst('https://', ''); + lemmyClient.changeBaseUrl(instance); + + // Build oauth provider url. + var authorizationEndpoint = Uri.parse(provider.authorizationEndpoint); + + String oauthState = const Uuid().v4(); + final url = Uri.https(authorizationEndpoint.host, authorizationEndpoint.path, { + 'response_type': 'code', + 'client_id': provider.clientId, + 'redirect_uri': redirectUri, + 'scope': provider.scopes, + 'state': oauthState, + }); + + // Present the login dialog to the user. + if (!await launchUrl(url)) { + throw Exception('Could not launch $url'); + } + + return emit(state.copyWith(oauthState: oauthState, oauthInstance: instance, oauthProvider: provider, oauthUsername: event.username)); + } on LemmyApiException catch (e) { + return emit(state.copyWith(status: AuthStatus.failure, account: null, isLoggedIn: false, errorMessage: e.toString(), oauthState: null, oauthInstance: null, oauthProvider: null)); + } catch (e) { + try { + // Restore the original baseUrl + lemmyClient.changeBaseUrl(originalBaseUrl); + } catch (e, s) { + return emit(state.copyWith(status: AuthStatus.failure, account: null, isLoggedIn: false, errorMessage: s.toString(), oauthState: null, oauthInstance: null, oauthProvider: null)); + } + return emit(state.copyWith(status: AuthStatus.failure, account: null, isLoggedIn: false, errorMessage: e.toString(), oauthState: null, oauthInstance: null, oauthProvider: null)); + } + }); + + /// Using the code from the previous step, login to lemmy instance an get the jwt. This is triggered by app_link callback. + on((event, emit) async { + LemmyClient lemmyClient = LemmyClient.instance; + String originalBaseUrl = lemmyClient.lemmyApiV3.host; + String providerResponse = event.link ?? state.oauthLink!; + String instance = state.oauthInstance!; + String? username = event.username ?? state.oauthUsername; + emit(state.copyWith(oauthLink: providerResponse)); + + if (instance.startsWith('https://')) instance = instance.replaceFirst('https://', ''); + lemmyClient.changeBaseUrl(instance); + + LemmyApiV3 lemmy = LemmyClient.instance.lemmyApiV3; + + try { + if (state.oauthState == null || state.oauthInstance == null || state.oauthProvider == null) { + throw Exception("OAuth login failed: oauthState, oauthInstance, or oauthProviderId is null."); + } + + // oauthProviderState must match oauthClientState to ensure the response came from the Provider. + String oauthProviderState = Uri.parse(providerResponse).queryParameters['state'] ?? "failed"; + if (oauthProviderState == "failed" || state.oauthState != oauthProviderState) { + throw Exception("OAuth state-check failed: oauthProviderState $oauthProviderState must match oauthClientState ${state.oauthState} to ensure the response came from the Provider."); + } + + // Extract the code from the response. + String code = Uri.parse(providerResponse).queryParameters['code'] ?? "failed"; + if (code == "failed") { + throw Exception("OAuth login failed: no code received from provider."); + } + + // Authenthicate to lemmy instance and get a jwt. + // Durring this step lemmy connects to the Provider to get the user info. + LoginResponse loginResponse = await lemmy.run(AuthenticateWithOAuth( + username: username, + code: code, + oauth_provider_id: state.oauthProvider!.id, + redirect_uri: redirectUri, + )); + + if (loginResponse.jwt == null) { + throw Exception("OAuth login failed: no jwt received from lemmy instance."); + } + + emit(state.copyWith(oauthJwt: loginResponse.jwt, oauthLink: null)); + + if (state.oauthJwt == null) { + return emit(state.copyWith(status: AuthStatus.failure, account: null, isLoggedIn: false)); + } + + GetSiteResponse getSiteResponse = await lemmy.run(GetSite(auth: state.oauthJwt)); + + // Create a new account in the database + Account? account = Account( + id: '', + username: getSiteResponse.myUser?.localUserView.person.name, + jwt: state.oauthJwt, + instance: state.oauthInstance ?? "", + userId: getSiteResponse.myUser?.localUserView.person.id, + index: -1, + ); + + // Save account to AuthBlock state and show the content warning. + if (getSiteResponse.siteView.site.contentWarning?.isNotEmpty == true) { + return emit(state.copyWith( + status: AuthStatus.oauthContentWarning, + contentWarning: getSiteResponse.siteView.site.contentWarning, + downvotesEnabled: getSiteResponse.siteView.localSite.enableDownvotes ?? false, + getSiteResponse: getSiteResponse, + oauthState: state.oauthState, + tempAccount: account, + )); + } + } on LemmyApiException catch (e) { + if (e.message == 'registration_username_required') { + return emit(state.copyWith(status: AuthStatus.oauthSignUp, oauthState: state.oauthState)); + } else { + return emit(state.copyWith( + status: AuthStatus.failure, + account: null, + isLoggedIn: false, + errorMessage: e.toString(), + oauthLink: null, + oauthState: null, + oauthInstance: null, + oauthProvider: null, + tempAccount: null, + )); + } + } catch (e) { + try { + // Restore the original baseUrl + lemmyClient.changeBaseUrl(originalBaseUrl); + } catch (e, s) { + return emit(state.copyWith( + status: AuthStatus.failure, + account: null, + isLoggedIn: false, + errorMessage: s.toString(), + oauthLink: null, + oauthState: null, + oauthInstance: null, + oauthProvider: null, + tempAccount: null, + )); + } + return emit(state.copyWith( + status: AuthStatus.failure, + account: null, + isLoggedIn: false, + errorMessage: e.toString(), + oauthLink: null, + oauthState: null, + oauthInstance: null, + oauthProvider: null, + tempAccount: null, + )); + } + }); + + /// Adds the tempAccount and sets it as the active account. + on((event, emit) async { + try { + Account? account = state.tempAccount ?? event.account; + if (account == null) { + return emit(state.copyWith(status: AuthStatus.failure, account: null, isLoggedIn: false)); + } + + account = await Account.insertAccount(account); + emit(state.copyWith(tempAccount: null)); + + if (account == null) { + return emit(state.copyWith(status: AuthStatus.failure, account: null, isLoggedIn: false)); + } + + // Set this account as the active account + SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; + prefs.setString('active_profile_id', account.id); + + return emit(state.copyWith( + status: AuthStatus.success, + account: account, + isLoggedIn: true, + tempAccount: null, + oauthState: null, + oauthInstance: null, + oauthProvider: null, + oauthUsername: null, + oauthJwt: null, + oauthLink: null, + )); + } catch (e) { + return emit(state.copyWith( + status: AuthStatus.failure, + tempAccount: null, + account: null, + isLoggedIn: false, + oauthState: null, + oauthInstance: null, + oauthProvider: null, + oauthUsername: null, + oauthJwt: null, + oauthLink: null, + )); + } + }); + on((event, emit) async { - return emit(state.copyWith(status: AuthStatus.failure, errorMessage: AppLocalizations.of(GlobalContext.context)!.loginAttemptCanceled)); + return emit(state.copyWith( + status: AuthStatus.failure, + errorMessage: AppLocalizations.of(GlobalContext.context)!.loginAttemptCanceled, + tempAccount: null, + oauthState: null, + oauthProvider: null, + oauthLink: null, + oauthJwt: null, + oauthInstance: null, + oauthUsername: null, + )); }); /// When we log out of all accounts, clear the instance information @@ -202,7 +428,7 @@ class AuthBloc extends Bloc { emit(state.copyWith(status: AuthStatus.loading, isLoggedIn: state.isLoggedIn, account: state.account)); // When the instance changes, update the fullSiteView - LemmyClient.instance.changeBaseUrl(event.instance.replaceAll('https://', '')); + LemmyClient.instance.changeBaseUrl(event.instance.replaceFirst('https://', '')); LemmyApiV3 lemmy = LemmyClient.instance.lemmyApiV3; // Check to see if there is an active, non-anonymous account @@ -211,7 +437,7 @@ class AuthBloc extends Bloc { Account? account = (activeProfileId != null) ? await Account.fetchAccount(activeProfileId) : null; GetSiteResponse getSiteResponse = await lemmy.run(GetSite(auth: account?.jwt)); - bool downvotesEnabled = getSiteResponse.siteView.localSite.enableDownvotes; + bool downvotesEnabled = getSiteResponse.siteView.localSite.enableDownvotes ?? false; return emit(state.copyWith(status: AuthStatus.success, account: account, isLoggedIn: activeProfileId?.isNotEmpty == true, downvotesEnabled: downvotesEnabled, getSiteResponse: getSiteResponse)); }); diff --git a/lib/core/auth/bloc/auth_event.dart b/lib/core/auth/bloc/auth_event.dart index d2a65b353..a3916e489 100644 --- a/lib/core/auth/bloc/auth_event.dart +++ b/lib/core/auth/bloc/auth_event.dart @@ -29,13 +29,42 @@ class LoginAttempt extends AuthEvent { const LoginAttempt({required this.username, required this.password, required this.instance, this.totp = "", this.showContentWarning = true}); } +/// The [OAuthLoginAttempt] event should be triggered whenever the user attempts to log in with OAuth. +/// This event is responsible for login authentication and handling related errors. +class OAuthLoginAttempt extends AuthEvent { + final String? instance; + final ProviderView? provider; + final String? username; + + const OAuthLoginAttempt({this.instance, this.provider, this.username}); +} + +/// The [OAuthLoginAttemptPart2] event should be triggered whenever the user attempts to log in with OAuth. +/// This event is responsible for login authentication and handling related errors. +class OAuthLoginAttemptPart2 extends AuthEvent { + final String? link; + final String? username; + const OAuthLoginAttemptPart2({required this.link, this.username}); +} + +class OAuthCreateAccount extends AuthEvent { + const OAuthCreateAccount(); +} + +class AddAccount extends AuthEvent { + final Account? account; + const AddAccount({this.account}); +} + /// Cancels a login attempt by emitting the `failure` state. class CancelLoginAttempt extends AuthEvent { const CancelLoginAttempt(); } -/// TODO: Consolidate logic to have adding accounts (for both authenticated and anonymous accounts) placed here -class AddAccount extends AuthEvent {} +/// Cancels a login attempt by emitting the `failure` state. +class ShowContentWarning extends AuthEvent { + const ShowContentWarning(); +} /// The [RemoveAccount] event should be triggered whenever the user removes a given account. /// Currently, this event only handles removing authenticated accounts. diff --git a/lib/core/auth/bloc/auth_state.dart b/lib/core/auth/bloc/auth_state.dart index 172d08cd7..7c4694392 100644 --- a/lib/core/auth/bloc/auth_state.dart +++ b/lib/core/auth/bloc/auth_state.dart @@ -1,50 +1,94 @@ part of 'auth_bloc.dart'; -enum AuthStatus { initial, loading, success, failure, failureCheckingInstance, contentWarning } +enum AuthStatus { initial, loading, success, failure, failureCheckingInstance, contentWarning, oauthContentWarning, oauthSignUp } class AuthState extends Equatable { const AuthState({ this.status = AuthStatus.initial, this.isLoggedIn = false, this.errorMessage, + this.tempAccount, this.account, this.downvotesEnabled = true, this.getSiteResponse, this.reload = true, this.contentWarning, + this.oauthInstance, + this.oauthJwt, + this.oauthLink, + this.oauthState, + this.oauthUsername, + this.oauthProvider, }); final AuthStatus status; final bool isLoggedIn; final String? errorMessage; final Account? account; + final Account? tempAccount; final bool downvotesEnabled; final GetSiteResponse? getSiteResponse; final bool reload; final String? contentWarning; + final String? oauthInstance; + final String? oauthJwt; + final String? oauthLink; + final String? oauthState; + final String? oauthUsername; + final ProviderView? oauthProvider; AuthState copyWith({ AuthStatus? status, bool? isLoggedIn, String? errorMessage, + Account? tempAccount, Account? account, bool? downvotesEnabled, GetSiteResponse? getSiteResponse, bool? reload, String? contentWarning, + String? oauthInstance, + String? oauthJwt, + String? oauthLink, + String? oauthState, + String? oauthUsername, + ProviderView? oauthProvider, }) { return AuthState( status: status ?? this.status, isLoggedIn: isLoggedIn ?? false, errorMessage: errorMessage, + tempAccount: tempAccount ?? this.tempAccount, account: account, downvotesEnabled: downvotesEnabled ?? this.downvotesEnabled, getSiteResponse: getSiteResponse ?? this.getSiteResponse, reload: reload ?? this.reload, contentWarning: contentWarning, + oauthInstance: oauthInstance ?? this.oauthInstance, + oauthJwt: oauthJwt ?? this.oauthJwt, + oauthLink: oauthLink ?? this.oauthLink, + oauthState: oauthState ?? this.oauthState, + oauthUsername: oauthUsername ?? this.oauthUsername, + oauthProvider: oauthProvider ?? this.oauthProvider, ); } @override - List get props => [status, isLoggedIn, errorMessage, account, downvotesEnabled, getSiteResponse, reload]; + List get props => [ + status, + isLoggedIn, + errorMessage, + tempAccount, + account, + downvotesEnabled, + getSiteResponse, + reload, + contentWarning, + oauthInstance, + oauthJwt, + oauthLink, + oauthState, + oauthUsername, + oauthProvider, + ]; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fb94439d0..f982d6c4f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1649,6 +1649,10 @@ "@openSettings": { "description": "Prompt for the user to open system settings" }, + "orLogInWithSso": "Or log in with SSO", + "@orLogInWithSso": { + "description": "Heading displayed on login page for SSO login options" + }, "orange": "Orange", "@orange": { "description": "The color orange" @@ -2407,6 +2411,10 @@ "@sidebarBottomNavDoubleTapDescription": {}, "sidebarBottomNavSwipeDescription": "Swipe bottom nav to open sidebar", "@sidebarBottomNavSwipeDescription": {}, + "signUp": "Sign Up", + "@signUp": { + "description": "Title for thunder sign up dialog." + }, "small": "Small", "@small": { "description": "Description for small font scale" diff --git a/lib/shared/input_dialogs.dart b/lib/shared/input_dialogs.dart index d97ec5c7a..9c73f9e56 100644 --- a/lib/shared/input_dialogs.dart +++ b/lib/shared/input_dialogs.dart @@ -527,3 +527,78 @@ void showInputDialog({ }), ); } + +/// Shows a dialog which takes input and offers suggestions +Future showBlockingInputDialog({ + required BuildContext context, + required String title, + required String inputLabel, + required Future Function({T? payload, String? value}) onSubmitted, + required FutureOr?> Function(String query) getSuggestions, + required Widget Function(T payload) suggestionBuilder, +}) async { + final textController = TextEditingController(); + // Capture our content widget's setState function so we can call it outside the widget + StateSetter? contentWidgetSetState; + String? contentWidgetError; + + await showThunderDialog( + context: context, + title: title, + onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), + secondaryButtonText: AppLocalizations.of(context)!.cancel, + primaryButtonInitialEnabled: false, + onPrimaryButtonPressed: (dialogContext, setPrimaryButtonEnabled) async { + setPrimaryButtonEnabled(false); + final String? submitError = await onSubmitted(value: textController.text); + contentWidgetSetState?.call(() => contentWidgetError = submitError); + Navigator.of(dialogContext).pop(); + }, + primaryButtonText: AppLocalizations.of(context)!.ok, + // Use a stateful widget for the content so we can update the error message + contentWidgetBuilder: (setPrimaryButtonEnabled) => StatefulBuilder(builder: (context, setState) { + contentWidgetSetState = setState; + return SizedBox( + width: min(MediaQuery.of(context).size.width, 700), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TypeAheadField( + controller: textController, + builder: (context, controller, focusNode) => TextField( + controller: controller, + focusNode: focusNode, + onChanged: (value) { + setPrimaryButtonEnabled(value.trim().isNotEmpty); + setState(() => contentWidgetError = null); + }, + autofocus: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: inputLabel, + errorText: contentWidgetError, + ), + onSubmitted: (text) async { + setPrimaryButtonEnabled(false); + final String? submitError = await onSubmitted(value: text); + setState(() => contentWidgetError = submitError); + }, + ), + suggestionsCallback: getSuggestions, + itemBuilder: (context, payload) => suggestionBuilder(payload), + onSelected: (payload) async { + setPrimaryButtonEnabled(false); + final String? submitError = await onSubmitted(payload: payload); + setState(() => contentWidgetError = submitError); + }, + hideOnEmpty: true, + hideOnLoading: true, + hideOnError: true, + ), + ], + ), + ); + }), + ); + return null; +} diff --git a/lib/thunder/cubits/deep_links_cubit/deep_links_cubit.dart b/lib/thunder/cubits/deep_links_cubit/deep_links_cubit.dart index 0d67621ef..f975de961 100644 --- a/lib/thunder/cubits/deep_links_cubit/deep_links_cubit.dart +++ b/lib/thunder/cubits/deep_links_cubit/deep_links_cubit.dart @@ -4,6 +4,7 @@ import 'dart:math'; import 'package:app_links/app_links.dart'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:thunder/thunder/enums/deep_link_enums.dart'; @@ -25,6 +26,7 @@ class DeepLinksCubit extends Cubit { try { // First, check to see if this is an internal Thunder link List internalLinks = ['thunder://setting-']; + debugPrint("APP LINK $link"); if (internalLinks.where((internalLink) => link.startsWith(internalLink)).isNotEmpty) { return emit(state.copyWith( @@ -33,8 +35,13 @@ class DeepLinksCubit extends Cubit { linkType: LinkType.thunder, )); } - - if (link.contains("/u/")) { + if (link.contains("/oauth/callback")) { + emit(state.copyWith( + deepLinkStatus: DeepLinkStatus.success, + link: link, + linkType: LinkType.oauth, + )); + } else if (link.contains("/u/")) { emit(state.copyWith( deepLinkStatus: DeepLinkStatus.success, link: link, diff --git a/lib/thunder/enums/deep_link_enums.dart b/lib/thunder/enums/deep_link_enums.dart index 8e8c65e41..9ae666102 100644 --- a/lib/thunder/enums/deep_link_enums.dart +++ b/lib/thunder/enums/deep_link_enums.dart @@ -7,4 +7,5 @@ enum LinkType { community, modlog, thunder, + oauth, } diff --git a/lib/thunder/pages/thunder_page.dart b/lib/thunder/pages/thunder_page.dart index 3f01410c7..af66c5a94 100644 --- a/lib/thunder/pages/thunder_page.dart +++ b/lib/thunder/pages/thunder_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; // Flutter @@ -59,6 +60,7 @@ import 'package:thunder/instance/utils/navigate_instance.dart'; import 'package:thunder/post/utils/navigate_post.dart'; import 'package:thunder/notification/utils/navigate_notification.dart'; import 'package:thunder/utils/settings_utils.dart'; +import 'package:http/http.dart' as http; String? currentIntent; @@ -241,6 +243,8 @@ class _ThunderState extends State { if (context.mounted) await _navigateToInstance(link); case LinkType.thunder: if (context.mounted) await _navigateToInternal(link); + case LinkType.oauth: + if (context.mounted) await _oauthCallback(link); case LinkType.unknown: if (context.mounted) { _showLinkProcessingError(context, AppLocalizations.of(context)!.uriNotSupported, link); @@ -248,6 +252,17 @@ class _ThunderState extends State { } } + Future _oauthCallback(String link) async { + try { + debugPrint("_oauthCallback $link"); + context.read().add(OAuthLoginAttemptPart2(link: link)); + } catch (e) { + if (context.mounted) { + _showLinkProcessingError(context, AppLocalizations.of(context)!.exceptionProcessingUri, link); + } + } + } + Future _navigateToInstance(String link) async { try { await navigateToInstancePage( @@ -531,6 +546,8 @@ class _ThunderState extends State { ), ); case AuthStatus.contentWarning: + case AuthStatus.oauthContentWarning: + case AuthStatus.oauthSignUp: case AuthStatus.success: Version? version = thunderBlocState.version; bool showInAppUpdateNotification = thunderBlocState.showInAppUpdateNotification; diff --git a/lib/user/pages/user_settings_page.dart b/lib/user/pages/user_settings_page.dart index 7517a423a..6a866edb8 100644 --- a/lib/user/pages/user_settings_page.dart +++ b/lib/user/pages/user_settings_page.dart @@ -327,7 +327,7 @@ class _UserSettingsPageState extends State { ), ListOption( description: l10n.defaultFeedSortType, - value: ListPickerItem(label: localUser.defaultSortType.value, icon: Icons.local_fire_department_rounded, payload: localUser.defaultSortType), + value: ListPickerItem(label: localUser.defaultSortType!.value, icon: Icons.local_fire_department_rounded, payload: localUser.defaultSortType), options: [ ...SortPicker.getDefaultSortTypeItems(minimumVersion: Version(0, 19, 0, preRelease: ["rc", "1"])), ...topSortTypeItems diff --git a/lib/utils/instance.dart b/lib/utils/instance.dart index 33cf60eed..430bf1719 100644 --- a/lib/utils/instance.dart +++ b/lib/utils/instance.dart @@ -179,6 +179,7 @@ class GetInstanceInfoResponse { final String? domain; final int? users; final int? id; + final List? oauthProviders; const GetInstanceInfoResponse({ required this.success, @@ -188,6 +189,7 @@ class GetInstanceInfoResponse { this.domain, this.users, this.id, + this.oauthProviders, }); bool isMetadataPopulated() => icon != null || version != null || name != null || users != null; @@ -208,6 +210,7 @@ Future getInstanceInfo(String? url, {int? id, Duration? domain: fetchInstanceNameFromUrl(site.siteView.site.actorId), users: site.siteView.counts.users, id: id, + oauthProviders: site.oauthProviders, ); } catch (e) { // Bad instances will throw an exception, so no icon diff --git a/pubspec.lock b/pubspec.lock index fe656aae3..5952fd123 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -530,18 +530,18 @@ packages: dependency: "direct main" description: name: flex_color_scheme - sha256: "09bea5d776f694c5a67f2229f2aa500cc7cce369322dc6500ab01cf9ad1b4e1a" + sha256: "90f4fe67b9561ae8a4af117df65a8ce9988624025667c54e6d304e65cff77d52" url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "8.0.2" flex_seed_scheme: dependency: transitive description: name: flex_seed_scheme - sha256: d3ba3c5c92d2d79d45e94b4c6c71d01fac3c15017da1545880c53864da5dfeb0 + sha256: "7639d2c86268eff84a909026eb169f008064af0fb3696a651b24b0fa24a40334" url: "https://pub.dev" source: hosted - version: "3.5.0" + version: "3.4.1" flutter: dependency: "direct main" description: flutter @@ -1126,9 +1126,9 @@ packages: dependency: "direct main" description: path: "." - ref: f352a17afad3d7a4fa98e1711a4886d1fa3929ae - resolved-ref: f352a17afad3d7a4fa98e1711a4886d1fa3929ae - url: "https://github.com/thunder-app/lemmy_api_client.git" + ref: "2886218a71c75d14cb21d2079bfb3666603d3221" + resolved-ref: "2886218a71c75d14cb21d2079bfb3666603d3221" + url: "https://github.com/gwbischof/lemmy_api_client.git" source: git version: "0.21.0" link_preview_generator: @@ -1912,7 +1912,7 @@ packages: source: hosted version: "3.1.3" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff diff --git a/pubspec.yaml b/pubspec.yaml index 435174c95..b5a5b08e5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,8 +14,10 @@ dependencies: path: packages/push/push lemmy_api_client: git: - url: "https://github.com/thunder-app/lemmy_api_client.git" - ref: f352a17afad3d7a4fa98e1711a4886d1fa3929ae + url: "https://github.com/gwbischof/lemmy_api_client.git" + ref: 2886218a71c75d14cb21d2079bfb3666603d3221 + #url: "https://github.com/thunder-app/lemmy_api_client.git" + #ref: 16d14a1c13ac9522e85188ad9cf23d8912ec8fee link_preview_generator: git: url: "https://github.com/thunder-app/link_preview_generator.git" @@ -62,7 +64,7 @@ dependencies: gal: "^2.2.0" html: "^0.15.4" html_unescape: "^2.0.0" - http: "^1.2.1" + http: ^1.2.2 image_picker: "^1.0.0" intl: "^0.19.0" jovial_svg: "^1.1.19" @@ -92,6 +94,7 @@ dependencies: youtube_player_flutter: "^9.1.0" youtube_player_iframe: "^5.2.0" freezed_annotation: ^2.4.4 + uuid: ^4.5.1 freezed: ^2.5.7 dev_dependencies: