Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
283 changes: 167 additions & 116 deletions lib/pages/settings/credentials_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,151 +8,202 @@ 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
Widget build(BuildContext context, WidgetRef ref) {
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,
),
),
),
],
);
}
}
35 changes: 19 additions & 16 deletions lib/riverpod/providers/auth.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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));
}
Expand All @@ -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!);
Comment thread
BothaGideon marked this conversation as resolved.
}
}
2 changes: 2 additions & 0 deletions lib/riverpod/providers/settings/credentials.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down