mirror of
https://github.com/Dr-Blank/Vaani.git
synced 2025-12-06 02:59:28 +00:00
Compare commits
4 commits
ad0cd6e2ad
...
23e5d73bea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23e5d73bea | ||
|
|
bae99292a2 | ||
|
|
25be7fda03 | ||
|
|
c8767b4e1e |
14 changed files with 708 additions and 522 deletions
|
|
@ -8,15 +8,15 @@ import 'package:vaani/settings/models/audiobookshelf_server.dart';
|
|||
import 'package:vaani/settings/models/authenticated_user.dart' as model;
|
||||
import 'package:vaani/shared/extensions/obfuscation.dart';
|
||||
|
||||
part 'authenticated_user_provider.g.dart';
|
||||
part 'authenticated_users_provider.g.dart';
|
||||
|
||||
final _box = AvailableHiveBoxes.authenticatedUserBox;
|
||||
|
||||
final _logger = Logger('authenticated_user_provider');
|
||||
final _logger = Logger('authenticated_users_provider');
|
||||
|
||||
/// provides with a set of authenticated users
|
||||
@riverpod
|
||||
class AuthenticatedUser extends _$AuthenticatedUser {
|
||||
class AuthenticatedUsers extends _$AuthenticatedUsers {
|
||||
@override
|
||||
Set<model.AuthenticatedUser> build() {
|
||||
ref.listenSelf((_, __) {
|
||||
|
|
@ -56,6 +56,7 @@ class AuthenticatedUser extends _$AuthenticatedUser {
|
|||
|
||||
void addUser(model.AuthenticatedUser user, {bool setActive = false}) {
|
||||
state = state..add(user);
|
||||
ref.invalidateSelf();
|
||||
if (setActive) {
|
||||
final apiSettings = ref.read(apiSettingsProvider);
|
||||
ref.read(apiSettingsProvider.notifier).updateState(
|
||||
|
|
@ -82,9 +83,12 @@ class AuthenticatedUser extends _$AuthenticatedUser {
|
|||
// also remove the user from the active user
|
||||
final apiSettings = ref.read(apiSettingsProvider);
|
||||
if (apiSettings.activeUser == user) {
|
||||
// replace the active user with the first user in the list
|
||||
// or null if there are no users left
|
||||
final newActiveUser = state.isNotEmpty ? state.first : null;
|
||||
ref.read(apiSettingsProvider.notifier).updateState(
|
||||
apiSettings.copyWith(
|
||||
activeUser: null,
|
||||
activeUser: newActiveUser,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,28 +1,30 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'authenticated_user_provider.dart';
|
||||
part of 'authenticated_users_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$authenticatedUserHash() => r'1983527595207c63a12bc84cf0bf1a3c1d729506';
|
||||
String _$authenticatedUsersHash() =>
|
||||
r'5fdd472f62fc3b73ff8417cdce9f02e86c33d00f';
|
||||
|
||||
/// provides with a set of authenticated users
|
||||
///
|
||||
/// Copied from [AuthenticatedUser].
|
||||
@ProviderFor(AuthenticatedUser)
|
||||
final authenticatedUserProvider = AutoDisposeNotifierProvider<AuthenticatedUser,
|
||||
Set<model.AuthenticatedUser>>.internal(
|
||||
AuthenticatedUser.new,
|
||||
name: r'authenticatedUserProvider',
|
||||
/// Copied from [AuthenticatedUsers].
|
||||
@ProviderFor(AuthenticatedUsers)
|
||||
final authenticatedUsersProvider = AutoDisposeNotifierProvider<
|
||||
AuthenticatedUsers, Set<model.AuthenticatedUser>>.internal(
|
||||
AuthenticatedUsers.new,
|
||||
name: r'authenticatedUsersProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$authenticatedUserHash,
|
||||
: _$authenticatedUsersHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AuthenticatedUser = AutoDisposeNotifier<Set<model.AuthenticatedUser>>;
|
||||
typedef _$AuthenticatedUsers
|
||||
= AutoDisposeNotifier<Set<model.AuthenticatedUser>>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:logging/logging.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:vaani/api/authenticated_user_provider.dart';
|
||||
import 'package:vaani/api/authenticated_users_provider.dart';
|
||||
import 'package:vaani/db/storage.dart';
|
||||
import 'package:vaani/settings/api_settings_provider.dart';
|
||||
import 'package:vaani/settings/models/audiobookshelf_server.dart' as model;
|
||||
|
|
@ -88,7 +88,7 @@ class AudiobookShelfServer extends _$AudiobookShelfServer {
|
|||
}
|
||||
// remove the users of this server
|
||||
if (removeUsers) {
|
||||
ref.read(authenticatedUserProvider.notifier).removeUsersOfServer(server);
|
||||
ref.read(authenticatedUsersProvider.notifier).removeUsersOfServer(server);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ part of 'server_provider.dart';
|
|||
// **************************************************************************
|
||||
|
||||
String _$audiobookShelfServerHash() =>
|
||||
r'09e7e37ddc794c45eafbaab7eba82c9dd17faa93';
|
||||
r'31a96b431221965cd586aad670a32ca901539e41';
|
||||
|
||||
/// provides with a set of servers added by the user
|
||||
///
|
||||
|
|
|
|||
|
|
@ -39,6 +39,22 @@ class OnboardingSinglePage extends HookConsumerWidget {
|
|||
}
|
||||
}
|
||||
|
||||
Widget fadeSlideTransitionBuilder(
|
||||
Widget child,
|
||||
Animation<double> animation,
|
||||
) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.3),
|
||||
end: const Offset(0, 0),
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class OnboardingBody extends HookConsumerWidget {
|
||||
const OnboardingBody({
|
||||
super.key,
|
||||
|
|
@ -54,22 +70,6 @@ class OnboardingBody extends HookConsumerWidget {
|
|||
|
||||
final canUserLogin = useState(apiSettings.activeServer != null);
|
||||
|
||||
fadeSlideTransitionBuilder(
|
||||
Widget child,
|
||||
Animation<double> animation,
|
||||
) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.3),
|
||||
end: const Offset(0, 0),
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
|
|
|
|||
|
|
@ -1,33 +1,42 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.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/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/settings/api_settings_provider.dart';
|
||||
import 'package:shelfsdk/audiobookshelf_api.dart' show AuthMethod;
|
||||
import 'package:vaani/api/api_provider.dart' show serverStatusProvider;
|
||||
import 'package:vaani/api/server_provider.dart'
|
||||
show ServerAlreadyExistsException, audiobookShelfServerProvider;
|
||||
import 'package:vaani/features/onboarding/view/onboarding_single_page.dart'
|
||||
show fadeSlideTransitionBuilder;
|
||||
import 'package:vaani/features/onboarding/view/user_login_with_open_id.dart'
|
||||
show UserLoginWithOpenID;
|
||||
import 'package:vaani/features/onboarding/view/user_login_with_password.dart'
|
||||
show UserLoginWithPassword;
|
||||
import 'package:vaani/features/onboarding/view/user_login_with_token.dart'
|
||||
show UserLoginWithToken;
|
||||
import 'package:vaani/hacks/fix_autofill_losing_focus.dart'
|
||||
show InactiveFocusScopeObserver;
|
||||
import 'package:vaani/models/error_response.dart' show ErrorResponseHandler;
|
||||
import 'package:vaani/settings/api_settings_provider.dart'
|
||||
show apiSettingsProvider;
|
||||
import 'package:vaani/settings/models/models.dart' as model;
|
||||
|
||||
class UserLoginWidget extends HookConsumerWidget {
|
||||
UserLoginWidget({
|
||||
const UserLoginWidget({
|
||||
super.key,
|
||||
required this.server,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
final Uri server;
|
||||
final serverStatusError = ErrorResponseHandler();
|
||||
final Function(model.AuthenticatedUser)? onSuccess;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final serverStatusError = useMemoized(() => ErrorResponseHandler(), []);
|
||||
final serverStatus =
|
||||
ref.watch(serverStatusProvider(server, serverStatusError.storeError));
|
||||
|
||||
final api = ref.watch(audiobookshelfApiProvider(server));
|
||||
|
||||
return serverStatus.when(
|
||||
data: (value) {
|
||||
if (value == null) {
|
||||
|
|
@ -42,6 +51,7 @@ class UserLoginWidget extends HookConsumerWidget {
|
|||
openIDAvailable:
|
||||
value.authMethods?.contains(AuthMethod.openid) ?? false,
|
||||
openIDButtonText: value.authFormData?.authOpenIDButtonText,
|
||||
onSuccess: onSuccess,
|
||||
);
|
||||
},
|
||||
loading: () {
|
||||
|
|
@ -88,6 +98,7 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
|
|||
this.openIDAvailable = false,
|
||||
this.onPressed,
|
||||
this.openIDButtonText,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
final Uri server;
|
||||
|
|
@ -95,6 +106,7 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
|
|||
final bool openIDAvailable;
|
||||
final void Function()? onPressed;
|
||||
final String? openIDButtonText;
|
||||
final Function(model.AuthenticatedUser)? onSuccess;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
|
@ -104,8 +116,6 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
|
|||
localAvailable ? AuthMethodChoice.local : AuthMethodChoice.authToken,
|
||||
);
|
||||
|
||||
final apiSettings = ref.watch(apiSettingsProvider);
|
||||
|
||||
model.AudiobookShelfServer addServer() {
|
||||
var newServer = model.AudiobookShelfServer(
|
||||
serverUrl: server,
|
||||
|
|
@ -119,9 +129,9 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
|
|||
newServer = e.server;
|
||||
} finally {
|
||||
ref.read(apiSettingsProvider.notifier).updateState(
|
||||
apiSettings.copyWith(
|
||||
activeServer: newServer,
|
||||
),
|
||||
ref.read(apiSettingsProvider).copyWith(
|
||||
activeServer: newServer,
|
||||
),
|
||||
);
|
||||
}
|
||||
return newServer;
|
||||
|
|
@ -172,26 +182,36 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
|
|||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
].animate(interval: 100.ms).fadeIn(
|
||||
duration: 150.ms,
|
||||
curve: Curves.easeIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: switch (methodChoice.value) {
|
||||
AuthMethodChoice.authToken => UserLoginWithToken(
|
||||
server: server,
|
||||
addServer: addServer,
|
||||
),
|
||||
AuthMethodChoice.local => UserLoginWithPassword(
|
||||
server: server,
|
||||
addServer: addServer,
|
||||
),
|
||||
AuthMethodChoice.openid => UserLoginWithOpenID(
|
||||
server: server,
|
||||
addServer: addServer,
|
||||
openIDButtonText: openIDButtonText,
|
||||
),
|
||||
},
|
||||
child: AnimatedSwitcher(
|
||||
duration: 200.ms,
|
||||
transitionBuilder: fadeSlideTransitionBuilder,
|
||||
child: switch (methodChoice.value) {
|
||||
AuthMethodChoice.authToken => UserLoginWithToken(
|
||||
server: server,
|
||||
addServer: addServer,
|
||||
onSuccess: onSuccess,
|
||||
),
|
||||
AuthMethodChoice.local => UserLoginWithPassword(
|
||||
server: server,
|
||||
addServer: addServer,
|
||||
onSuccess: onSuccess,
|
||||
),
|
||||
AuthMethodChoice.openid => UserLoginWithOpenID(
|
||||
server: server,
|
||||
addServer: addServer,
|
||||
openIDButtonText: openIDButtonText,
|
||||
onSuccess: onSuccess,
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -20,12 +20,14 @@ class UserLoginWithOpenID extends HookConsumerWidget {
|
|||
required this.server,
|
||||
required this.addServer,
|
||||
this.openIDButtonText,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
final Uri server;
|
||||
final model.AudiobookShelfServer Function() addServer;
|
||||
final String? openIDButtonText;
|
||||
final responseErrorHandler = ErrorResponseHandler(name: 'OpenID');
|
||||
final Function(model.AuthenticatedUser)? onSuccess;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ 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/authenticated_users_provider.dart';
|
||||
import 'package:vaani/hacks/fix_autofill_losing_focus.dart';
|
||||
import 'package:vaani/models/error_response.dart';
|
||||
import 'package:vaani/router/router.dart';
|
||||
|
|
@ -18,11 +18,13 @@ class UserLoginWithPassword extends HookConsumerWidget {
|
|||
super.key,
|
||||
required this.server,
|
||||
required this.addServer,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
final Uri server;
|
||||
final model.AudiobookShelfServer Function() addServer;
|
||||
final serverErrorResponse = ErrorResponseHandler();
|
||||
final Function(model.AuthenticatedUser)? onSuccess;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
|
@ -81,13 +83,16 @@ class UserLoginWithPassword extends HookConsumerWidget {
|
|||
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);
|
||||
if (onSuccess != null) {
|
||||
onSuccess!(authenticatedUser);
|
||||
} else {
|
||||
// add the user to the list of users
|
||||
ref
|
||||
.read(authenticatedUsersProvider.notifier)
|
||||
.addUser(authenticatedUser, setActive: true);
|
||||
context.goNamed(Routes.home.name);
|
||||
}
|
||||
}
|
||||
|
||||
return Center(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ 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/api/authenticated_users_provider.dart';
|
||||
import 'package:vaani/models/error_response.dart';
|
||||
import 'package:vaani/router/router.dart';
|
||||
import 'package:vaani/settings/models/models.dart' as model;
|
||||
|
|
@ -14,11 +14,13 @@ class UserLoginWithToken extends HookConsumerWidget {
|
|||
super.key,
|
||||
required this.server,
|
||||
required this.addServer,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
final Uri server;
|
||||
final model.AudiobookShelfServer Function() addServer;
|
||||
final serverErrorResponse = ErrorResponseHandler();
|
||||
final Function(model.AuthenticatedUser)? onSuccess;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
|
@ -65,11 +67,14 @@ class UserLoginWithToken extends HookConsumerWidget {
|
|||
authToken: api.token!,
|
||||
);
|
||||
|
||||
ref
|
||||
.read(authenticatedUserProvider.notifier)
|
||||
.addUser(authenticatedUser, setActive: true);
|
||||
|
||||
context.goNamed(Routes.home.name);
|
||||
if (onSuccess != null) {
|
||||
onSuccess!(authenticatedUser);
|
||||
} else {
|
||||
ref
|
||||
.read(authenticatedUsersProvider.notifier)
|
||||
.addUser(authenticatedUser, setActive: true);
|
||||
context.goNamed(Routes.home.name);
|
||||
}
|
||||
}
|
||||
|
||||
return Form(
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:vaani/features/player/providers/audiobook_player.dart';
|
||||
import 'package:vaani/features/player/providers/currently_playing_provider.dart';
|
||||
import 'package:vaani/features/player/view/player_when_expanded.dart';
|
||||
import 'package:vaani/main.dart';
|
||||
import 'package:vaani/shared/extensions/chapter.dart';
|
||||
import 'package:vaani/shared/extensions/duration_format.dart';
|
||||
import 'package:vaani/shared/hooks.dart';
|
||||
import 'package:vaani/features/player/providers/audiobook_player.dart'
|
||||
show audiobookPlayerProvider;
|
||||
import 'package:vaani/features/player/providers/currently_playing_provider.dart'
|
||||
show currentPlayingChapterProvider, currentlyPlayingBookProvider;
|
||||
import 'package:vaani/features/player/view/player_when_expanded.dart'
|
||||
show pendingPlayerModals;
|
||||
import 'package:vaani/features/player/view/widgets/playing_indicator_icon.dart';
|
||||
import 'package:vaani/main.dart' show appLogger;
|
||||
import 'package:vaani/shared/extensions/chapter.dart' show ChapterDuration;
|
||||
import 'package:vaani/shared/extensions/duration_format.dart'
|
||||
show DurationFormat;
|
||||
import 'package:vaani/shared/hooks.dart' show useTimer;
|
||||
|
||||
class ChapterSelectionButton extends HookConsumerWidget {
|
||||
const ChapterSelectionButton({
|
||||
|
|
@ -67,6 +72,7 @@ class ChapterSelectionModal extends HookConsumerWidget {
|
|||
|
||||
useTimer(scrollToCurrentChapter, 500.ms);
|
||||
// useInterval(scrollToCurrentChapter, 500.ms);
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
|
|
@ -81,24 +87,41 @@ class ChapterSelectionModal extends HookConsumerWidget {
|
|||
child: currentBook?.chapters == null
|
||||
? const Text('No chapters found')
|
||||
: Column(
|
||||
children: [
|
||||
for (final chapter in currentBook!.chapters)
|
||||
ListTile(
|
||||
title: Text(chapter.title),
|
||||
trailing: Text(
|
||||
'(${chapter.duration.smartBinaryFormat})',
|
||||
),
|
||||
selected: currentChapterIndex == chapter.id,
|
||||
key: currentChapterIndex == chapter.id
|
||||
? chapterKey
|
||||
children: currentBook!.chapters.map(
|
||||
(chapter) {
|
||||
final isCurrent = currentChapterIndex == chapter.id;
|
||||
final isPlayed = currentChapterIndex != null &&
|
||||
chapter.id < currentChapterIndex;
|
||||
return ListTile(
|
||||
autofocus: isCurrent,
|
||||
iconColor: isPlayed && !isCurrent
|
||||
? theme.disabledColor
|
||||
: null,
|
||||
title: Text(
|
||||
chapter.title,
|
||||
style: isPlayed && !isCurrent
|
||||
? TextStyle(color: theme.disabledColor)
|
||||
: null,
|
||||
),
|
||||
subtitle: Text(
|
||||
'(${chapter.duration.smartBinaryFormat})',
|
||||
style: isPlayed && !isCurrent
|
||||
? TextStyle(color: theme.disabledColor)
|
||||
: null,
|
||||
),
|
||||
trailing: isCurrent
|
||||
? const PlayingIndicatorIcon()
|
||||
: const Icon(Icons.play_arrow),
|
||||
selected: isCurrent,
|
||||
key: isCurrent ? chapterKey : null,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
notifier.seek(chapter.start + 90.ms);
|
||||
notifier.play();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
194
lib/features/player/view/widgets/playing_indicator_icon.dart
Normal file
194
lib/features/player/view/widgets/playing_indicator_icon.dart
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
|
||||
/// An icon that animates like audio equalizer bars to indicate playback.
|
||||
///
|
||||
/// Creates multiple vertical bars that independently animate their height
|
||||
/// in a looping, visually dynamic pattern.
|
||||
class PlayingIndicatorIcon extends StatefulWidget {
|
||||
/// The number of vertical bars in the indicator.
|
||||
final int barCount;
|
||||
|
||||
/// The total width and height of the icon area.
|
||||
final double size;
|
||||
|
||||
/// The color of the bars. Defaults to the current [IconTheme] color.
|
||||
final Color? color;
|
||||
|
||||
/// The minimum height factor for a bar (relative to [size]).
|
||||
/// When [centerSymmetric] is true, this represents the minimum height
|
||||
/// extending from the center line (so total minimum height is 2 * minHeightFactor * size).
|
||||
/// When false, it's the minimum height from the bottom.
|
||||
final double minHeightFactor;
|
||||
|
||||
/// The maximum height factor for a bar (relative to [size]).
|
||||
/// When [centerSymmetric] is true, this represents the maximum height
|
||||
/// extending from the center line (so total maximum height is 2 * maxHeightFactor * size).
|
||||
/// When false, it's the maximum height from the bottom.
|
||||
final double maxHeightFactor;
|
||||
|
||||
/// Base duration for a full up/down animation cycle for a single bar.
|
||||
/// Actual duration will vary slightly per bar.
|
||||
final Duration baseCycleDuration;
|
||||
|
||||
/// If true, the bars animate symmetrically expanding/collapsing from the
|
||||
/// horizontal center line. If false (default), they expand/collapse from
|
||||
/// the bottom edge.
|
||||
final bool centerSymmetric;
|
||||
|
||||
const PlayingIndicatorIcon({
|
||||
super.key,
|
||||
this.barCount = 4,
|
||||
this.size = 20.0,
|
||||
this.color,
|
||||
this.minHeightFactor = 0.2,
|
||||
this.maxHeightFactor = 1.0,
|
||||
this.baseCycleDuration = const Duration(milliseconds: 350),
|
||||
this.centerSymmetric = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlayingIndicatorIcon> createState() => _PlayingIndicatorIconState();
|
||||
}
|
||||
|
||||
class _PlayingIndicatorIconState extends State<PlayingIndicatorIcon> {
|
||||
late List<_BarAnimationParams> _animationParams;
|
||||
final _random = Random();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationParams =
|
||||
List.generate(widget.barCount, _createRandomParams, growable: false);
|
||||
}
|
||||
|
||||
// Helper to generate random parameters for one bar's animation cycle
|
||||
_BarAnimationParams _createRandomParams(int index) {
|
||||
final duration1 =
|
||||
(widget.baseCycleDuration * (0.8 + _random.nextDouble() * 0.4));
|
||||
final duration2 =
|
||||
(widget.baseCycleDuration * (0.8 + _random.nextDouble() * 0.4));
|
||||
|
||||
// Note: These factors represent the scale relative to the *half-height*
|
||||
// if centerSymmetric is true, controlled by the alignment in scaleY.
|
||||
final targetHeightFactor1 = widget.minHeightFactor +
|
||||
_random.nextDouble() *
|
||||
(widget.maxHeightFactor - widget.minHeightFactor);
|
||||
final targetHeightFactor2 = widget.minHeightFactor +
|
||||
_random.nextDouble() *
|
||||
(widget.maxHeightFactor - widget.minHeightFactor);
|
||||
|
||||
// --- Random initial delay ---
|
||||
final initialDelay =
|
||||
(_random.nextDouble() * (widget.baseCycleDuration.inMilliseconds / 4))
|
||||
.ms;
|
||||
|
||||
return _BarAnimationParams(
|
||||
duration1: duration1,
|
||||
duration2: duration2,
|
||||
targetHeightFactor1: targetHeightFactor1,
|
||||
targetHeightFactor2: targetHeightFactor2,
|
||||
initialDelay: initialDelay,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = widget.color ??
|
||||
IconTheme.of(context).color ??
|
||||
Theme.of(context).colorScheme.primary;
|
||||
|
||||
// --- Bar geometry calculation ---
|
||||
final double totalSpacing = widget.size * 0.2;
|
||||
// Ensure at least 1px spacing if size is very small
|
||||
final double barSpacing = max(1.0, totalSpacing / (widget.barCount + 1));
|
||||
final double availableWidthForBars =
|
||||
widget.size - (barSpacing * (widget.barCount + 1));
|
||||
final double barWidth = max(1.0, availableWidthForBars / widget.barCount);
|
||||
// Max height remains the full size potential for the container
|
||||
final double maxHeight = widget.size;
|
||||
|
||||
// Determine the alignment for scaling based on the symmetric flag
|
||||
final Alignment scaleAlignment =
|
||||
widget.centerSymmetric ? Alignment.center : Alignment.bottomCenter;
|
||||
|
||||
// Determine the cross axis alignment for the Row
|
||||
final CrossAxisAlignment rowAlignment = widget.centerSymmetric
|
||||
? CrossAxisAlignment.center
|
||||
: CrossAxisAlignment.end;
|
||||
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
// Clip ensures bars don't draw outside the SizedBox bounds
|
||||
// especially important for center alignment if maxFactor > 0.5
|
||||
child: ClipRect(
|
||||
child: Row(
|
||||
// Use calculated alignment
|
||||
crossAxisAlignment: rowAlignment,
|
||||
// Use spaceEvenly for better distribution, especially with center alignment
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: List.generate(
|
||||
widget.barCount,
|
||||
(index) {
|
||||
final params = _animationParams[index];
|
||||
// The actual bar widget that will be animated
|
||||
return Container(
|
||||
width: barWidth,
|
||||
// Set initial height to the max potential height
|
||||
// The scaleY animation will control the visible height
|
||||
height: maxHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(barWidth / 2),
|
||||
),
|
||||
)
|
||||
.animate(
|
||||
delay: params.initialDelay,
|
||||
onPlay: (controller) => controller.repeat(
|
||||
reverse: true,
|
||||
),
|
||||
)
|
||||
// 1. Scale to targetHeightFactor1
|
||||
.scaleY(
|
||||
begin:
|
||||
widget.minHeightFactor, // Scale factor starts near min
|
||||
end: params.targetHeightFactor1,
|
||||
duration: params.duration1,
|
||||
curve: Curves.easeInOutCirc,
|
||||
alignment: scaleAlignment, // Apply chosen alignment
|
||||
)
|
||||
// 2. Then scale to targetHeightFactor2
|
||||
.then()
|
||||
.scaleY(
|
||||
end: params.targetHeightFactor2,
|
||||
duration: params.duration2,
|
||||
curve: Curves.easeInOutCirc,
|
||||
alignment: scaleAlignment, // Apply chosen alignment
|
||||
);
|
||||
},
|
||||
growable: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper class: Renamed height fields for clarity
|
||||
class _BarAnimationParams {
|
||||
final Duration duration1;
|
||||
final Duration duration2;
|
||||
final double targetHeightFactor1; // Factor relative to total size
|
||||
final double targetHeightFactor2; // Factor relative to total size
|
||||
final Duration initialDelay;
|
||||
|
||||
_BarAnimationParams({
|
||||
required this.duration1,
|
||||
required this.duration2,
|
||||
required this.targetHeightFactor1,
|
||||
required this.targetHeightFactor2,
|
||||
required this.initialDelay,
|
||||
});
|
||||
}
|
||||
|
|
@ -2,17 +2,22 @@ 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/api/authenticated_user_provider.dart';
|
||||
import 'package:vaani/api/server_provider.dart';
|
||||
import 'package:vaani/features/player/view/mini_player_bottom_padding.dart';
|
||||
import 'package:vaani/main.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/api/api_provider.dart' show makeBaseUrl;
|
||||
import 'package:vaani/api/authenticated_users_provider.dart'
|
||||
show authenticatedUsersProvider;
|
||||
import 'package:vaani/api/server_provider.dart'
|
||||
show ServerAlreadyExistsException, audiobookShelfServerProvider;
|
||||
import 'package:vaani/features/onboarding/view/user_login.dart'
|
||||
show UserLoginWidget;
|
||||
import 'package:vaani/features/player/view/mini_player_bottom_padding.dart'
|
||||
show MiniPlayerBottomPadding;
|
||||
import 'package:vaani/main.dart' show appLogger;
|
||||
import 'package:vaani/router/router.dart' show Routes;
|
||||
import 'package:vaani/settings/api_settings_provider.dart'
|
||||
show apiSettingsProvider;
|
||||
import 'package:vaani/settings/models/models.dart' as model;
|
||||
import 'package:vaani/shared/extensions/obfuscation.dart';
|
||||
import 'package:vaani/shared/widgets/add_new_server.dart';
|
||||
import 'package:vaani/shared/extensions/obfuscation.dart' show ObfuscateSet;
|
||||
import 'package:vaani/shared/widgets/add_new_server.dart' show AddNewServer;
|
||||
|
||||
class ServerManagerPage extends HookConsumerWidget {
|
||||
const ServerManagerPage({
|
||||
|
|
@ -21,15 +26,6 @@ class ServerManagerPage extends HookConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final apiSettings = ref.watch(apiSettingsProvider);
|
||||
final registeredServers = ref.watch(audiobookShelfServerProvider);
|
||||
final registeredServersAsList = registeredServers.toList();
|
||||
final availableUsers = ref.watch(authenticatedUserProvider);
|
||||
final serverURIController = useTextEditingController();
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
appLogger.fine('registered servers: ${registeredServers.obfuscate()}');
|
||||
appLogger.fine('available users: ${availableUsers.obfuscate()}');
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Manage Accounts'),
|
||||
|
|
@ -37,241 +33,119 @@ class ServerManagerPage extends HookConsumerWidget {
|
|||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
// crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
const Text(
|
||||
'Registered Servers',
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: registeredServers.length,
|
||||
reverse: true,
|
||||
itemBuilder: (context, index) {
|
||||
var registeredServer = registeredServersAsList[index];
|
||||
return ExpansionTile(
|
||||
title: Text(registeredServer.serverUrl.toString()),
|
||||
subtitle: Text(
|
||||
'Users: ${availableUsers.where((element) => element.server == registeredServer).length}',
|
||||
),
|
||||
// trailing: _DeleteServerButton(
|
||||
// registeredServer: registeredServer,
|
||||
// ),
|
||||
// children are list of users of this server
|
||||
children: availableUsers
|
||||
.where(
|
||||
(element) => element.server == registeredServer,
|
||||
)
|
||||
.map(
|
||||
(e) => ListTile(
|
||||
selected: apiSettings.activeUser == e,
|
||||
leading: apiSettings.activeUser == e
|
||||
? const Icon(Icons.person)
|
||||
: const Icon(Icons.person_off_outlined),
|
||||
title: Text(e.username ?? 'Anonymous'),
|
||||
onTap: apiSettings.activeUser == e
|
||||
? null
|
||||
: () {
|
||||
ref
|
||||
.read(apiSettingsProvider.notifier)
|
||||
.updateState(
|
||||
apiSettings.copyWith(
|
||||
activeUser: e,
|
||||
),
|
||||
);
|
||||
// pop all routes and go to the home page
|
||||
// while (context.canPop()) {
|
||||
// context.pop();
|
||||
// }
|
||||
context.goNamed(
|
||||
Routes.home.name,
|
||||
);
|
||||
},
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Delete User'),
|
||||
content: const Text(
|
||||
'Are you sure you want to delete this user?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
authenticatedUserProvider
|
||||
.notifier,
|
||||
)
|
||||
.removeUser(e);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
.nonNulls
|
||||
.toList()
|
||||
|
||||
// add buttons of delete server and add user to server at the end
|
||||
..addAll([
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_add),
|
||||
title: const Text('Add User'),
|
||||
onTap: () async {
|
||||
// open a dialog to add a new user with username and password or another method using only auth token
|
||||
final addedUser = await showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return _AddUserDialog(
|
||||
server: registeredServer,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// if (addedUser != null) {
|
||||
// // show a snackbar that the user has been added and ask if change to this user
|
||||
// ScaffoldMessenger.of(context).showSnackBar(
|
||||
// SnackBar(
|
||||
// content: const Text(
|
||||
// 'User added successfully, do you want to switch to this user?',
|
||||
// ),
|
||||
// action: SnackBarAction(
|
||||
// label: 'Switch',
|
||||
// onPressed: () {
|
||||
// // set the active user
|
||||
// ref
|
||||
// .read(apiSettingsProvider.notifier)
|
||||
// .updateState(
|
||||
// apiSettings.copyWith(
|
||||
// activeUser: addedUser,
|
||||
// ),
|
||||
// );
|
||||
|
||||
// context.goNamed(Routes.home.name);
|
||||
// },
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete),
|
||||
title: const Text('Delete Server'),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Delete Server'),
|
||||
content: const Text(
|
||||
'Are you sure you want to delete this server and all its users?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
audiobookShelfServerProvider
|
||||
.notifier,
|
||||
)
|
||||
.removeServer(
|
||||
registeredServer,
|
||||
removeUsers: true,
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Text('Add New Server'),
|
||||
),
|
||||
Form(
|
||||
key: formKey,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
child: AddNewServer(
|
||||
controller: serverURIController,
|
||||
onPressed: () {
|
||||
if (formKey.currentState!.validate()) {
|
||||
try {
|
||||
final newServer = model.AudiobookShelfServer(
|
||||
serverUrl: makeBaseUrl(serverURIController.text),
|
||||
);
|
||||
ref
|
||||
.read(audiobookShelfServerProvider.notifier)
|
||||
.addServer(
|
||||
newServer,
|
||||
);
|
||||
ref.read(apiSettingsProvider.notifier).updateState(
|
||||
apiSettings.copyWith(
|
||||
activeServer: newServer,
|
||||
),
|
||||
);
|
||||
serverURIController.clear();
|
||||
} on ServerAlreadyExistsException catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.toString()),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Invalid URL'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
MiniPlayerBottomPadding(),
|
||||
],
|
||||
),
|
||||
child: ServerManagerBody(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddUserDialog extends HookConsumerWidget {
|
||||
const _AddUserDialog({
|
||||
class ServerManagerBody extends HookConsumerWidget {
|
||||
const ServerManagerBody({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final registeredServers = ref.watch(audiobookShelfServerProvider);
|
||||
final registeredServersAsList = registeredServers.toList();
|
||||
final availableUsers = ref.watch(authenticatedUsersProvider);
|
||||
final apiSettings = ref.watch(apiSettingsProvider);
|
||||
final serverURIController = useTextEditingController();
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
appLogger.fine('registered servers: ${registeredServers.obfuscate()}');
|
||||
appLogger.fine('available users: ${availableUsers.obfuscate()}');
|
||||
|
||||
return Column(
|
||||
// crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
const Text(
|
||||
'Registered Servers',
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: registeredServers.length,
|
||||
reverse: true,
|
||||
itemBuilder: (context, index) {
|
||||
var registeredServer = registeredServersAsList[index];
|
||||
return ExpansionTile(
|
||||
title: Text(registeredServer.serverUrl.toString()),
|
||||
subtitle: Text(
|
||||
'Users: ${availableUsers.where((element) => element.server == registeredServer).length}',
|
||||
),
|
||||
// children are list of users of this server
|
||||
children: availableUsers
|
||||
.where(
|
||||
(element) => element.server == registeredServer,
|
||||
)
|
||||
.map<Widget>(
|
||||
(e) => AvailableUserTile(user: e),
|
||||
)
|
||||
.nonNulls
|
||||
.toList()
|
||||
|
||||
// add buttons of delete server and add user to server at the end
|
||||
..addAll([
|
||||
AddUserTile(server: registeredServer),
|
||||
DeleteServerTile(server: registeredServer),
|
||||
]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Text('Add New Server'),
|
||||
),
|
||||
Form(
|
||||
key: formKey,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
child: AddNewServer(
|
||||
controller: serverURIController,
|
||||
onPressed: () {
|
||||
if (formKey.currentState!.validate()) {
|
||||
try {
|
||||
final newServer = model.AudiobookShelfServer(
|
||||
serverUrl: makeBaseUrl(serverURIController.text),
|
||||
);
|
||||
ref.read(audiobookShelfServerProvider.notifier).addServer(
|
||||
newServer,
|
||||
);
|
||||
ref.read(apiSettingsProvider.notifier).updateState(
|
||||
apiSettings.copyWith(
|
||||
activeServer: newServer,
|
||||
),
|
||||
);
|
||||
serverURIController.clear();
|
||||
} on ServerAlreadyExistsException catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.toString()),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Invalid URL'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
MiniPlayerBottomPadding(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeleteServerTile extends HookConsumerWidget {
|
||||
const DeleteServerTile({
|
||||
super.key,
|
||||
required this.server,
|
||||
});
|
||||
|
||||
|
|
@ -279,178 +153,220 @@ class _AddUserDialog extends HookConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final usernameController = useTextEditingController();
|
||||
final passwordController = useTextEditingController();
|
||||
final authTokensController = useTextEditingController();
|
||||
final isPasswordVisible = useState(false);
|
||||
final apiSettings = ref.watch(apiSettingsProvider);
|
||||
final isMethodAuth = useState(false);
|
||||
final api = ref.watch(audiobookshelfApiProvider(server.serverUrl));
|
||||
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
final serverErrorResponse = ErrorResponseHandler();
|
||||
|
||||
/// Login to the server and save the user
|
||||
Future<model.AuthenticatedUser?> loginAndSave() async {
|
||||
model.AuthenticatedUser? authenticatedUser;
|
||||
if (isMethodAuth.value) {
|
||||
api.token = authTokensController.text;
|
||||
final success = await api.misc.authorize(
|
||||
responseErrorHandler: serverErrorResponse.storeError,
|
||||
);
|
||||
if (success != null) {
|
||||
authenticatedUser = model.AuthenticatedUser(
|
||||
server: server,
|
||||
id: success.user.id,
|
||||
username: success.user.username,
|
||||
authToken: api.token!,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
final username = usernameController.text;
|
||||
final password = passwordController.text;
|
||||
final success = await api.login(
|
||||
username: username,
|
||||
password: password,
|
||||
responseErrorHandler: serverErrorResponse.storeError,
|
||||
);
|
||||
if (success != null) {
|
||||
authenticatedUser = model.AuthenticatedUser(
|
||||
server: server,
|
||||
id: success.user.id,
|
||||
username: username,
|
||||
authToken: api.token!,
|
||||
);
|
||||
}
|
||||
}
|
||||
// add the user to the list of users
|
||||
if (authenticatedUser != null) {
|
||||
ref.read(authenticatedUserProvider.notifier).addUser(authenticatedUser);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Login failed. Got response: ${serverErrorResponse.response.body} (${serverErrorResponse.response.statusCode})',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return authenticatedUser;
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
// title: Text('Add User for ${server.serverUrl}'),
|
||||
title: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'Add User for ',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
TextSpan(
|
||||
text: server.serverUrl.toString(),
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.delete),
|
||||
title: const Text('Delete Server'),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Remove Server and Users'),
|
||||
// Make content scrollable in case of smaller screens/keyboard
|
||||
content: SingleChildScrollView(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
const TextSpan(
|
||||
text: 'This will remove the server ',
|
||||
),
|
||||
TextSpan(
|
||||
text: server.serverUrl.host,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const TextSpan(
|
||||
text: ' and all its users\' login info from this app.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 8.0,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('Username/Password'),
|
||||
selected: !isMethodAuth.value,
|
||||
onSelected: (selected) {
|
||||
isMethodAuth.value = !selected;
|
||||
},
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text('Auth Token'),
|
||||
selected: isMethodAuth.value,
|
||||
onSelected: (selected) {
|
||||
isMethodAuth.value = selected;
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
audiobookShelfServerProvider.notifier,
|
||||
)
|
||||
.removeServer(
|
||||
server,
|
||||
removeUsers: true,
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (isMethodAuth.value)
|
||||
TextFormField(
|
||||
controller: authTokensController,
|
||||
decoration: const InputDecoration(labelText: 'Auth Token'),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter an auth token';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
)
|
||||
else ...[
|
||||
TextFormField(
|
||||
controller: usernameController,
|
||||
decoration: const InputDecoration(labelText: 'Username'),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a username';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextFormField(
|
||||
controller: passwordController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
isPasswordVisible.value
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
),
|
||||
onPressed: () {
|
||||
isPasswordVisible.value = !isPasswordVisible.value;
|
||||
},
|
||||
),
|
||||
),
|
||||
obscureText: !isPasswordVisible.value,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a password';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
);
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (formKey.currentState!.validate()) {
|
||||
final addedUser = await loginAndSave();
|
||||
if (addedUser != null) {
|
||||
Navigator.of(context).pop(addedUser);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Add User'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AddUserTile extends HookConsumerWidget {
|
||||
const AddUserTile({
|
||||
super.key,
|
||||
required this.server,
|
||||
});
|
||||
|
||||
final model.AudiobookShelfServer server;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.person_add),
|
||||
title: const Text('Add User'),
|
||||
onTap: () async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
// barrierDismissible: false, // Optional: prevent closing by tapping outside
|
||||
builder: (dialogContext) {
|
||||
// Use a different context name to avoid conflicts
|
||||
return AlertDialog(
|
||||
title: Text('Add User to ${server.serverUrl.host}'),
|
||||
// Make content scrollable in case of smaller screens/keyboard
|
||||
content: SingleChildScrollView(
|
||||
child: UserLoginWidget(
|
||||
server: server.serverUrl,
|
||||
// Pass the callback to pop the dialog on success
|
||||
onSuccess: (user) {
|
||||
// Add the user to the server
|
||||
ref.read(authenticatedUsersProvider.notifier).addUser(user);
|
||||
Navigator.of(dialogContext).pop(); // Close the dialog
|
||||
// Optional: Show a confirmation SnackBar
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('User added successfully! Switch?'),
|
||||
action: SnackBarAction(
|
||||
label: 'Switch',
|
||||
onPressed: () {
|
||||
// Switch to the new user
|
||||
ref.read(apiSettingsProvider.notifier).updateState(
|
||||
ref.read(apiSettingsProvider).copyWith(
|
||||
activeUser: user,
|
||||
),
|
||||
);
|
||||
context.goNamed(Routes.home.name);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop(); // Close the dialog
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
// No need for the SnackBar asking to switch user here anymore.
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AvailableUserTile extends HookConsumerWidget {
|
||||
const AvailableUserTile({
|
||||
super.key,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
final model.AuthenticatedUser user;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final apiSettings = ref.watch(apiSettingsProvider);
|
||||
|
||||
return ListTile(
|
||||
selected: apiSettings.activeUser == user,
|
||||
leading: apiSettings.activeUser == user
|
||||
? const Icon(Icons.person)
|
||||
: const Icon(Icons.person_off_outlined),
|
||||
title: Text(user.username ?? 'Anonymous'),
|
||||
onTap: apiSettings.activeUser == user
|
||||
? null
|
||||
: () {
|
||||
ref.read(apiSettingsProvider.notifier).updateState(
|
||||
apiSettings.copyWith(
|
||||
activeUser: user,
|
||||
),
|
||||
);
|
||||
// pop all routes and go to the home page
|
||||
// while (context.canPop()) {
|
||||
// context.pop();
|
||||
// }
|
||||
context.goNamed(
|
||||
Routes.home.name,
|
||||
);
|
||||
},
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Remove User Login'),
|
||||
content: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
const TextSpan(
|
||||
text: 'This will remove login details of the user ',
|
||||
),
|
||||
TextSpan(
|
||||
text: user.username ?? 'Anonymous',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const TextSpan(
|
||||
text: ' from this app.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
authenticatedUsersProvider.notifier,
|
||||
)
|
||||
.removeUser(user);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,6 +183,10 @@ class UserBar extends HookConsumerWidget {
|
|||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final me = ref.watch(meProvider);
|
||||
final api = ref.watch(authenticatedApiProvider);
|
||||
|
||||
final themeData = Theme.of(context);
|
||||
final textTheme = themeData.textTheme;
|
||||
|
||||
return me.when(
|
||||
data: (userData) {
|
||||
|
|
@ -194,19 +198,30 @@ class UserBar extends HookConsumerWidget {
|
|||
// first letter of the username
|
||||
child: Text(
|
||||
userData.username[0].toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
style: textTheme.headlineLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
userData.username,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
userData.username,
|
||||
style: textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
api.baseUrl.toString(),
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color:
|
||||
themeData.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
|||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.0.17+8
|
||||
version: 0.0.18+9
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.4 <4.0.0"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue