Skip to content

Commit

Permalink
Add option for quick connect logins
Browse files Browse the repository at this point in the history
This change changes the login flow to first request the uri of the server, then the user selects login with password or quick connect. At that point, the existing password login flow takes over, or a new one that gets a quick connect code is used.

As part of this change, the auth information stored by the system has been changed. Previously the password had been stored. This is probably not needed (at least basic usage shows it doesn't affect anything) since there's a refresh token there. Since both login flows provide a refresh token, we can store that instead.
  • Loading branch information
twsouthwick committed Nov 10, 2024
1 parent e370982 commit 12fc38e
Show file tree
Hide file tree
Showing 9 changed files with 592 additions and 202 deletions.
2 changes: 2 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@
"addProfile": "Add Profile",
"appSubtitle": "Another Jellyfin client",
"serverAddress": "Server Address",
"loginWithPassword": "Use Password",
"loginWithQuickConnect": "Use QuickConnect",
"login": "Login",
"username": "Username",
"password": "Password",
Expand Down
5 changes: 5 additions & 0 deletions lib/models/screen_paths.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,8 @@ class ScreenPaths {
static const String offlinePlayer = '/offlinePlayer';
static const String loading = '/loading';
}

class LoginRouteNames {
static const String password = 'password';
static const String quickConnect = 'quickConnect';
}
22 changes: 22 additions & 0 deletions lib/navigation/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import 'package:jellyflix/screens/download_screen.dart';
import 'package:jellyflix/screens/home_screen.dart';
import 'package:jellyflix/screens/library_screen.dart';
import 'package:jellyflix/screens/loading_screen.dart';
import 'package:jellyflix/screens/login_password.dart';
import 'package:jellyflix/screens/login_quickconnect.dart';
import 'package:jellyflix/screens/login_wrapper_screen.dart';
import 'package:jellyflix/screens/offline_player_screen.dart';
import 'package:jellyflix/screens/profile_screen.dart';
Expand Down Expand Up @@ -138,6 +140,26 @@ class AppRouter {
child: const LoginWrapperScreen(),
),
),
GoRoute(
path: '${ScreenPaths.login}/:server/${LoginRouteNames.password}',
name: LoginRouteNames.password,
pageBuilder: (context, state) => buildPageWithDefaultTransition(
context: context,
state: state,
maintainState: false,
child: LoginWithPasswordScreen(serverAddress: state.pathParameters['server']!),
),
),
GoRoute(
path: '${ScreenPaths.login}/:server/${LoginRouteNames.quickConnect}',
name: LoginRouteNames.quickConnect,
pageBuilder: (context, state) => buildPageWithDefaultTransition(
context: context,
state: state,
maintainState: false,
child: LoginWithQuickConnectScreen(serverAddress: state.pathParameters['server']!),
),
),
],
errorBuilder: (context, state) {
//TODO Add 404 screen
Expand Down
86 changes: 86 additions & 0 deletions lib/screens/login_messages.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

Future<void> showErrorDialog(
BuildContext context,
Object e,
) {
return showInfoDialog(
context,
Text(
AppLocalizations.of(context)!.errorConnectingToServer,
),
content: Text(e.toString())
);
}

Future<void> showHttpErrorDialog(
BuildContext context,
DioException e,
) {
return showInfoDialog(
context,
Text(
AppLocalizations.of(context)!.errorConnectingToServer,
),
content: e.response?.statusCode == null
? Text(e.toString())
: Text(_formatHttpErrorCode(e.response)),
);
}

Future<void> showInfoDialog(
BuildContext context,
Widget title, {
Widget? content,
}) async {
await showDialog(
context: context,
builder: (context) => CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
const SingleActivator(LogicalKeyboardKey.enter): () {
Navigator.pop(context);
}
},
child: FocusScope(
autofocus: true,
child: AlertDialog(
title: title,
content: content,
actions: [
ElevatedButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text('Ok'),
)
],
),
),
),
);
}

String _formatHttpErrorCode(Response? resp) {
// todo PLACEHOLDER MESSAGES NOT FINAL
var message = '';
switch (resp!.statusCode) {
case 400:
message =
'The server could not understand the request, if you are using proxies check the configuration, if the issue still persists let us know';
case 401:
message = 'Your username or password may be incorrect';
case 403:
message =
'The server is blocking request from this device, this probably means the device has been banned, please contact your admin to resolve this issue';
default:
message = '';
}

return '$message\n\n'
'Http Code: ${resp.statusCode ?? 'Unknown'}\n\n'
'Http Response: ${resp.statusMessage ?? 'Unknown'}\n\n'
.trim();
}
198 changes: 198 additions & 0 deletions lib/screens/login_password.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:jellyflix/models/screen_paths.dart';
import 'package:jellyflix/models/user.dart';
import 'package:jellyflix/providers/auth_provider.dart';
import 'package:jellyflix/screens/login_messages.dart';

class LoginWithPasswordScreen extends HookConsumerWidget {
final String serverAddress;
const LoginWithPasswordScreen({super.key, required this.serverAddress});

@override
Widget build(BuildContext context, WidgetRef ref) {
final userName = useTextEditingController();
final password = useTextEditingController();

final loadingListenable = useValueNotifier<bool>(false);

return Scaffold(
appBar: AppBar(),
body: CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
const SingleActivator(LogicalKeyboardKey.enter): () async {
await login(
context,
ref,
loadingListenable,
username: userName.text,
serverAddress: serverAddress,
password: password.text,
);
}
},
child: FocusScope(
// needed for enter shortcut to work
autofocus: true,
child: Center(
child: SingleChildScrollView(
child: SizedBox(
width: 400,
child: Padding(
padding: const EdgeInsets.all(20.0),
child: AutofillGroup(
child: Column(
children: [
Text(AppLocalizations.of(context)!.appName,
style: Theme.of(context).textTheme.displaySmall),
Text(
AppLocalizations.of(context)!.appSubtitle,
),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(vertical: 10.0),
child: Text(
serverAddress,
textAlign: TextAlign.left,
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 10.0),
child: TextField(
controller: userName,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.username,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 10.0),
child: TextField(
obscureText: true,
autofillHints: const [AutofillHints.password],
textInputAction: TextInputAction.go,
controller: password,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.password,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 10.0),
child: SizedBox(
height: 45,
width: 100,
child: ValueListenableBuilder(
valueListenable: loadingListenable,
builder: (context, isLoading, _) {
return isLoading
? const Center(
child: CircularProgressIndicator())
: FilledButton(
onPressed: () async => await login(
context,
ref,
loadingListenable,
username: userName.text,
serverAddress: serverAddress,
password: password.text,
),
child: Text(
AppLocalizations.of(context)!
.login,
),
);
}),
)),
kIsWeb
? Text(AppLocalizations.of(context)!.webDemoNote)
: const SizedBox(),
],
),
),
),
),
),
),
),
),
);
}

Future<void> login(
BuildContext context,
WidgetRef ref,
ValueNotifier<bool> loadingListenable, {
required String serverAddress,
required String username,
required String password,
}) async {
loadingListenable.value = true;
try {
final missingFields = formatMissingFields(
context,
username,
serverAddress,
);
if (missingFields.isNotEmpty) {
loadingListenable.value = false;
await showInfoDialog(
context,
Text(
AppLocalizations.of(context)!.emptyFields,
),
content: Text(missingFields),
);

return;
}

User user = User(
name: username,
password: password,
serverAdress: serverAddress,
);
await ref.read(authProvider).login(user);
loadingListenable.value = false;
if (context.mounted) {
context.go(ScreenPaths.home);
}
} on DioException catch (e) {
if (!context.mounted) return;
loadingListenable.value = false;
await showHttpErrorDialog(context, e);
return;
} catch (e) {
if (!context.mounted) return;
loadingListenable.value = false;
await showErrorDialog(context, e);
return;
}
loadingListenable.value = false;
}
}

String formatMissingFields(
BuildContext context,
String username,
String serverAddress,
) {
var missingFields = '';
if (username.isEmpty) {
missingFields += '${AppLocalizations.of(context)!.emptyUsername}\n\n';
}
if (serverAddress.isEmpty) {
missingFields += '${AppLocalizations.of(context)!.emptyAddress}\n\n';
}

return missingFields;
}
Loading

0 comments on commit 12fc38e

Please sign in to comment.