diff --git a/lib/pages/settings/credentials_settings.dart b/lib/pages/settings/credentials_settings.dart index 03389f8b..478204eb 100644 --- a/lib/pages/settings/credentials_settings.dart +++ b/lib/pages/settings/credentials_settings.dart @@ -8,7 +8,7 @@ import 'package:kover/utils/layout_constants.dart'; import 'package:kover/widgets/util/async_value.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -class CredentialsSettings extends HookConsumerWidget { +class CredentialsSettings extends ConsumerWidget { const CredentialsSettings({super.key}); @override @@ -16,143 +16,194 @@ class CredentialsSettings extends HookConsumerWidget { final settings = ref.watch(credentialsProvider); final loginStatus = ref.watch(loginStatusProvider); - final obscureKey = useState(true); - return Card( margin: LayoutConstants.mediumEdgeInsets, child: Padding( padding: LayoutConstants.mediumEdgeInsets, child: Async( asyncValue: settings, - data: (data) { - final urlController = TextEditingController(text: data.url); - final apiKeyController = TextEditingController(text: data.apiKey); - - return Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - spacing: LayoutConstants.mediumPadding, - children: [ - Text( - 'Credentials', - style: Theme.of(context).textTheme.headlineSmall, - ), - TextField( - enabled: loginStatus != .loading, - controller: urlController, - decoration: const InputDecoration( - labelText: 'Base URL', - ), - ), - TextField( - obscureText: obscureKey.value, - enabled: loginStatus != .loading, - controller: apiKeyController, - decoration: InputDecoration( - labelText: 'API Key', - suffixIcon: Padding( - padding: const EdgeInsetsGeometry.symmetric( - horizontal: LayoutConstants.smallPadding, - ), - child: IconButton( - onPressed: () { - obscureKey.value = !obscureKey.value; - }, - icon: Icon( - obscureKey.value - ? LucideIcons.eye - : LucideIcons.eyeOff, - ), - ), - ), - ), - ), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: .spaceBetween, - children: [ - const _User(), - FilledButton.icon( - onPressed: () { - ref - .read(credentialsProvider.notifier) - .updateCredentials( - CredentialsState( - url: urlController.text, - apiKey: apiKeyController.text, - ), - ); - }, - label: const Text('Save'), - icon: const Icon(LucideIcons.save), - ), - ], - ), - ], - ); - }, + data: (data) => _CredentialsForm( + data: data, + loginStatus: loginStatus, + ), ), ), ); } } -class _User extends ConsumerWidget { - const _User(); +class _CredentialsForm extends HookConsumerWidget { + final CredentialsState data; + final LoginStatus loginStatus; + + const _CredentialsForm({required this.data, required this.loginStatus}); @override Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final currentUser = ref.watch(currentUserProvider); - final serverVersion = ref.watch(serverVersionProvider); - - return Async2( - asyncValue1: currentUser, - asyncValue2: serverVersion, - data: (user, version) { - final name = user.username; - final initials = name.isNotEmpty ? name[0].toUpperCase() : '?'; + final obscureKey = useState(true); + final urlController = useTextEditingController(text: data.url ?? ''); + final apiKeyController = useTextEditingController(text: data.apiKey ?? ''); - return Row( - spacing: LayoutConstants.smallPadding, - children: [ - CircleAvatar(child: Text(initials)), - Text( - name, - style: Theme.of( - context, - ).textTheme.titleMedium, - ), - if (version != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: LayoutConstants.smallPadding, - vertical: LayoutConstants.smallerPadding, - ), - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular( - LayoutConstants.smallPadding, - ), - ), - child: Text( - 'v$version', - style: theme.textTheme.labelMedium?.copyWith( - color: theme.colorScheme.onPrimaryContainer, - ), + return Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + spacing: LayoutConstants.mediumPadding, + children: [ + Text( + 'Credentials', + style: Theme.of(context).textTheme.headlineSmall, + ), + TextField( + enabled: loginStatus != .loading, + controller: urlController, + decoration: const InputDecoration( + labelText: 'Base URL', + ), + ), + TextField( + obscureText: obscureKey.value, + enabled: loginStatus != .loading, + controller: apiKeyController, + decoration: InputDecoration( + labelText: 'API Key', + suffixIcon: Padding( + padding: const EdgeInsetsGeometry.symmetric( + horizontal: LayoutConstants.smallPadding, + ), + child: IconButton( + onPressed: () { + obscureKey.value = !obscureKey.value; + }, + icon: Icon( + obscureKey.value ? LucideIcons.eye : LucideIcons.eyeOff, ), ), + ), + ), + ), + Row( + crossAxisAlignment: .center, + mainAxisAlignment: .spaceBetween, + children: [ + _User(loginStatus: loginStatus), + FilledButton.icon( + onPressed: loginStatus == .loading + ? null + : () { + ref + .read(credentialsProvider.notifier) + .updateCredentials( + CredentialsState( + url: urlController.text, + apiKey: apiKeyController.text, + ), + ); + }, + label: const Text('Save'), + icon: const Icon(LucideIcons.save), + ), ], - ); - }, - loading: () => const SizedBox.square( + ), + ], + ); + } +} + +class _User extends ConsumerWidget { + final LoginStatus loginStatus; + + const _User({required this.loginStatus}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return switch (loginStatus) { + LoginStatus.noCredentials => const SizedBox.shrink(), + LoginStatus.loading => const SizedBox.square( dimension: LayoutConstants.mediumIcon, - child: CircularProgressIndicator(), + child: CircularProgressIndicator(strokeWidth: 2), ), - error: (_, _) => Icon( - LucideIcons.circleX, - color: Theme.of(context).colorScheme.error, + LoginStatus.error => Row( + spacing: LayoutConstants.smallPadding, + children: [ + Icon( + LucideIcons.circleX, + color: Theme.of(context).colorScheme.error, + ), + Text( + 'Invalid credentials', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ], ), + LoginStatus.loggedIn => const _LoggedInUser(), + }; + } +} + +class _LoggedInUser extends ConsumerWidget { + const _LoggedInUser(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final user = ref + .watch(currentUserProvider) + .whenOrNull( + data: (user) => user, + ); + final version = ref + .watch(serverVersionProvider) + .whenOrNull( + data: (version) => version, + ); + + if (user == null) { + return const SizedBox.square( + dimension: LayoutConstants.mediumIcon, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } + + final name = user.username; + final initials = name.isNotEmpty ? name[0].toUpperCase() : '?'; + + return Row( + spacing: LayoutConstants.smallPadding, + children: [ + Icon( + LucideIcons.check, + color: theme.colorScheme.primary, + size: LayoutConstants.mediumIcon, + ), + CircleAvatar(child: Text(initials)), + Text( + name, + style: Theme.of( + context, + ).textTheme.titleMedium, + ), + if (version != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: LayoutConstants.smallPadding, + vertical: LayoutConstants.smallerPadding, + ), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular( + LayoutConstants.smallPadding, + ), + ), + child: Text( + 'v$version', + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ), + ], ); } } diff --git a/lib/riverpod/providers/auth.dart b/lib/riverpod/providers/auth.dart index e9518372..c94051a8 100644 --- a/lib/riverpod/providers/auth.dart +++ b/lib/riverpod/providers/auth.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:io'; + import 'package:hooks_riverpod/experimental/persist.dart'; import 'package:kover/models/user_model.dart'; import 'package:kover/riverpod/providers/client.dart'; @@ -11,9 +14,14 @@ part 'auth.g.dart'; class NoCredentialsException implements Exception {} Duration? _retry(int retryCount, Object error) { - if (error is NoCredentialsException || retryCount >= 3) { - return null; - } + // Never retry missing credentials + if (error is NoCredentialsException) return null; + + // Never retry network errors (offline) - fail fast + if (error is SocketException || error is TimeoutException) return null; + + // Retry other errors up to 3 times + if (retryCount >= 3) return null; return Duration(milliseconds: 200 * (1 << retryCount)); } @@ -33,19 +41,14 @@ class CurrentUser extends _$CurrentUser { if (state.hasValue) state = AsyncData(state.value!); - try { - final client = ref.watch(restClientProvider); - final res = await client.apiPluginAuthenticatePost( - apiKey: apiKey, - pluginName: 'kover', - ); - if (!res.isSuccessful || res.body == null) { - throw Exception('Failed to authenticate: ${res.error}'); - } - return UserModel.fromUserDto(res.body!); - } catch (e) { - if (state.hasValue) return state.value!; - rethrow; + final client = ref.watch(restClientProvider); + final res = await client.apiPluginAuthenticatePost( + apiKey: apiKey, + pluginName: 'kover', + ); + if (!res.isSuccessful || res.body == null) { + throw Exception('Failed to authenticate: ${res.error}'); } + return UserModel.fromUserDto(res.body!); } } diff --git a/lib/riverpod/providers/settings/credentials.dart b/lib/riverpod/providers/settings/credentials.dart index 10ffd102..850216f9 100644 --- a/lib/riverpod/providers/settings/credentials.dart +++ b/lib/riverpod/providers/settings/credentials.dart @@ -37,6 +37,8 @@ class Credentials extends _$Credentials { void updateCredentials(CredentialsState settings) { state = AsyncValue.data(settings); + // Trigger re-validation by invalidating the current user provider + ref.invalidate(currentUserProvider); } }