mirror of
https://github.com/gabehf/Fladder.git
synced 2026-03-13 01:10:31 -07:00
feat: Android TV support (#503)
Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
parent
7ab8c015b9
commit
c299492d6d
168 changed files with 12019 additions and 3073 deletions
0
lib/screens/login/controllers/login_controller.dart
Normal file
0
lib/screens/login/controllers/login_controller.dart
Normal file
140
lib/screens/login/login_code_dialog.dart
Normal file
140
lib/screens/login/login_code_dialog.dart
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
|
||||
import 'package:fladder/providers/api_provider.dart';
|
||||
import 'package:fladder/providers/auth_provider.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
|
||||
Future<void> openLoginCodeDialog(
|
||||
BuildContext context, {
|
||||
required QuickConnectResult quickConnectInfo,
|
||||
required Function(BuildContext context, String secret) onAuthenticated,
|
||||
}) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => LoginCodeDialog(
|
||||
quickConnectInfo: quickConnectInfo,
|
||||
onAuthenticated: onAuthenticated,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class LoginCodeDialog extends ConsumerStatefulWidget {
|
||||
final QuickConnectResult quickConnectInfo;
|
||||
final Function(BuildContext context, String secret) onAuthenticated;
|
||||
const LoginCodeDialog({
|
||||
required this.quickConnectInfo,
|
||||
required this.onAuthenticated,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginCodeDialog> createState() => _LoginCodeDialogState();
|
||||
}
|
||||
|
||||
class _LoginCodeDialogState extends ConsumerState<LoginCodeDialog> {
|
||||
late QuickConnectResult quickConnectInfo = widget.quickConnectInfo;
|
||||
|
||||
RestartableTimer? timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
createTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void createTimer() {
|
||||
timer?.cancel();
|
||||
timer = RestartableTimer(const Duration(seconds: 1), () async {
|
||||
final result = await ref.read(jellyApiProvider).quickConnectConnectGet(
|
||||
secret: quickConnectInfo.secret,
|
||||
);
|
||||
final newSecret = result.body?.secret;
|
||||
if (result.isSuccessful && result.body?.authenticated == true && newSecret != null) {
|
||||
widget.onAuthenticated.call(context, newSecret);
|
||||
} else {
|
||||
timer?.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final code = quickConnectInfo.code;
|
||||
final serverName = ref.watch(authProvider.select((value) => value.serverLoginModel?.tempCredentials.serverName));
|
||||
return Dialog(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 16,
|
||||
children: [
|
||||
Text(
|
||||
serverName?.isNotEmpty == true
|
||||
? "${context.localized.quickConnectTitle} - $serverName"
|
||||
: context.localized.quickConnectTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const Divider(),
|
||||
ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
if (code != null) ...[
|
||||
Text(
|
||||
context.localized.quickConnectEnterCodeDescription,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
IntrinsicWidth(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Text(
|
||||
code,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
wordSpacing: 8,
|
||||
letterSpacing: 8,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
semanticsLabel: code,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final response = await ref.read(jellyApiProvider).quickConnectInitiate();
|
||||
if (response.isSuccessful && response.body != null) {
|
||||
setState(() {
|
||||
quickConnectInfo = response.body!;
|
||||
});
|
||||
createTimer();
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
context.localized.refresh,
|
||||
),
|
||||
)
|
||||
].addInBetween(const SizedBox(height: 16)),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
|
|
@ -7,25 +5,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import 'package:iconsax_plus/iconsax_plus.dart';
|
||||
|
||||
import 'package:fladder/models/account_model.dart';
|
||||
import 'package:fladder/models/login_screen_model.dart';
|
||||
import 'package:fladder/providers/auth_provider.dart';
|
||||
import 'package:fladder/providers/shared_provider.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/routes/auto_router.gr.dart';
|
||||
import 'package:fladder/screens/login/lock_screen.dart';
|
||||
import 'package:fladder/screens/login/login_edit_user.dart';
|
||||
import 'package:fladder/screens/login/login_screen_credentials.dart';
|
||||
import 'package:fladder/screens/login/login_user_grid.dart';
|
||||
import 'package:fladder/screens/login/widgets/discover_servers_widget.dart';
|
||||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:fladder/screens/shared/fladder_logo.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/screens/shared/outlined_text_field.dart';
|
||||
import 'package:fladder/screens/shared/passcode_input.dart';
|
||||
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
||||
import 'package:fladder/util/auth_service.dart';
|
||||
import 'package:fladder/util/fladder_config.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/fladder_app_bar.dart';
|
||||
|
||||
@RoutePage()
|
||||
|
|
@ -37,373 +24,85 @@ class LoginScreen extends ConsumerStatefulWidget {
|
|||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginScreen> {
|
||||
List<AccountModel> users = const [];
|
||||
bool loading = false;
|
||||
String? invalidUrl = "";
|
||||
bool startCheckingForErrors = false;
|
||||
bool addingNewUser = false;
|
||||
bool editingUsers = false;
|
||||
late final TextEditingController serverTextController = TextEditingController(text: '');
|
||||
|
||||
final usernameController = TextEditingController();
|
||||
final passwordController = TextEditingController();
|
||||
final FocusNode focusNode = FocusNode();
|
||||
|
||||
void startAddingNewUser() {
|
||||
setState(() {
|
||||
addingNewUser = true;
|
||||
editingUsers = false;
|
||||
});
|
||||
if (FladderConfig.baseUrl != null) {
|
||||
serverTextController.text = FladderConfig.baseUrl!;
|
||||
_parseUrl(FladderConfig.baseUrl!);
|
||||
retrieveListOfUsers();
|
||||
}
|
||||
}
|
||||
bool editUsersMode = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() {
|
||||
ref.read(userProvider.notifier).clear();
|
||||
final currentAccounts = ref.read(authProvider.notifier).getSavedAccounts();
|
||||
addingNewUser = currentAccounts.isEmpty;
|
||||
ref.read(lockScreenActiveProvider.notifier).update((state) => true);
|
||||
if (FladderConfig.baseUrl != null) {
|
||||
serverTextController.text = FladderConfig.baseUrl!;
|
||||
_parseUrl(FladderConfig.baseUrl!);
|
||||
retrieveListOfUsers();
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(authProvider.notifier).initModel(context);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loggedInUsers = ref.watch(authProvider.select((value) => value.accounts));
|
||||
final authLoading = ref.watch(authProvider.select((value) => value.loading));
|
||||
final screen = ref.watch(authProvider.select((value) => value.screen));
|
||||
final accounts = ref.watch(authProvider.select((value) => value.accounts));
|
||||
return Scaffold(
|
||||
appBar: const FladderAppBar(),
|
||||
floatingActionButton: !addingNewUser
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (!AdaptiveLayout.of(context).isDesktop)
|
||||
FloatingActionButton(
|
||||
key: const Key("edit_button"),
|
||||
heroTag: "edit_button",
|
||||
backgroundColor: editingUsers ? Theme.of(context).colorScheme.errorContainer : null,
|
||||
child: const Icon(IconsaxPlusLinear.edit_2),
|
||||
onPressed: () => setState(() => editingUsers = !editingUsers),
|
||||
),
|
||||
FloatingActionButton(
|
||||
key: const Key("new_button"),
|
||||
heroTag: "new_button",
|
||||
child: const Icon(IconsaxPlusLinear.add_square),
|
||||
onPressed: startAddingNewUser,
|
||||
),
|
||||
].addInBetween(const SizedBox(width: 16)),
|
||||
)
|
||||
: null,
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 900),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
floatingActionButton: switch (screen) {
|
||||
LoginScreenType.users => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
spacing: 16,
|
||||
children: [
|
||||
const Center(
|
||||
child: FladderLogo(),
|
||||
if (!AdaptiveLayout.of(context).isDesktop)
|
||||
FloatingActionButton(
|
||||
key: const Key("edit_user_button"),
|
||||
heroTag: "edit_user_button",
|
||||
backgroundColor: editUsersMode ? Theme.of(context).colorScheme.errorContainer : null,
|
||||
child: const Icon(IconsaxPlusLinear.edit_2),
|
||||
onPressed: () => setState(() => editUsersMode = !editUsersMode),
|
||||
),
|
||||
FloatingActionButton(
|
||||
key: const Key("new_user_button"),
|
||||
heroTag: "new_user_button",
|
||||
child: const Icon(IconsaxPlusLinear.add_square),
|
||||
onPressed: () => ref.read(authProvider.notifier).addNewUser(),
|
||||
),
|
||||
AnimatedFadeSize(
|
||||
child: addingNewUser
|
||||
? addUserFields(loggedInUsers, authLoading)
|
||||
: Column(
|
||||
key: UniqueKey(),
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
LoginUserGrid(
|
||||
users: loggedInUsers,
|
||||
editMode: editingUsers,
|
||||
onPressed: (user) async => tapLoggedInAccount(user),
|
||||
onLongPress: (user) => openUserEditDialogue(context, user),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
].addPadding(const EdgeInsets.symmetric(vertical: 16)),
|
||||
],
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
body: Center(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: MediaQuery.paddingOf(context).add(const EdgeInsetsGeometry.all(16)),
|
||||
children: [
|
||||
const FladderLogo(),
|
||||
const SizedBox(height: 24),
|
||||
AnimatedFadeSize(
|
||||
child: switch (screen) {
|
||||
LoginScreenType.login || LoginScreenType.code => const LoginScreenCredentials(),
|
||||
_ => LoginUserGrid(
|
||||
users: accounts,
|
||||
editMode: editUsersMode,
|
||||
onPressed: (user) => tapLoggedInAccount(context, user, ref),
|
||||
onLongPress: (user) => openUserEditDialogue(context, user),
|
||||
),
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _parseUrl(String url) {
|
||||
setState(() {
|
||||
ref.read(authProvider.notifier).setServer("");
|
||||
users = [];
|
||||
|
||||
if (url.isEmpty) {
|
||||
invalidUrl = "";
|
||||
return;
|
||||
}
|
||||
if (!Uri.parse(url).isAbsolute) {
|
||||
invalidUrl = context.localized.invalidUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!url.startsWith('https://') && !url.startsWith('http://')) {
|
||||
invalidUrl = context.localized.invalidUrlDesc;
|
||||
return;
|
||||
}
|
||||
|
||||
invalidUrl = null;
|
||||
|
||||
if (invalidUrl == null) {
|
||||
ref.read(authProvider.notifier).setServer(url.rtrim('/'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void openUserEditDialogue(BuildContext context, AccountModel user) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => LoginEditUser(
|
||||
user: user,
|
||||
onTapServer: (value) {
|
||||
setState(() {
|
||||
_parseUrl(value);
|
||||
serverTextController.text = value;
|
||||
startAddingNewUser();
|
||||
});
|
||||
ref.read(authProvider.notifier).setServer(value);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void tapLoggedInAccount(AccountModel user) async {
|
||||
switch (user.authMethod) {
|
||||
case Authentication.autoLogin:
|
||||
handleLogin(user);
|
||||
break;
|
||||
case Authentication.biometrics:
|
||||
final authenticated = await AuthService.authenticateUser(context, user);
|
||||
if (authenticated) {
|
||||
handleLogin(user);
|
||||
}
|
||||
break;
|
||||
case Authentication.passcode:
|
||||
if (context.mounted) {
|
||||
showPassCodeDialog(context, (newPin) {
|
||||
if (newPin == user.localPin) {
|
||||
handleLogin(user);
|
||||
} else {
|
||||
fladderSnackbar(context, title: context.localized.incorrectPinTryAgain);
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
case Authentication.none:
|
||||
handleLogin(user);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleLogin(AccountModel user) async {
|
||||
await ref.read(authProvider.notifier).switchUser();
|
||||
await ref.read(sharedUtilityProvider).updateAccountInfo(user.copyWith(
|
||||
lastUsed: DateTime.now(),
|
||||
));
|
||||
ref.read(userProvider.notifier).updateUser(user.copyWith(lastUsed: DateTime.now()));
|
||||
|
||||
loggedInGoToHome();
|
||||
}
|
||||
|
||||
void loggedInGoToHome() {
|
||||
ref.read(lockScreenActiveProvider.notifier).update((state) => false);
|
||||
if (context.mounted) {
|
||||
context.router.replaceAll([const DashboardRoute()]);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Null> Function()? get enterCredentialsTryLogin => emptyFields()
|
||||
? null
|
||||
: () async {
|
||||
serverTextController.text = serverTextController.text.rtrim('/');
|
||||
ref.read(authProvider.notifier).setServer(serverTextController.text.rtrim('/'));
|
||||
final response = await ref.read(authProvider.notifier).authenticateByName(
|
||||
usernameController.text,
|
||||
passwordController.text,
|
||||
);
|
||||
if (response?.isSuccessful == false) {
|
||||
fladderSnackbar(context,
|
||||
title:
|
||||
"(${response?.base.statusCode}) ${response?.base.reasonPhrase ?? context.localized.somethingWentWrongPasswordCheck}");
|
||||
} else if (response?.body != null) {
|
||||
loggedInGoToHome();
|
||||
}
|
||||
};
|
||||
|
||||
bool emptyFields() => usernameController.text.isEmpty;
|
||||
|
||||
void retrieveListOfUsers() async {
|
||||
serverTextController.text = serverTextController.text.rtrim('/');
|
||||
ref.read(authProvider.notifier).setServer(serverTextController.text);
|
||||
setState(() => loading = true);
|
||||
final response = await ref.read(authProvider.notifier).getPublicUsers();
|
||||
if ((response == null || response.isSuccessful == false) && context.mounted) {
|
||||
fladderSnackbar(context, title: response?.base.reasonPhrase ?? context.localized.unableToConnectHost);
|
||||
setState(() => startCheckingForErrors = true);
|
||||
}
|
||||
if (response?.body?.isEmpty == true) {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
setState(() {
|
||||
users = response?.body ?? [];
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Widget addUserFields(List<AccountModel> accounts, bool authLoading) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (accounts.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: IconButton.filledTonal(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
addingNewUser = false;
|
||||
loading = false;
|
||||
startCheckingForErrors = false;
|
||||
serverTextController.text = "";
|
||||
usernameController.text = "";
|
||||
passwordController.text = "";
|
||||
invalidUrl = "";
|
||||
});
|
||||
ref.read(authProvider.notifier).setServer("");
|
||||
},
|
||||
icon: const Icon(
|
||||
IconsaxPlusLinear.arrow_left_2,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (FladderConfig.baseUrl == null) ...[
|
||||
Flexible(
|
||||
child: OutlinedTextField(
|
||||
controller: serverTextController,
|
||||
onChanged: _parseUrl,
|
||||
onSubmitted: (value) => retrieveListOfUsers(),
|
||||
autoFillHints: const [AutofillHints.url],
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
textInputAction: TextInputAction.go,
|
||||
label: context.localized.server,
|
||||
errorText: (invalidUrl == null || serverTextController.text.isEmpty || !startCheckingForErrors)
|
||||
? null
|
||||
: invalidUrl,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Tooltip(
|
||||
message: context.localized.retrievePublicListOfUsers,
|
||||
waitDuration: const Duration(seconds: 1),
|
||||
child: IconButton.filled(
|
||||
onPressed: () => retrieveListOfUsers(),
|
||||
icon: const Icon(
|
||||
IconsaxPlusLinear.refresh,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
AnimatedFadeSize(
|
||||
child: invalidUrl == null
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (loading || users.isNotEmpty)
|
||||
AnimatedFadeSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: loading
|
||||
? CircularProgressIndicator(key: UniqueKey(), strokeCap: StrokeCap.round)
|
||||
: LoginUserGrid(
|
||||
users: users,
|
||||
onPressed: (value) {
|
||||
usernameController.text = value.name;
|
||||
passwordController.text = "";
|
||||
focusNode.requestFocus();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
AutofillGroup(
|
||||
child: Column(
|
||||
children: [
|
||||
OutlinedTextField(
|
||||
controller: usernameController,
|
||||
autoFillHints: const [AutofillHints.username],
|
||||
textInputAction: TextInputAction.next,
|
||||
autocorrect: false,
|
||||
onChanged: (value) => setState(() {}),
|
||||
label: context.localized.userName,
|
||||
),
|
||||
OutlinedTextField(
|
||||
controller: passwordController,
|
||||
autoFillHints: const [AutofillHints.password],
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
focusNode: focusNode,
|
||||
autocorrect: false,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (value) => enterCredentialsTryLogin?.call(),
|
||||
onChanged: (value) => setState(() {}),
|
||||
label: context.localized.password,
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: enterCredentialsTryLogin,
|
||||
child: authLoading
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
color: Theme.of(context).colorScheme.inversePrimary,
|
||||
strokeCap: StrokeCap.round),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(context.localized.login),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(IconsaxPlusBold.send_1),
|
||||
],
|
||||
),
|
||||
),
|
||||
].addPadding(const EdgeInsets.symmetric(vertical: 4)),
|
||||
),
|
||||
),
|
||||
].addPadding(const EdgeInsets.symmetric(vertical: 10)),
|
||||
)
|
||||
: DiscoverServersWidget(
|
||||
serverCredentials: accounts.map((e) => e.credentials).toList(),
|
||||
onPressed: (server) {
|
||||
serverTextController.text = server.address;
|
||||
_parseUrl(server.address);
|
||||
retrieveListOfUsers();
|
||||
},
|
||||
),
|
||||
)
|
||||
].addPadding(const EdgeInsets.symmetric(vertical: 8)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
315
lib/screens/login/login_screen_credentials.dart
Normal file
315
lib/screens/login/login_screen_credentials.dart
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:iconsax_plus/iconsax_plus.dart';
|
||||
|
||||
import 'package:fladder/models/account_model.dart';
|
||||
import 'package:fladder/providers/api_provider.dart';
|
||||
import 'package:fladder/providers/auth_provider.dart';
|
||||
import 'package:fladder/providers/shared_provider.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/routes/auto_router.gr.dart';
|
||||
import 'package:fladder/screens/login/lock_screen.dart';
|
||||
import 'package:fladder/screens/login/login_code_dialog.dart';
|
||||
import 'package:fladder/screens/login/login_user_grid.dart';
|
||||
import 'package:fladder/screens/login/widgets/discover_servers_widget.dart';
|
||||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/screens/shared/outlined_text_field.dart';
|
||||
import 'package:fladder/screens/shared/passcode_input.dart';
|
||||
import 'package:fladder/util/auth_service.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
|
||||
class LoginScreenCredentials extends ConsumerStatefulWidget {
|
||||
const LoginScreenCredentials({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _LoginScreenCredentialsState();
|
||||
}
|
||||
|
||||
class _LoginScreenCredentialsState extends ConsumerState<LoginScreenCredentials> {
|
||||
late final TextEditingController serverTextController = TextEditingController(text: '');
|
||||
final usernameController = TextEditingController();
|
||||
final passwordController = TextEditingController();
|
||||
final FocusNode focusNode = FocusNode();
|
||||
|
||||
bool loggingIn = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final existingUsers = ref.watch(authProvider.select((value) => value.accounts));
|
||||
final otherCredentials = existingUsers.map((e) => e.credentials).toList();
|
||||
final serverCredentials = ref.watch(authProvider.select((value) => value.serverLoginModel));
|
||||
final users = serverCredentials?.accounts ?? [];
|
||||
final provider = ref.read(authProvider.notifier);
|
||||
final loading = ref.watch(authProvider.select((value) => value.loading));
|
||||
final hasBaseUrl = ref.watch(authProvider.select((value) => value.hasBaseUrl));
|
||||
final urlError = ref.watch(authProvider.select((value) => value.errorMessage));
|
||||
final hasQuickConnect = ref.watch(authProvider.select((value) => value.serverLoginModel?.hasQuickConnect ?? false));
|
||||
|
||||
ref.listen(
|
||||
authProvider.select((value) => value.serverLoginModel),
|
||||
(previous, next) {
|
||||
if (next?.tempCredentials.server.isNotEmpty == true) {
|
||||
serverTextController.text = next?.tempCredentials.server ?? "";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
spacing: 16,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (existingUsers.isNotEmpty)
|
||||
IconButton.filledTonal(
|
||||
onPressed: () => provider.goUserSelect(),
|
||||
iconSize: 28,
|
||||
icon: const Icon(
|
||||
IconsaxPlusLinear.arrow_left_2,
|
||||
),
|
||||
),
|
||||
if (!hasBaseUrl)
|
||||
Flexible(
|
||||
child: OutlinedTextField(
|
||||
controller: serverTextController,
|
||||
onChanged: (value) => provider.tryParseUrl(value),
|
||||
onSubmitted: (value) => provider.setServer(value),
|
||||
autoFillHints: const [AutofillHints.url],
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
textInputAction: TextInputAction.go,
|
||||
label: context.localized.server,
|
||||
errorText: urlError,
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: context.localized.retrievePublicListOfUsers,
|
||||
waitDuration: const Duration(seconds: 1),
|
||||
child: IconButton.filled(
|
||||
onPressed: () => provider.setServer(serverTextController.text),
|
||||
iconSize: 28,
|
||||
icon: const Icon(
|
||||
IconsaxPlusLinear.refresh,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (serverCredentials == null)
|
||||
DiscoverServersWidget(
|
||||
serverCredentials: otherCredentials,
|
||||
onPressed: (info) => provider.setServer(info.address),
|
||||
)
|
||||
else ...[
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
spacing: 16,
|
||||
children: [
|
||||
if (loading || users.isNotEmpty)
|
||||
AnimatedFadeSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: loading
|
||||
? CircularProgressIndicator(key: UniqueKey(), strokeCap: StrokeCap.round)
|
||||
: LoginUserGrid(
|
||||
users: users,
|
||||
onPressed: (value) {
|
||||
usernameController.text = value.name;
|
||||
passwordController.text = "";
|
||||
focusNode.requestFocus();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
AutofillGroup(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Flexible(
|
||||
child: OutlinedTextField(
|
||||
controller: usernameController,
|
||||
autoFillHints: const [AutofillHints.username],
|
||||
textInputAction: TextInputAction.next,
|
||||
autocorrect: false,
|
||||
onChanged: (value) => setState(() {}),
|
||||
label: context.localized.userName,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: OutlinedTextField(
|
||||
controller: passwordController,
|
||||
autoFillHints: const [AutofillHints.password],
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
focusNode: focusNode,
|
||||
autocorrect: false,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (value) => enterCredentialsTryLogin?.call(),
|
||||
onChanged: (value) => setState(() {}),
|
||||
label: context.localized.password,
|
||||
),
|
||||
),
|
||||
const Divider(
|
||||
indent: 32,
|
||||
endIndent: 32,
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: enterCredentialsTryLogin,
|
||||
child: loggingIn
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
color: Theme.of(context).colorScheme.inversePrimary, strokeCap: StrokeCap.round),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(context.localized.login),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(IconsaxPlusBold.send_1),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (hasQuickConnect)
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final result = await ref.read(jellyApiProvider).quickConnectInitiate();
|
||||
if (result.body != null) {
|
||||
await openLoginCodeDialog(
|
||||
context,
|
||||
quickConnectInfo: result.body!,
|
||||
onAuthenticated: (context, secret) async {
|
||||
context.pop();
|
||||
if (secret.isNotEmpty) {
|
||||
await loginUsingSecret(secret);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
fladderSnackbar(context, title: context.localized.quickConnectPostFailed);
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(context.localized.quickConnectLoginUsingCode),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(IconsaxPlusBold.scan_barcode),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (serverCredentials.serverMessage?.isEmpty == false) ...[
|
||||
const Divider(),
|
||||
Text(
|
||||
serverCredentials.serverMessage ?? "",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> Function()? get enterCredentialsTryLogin => emptyFields() ? null : () => loginUsingCredentials();
|
||||
|
||||
Future<void> loginUsingCredentials() async {
|
||||
setState(() {
|
||||
loggingIn = true;
|
||||
});
|
||||
final response = await ref.read(authProvider.notifier).authenticateByName(
|
||||
usernameController.text,
|
||||
passwordController.text,
|
||||
);
|
||||
if (response?.isSuccessful == false) {
|
||||
fladderSnackbar(context,
|
||||
title:
|
||||
"(${response?.base.statusCode}) ${response?.base.reasonPhrase ?? context.localized.somethingWentWrongPasswordCheck}");
|
||||
} else if (response?.body != null) {
|
||||
loggedInGoToHome(context, ref);
|
||||
}
|
||||
setState(() {
|
||||
loggingIn = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> loginUsingSecret(String secret) async {
|
||||
setState(() {
|
||||
loggingIn = true;
|
||||
});
|
||||
final response = await ref.read(authProvider.notifier).authenticateUsingSecret(secret);
|
||||
if (response?.isSuccessful == false) {
|
||||
fladderSnackbar(context,
|
||||
title:
|
||||
"(${response?.base.statusCode}) ${response?.base.reasonPhrase ?? context.localized.somethingWentWrongPasswordCheck}");
|
||||
} else if (response?.body != null) {
|
||||
loggedInGoToHome(context, ref);
|
||||
}
|
||||
setState(() {
|
||||
loggingIn = false;
|
||||
});
|
||||
}
|
||||
|
||||
bool emptyFields() => usernameController.text.isEmpty;
|
||||
}
|
||||
|
||||
void loggedInGoToHome(BuildContext context, WidgetRef ref) {
|
||||
ref.read(lockScreenActiveProvider.notifier).update((state) => false);
|
||||
if (context.mounted) {
|
||||
context.router.replaceAll([const DashboardRoute()]);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleLogin(BuildContext context, AccountModel user, WidgetRef ref) async {
|
||||
await ref.read(authProvider.notifier).switchUser();
|
||||
await ref.read(sharedUtilityProvider).updateAccountInfo(user.copyWith(
|
||||
lastUsed: DateTime.now(),
|
||||
));
|
||||
ref.read(userProvider.notifier).updateUser(user.copyWith(lastUsed: DateTime.now()));
|
||||
|
||||
loggedInGoToHome(context, ref);
|
||||
}
|
||||
|
||||
void tapLoggedInAccount(BuildContext context, AccountModel user, WidgetRef ref) async {
|
||||
Future<void> loginFunction() => _handleLogin(context, user, ref);
|
||||
switch (user.authMethod) {
|
||||
case Authentication.autoLogin:
|
||||
loginFunction();
|
||||
break;
|
||||
case Authentication.biometrics:
|
||||
final authenticated = await AuthService.authenticateUser(context, user);
|
||||
if (authenticated) {
|
||||
loginFunction();
|
||||
}
|
||||
break;
|
||||
case Authentication.passcode:
|
||||
if (context.mounted) {
|
||||
showPassCodeDialog(context, (newPin) {
|
||||
if (newPin == user.localPin) {
|
||||
loginFunction();
|
||||
} else {
|
||||
fladderSnackbar(context, title: context.localized.incorrectPinTryAgain);
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
case Authentication.none:
|
||||
loginFunction();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,9 +6,9 @@ import 'package:reorderable_grid/reorderable_grid.dart';
|
|||
|
||||
import 'package:fladder/models/account_model.dart';
|
||||
import 'package:fladder/providers/auth_provider.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/screens/shared/user_icon.dart';
|
||||
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
||||
import 'package:fladder/util/focus_provider.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
|
||||
class LoginUserGrid extends ConsumerWidget {
|
||||
|
|
@ -21,127 +21,118 @@ class LoginUserGrid extends ConsumerWidget {
|
|||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mainAxisExtent = 175.0;
|
||||
final maxCount = (MediaQuery.of(context).size.width ~/ mainAxisExtent).clamp(1, 3);
|
||||
final maxCount = (MediaQuery.of(context).size.width / mainAxisExtent).floor().clamp(1, 3);
|
||||
|
||||
return ReorderableGridView.builder(
|
||||
onReorder: (oldIndex, newIndex) => ref.read(authProvider.notifier).reOrderUsers(oldIndex, newIndex),
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
autoScroll: true,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: users.length == 1 ? 1 : maxCount,
|
||||
mainAxisSpacing: 24,
|
||||
crossAxisSpacing: 24,
|
||||
mainAxisExtent: mainAxisExtent,
|
||||
),
|
||||
itemCount: users.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = users[index];
|
||||
return FlatButton(
|
||||
key: Key(user.id),
|
||||
onTap: () => editMode ? onLongPress?.call(user) : onPressed?.call(user),
|
||||
onLongPress:
|
||||
AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer ? () => onLongPress?.call(user) : null,
|
||||
child: _CardHolder(
|
||||
content: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: UserIcon(
|
||||
labelStyle: Theme.of(context).textTheme.headlineMedium,
|
||||
user: user,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
final crossAxisCount = users.length == 1 ? 1 : maxCount;
|
||||
|
||||
final neededWidth = crossAxisCount * mainAxisExtent + (crossAxisCount - 1) * 24.0;
|
||||
|
||||
return SizedBox(
|
||||
width: neededWidth,
|
||||
child: ReorderableGridView.builder(
|
||||
onReorder: (oldIndex, newIndex) => ref.read(authProvider.notifier).reOrderUsers(oldIndex, newIndex),
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
autoScroll: true,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: (users.length == 1 ? 1 : maxCount).toInt(),
|
||||
mainAxisSpacing: 24,
|
||||
crossAxisSpacing: 24,
|
||||
mainAxisExtent: mainAxisExtent,
|
||||
),
|
||||
itemCount: users.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = users[index];
|
||||
return Center(
|
||||
key: Key(user.id),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: FocusButton(
|
||||
onTap: () => editMode ? onLongPress?.call(user) : onPressed?.call(user),
|
||||
onLongPress: switch (AdaptiveLayout.inputDeviceOf(context)) {
|
||||
InputDevice.dpad || InputDevice.pointer => () => onLongPress?.call(user),
|
||||
InputDevice.touch => null,
|
||||
},
|
||||
darkOverlay: false,
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
user.authMethod.icon,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
user.name,
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
)),
|
||||
],
|
||||
),
|
||||
if (user.credentials.serverName.isNotEmpty)
|
||||
Opacity(
|
||||
opacity: 0.75,
|
||||
child: Row(
|
||||
child: UserIcon(
|
||||
labelStyle: Theme.of(context).textTheme.headlineMedium,
|
||||
user: user,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
const Icon(
|
||||
IconsaxPlusBold.driver_2,
|
||||
size: 14,
|
||||
Icon(
|
||||
user.authMethod.icon,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
user.credentials.serverName,
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
user.name,
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
)),
|
||||
],
|
||||
),
|
||||
)
|
||||
].addInBetween(const SizedBox(width: 4, height: 4)),
|
||||
),
|
||||
),
|
||||
if (editMode)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Icon(
|
||||
IconsaxPlusBold.edit_2,
|
||||
size: 14,
|
||||
if (user.credentials.serverName.isNotEmpty)
|
||||
Opacity(
|
||||
opacity: 0.75,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
const Icon(
|
||||
IconsaxPlusBold.driver_2,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
user.credentials.serverName,
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
].addInBetween(const SizedBox(width: 4, height: 4)),
|
||||
),
|
||||
),
|
||||
if (editMode)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Icon(
|
||||
IconsaxPlusBold.edit_2,
|
||||
size: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CardHolder extends StatelessWidget {
|
||||
final Widget content;
|
||||
|
||||
const _CardHolder({
|
||||
required this.content,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 1,
|
||||
shadowColor: Colors.transparent,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
margin: EdgeInsets.zero,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 150, maxWidth: 150),
|
||||
child: content,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
0
lib/screens/login/screens/server_selection_screen.dart
Normal file
0
lib/screens/login/screens/server_selection_screen.dart
Normal file
0
lib/screens/login/widgets/credentials_input_section.dart
Normal file
0
lib/screens/login/widgets/credentials_input_section.dart
Normal file
|
|
@ -1,8 +1,8 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:iconsax_plus/iconsax_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:iconsax_plus/iconsax_plus.dart';
|
||||
|
||||
import 'package:fladder/models/credentials_model.dart';
|
||||
import 'package:fladder/providers/discovery_provider.dart';
|
||||
|
|
@ -37,6 +37,7 @@ class DiscoverServersWidget extends ConsumerWidget {
|
|||
return ListView(
|
||||
padding: const EdgeInsets.all(6),
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
if (existingServers.isNotEmpty) ...[
|
||||
Row(
|
||||
|
|
@ -123,8 +124,8 @@ class _ServerInfoCard extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: () => onPressed(server),
|
||||
child: TextButton(
|
||||
onPressed: () => onPressed(server),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
|
||||
child: Row(
|
||||
|
|
|
|||
0
lib/screens/login/widgets/server_input_section.dart
Normal file
0
lib/screens/login/widgets/server_input_section.dart
Normal file
0
lib/screens/login/widgets/server_url_input.dart
Normal file
0
lib/screens/login/widgets/server_url_input.dart
Normal file
Loading…
Add table
Add a link
Reference in a new issue