mirror of
https://github.com/gabehf/Fladder.git
synced 2026-03-07 21:48:14 -08:00
feat: Implement custom keyboard for Android TV (#523)
Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
parent
721fc28060
commit
75c2f958b4
22 changed files with 927 additions and 157 deletions
|
|
@ -14,6 +14,7 @@ import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
|||
import 'package:fladder/util/input_handler.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:fladder/widgets/keyboard/custom_keyboard.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/destination_model.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/navigation_scaffold.dart';
|
||||
|
|
@ -148,10 +149,12 @@ class HomeScreen extends ConsumerWidget {
|
|||
controller: HeroController(),
|
||||
child: AutoRouter(
|
||||
builder: (context, child) {
|
||||
return NavigationScaffold(
|
||||
destinations: destinations.nonNulls.toList(),
|
||||
currentRouteName: context.router.current.name,
|
||||
nestedChild: child,
|
||||
return CustomKeyboardWrapper(
|
||||
child: NavigationScaffold(
|
||||
destinations: destinations.nonNulls.toList(),
|
||||
currentRouteName: context.router.current.name,
|
||||
nestedChild: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -400,14 +400,15 @@ class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
|
|||
spacing: 2,
|
||||
children: [
|
||||
const SizedBox(width: 2),
|
||||
Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 47,
|
||||
child: Card(
|
||||
child: context.router.backButton(),
|
||||
if (AdaptiveLayout.inputDeviceOf(context) != InputDevice.dPad)
|
||||
Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 47,
|
||||
child: Card(
|
||||
child: context.router.backButton(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Hero(
|
||||
tag: "PrimarySearch",
|
||||
|
|
|
|||
|
|
@ -100,6 +100,15 @@ class _SearchBarState extends ConsumerState<SuggestionSearchBar> {
|
|||
isEmpty = value.isEmpty;
|
||||
});
|
||||
},
|
||||
searchQuery: (query) async {
|
||||
if (query.isEmpty) return [];
|
||||
if (widget.key != null) {
|
||||
final items =
|
||||
await ref.read(librarySearchProvider(widget.key!).notifier).fetchSuggestions(query, limit: 5);
|
||||
return items.map((e) => e.name).toList();
|
||||
}
|
||||
return [];
|
||||
},
|
||||
placeHolder: widget.title ?? "${context.localized.search}...",
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.title ?? "${context.localized.search}...",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import 'package:fladder/screens/login/login_user_grid.dart';
|
|||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:fladder/screens/shared/fladder_logo.dart';
|
||||
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
||||
import 'package:fladder/widgets/keyboard/custom_keyboard.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/fladder_app_bar.dart';
|
||||
|
||||
@RoutePage()
|
||||
|
|
@ -42,52 +43,54 @@ class _LoginPageState extends ConsumerState<LoginScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
final screen = ref.watch(authProvider.select((value) => value.screen));
|
||||
final accounts = ref.watch(authProvider.select((value) => value.accounts));
|
||||
return Scaffold(
|
||||
appBar: const FladderAppBar(),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
floatingActionButton: switch (screen) {
|
||||
LoginScreenType.users => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
spacing: 16,
|
||||
children: [
|
||||
if (!AdaptiveLayout.of(context).isDesktop)
|
||||
return CustomKeyboardWrapper(
|
||||
child: Scaffold(
|
||||
appBar: const FladderAppBar(),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
floatingActionButton: switch (screen) {
|
||||
LoginScreenType.users => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
spacing: 16,
|
||||
children: [
|
||||
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("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),
|
||||
key: const Key("new_user_button"),
|
||||
heroTag: "new_user_button",
|
||||
child: const Icon(IconsaxPlusLinear.add_square),
|
||||
onPressed: () => ref.read(authProvider.notifier).addNewUser(),
|
||||
),
|
||||
FloatingActionButton(
|
||||
key: const Key("new_user_button"),
|
||||
heroTag: "new_user_button",
|
||||
child: const Icon(IconsaxPlusLinear.add_square),
|
||||
onPressed: () => ref.read(authProvider.notifier).addNewUser(),
|
||||
),
|
||||
],
|
||||
),
|
||||
_ => 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),
|
||||
),
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
_ => 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),
|
||||
),
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/providers/arguments_provider.dart';
|
||||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/providers/settings/home_settings_provider.dart';
|
||||
import 'package:fladder/screens/settings/settings_list_tile.dart';
|
||||
import 'package:fladder/screens/settings/widgets/settings_label_divider.dart';
|
||||
|
|
@ -79,6 +81,18 @@ List<Widget> buildClientSettingsAdvanced(BuildContext context, WidgetRef ref) {
|
|||
),
|
||||
),
|
||||
),
|
||||
if (ref.read(argumentsStateProvider).leanBackMode)
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.clientSettingsUseSystemIMETitle),
|
||||
subLabel: Text(context.localized.clientSettingsUseSystemIMEDesc),
|
||||
onTap: () => ref
|
||||
.read(clientSettingsProvider.notifier)
|
||||
.useSystemIME(!ref.read(clientSettingsProvider.select((value) => value.useSystemIME))),
|
||||
trailing: Switch(
|
||||
value: ref.watch(clientSettingsProvider.select((value) => value.useSystemIME)),
|
||||
onChanged: (value) => ref.read(clientSettingsProvider.notifier).useSystemIME(value),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,24 +63,26 @@ class _DetailedBannerState extends ConsumerState<DetailedBanner> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16).copyWith(bottom: 4),
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone ? 1.0 : 0.55,
|
||||
child: OverviewHeader(
|
||||
name: selectedPoster.parentBaseModel.name,
|
||||
subTitle: selectedPoster.label(context),
|
||||
image: selectedPoster.getPosters,
|
||||
logoAlignment: AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone
|
||||
? Alignment.center
|
||||
: Alignment.centerLeft,
|
||||
summary: selectedPoster.overview.summary,
|
||||
productionYear: selectedPoster.overview.productionYear,
|
||||
runTime: selectedPoster.overview.runTime,
|
||||
genres: selectedPoster.overview.genreItems,
|
||||
studios: selectedPoster.overview.studios,
|
||||
officialRating: selectedPoster.overview.parentalRating,
|
||||
communityRating: selectedPoster.overview.communityRating,
|
||||
child: ExcludeFocus(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16).copyWith(bottom: 4),
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone ? 1.0 : 0.55,
|
||||
child: OverviewHeader(
|
||||
name: selectedPoster.parentBaseModel.name,
|
||||
subTitle: selectedPoster.label(context),
|
||||
image: selectedPoster.getPosters,
|
||||
logoAlignment: AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone
|
||||
? Alignment.center
|
||||
: Alignment.centerLeft,
|
||||
summary: selectedPoster.overview.summary,
|
||||
productionYear: selectedPoster.overview.productionYear,
|
||||
runTime: selectedPoster.overview.runTime,
|
||||
genres: selectedPoster.overview.genreItems,
|
||||
studios: selectedPoster.overview.studios,
|
||||
officialRating: selectedPoster.overview.parentalRating,
|
||||
communityRating: selectedPoster.overview.communityRating,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/providers/arguments_provider.dart';
|
||||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:fladder/theme.dart';
|
||||
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
||||
import 'package:fladder/util/focus_provider.dart';
|
||||
import 'package:fladder/widgets/keyboard/custom_keyboard.dart';
|
||||
import 'package:fladder/widgets/shared/ensure_visible.dart';
|
||||
|
||||
class OutlinedTextField extends ConsumerStatefulWidget {
|
||||
|
|
@ -18,6 +23,7 @@ class OutlinedTextField extends ConsumerStatefulWidget {
|
|||
final Function()? onTap;
|
||||
final Function(String value)? onChanged;
|
||||
final Function(String value)? onSubmitted;
|
||||
final FutureOr<List<String>> Function(String query)? searchQuery;
|
||||
final List<String>? autoFillHints;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final bool autocorrect;
|
||||
|
|
@ -42,6 +48,7 @@ class OutlinedTextField extends ConsumerStatefulWidget {
|
|||
this.onTap,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.searchQuery,
|
||||
this.fillColor,
|
||||
this.style,
|
||||
this.borderWidth = 1,
|
||||
|
|
@ -71,11 +78,15 @@ class _OutlinedTextFieldState extends ConsumerState<OutlinedTextField> {
|
|||
hasFocus = _wrapperFocus.hasFocus;
|
||||
if (hasFocus) {
|
||||
context.ensureVisible();
|
||||
if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) {
|
||||
_textFocus.requestFocus();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
bool hasFocus = false;
|
||||
bool keyboardFocus = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
|
@ -96,17 +107,80 @@ class _OutlinedTextFieldState extends ConsumerState<OutlinedTextField> {
|
|||
return Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.35);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final useCustomKeyboard = ref.watch(argumentsStateProvider.select((value) => value.leanBackMode)) &&
|
||||
ref.watch(clientSettingsProvider.select((value) => !value.useSystemIME));
|
||||
if (widget.autoFocus) {
|
||||
if (useCustomKeyboard) {
|
||||
_wrapperFocus.requestFocus();
|
||||
} else {
|
||||
_textFocus.requestFocus();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isPasswordField = widget.keyboardType == TextInputType.visiblePassword;
|
||||
final leanBackMode = ref.watch(argumentsStateProvider).leanBackMode;
|
||||
if (widget.autoFocus) {
|
||||
if (leanBackMode) {
|
||||
_wrapperFocus.requestFocus();
|
||||
} else {
|
||||
_textFocus.requestFocus();
|
||||
}
|
||||
}
|
||||
final useCustomKeyboard = ref.watch(argumentsStateProvider.select((value) => value.leanBackMode)) &&
|
||||
ref.watch(clientSettingsProvider.select((value) => !value.useSystemIME));
|
||||
|
||||
final textField = TextField(
|
||||
controller: widget.controller,
|
||||
onChanged: widget.onChanged,
|
||||
focusNode: _textFocus,
|
||||
onTap: widget.onTap,
|
||||
readOnly: useCustomKeyboard,
|
||||
autofillHints: widget.autoFillHints,
|
||||
keyboardType: widget.keyboardType,
|
||||
autocorrect: widget.autocorrect,
|
||||
onSubmitted: widget.onSubmitted != null
|
||||
? (value) {
|
||||
widget.onSubmitted?.call(value);
|
||||
Future.microtask(() async {
|
||||
await Future.delayed(const Duration(milliseconds: 125));
|
||||
_wrapperFocus.requestFocus();
|
||||
});
|
||||
}
|
||||
: null,
|
||||
textInputAction: widget.textInputAction,
|
||||
obscureText: isPasswordField ? _obscureText : false,
|
||||
style: widget.style,
|
||||
maxLines: widget.maxLines,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
textAlign: widget.textAlign,
|
||||
canRequestFocus: true,
|
||||
decoration: widget.decoration ??
|
||||
InputDecoration(
|
||||
border: InputBorder.none,
|
||||
filled: widget.fillColor != null,
|
||||
fillColor: widget.fillColor,
|
||||
labelText: widget.label,
|
||||
suffix: widget.suffix != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: Text(widget.suffix!),
|
||||
)
|
||||
: null,
|
||||
hintText: widget.placeHolder,
|
||||
// errorText: widget.errorText,
|
||||
suffixIcon: isPasswordField
|
||||
? InkWell(
|
||||
onTap: _toggle,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
child: Icon(
|
||||
_obscureText ? Icons.visibility : Icons.visibility_off,
|
||||
size: 16.0,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
|
|
@ -116,7 +190,7 @@ class _OutlinedTextFieldState extends ConsumerState<OutlinedTextField> {
|
|||
borderRadius: FladderTheme.smallShape.borderRadius,
|
||||
border: BoxBorder.all(
|
||||
width: 2,
|
||||
color: hasFocus ? Theme.of(context).colorScheme.primaryFixed : Colors.transparent,
|
||||
color: hasFocus || keyboardFocus ? Theme.of(context).colorScheme.primaryFixed : Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
|
|
@ -126,66 +200,33 @@ class _OutlinedTextFieldState extends ConsumerState<OutlinedTextField> {
|
|||
child: KeyboardListener(
|
||||
focusNode: _wrapperFocus,
|
||||
onKeyEvent: (KeyEvent event) {
|
||||
if (event is KeyUpEvent && acceptKeys.contains(event.logicalKey)) {
|
||||
if (keyboardFocus) return;
|
||||
if (event is KeyDownEvent && acceptKeys.contains(event.logicalKey)) {
|
||||
if (_textFocus.hasFocus) {
|
||||
_textFocus.unfocus();
|
||||
_wrapperFocus.requestFocus();
|
||||
} else if (_wrapperFocus.hasFocus) {
|
||||
_textFocus.requestFocus();
|
||||
if (useCustomKeyboard) {
|
||||
CustomKeyboard.of(context).openKeyboard(
|
||||
textField,
|
||||
onClosed: () {
|
||||
setState(() {
|
||||
keyboardFocus = false;
|
||||
});
|
||||
_wrapperFocus.requestFocus();
|
||||
},
|
||||
query: widget.searchQuery,
|
||||
);
|
||||
setState(() {
|
||||
keyboardFocus = true;
|
||||
});
|
||||
} else {
|
||||
_textFocus.requestFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
child: ExcludeFocusTraversal(
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
onChanged: widget.onChanged,
|
||||
focusNode: _textFocus,
|
||||
onTap: widget.onTap,
|
||||
autofillHints: widget.autoFillHints,
|
||||
keyboardType: widget.keyboardType,
|
||||
autocorrect: widget.autocorrect,
|
||||
onSubmitted: widget.onSubmitted != null
|
||||
? (value) {
|
||||
widget.onSubmitted?.call(value);
|
||||
Future.microtask(() async {
|
||||
await Future.delayed(const Duration(milliseconds: 125));
|
||||
_wrapperFocus.requestFocus();
|
||||
});
|
||||
}
|
||||
: null,
|
||||
textInputAction: widget.textInputAction,
|
||||
obscureText: isPasswordField ? _obscureText : false,
|
||||
style: widget.style,
|
||||
maxLines: widget.maxLines,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
textAlign: widget.textAlign,
|
||||
canRequestFocus: true,
|
||||
decoration: widget.decoration ??
|
||||
InputDecoration(
|
||||
border: InputBorder.none,
|
||||
filled: widget.fillColor != null,
|
||||
fillColor: widget.fillColor,
|
||||
labelText: widget.label,
|
||||
suffix: widget.suffix != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: Text(widget.suffix!),
|
||||
)
|
||||
: null,
|
||||
hintText: widget.placeHolder,
|
||||
// errorText: widget.errorText,
|
||||
suffixIcon: isPasswordField
|
||||
? InkWell(
|
||||
onTap: _toggle,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
child: Icon(
|
||||
_obscureText ? Icons.visibility : Icons.visibility_off,
|
||||
size: 16.0,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
child: textField,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
||||
|
||||
class PassCodeInput extends ConsumerStatefulWidget {
|
||||
final ValueChanged<String> passCode;
|
||||
|
|
@ -107,11 +108,16 @@ class _PassCodeInputState extends ConsumerState<PassCodeInput> {
|
|||
}
|
||||
|
||||
Widget passCodeNumber(int value) {
|
||||
return IconButton.filledTonal(
|
||||
return ElevatedButton(
|
||||
autofocus: AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad ? value == 1 : false,
|
||||
onPressed: () {
|
||||
addToPassCode(value.toString());
|
||||
},
|
||||
icon: Container(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.all(8),
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
child: Container(
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
alignment: Alignment.center,
|
||||
|
|
@ -146,7 +152,6 @@ class _PassCodeInputState extends ConsumerState<PassCodeInput> {
|
|||
|
||||
Widget get clearAllButton {
|
||||
return IconButton.filled(
|
||||
autofocus: true,
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.errorContainer),
|
||||
iconColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue