From 75c2f958b4d36e92812c51e109bdba40993e489d Mon Sep 17 00:00:00 2001 From: PartyDonut <42371342+PartyDonut@users.noreply.github.com> Date: Thu, 9 Oct 2025 09:59:30 +0200 Subject: [PATCH] feat: Implement custom keyboard for Android TV (#523) Co-authored-by: PartyDonut --- .../composables/dialogs/AudioSelection.kt | 6 +- .../composables/dialogs/SubtitlePicker.kt | 6 +- lib/l10n/app_en.arb | 4 +- .../settings/client_settings_model.dart | 1 + .../client_settings_model.freezed.dart | 29 +- .../settings/client_settings_model.g.dart | 2 + lib/providers/library_search_provider.dart | 8 +- .../settings/client_settings_provider.dart | 2 + lib/screens/home_screen.dart | 11 +- .../library_search/library_search_screen.dart | 13 +- .../widgets/suggestion_search_bar.dart | 9 + lib/screens/login/login_screen.dart | 87 ++--- .../client_settings_advanced.dart | 14 + lib/screens/shared/media/detailed_banner.dart | 38 ++- lib/screens/shared/outlined_text_field.dart | 165 ++++++---- lib/screens/shared/passcode_input.dart | 11 +- lib/theme.dart | 14 +- lib/util/focus_provider.dart | 6 +- .../keyboard/alpha_numeric_keyboard.dart | 254 ++++++++++++++ lib/widgets/keyboard/custom_keyboard.dart | 309 ++++++++++++++++++ .../keyboard/keyboard_localization.dart | 92 ++++++ lib/widgets/shared/horizontal_list.dart | 3 +- 22 files changed, 927 insertions(+), 157 deletions(-) create mode 100644 lib/widgets/keyboard/alpha_numeric_keyboard.dart create mode 100644 lib/widgets/keyboard/custom_keyboard.dart create mode 100644 lib/widgets/keyboard/keyboard_localization.dart diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/AudioSelection.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/AudioSelection.kt index f6eb79f..60eed29 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/AudioSelection.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/AudioSelection.kt @@ -33,6 +33,7 @@ fun AudioPicker( val listState = rememberLazyListState() LaunchedEffect(selectedIndex) { + if (selectedIndex == -1) return@LaunchedEffect listState.scrollToItem( audioTracks.indexOfFirst { it.index == selectedIndex.toLong() } ) @@ -50,15 +51,16 @@ fun AudioPicker( verticalArrangement = Arrangement.spacedBy(6.dp) ) { item { + val selectedOff = -1 == selectedIndex TrackButton( modifier = Modifier .fillMaxWidth() - .defaultSelected(-1 == selectedIndex), + .defaultSelected(selectedOff), onClick = { VideoPlayerObject.setAudioTrackIndex(-1) player.clearAudioTrack() }, - selected = -1 == selectedIndex + selected = selectedOff ) { Text( text = "Off", diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/SubtitlePicker.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/SubtitlePicker.kt index 41be3ca..ab65be3 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/SubtitlePicker.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/SubtitlePicker.kt @@ -35,6 +35,7 @@ fun SubtitlePicker( val listState = rememberLazyListState() LaunchedEffect(selectedIndex) { + if (selectedIndex == -1) return@LaunchedEffect listState.scrollToItem( subTitles.indexOfFirst { it.index == selectedIndex.toLong() } ) @@ -52,15 +53,16 @@ fun SubtitlePicker( verticalArrangement = Arrangement.spacedBy(6.dp) ) { item { + val selectedOff = -1 == selectedIndex TrackButton( modifier = Modifier .fillMaxWidth() - .defaultSelected(-1 == selectedIndex), + .defaultSelected(selectedOff), onClick = { VideoPlayerObject.setSubtitleTrackIndex(-1) player.clearSubtitleTrack() }, - selected = -1 == selectedIndex + selected = selectedOff ) { Column( horizontalAlignment = Alignment.Start, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c20482a..86ab68b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1346,5 +1346,7 @@ "itemColorsTitle": "Item colors", "itemColorsDesc": "Use item's primary color to theme the details page", "mediaTunnelingTitle": "Media tunneling", - "mediaTunnelingDesc": "Enable media tunneling for native player" + "mediaTunnelingDesc": "Enable media tunneling for native player", + "clientSettingsUseSystemIMETitle": "Use system keyboard", + "clientSettingsUseSystemIMEDesc": "Use the built-in keyboard provided by your system" } \ No newline at end of file diff --git a/lib/models/settings/client_settings_model.dart b/lib/models/settings/client_settings_model.dart index 9814312..39f92d6 100644 --- a/lib/models/settings/client_settings_model.dart +++ b/lib/models/settings/client_settings_model.dart @@ -78,6 +78,7 @@ abstract class ClientSettingsModel with _$ClientSettingsModel { @Default(BackgroundType.blurred) BackgroundType backgroundImage, @Default(true) bool checkForUpdates, @Default(false) bool usePosterForLibrary, + @Default(false) bool useSystemIME, String? lastViewedUpdate, int? libraryPageSize, @Default({}) Map shortcuts, diff --git a/lib/models/settings/client_settings_model.freezed.dart b/lib/models/settings/client_settings_model.freezed.dart index a097167..ea1425c 100644 --- a/lib/models/settings/client_settings_model.freezed.dart +++ b/lib/models/settings/client_settings_model.freezed.dart @@ -38,6 +38,7 @@ mixin _$ClientSettingsModel implements DiagnosticableTreeMixin { BackgroundType get backgroundImage; bool get checkForUpdates; bool get usePosterForLibrary; + bool get useSystemIME; String? get lastViewedUpdate; int? get libraryPageSize; Map get shortcuts; @@ -82,6 +83,7 @@ mixin _$ClientSettingsModel implements DiagnosticableTreeMixin { ..add(DiagnosticsProperty('backgroundImage', backgroundImage)) ..add(DiagnosticsProperty('checkForUpdates', checkForUpdates)) ..add(DiagnosticsProperty('usePosterForLibrary', usePosterForLibrary)) + ..add(DiagnosticsProperty('useSystemIME', useSystemIME)) ..add(DiagnosticsProperty('lastViewedUpdate', lastViewedUpdate)) ..add(DiagnosticsProperty('libraryPageSize', libraryPageSize)) ..add(DiagnosticsProperty('shortcuts', shortcuts)); @@ -89,7 +91,7 @@ mixin _$ClientSettingsModel implements DiagnosticableTreeMixin { @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'ClientSettingsModel(syncPath: $syncPath, position: $position, size: $size, timeOut: $timeOut, nextUpDateCutoff: $nextUpDateCutoff, themeMode: $themeMode, themeColor: $themeColor, deriveColorsFromItem: $deriveColorsFromItem, amoledBlack: $amoledBlack, blurPlaceHolders: $blurPlaceHolders, blurUpcomingEpisodes: $blurUpcomingEpisodes, selectedLocale: $selectedLocale, enableMediaKeys: $enableMediaKeys, posterSize: $posterSize, pinchPosterZoom: $pinchPosterZoom, mouseDragSupport: $mouseDragSupport, requireWifi: $requireWifi, showAllCollectionTypes: $showAllCollectionTypes, maxConcurrentDownloads: $maxConcurrentDownloads, schemeVariant: $schemeVariant, backgroundImage: $backgroundImage, checkForUpdates: $checkForUpdates, usePosterForLibrary: $usePosterForLibrary, lastViewedUpdate: $lastViewedUpdate, libraryPageSize: $libraryPageSize, shortcuts: $shortcuts)'; + return 'ClientSettingsModel(syncPath: $syncPath, position: $position, size: $size, timeOut: $timeOut, nextUpDateCutoff: $nextUpDateCutoff, themeMode: $themeMode, themeColor: $themeColor, deriveColorsFromItem: $deriveColorsFromItem, amoledBlack: $amoledBlack, blurPlaceHolders: $blurPlaceHolders, blurUpcomingEpisodes: $blurUpcomingEpisodes, selectedLocale: $selectedLocale, enableMediaKeys: $enableMediaKeys, posterSize: $posterSize, pinchPosterZoom: $pinchPosterZoom, mouseDragSupport: $mouseDragSupport, requireWifi: $requireWifi, showAllCollectionTypes: $showAllCollectionTypes, maxConcurrentDownloads: $maxConcurrentDownloads, schemeVariant: $schemeVariant, backgroundImage: $backgroundImage, checkForUpdates: $checkForUpdates, usePosterForLibrary: $usePosterForLibrary, useSystemIME: $useSystemIME, lastViewedUpdate: $lastViewedUpdate, libraryPageSize: $libraryPageSize, shortcuts: $shortcuts)'; } } @@ -123,6 +125,7 @@ abstract mixin class $ClientSettingsModelCopyWith<$Res> { BackgroundType backgroundImage, bool checkForUpdates, bool usePosterForLibrary, + bool useSystemIME, String? lastViewedUpdate, int? libraryPageSize, Map shortcuts}); @@ -164,6 +167,7 @@ class _$ClientSettingsModelCopyWithImpl<$Res> Object? backgroundImage = null, Object? checkForUpdates = null, Object? usePosterForLibrary = null, + Object? useSystemIME = null, Object? lastViewedUpdate = freezed, Object? libraryPageSize = freezed, Object? shortcuts = null, @@ -261,6 +265,10 @@ class _$ClientSettingsModelCopyWithImpl<$Res> ? _self.usePosterForLibrary : usePosterForLibrary // ignore: cast_nullable_to_non_nullable as bool, + useSystemIME: null == useSystemIME + ? _self.useSystemIME + : useSystemIME // ignore: cast_nullable_to_non_nullable + as bool, lastViewedUpdate: freezed == lastViewedUpdate ? _self.lastViewedUpdate : lastViewedUpdate // ignore: cast_nullable_to_non_nullable @@ -394,6 +402,7 @@ extension ClientSettingsModelPatterns on ClientSettingsModel { BackgroundType backgroundImage, bool checkForUpdates, bool usePosterForLibrary, + bool useSystemIME, String? lastViewedUpdate, int? libraryPageSize, Map shortcuts)? @@ -427,6 +436,7 @@ extension ClientSettingsModelPatterns on ClientSettingsModel { _that.backgroundImage, _that.checkForUpdates, _that.usePosterForLibrary, + _that.useSystemIME, _that.lastViewedUpdate, _that.libraryPageSize, _that.shortcuts); @@ -474,6 +484,7 @@ extension ClientSettingsModelPatterns on ClientSettingsModel { BackgroundType backgroundImage, bool checkForUpdates, bool usePosterForLibrary, + bool useSystemIME, String? lastViewedUpdate, int? libraryPageSize, Map shortcuts) @@ -506,6 +517,7 @@ extension ClientSettingsModelPatterns on ClientSettingsModel { _that.backgroundImage, _that.checkForUpdates, _that.usePosterForLibrary, + _that.useSystemIME, _that.lastViewedUpdate, _that.libraryPageSize, _that.shortcuts); @@ -552,6 +564,7 @@ extension ClientSettingsModelPatterns on ClientSettingsModel { BackgroundType backgroundImage, bool checkForUpdates, bool usePosterForLibrary, + bool useSystemIME, String? lastViewedUpdate, int? libraryPageSize, Map shortcuts)? @@ -584,6 +597,7 @@ extension ClientSettingsModelPatterns on ClientSettingsModel { _that.backgroundImage, _that.checkForUpdates, _that.usePosterForLibrary, + _that.useSystemIME, _that.lastViewedUpdate, _that.libraryPageSize, _that.shortcuts); @@ -621,6 +635,7 @@ class _ClientSettingsModel extends ClientSettingsModel this.backgroundImage = BackgroundType.blurred, this.checkForUpdates = true, this.usePosterForLibrary = false, + this.useSystemIME = false, this.lastViewedUpdate, this.libraryPageSize, final Map shortcuts = const {}}) @@ -696,6 +711,9 @@ class _ClientSettingsModel extends ClientSettingsModel @JsonKey() final bool usePosterForLibrary; @override + @JsonKey() + final bool useSystemIME; + @override final String? lastViewedUpdate; @override final int? libraryPageSize; @@ -753,6 +771,7 @@ class _ClientSettingsModel extends ClientSettingsModel ..add(DiagnosticsProperty('backgroundImage', backgroundImage)) ..add(DiagnosticsProperty('checkForUpdates', checkForUpdates)) ..add(DiagnosticsProperty('usePosterForLibrary', usePosterForLibrary)) + ..add(DiagnosticsProperty('useSystemIME', useSystemIME)) ..add(DiagnosticsProperty('lastViewedUpdate', lastViewedUpdate)) ..add(DiagnosticsProperty('libraryPageSize', libraryPageSize)) ..add(DiagnosticsProperty('shortcuts', shortcuts)); @@ -760,7 +779,7 @@ class _ClientSettingsModel extends ClientSettingsModel @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'ClientSettingsModel(syncPath: $syncPath, position: $position, size: $size, timeOut: $timeOut, nextUpDateCutoff: $nextUpDateCutoff, themeMode: $themeMode, themeColor: $themeColor, deriveColorsFromItem: $deriveColorsFromItem, amoledBlack: $amoledBlack, blurPlaceHolders: $blurPlaceHolders, blurUpcomingEpisodes: $blurUpcomingEpisodes, selectedLocale: $selectedLocale, enableMediaKeys: $enableMediaKeys, posterSize: $posterSize, pinchPosterZoom: $pinchPosterZoom, mouseDragSupport: $mouseDragSupport, requireWifi: $requireWifi, showAllCollectionTypes: $showAllCollectionTypes, maxConcurrentDownloads: $maxConcurrentDownloads, schemeVariant: $schemeVariant, backgroundImage: $backgroundImage, checkForUpdates: $checkForUpdates, usePosterForLibrary: $usePosterForLibrary, lastViewedUpdate: $lastViewedUpdate, libraryPageSize: $libraryPageSize, shortcuts: $shortcuts)'; + return 'ClientSettingsModel(syncPath: $syncPath, position: $position, size: $size, timeOut: $timeOut, nextUpDateCutoff: $nextUpDateCutoff, themeMode: $themeMode, themeColor: $themeColor, deriveColorsFromItem: $deriveColorsFromItem, amoledBlack: $amoledBlack, blurPlaceHolders: $blurPlaceHolders, blurUpcomingEpisodes: $blurUpcomingEpisodes, selectedLocale: $selectedLocale, enableMediaKeys: $enableMediaKeys, posterSize: $posterSize, pinchPosterZoom: $pinchPosterZoom, mouseDragSupport: $mouseDragSupport, requireWifi: $requireWifi, showAllCollectionTypes: $showAllCollectionTypes, maxConcurrentDownloads: $maxConcurrentDownloads, schemeVariant: $schemeVariant, backgroundImage: $backgroundImage, checkForUpdates: $checkForUpdates, usePosterForLibrary: $usePosterForLibrary, useSystemIME: $useSystemIME, lastViewedUpdate: $lastViewedUpdate, libraryPageSize: $libraryPageSize, shortcuts: $shortcuts)'; } } @@ -796,6 +815,7 @@ abstract mixin class _$ClientSettingsModelCopyWith<$Res> BackgroundType backgroundImage, bool checkForUpdates, bool usePosterForLibrary, + bool useSystemIME, String? lastViewedUpdate, int? libraryPageSize, Map shortcuts}); @@ -837,6 +857,7 @@ class __$ClientSettingsModelCopyWithImpl<$Res> Object? backgroundImage = null, Object? checkForUpdates = null, Object? usePosterForLibrary = null, + Object? useSystemIME = null, Object? lastViewedUpdate = freezed, Object? libraryPageSize = freezed, Object? shortcuts = null, @@ -934,6 +955,10 @@ class __$ClientSettingsModelCopyWithImpl<$Res> ? _self.usePosterForLibrary : usePosterForLibrary // ignore: cast_nullable_to_non_nullable as bool, + useSystemIME: null == useSystemIME + ? _self.useSystemIME + : useSystemIME // ignore: cast_nullable_to_non_nullable + as bool, lastViewedUpdate: freezed == lastViewedUpdate ? _self.lastViewedUpdate : lastViewedUpdate // ignore: cast_nullable_to_non_nullable diff --git a/lib/models/settings/client_settings_model.g.dart b/lib/models/settings/client_settings_model.g.dart index 22d83ae..92e2a58 100644 --- a/lib/models/settings/client_settings_model.g.dart +++ b/lib/models/settings/client_settings_model.g.dart @@ -46,6 +46,7 @@ _ClientSettingsModel _$ClientSettingsModelFromJson(Map json) => BackgroundType.blurred, checkForUpdates: json['checkForUpdates'] as bool? ?? true, usePosterForLibrary: json['usePosterForLibrary'] as bool? ?? false, + useSystemIME: json['useSystemIME'] as bool? ?? false, lastViewedUpdate: json['lastViewedUpdate'] as String?, libraryPageSize: (json['libraryPageSize'] as num?)?.toInt(), shortcuts: (json['shortcuts'] as Map?)?.map( @@ -81,6 +82,7 @@ Map _$ClientSettingsModelToJson( 'backgroundImage': _$BackgroundTypeEnumMap[instance.backgroundImage]!, 'checkForUpdates': instance.checkForUpdates, 'usePosterForLibrary': instance.usePosterForLibrary, + 'useSystemIME': instance.useSystemIME, 'lastViewedUpdate': instance.lastViewedUpdate, 'libraryPageSize': instance.libraryPageSize, 'shortcuts': instance.shortcuts diff --git a/lib/providers/library_search_provider.dart b/lib/providers/library_search_provider.dart index ad5585c..8f002a5 100644 --- a/lib/providers/library_search_provider.dart +++ b/lib/providers/library_search_provider.dart @@ -325,14 +325,14 @@ class LibrarySearchNotifier extends StateNotifier { return response.body; } - Future> fetchSuggestions(String searchTerm) async { + Future> fetchSuggestions(String searchTerm, {int limit = 25}) async { if (state.folderOverwrite.isNotEmpty) { - final response = await _loadLibrary(id: state.nestedCurrentItem?.id ?? "", searchTerm: searchTerm, limit: 25); + final response = await _loadLibrary(id: state.nestedCurrentItem?.id ?? "", searchTerm: searchTerm, limit: limit); return response?.items ?? []; } else { if (state.views.hasEnabled) { final mappedList = await Future.wait(state.views.included - .map((viewModel) => _loadLibrary(viewModel: viewModel, limit: 25, searchTerm: searchTerm))); + .map((viewModel) => _loadLibrary(viewModel: viewModel, limit: limit, searchTerm: searchTerm))); return mappedList .expand((innerList) => innerList?.items ?? []) .where((item) => item != null) @@ -342,7 +342,7 @@ class LibrarySearchNotifier extends StateNotifier { if (searchTerm.isEmpty) { return []; } else { - final response = await _loadLibrary(limit: 25, recursive: true, searchTerm: searchTerm); + final response = await _loadLibrary(limit: limit, recursive: true, searchTerm: searchTerm); return response?.items ?? []; } } diff --git a/lib/providers/settings/client_settings_provider.dart b/lib/providers/settings/client_settings_provider.dart index 1958233..035d69a 100644 --- a/lib/providers/settings/client_settings_provider.dart +++ b/lib/providers/settings/client_settings_provider.dart @@ -41,6 +41,8 @@ class ClientSettingsNotifier extends StateNotifier { void setDerivedColorsFromItem(bool? value) => state = state.copyWith(deriveColorsFromItem: value ?? false); + void useSystemIME(bool? value) => state = state.copyWith(useSystemIME: value ?? false); + void setBlurPlaceholders(bool value) => state = state.copyWith(blurPlaceHolders: value); void setTimeOut(Duration? duration) => state = state.copyWith(timeOut: duration); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index cd8e4ce..75dbfdd 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -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, + ), ); }, ), diff --git a/lib/screens/library_search/library_search_screen.dart b/lib/screens/library_search/library_search_screen.dart index 737b45e..252c67d 100644 --- a/lib/screens/library_search/library_search_screen.dart +++ b/lib/screens/library_search/library_search_screen.dart @@ -400,14 +400,15 @@ class _LibrarySearchScreenState extends ConsumerState { 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", diff --git a/lib/screens/library_search/widgets/suggestion_search_bar.dart b/lib/screens/library_search/widgets/suggestion_search_bar.dart index 576b13f..5add4ca 100644 --- a/lib/screens/library_search/widgets/suggestion_search_bar.dart +++ b/lib/screens/library_search/widgets/suggestion_search_bar.dart @@ -100,6 +100,15 @@ class _SearchBarState extends ConsumerState { 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}...", diff --git a/lib/screens/login/login_screen.dart b/lib/screens/login/login_screen.dart index 66350de..45837d4 100644 --- a/lib/screens/login/login_screen.dart +++ b/lib/screens/login/login_screen.dart @@ -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 { 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), - ), - }, - ) - ], ), ), ); diff --git a/lib/screens/settings/client_sections/client_settings_advanced.dart b/lib/screens/settings/client_sections/client_settings_advanced.dart index a736658..791d61d 100644 --- a/lib/screens/settings/client_sections/client_settings_advanced.dart +++ b/lib/screens/settings/client_sections/client_settings_advanced.dart @@ -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 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), + ), + ), ], ); } diff --git a/lib/screens/shared/media/detailed_banner.dart b/lib/screens/shared/media/detailed_banner.dart index c2880b3..dc494eb 100644 --- a/lib/screens/shared/media/detailed_banner.dart +++ b/lib/screens/shared/media/detailed_banner.dart @@ -63,24 +63,26 @@ class _DetailedBannerState extends ConsumerState { 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, + ), ), ), ), diff --git a/lib/screens/shared/outlined_text_field.dart b/lib/screens/shared/outlined_text_field.dart index 99d962b..f8593d0 100644 --- a/lib/screens/shared/outlined_text_field.dart +++ b/lib/screens/shared/outlined_text_field.dart @@ -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> Function(String query)? searchQuery; final List? autoFillHints; final List? 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 { 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 { 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 { 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 { 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, ), ), ), diff --git a/lib/screens/shared/passcode_input.dart b/lib/screens/shared/passcode_input.dart index cb166a2..366f3e5 100644 --- a/lib/screens/shared/passcode_input.dart +++ b/lib/screens/shared/passcode_input.dart @@ -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 passCode; @@ -107,11 +108,16 @@ class _PassCodeInputState extends ConsumerState { } 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 { Widget get clearAllButton { return IconButton.filled( - autofocus: true, style: ButtonStyle( backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.errorContainer), iconColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer), diff --git a/lib/theme.dart b/lib/theme.dart index 1a4fcbc..451c7da 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -37,11 +37,11 @@ class FladderTheme { static ThemeData theme(ColorScheme? colorScheme, DynamicSchemeVariant dynamicSchemeVariant) { final ColorScheme? scheme = generateDynamicColourSchemes(colorScheme, dynamicSchemeVariant); - final buttonState = WidgetStateProperty.resolveWith( + final buttonSides = WidgetStateProperty.resolveWith( (states) { return BorderSide( width: 3, - color: scheme?.onPrimaryContainer.withValues(alpha: states.contains(WidgetState.focused) ? 0.9 : 0.0) ?? + color: scheme?.onPrimaryContainer.withValues(alpha: states.contains(WidgetState.focused) ? 1.0 : 0.0) ?? Colors.transparent, ); }, @@ -152,31 +152,31 @@ class FladderTheme { iconButtonTheme: IconButtonThemeData( style: ButtonStyle( shape: WidgetStatePropertyAll(smallShape), - side: buttonState, + side: buttonSides, ), ), elevatedButtonTheme: ElevatedButtonThemeData( style: ButtonStyle( shape: WidgetStatePropertyAll(smallShape), - side: buttonState, + side: buttonSides, ), ), filledButtonTheme: FilledButtonThemeData( style: ButtonStyle( shape: WidgetStatePropertyAll(smallShape), - side: buttonState, + side: buttonSides, ), ), outlinedButtonTheme: OutlinedButtonThemeData( style: ButtonStyle( shape: WidgetStatePropertyAll(smallShape), - side: buttonState, + side: buttonSides, ), ), textButtonTheme: TextButtonThemeData( style: ButtonStyle( shape: WidgetStatePropertyAll(smallShape), - side: buttonState, + side: buttonSides, ), ), textTheme: textTheme.copyWith( diff --git a/lib/util/focus_provider.dart b/lib/util/focus_provider.dart index 7201e7e..0c4fec8 100644 --- a/lib/util/focus_provider.dart +++ b/lib/util/focus_provider.dart @@ -8,11 +8,11 @@ import 'package:fladder/theme.dart'; import 'package:fladder/widgets/navigation_scaffold/components/navigation_body.dart'; final acceptKeys = { - LogicalKeyboardKey.space, LogicalKeyboardKey.enter, LogicalKeyboardKey.accept, LogicalKeyboardKey.select, LogicalKeyboardKey.gameButtonA, + LogicalKeyboardKey.space, }; class FocusProvider extends InheritedWidget { @@ -45,6 +45,7 @@ class FocusProvider extends InheritedWidget { class FocusButton extends StatefulWidget { final Widget? child; final bool autoFocus; + final FocusNode? focusNode; final List overlays; final Function()? onTap; final Function()? onLongPress; @@ -55,6 +56,7 @@ class FocusButton extends StatefulWidget { const FocusButton({ this.child, this.autoFocus = false, + this.focusNode, this.overlays = const [], this.onTap, this.onLongPress, @@ -69,7 +71,7 @@ class FocusButton extends StatefulWidget { } class FocusButtonState extends State { - FocusNode focusNode = FocusNode(); + late FocusNode focusNode = widget.focusNode ?? FocusNode(); ValueNotifier onHover = ValueNotifier(false); Timer? _longPressTimer; bool _longPressTriggered = false; diff --git a/lib/widgets/keyboard/alpha_numeric_keyboard.dart b/lib/widgets/keyboard/alpha_numeric_keyboard.dart new file mode 100644 index 0000000..6ef0255 --- /dev/null +++ b/lib/widgets/keyboard/alpha_numeric_keyboard.dart @@ -0,0 +1,254 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; + +import 'package:fladder/providers/settings/client_settings_provider.dart'; +import 'package:fladder/theme.dart'; +import 'package:fladder/widgets/keyboard/keyboard_localization.dart'; + +class AlphaNumericKeyboard extends ConsumerStatefulWidget { + final void Function(String character) onCharacter; + final TextInputType keyboardType; + final TextInputAction keyboardActionType; + final VoidCallback onBackspace; + final VoidCallback onClear; + final VoidCallback onDone; + + const AlphaNumericKeyboard({ + required this.onCharacter, + this.keyboardType = TextInputType.name, + this.keyboardActionType = TextInputAction.done, + required this.onBackspace, + required this.onClear, + required this.onDone, + super.key, + }); + + @override + ConsumerState createState() => _AlphaNumericKeyboardState(); +} + +class _AlphaNumericKeyboardState extends ConsumerState { + bool usingAlpha = true; + bool shift = false; + late TextInputType type = widget.keyboardType; + late TextInputAction actionType = widget.keyboardActionType; + + List> activeLayout(Locale locale) { + if (type == TextInputType.number) { + return [ + ['1', '2', '3', '⌫'], + ['4', '5', '6', ','], + ['7', '8', '9', '.'], + ['', '0', ''], + ]; + } + + final localeLayouts = KeyboardLayouts.layouts.containsKey(locale.languageCode) + ? KeyboardLayouts.layouts[locale.languageCode]! + : KeyboardLayouts.layouts['en']!; + return usingAlpha ? localeLayouts[KeyboardLayer.alpha]! : localeLayouts[KeyboardLayer.numericExtra]!; + } + + Widget buildKey(String label, {bool autofocus = false}) { + return Padding( + padding: const EdgeInsets.all(4), + child: ExcludeFocus( + excluding: label.isEmpty, + child: ElevatedButton( + autofocus: autofocus, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.all(8), + foregroundColor: Theme.of(context).colorScheme.onSurface, + ), + onPressed: label.isNotEmpty + ? () { + switch (label) { + case '⌫': + widget.onBackspace(); + break; + case '123': + case 'ABC': + setState(() => usingAlpha = !usingAlpha); + break; + default: + widget.onCharacter(shift ? label.toUpperCase() : label.toLowerCase()); + } + } + : null, + child: SizedBox.square( + dimension: 52, + child: FittedBox( + child: switch (label) { + '⌫' => const Padding( + padding: EdgeInsets.all(4), + child: Icon(Icons.backspace_rounded), + ), + _ => Text( + shift ? label.toUpperCase() : label.toLowerCase(), + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ) + }, + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final language = ref.watch(clientSettingsProvider + .select((value) => value.selectedLocale ?? WidgetsBinding.instance.platformDispatcher.locale)); + final rows = activeLayout(language); + + final helperButtons = switch (type) { + TextInputType.url => ["https://", "http://", ".", "://", ".com", ".nl"], + TextInputType.emailAddress => ["@gmail.com", "@hotmail.com"], + _ => [], + }; + + return FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 12, + children: [ + FittedBox( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int r = 0; r < rows.length; r++) + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + for (int c = 0; c < rows[r].length; c++) + buildKey( + rows[r][c], + autofocus: r == 0 && c == 0, + ), + ], + ), + ], + ), + ), + if (helperButtons.isNotEmpty) ...[ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + spacing: 8, + children: helperButtons + .map( + (e) => FilledButton.tonal( + onPressed: () => widget.onCharacter(e), + style: FilledButton.styleFrom( + shape: FladderTheme.smallShape, + ), + child: Text( + e, + ), + ), + ) + .toList(), + ), + ), + const Divider(), + ], + FittedBox( + child: SizedBox( + height: 58, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + spacing: 12, + children: [ + ...KeyboardActions.values.map( + (action) => FittedBox( + child: FilledButton.tonal( + style: FilledButton.styleFrom( + shape: FladderTheme.smallShape, + backgroundColor: switch (action) { + KeyboardActions.shift => shift ? Theme.of(context).colorScheme.primary : null, + _ => null, + }, + iconColor: switch (action) { + KeyboardActions.shift => shift ? Theme.of(context).colorScheme.onPrimary : null, + _ => null, + }, + ), + onPressed: () { + switch (action) { + case KeyboardActions.space: + widget.onCharacter(" "); + break; + case KeyboardActions.clear: + widget.onClear(); + break; + case KeyboardActions.action: + widget.onDone(); + break; + case KeyboardActions.shift: + setState(() { + shift = !shift; + }); + break; + } + }, + child: Padding( + padding: const EdgeInsets.all(12), + child: switch (action) { + KeyboardActions.action => Icon( + switch (actionType) { + TextInputAction.next => IconsaxPlusBold.next, + TextInputAction.done => Icons.check_rounded, + TextInputAction.send => IconsaxPlusBold.send_1, + _ => IconsaxPlusBold.send_1, + }, + size: 32, + ), + _ => switch (action) { + KeyboardActions.shift => const Icon(Icons.keyboard_capslock_rounded, size: 32), + _ => Text( + action.label(context).toUpperCase(), + style: + Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ) + } + }, + ), + ), + ), + ), + ], + ), + ), + ) + ], + ), + ); + } +} + +enum KeyboardActions { + shift, + space, + clear, + action; + + const KeyboardActions(); + + String label(BuildContext context) { + return switch (this) { + KeyboardActions.space => "Space", + KeyboardActions.clear => "Clear", + KeyboardActions.action => "Action", + KeyboardActions.shift => "Shift", + }; + } +} diff --git a/lib/widgets/keyboard/custom_keyboard.dart b/lib/widgets/keyboard/custom_keyboard.dart new file mode 100644 index 0000000..e527735 --- /dev/null +++ b/lib/widgets/keyboard/custom_keyboard.dart @@ -0,0 +1,309 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:iconsax_plus/iconsax_plus.dart'; + +import 'package:fladder/screens/shared/animated_fade_size.dart'; +import 'package:fladder/util/focus_provider.dart'; +import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/widgets/keyboard/alpha_numeric_keyboard.dart'; + +class CustomKeyboard extends InheritedWidget { + final CustomKeyboardState state; + + const CustomKeyboard({ + required this.state, + required super.child, + super.key, + }); + + static CustomKeyboardState of(BuildContext context) { + final inherited = context.dependOnInheritedWidgetOfExactType(); + assert(inherited != null, 'No CustomKeyboard found in context'); + return inherited!.state; + } + + @override + bool updateShouldNotify(CustomKeyboard oldWidget) => state != oldWidget.state; +} + +class CustomKeyboardWrapper extends StatefulWidget { + final Widget child; + + const CustomKeyboardWrapper({super.key, required this.child}); + + @override + CustomKeyboardState createState() => CustomKeyboardState(); +} + +class CustomKeyboardState extends State { + bool _isOpen = false; + TextEditingController? _controller; + TextField? _textField; + + bool get isOpen => _isOpen; + + VoidCallback? onCloseCall; + + FutureOr> Function(String query)? searchQuery; + + void openKeyboard( + TextField textField, { + VoidCallback? onClosed, + FutureOr> Function(String query)? query, + }) { + onCloseCall = onClosed; + + searchQuery = query; + + setState(() { + _isOpen = true; + _textField = textField; + _controller = textField.controller; + }); + _controller?.addListener(() { + _textField?.onChanged?.call(_controller?.text ?? ""); + }); + } + + void closeKeyboard() { + setState(() { + _isOpen = false; + }); + onCloseCall?.call(); + onCloseCall = null; + if (_controller != null) { + _textField?.onSubmitted?.call(_controller?.text ?? ""); + _textField?.onEditingComplete?.call(); + _controller?.removeListener(() { + _textField?.onChanged?.call(_controller?.text ?? ""); + }); + } + } + + @override + void dispose() { + super.dispose(); + _controller?.dispose(); + } + + @override + Widget build(BuildContext context) { + final mq = MediaQuery.of(context); + return BackButtonListener( + onBackButtonPressed: () async { + if (!context.mounted) return false; + if (_isOpen && context.mounted) { + closeKeyboard(); + return true; + } else { + return false; + } + }, + child: CustomKeyboard( + state: this, + child: Container( + color: Theme.of(context).colorScheme.surface, + alignment: Alignment.center, + child: Row( + children: [ + AnimatedSize( + duration: const Duration(milliseconds: 125), + child: _isOpen + ? SizedBox( + width: mq.size.width * 0.20, + height: double.infinity, + child: Material( + color: Theme.of(context).colorScheme.surface, + child: _CustomKeyboardView( + controller: _controller!, + keyboardType: _textField?.keyboardType, + keyboardActionType: _textField?.textInputAction, + onClose: closeKeyboard, + onChanged: () => _textField?.onChanged?.call(_controller?.text ?? ""), + searchQuery: searchQuery, + ), + ), + ) + : const SizedBox.shrink(), + ), + Expanded(child: widget.child), + ], + ), + ), + ), + ); + } +} + +class _CustomKeyboardView extends StatefulWidget { + final TextEditingController controller; + final TextInputAction? keyboardActionType; + final TextInputType? keyboardType; + + final VoidCallback onChanged; + final VoidCallback onClose; + final FutureOr> Function(String query)? searchQuery; + + const _CustomKeyboardView({ + required this.controller, + required this.onChanged, + required this.onClose, + this.keyboardActionType, + this.keyboardType, + this.searchQuery, + }); + + @override + State<_CustomKeyboardView> createState() => _CustomKeyboardViewState(); +} + +class _CustomKeyboardViewState extends State<_CustomKeyboardView> { + final FocusScopeNode scope = FocusScopeNode(); + + ValueNotifier> searchQueryResults = ValueNotifier([]); + + Future startUpdate(String text) async { + final newValues = await widget.searchQuery?.call(widget.controller.text) ?? []; + searchQueryResults.value = newValues; + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((value) { + startUpdate(widget.controller.text); + }); + } + + @override + Widget build(BuildContext context) { + if (!scope.hasFocus) { + scope.requestFocus(); + } + return FocusScope( + node: scope, + autofocus: true, + child: Padding( + padding: const EdgeInsets.all(12.0).add(MediaQuery.paddingOf(context)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + widget.keyboardType == TextInputType.visiblePassword + ? List.generate( + widget.controller.text.length, + (index) => "*", + ).join() + : widget.controller.text, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ), + if (widget.searchQuery != null) + ValueListenableBuilder( + valueListenable: searchQueryResults, + builder: (context, values, child) => _SearchResults( + results: values, + query: widget.controller.text, + onTap: (value) { + widget.controller.text = value; + widget.onClose(); + }, + ), + ), + Flexible( + child: AlphaNumericKeyboard( + onCharacter: (value) => setState(() { + widget.controller.text += value; + startUpdate(widget.controller.text); + }), + keyboardType: widget.keyboardType ?? TextInputType.name, + keyboardActionType: widget.keyboardActionType ?? TextInputAction.done, + onBackspace: () { + setState(() { + widget.controller.text = widget.controller.text.substring(0, widget.controller.text.length - 1); + widget.onChanged(); + }); + startUpdate(widget.controller.text); + }, + onClear: () => setState(() => widget.controller.clear()), + onDone: widget.onClose, + ), + ), + ], + ), + ), + ); + } +} + +class _SearchResults extends StatelessWidget { + final List results; + final String query; + final Function(String value) onTap; + const _SearchResults({ + required this.results, + required this.query, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final minHeight = MediaQuery.sizeOf(context).height * 0.25; + return AnimatedFadeSize( + alignment: Alignment.topCenter, + child: results.isNotEmpty && query.isNotEmpty + ? ConstrainedBox( + constraints: BoxConstraints(minHeight: minHeight), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 6, + children: [ + ...results.map( + (result) => FocusButton( + onTap: () => onTap(result), + child: Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisSize: MainAxisSize.max, + spacing: 8, + children: [ + const Icon(IconsaxPlusLinear.arrow_right), + Flexible( + child: Text( + result, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ) + : SizedBox( + height: minHeight, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8, + children: [ + const Icon(IconsaxPlusLinear.search_status_1), + Text(context.localized.noResults), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/keyboard/keyboard_localization.dart b/lib/widgets/keyboard/keyboard_localization.dart new file mode 100644 index 0000000..bf1c7de --- /dev/null +++ b/lib/widgets/keyboard/keyboard_localization.dart @@ -0,0 +1,92 @@ +class KeyboardLayouts { + static const Map>>> layouts = { + 'en': { + KeyboardLayer.alpha: [ + ['A', 'B', 'C', 'D', 'E', 'F', 'G', '⌫'], + ['H', 'I', 'J', 'K', 'L', 'M', 'N', '123'], + ['O', 'P', 'Q', 'R', 'S', 'T', 'U'], + ['V', 'W', 'X', 'Y', 'Z'], + ], + KeyboardLayer.numericExtra: [ + ['1', '2', '3', '&', '#', '(', ')', '⌫'], + ['4', '5', '6', '@', '!', '?', ':', 'ABC'], + ['7', '8', '9', '.', '-', '_', '"', ':'], + ['0', '/', '\$', '%', '+', '[', ']'], + ], + }, + 'es': { + KeyboardLayer.alpha: [ + ['A', 'B', 'C', 'D', 'E', 'F', 'G', '⌫'], + ['H', 'I', 'J', 'K', 'L', 'M', 'N', '123'], + ['O', 'P', 'Q', 'R', 'S', 'T', 'U'], + ['V', 'W', 'X', 'Y', 'Z', 'Ñ'], + ], + KeyboardLayer.numericExtra: [ + ['1', '2', '3', '&', '#', '(', ')', '⌫'], + ['4', '5', '6', '@', '!', '?', ';', 'ABC'], + ['7', '8', '9', '.', '-', '_', '"'], + ['0', '/', '\$', '%', '+', '[', ']'], + ], + }, + 'de': { + KeyboardLayer.alpha: [ + ['A', 'B', 'C', 'D', 'E', 'F', 'G', '⌫'], + ['H', 'I', 'J', 'K', 'L', 'M', 'N', '123'], + ['O', 'P', 'Q', 'R', 'S', 'T', 'U'], + ['V', 'W', 'X', 'Y', 'Z', 'Ä', 'Ö', 'Ü'], + ], + KeyboardLayer.numericExtra: [ + ['1', '2', '3', '&', '#', '(', ')', '⌫'], + ['4', '5', '6', '@', '!', '?', ';', 'ABC'], + ['7', '8', '9', '.', '-', '_', '"'], + ['0', '/', '\$', '%', '+', '[', ']'], + ], + }, + 'fr': { + KeyboardLayer.alpha: [ + ['A', 'B', 'C', 'D', 'E', 'F', 'G', '⌫'], + ['H', 'I', 'J', 'K', 'L', 'M', 'N', '123'], + ['O', 'P', 'Q', 'R', 'S', 'T', 'U'], + ['V', 'W', 'X', 'Y', 'Z', 'É', 'È', 'À'], + ], + KeyboardLayer.numericExtra: [ + ['1', '2', '3', '&', '#', '(', ')', '⌫'], + ['4', '5', '6', '@', '!', '?', ';', 'ABC'], + ['7', '8', '9', '.', '-', '_', '"'], + ['0', '/', '\$', '%', '+', '[', ']'], + ], + }, + 'ja': { + KeyboardLayer.alpha: [ + ['あ', 'い', 'う', 'え', 'お', 'か', 'き', '⌫'], + ['さ', 'し', 'す', 'せ', 'そ', 'た', 'ち', '123'], + ['な', 'に', 'ぬ', 'ね', 'の', 'は', 'ひ'], + ['ふ', 'へ', 'ほ', 'ま', 'み', 'む', 'も', 'や', 'ゆ', 'よ'], + ], + KeyboardLayer.numericExtra: [ + ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '⌫'], + ['!', '@', '#', '\$', '%', '^', '&', '*', '(', ')', 'ABC'], + ], + }, + 'zh': { + KeyboardLayer.alpha: [ + ['啊', '波', '从', '的', '饿', '发', '个', '⌫'], + ['喝', '衣', '机', '卡', '拉', '马', '呢', '123'], + ['哦', '啪', '期', '然', '色', '他', '乌'], + ['为', '西', '行', '呀', '月', '子'], + ], + KeyboardLayer.numericExtra: [ + ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '⌫'], + ['!', '@', '#', '\$', '%', '^', '&', '*', '(', ')', 'ABC'], + ], + }, + }; +} + +enum KeyboardLayer { + alpha, + numericExtra, + numeric, + email, + domain, +} diff --git a/lib/widgets/shared/horizontal_list.dart b/lib/widgets/shared/horizontal_list.dart index d85022e..1f1c916 100644 --- a/lib/widgets/shared/horizontal_list.dart +++ b/lib/widgets/shared/horizontal_list.dart @@ -61,7 +61,6 @@ class _HorizontalListState extends ConsumerState { final contentPadding = 8.0; double? contentWidth; double? _firstItemWidth; - bool hasFocus = false; @override void initState() { @@ -256,7 +255,7 @@ class _HorizontalListState extends ConsumerState { position.ensureVisible( renderObject, alignment: _calcAlignmentWithPadding(nodeContext), - duration: const Duration(milliseconds: 200), + duration: const Duration(milliseconds: 175), curve: Curves.fastOutSlowIn, ); }