chore: Improved custom keyboard logic

This commit is contained in:
PartyDonut 2025-10-11 18:46:25 +02:00
parent 07972ea5ee
commit 117d873683
10 changed files with 203 additions and 202 deletions

View file

@ -33,19 +33,21 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
ImageType.banner, ImageType.banner,
}.toList(); }.toList();
final fieldsToFetch = [
ItemFields.parentid,
ItemFields.mediastreams,
ItemFields.mediasources,
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
ItemFields.overview,
ItemFields.genres,
];
if (viewTypes.containsAny([CollectionType.movies, CollectionType.tvshows])) { if (viewTypes.containsAny([CollectionType.movies, CollectionType.tvshows])) {
final resumeVideoResponse = await api.usersUserIdItemsResumeGet( final resumeVideoResponse = await api.usersUserIdItemsResumeGet(
enableImageTypes: imagesToFetch, enableImageTypes: imagesToFetch,
fields: [ fields: fieldsToFetch,
ItemFields.parentid,
ItemFields.mediastreams,
ItemFields.mediasources,
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
ItemFields.overview,
ItemFields.genres,
],
mediaTypes: [MediaType.video], mediaTypes: [MediaType.video],
enableTotalRecordCount: false, enableTotalRecordCount: false,
); );
@ -58,16 +60,7 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
if (viewTypes.contains(CollectionType.music)) { if (viewTypes.contains(CollectionType.music)) {
final resumeAudioResponse = await api.usersUserIdItemsResumeGet( final resumeAudioResponse = await api.usersUserIdItemsResumeGet(
enableImageTypes: imagesToFetch, enableImageTypes: imagesToFetch,
fields: [ fields: fieldsToFetch,
ItemFields.parentid,
ItemFields.mediastreams,
ItemFields.mediasources,
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
ItemFields.overview,
ItemFields.genres,
],
mediaTypes: [MediaType.audio], mediaTypes: [MediaType.audio],
enableTotalRecordCount: false, enableTotalRecordCount: false,
); );
@ -80,16 +73,7 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
if (viewTypes.contains(CollectionType.books)) { if (viewTypes.contains(CollectionType.books)) {
final resumeBookResponse = await api.usersUserIdItemsResumeGet( final resumeBookResponse = await api.usersUserIdItemsResumeGet(
enableImageTypes: imagesToFetch, enableImageTypes: imagesToFetch,
fields: [ fields: fieldsToFetch,
ItemFields.parentid,
ItemFields.mediastreams,
ItemFields.mediasources,
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
ItemFields.overview,
ItemFields.genres,
],
mediaTypes: [MediaType.book], mediaTypes: [MediaType.book],
enableTotalRecordCount: false, enableTotalRecordCount: false,
); );
@ -102,16 +86,7 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
final nextResponse = await api.showsNextUpGet( final nextResponse = await api.showsNextUpGet(
nextUpDateCutoff: DateTime.now().subtract( nextUpDateCutoff: DateTime.now().subtract(
ref.read(clientSettingsProvider.select((value) => value.nextUpDateCutoff ?? const Duration(days: 28)))), ref.read(clientSettingsProvider.select((value) => value.nextUpDateCutoff ?? const Duration(days: 28)))),
fields: [ fields: fieldsToFetch,
ItemFields.parentid,
ItemFields.mediastreams,
ItemFields.mediasources,
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
ItemFields.overview,
ItemFields.genres,
],
); );
final next = nextResponse.body?.items final next = nextResponse.body?.items

View file

@ -105,7 +105,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
valueListenable: selectedPoster, valueListenable: selectedPoster,
builder: (_, value, __) { builder: (_, value, __) {
return BackgroundImage( return BackgroundImage(
items: value != null ? [value] : [...homeCarouselItems, ...dashboardData.nextUp, ...allResume], images: (value != null
? [value]
: [
...homeCarouselItems,
...dashboardData.nextUp,
...allResume,
])
.map((e) => e.images)
.nonNulls
.toList(),
); );
}, },
), ),

View file

@ -14,7 +14,7 @@ import 'package:fladder/screens/shared/fladder_snackbar.dart';
import 'package:fladder/util/input_handler.dart'; import 'package:fladder/util/input_handler.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/string_extensions.dart'; import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/widgets/keyboard/custom_keyboard.dart'; import 'package:fladder/widgets/keyboard/slide_in_keyboard.dart';
import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.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/components/destination_model.dart';
import 'package:fladder/widgets/navigation_scaffold/navigation_scaffold.dart'; import 'package:fladder/widgets/navigation_scaffold/navigation_scaffold.dart';

View file

@ -13,7 +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/animated_fade_size.dart';
import 'package:fladder/screens/shared/fladder_logo.dart'; import 'package:fladder/screens/shared/fladder_logo.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/widgets/keyboard/custom_keyboard.dart'; import 'package:fladder/widgets/keyboard/slide_in_keyboard.dart';
import 'package:fladder/widgets/navigation_scaffold/components/fladder_app_bar.dart'; import 'package:fladder/widgets/navigation_scaffold/components/fladder_app_bar.dart';
@RoutePage() @RoutePage()

View file

@ -251,7 +251,7 @@ class _PosterImageState extends ConsumerState<PosterImage> {
), ),
), ),
if ((widget.poster.unPlayedItemCount != null && widget.poster is SeriesModel) || if ((widget.poster.unPlayedItemCount != null && widget.poster is SeriesModel) ||
(widget.poster.playAble && !widget.poster.unWatched)) (widget.poster.playAble && !widget.poster.unWatched && widget.poster is! PhotoAlbumModel))
IgnorePointer( IgnorePointer(
child: Align( child: Align(
alignment: Alignment.topRight, alignment: Alignment.topRight,

View file

@ -11,7 +11,7 @@ import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/theme.dart'; import 'package:fladder/theme.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/widgets/keyboard/custom_keyboard.dart'; import 'package:fladder/widgets/keyboard/slide_in_keyboard.dart';
import 'package:fladder/widgets/shared/ensure_visible.dart'; import 'package:fladder/widgets/shared/ensure_visible.dart';
class OutlinedTextField extends ConsumerStatefulWidget { class OutlinedTextField extends ConsumerStatefulWidget {
@ -71,6 +71,7 @@ class OutlinedTextField extends ConsumerStatefulWidget {
} }
class _OutlinedTextFieldState extends ConsumerState<OutlinedTextField> { class _OutlinedTextFieldState extends ConsumerState<OutlinedTextField> {
late final controller = widget.controller ?? TextEditingController();
late final FocusNode _textFocus = widget.focusNode ?? FocusNode(); late final FocusNode _textFocus = widget.focusNode ?? FocusNode();
late final FocusNode _wrapperFocus = FocusNode() late final FocusNode _wrapperFocus = FocusNode()
..addListener(() { ..addListener(() {
@ -130,7 +131,7 @@ class _OutlinedTextFieldState extends ConsumerState<OutlinedTextField> {
ref.watch(clientSettingsProvider.select((value) => !value.useSystemIME)); ref.watch(clientSettingsProvider.select((value) => !value.useSystemIME));
final textField = TextField( final textField = TextField(
controller: widget.controller, controller: controller,
onChanged: widget.onChanged, onChanged: widget.onChanged,
focusNode: _textFocus, focusNode: _textFocus,
onTap: widget.onTap, onTap: widget.onTap,
@ -199,26 +200,28 @@ class _OutlinedTextFieldState extends ConsumerState<OutlinedTextField> {
ignoring: widget.enabled == false, ignoring: widget.enabled == false,
child: KeyboardListener( child: KeyboardListener(
focusNode: _wrapperFocus, focusNode: _wrapperFocus,
onKeyEvent: (KeyEvent event) { onKeyEvent: (KeyEvent event) async {
if (keyboardFocus || AdaptiveLayout.inputDeviceOf(context) != InputDevice.dPad) return; if (keyboardFocus || AdaptiveLayout.inputDeviceOf(context) != InputDevice.dPad) return;
if (event is KeyDownEvent && acceptKeys.contains(event.logicalKey)) { if (event is KeyDownEvent && acceptKeys.contains(event.logicalKey)) {
if (_textFocus.hasFocus) { if (_textFocus.hasFocus) {
_wrapperFocus.requestFocus(); _wrapperFocus.requestFocus();
} else if (_wrapperFocus.hasFocus) { } else if (_wrapperFocus.hasFocus) {
if (useCustomKeyboard) { if (useCustomKeyboard) {
CustomKeyboard.of(context).openKeyboard( await openKeyboard(
textField, context,
onClosed: () { controller,
setState(() { inputType: widget.keyboardType,
keyboardFocus = false; inputAction: widget.textInputAction,
}); searchQuery: widget.searchQuery,
_wrapperFocus.requestFocus(); onChanged: () {
widget.onChanged?.call(controller.text);
}, },
query: widget.searchQuery,
); );
widget.onSubmitted?.call(controller.text);
setState(() { setState(() {
keyboardFocus = true; keyboardFocus = false;
}); });
_wrapperFocus.requestFocus();
} else { } else {
_textFocus.requestFocus(); _textFocus.requestFocus();
} }

View file

@ -12,6 +12,7 @@ import 'package:fladder/util/debug_banner.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/poster_defaults.dart'; import 'package:fladder/util/poster_defaults.dart';
import 'package:fladder/util/resolution_checker.dart'; import 'package:fladder/util/resolution_checker.dart';
import 'package:fladder/widgets/keyboard/slide_in_keyboard.dart';
enum InputDevice { enum InputDevice {
touch, touch,
@ -209,32 +210,40 @@ class _AdaptiveLayoutBuilderState extends ConsumerState<AdaptiveLayoutBuilder> {
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
return MediaQuery( return ValueListenableBuilder(
data: mediaQuery.copyWith( valueListenable: isKeyboardOpen,
padding: isDesktop || kIsWeb ? const EdgeInsets.only(top: defaultTitleBarHeight, bottom: 16) : null, builder: (context, value, child) {
viewPadding: isDesktop || kIsWeb ? const EdgeInsets.only(top: defaultTitleBarHeight, bottom: 16) : null, return MediaQuery(
), data: mediaQuery.copyWith(
child: AdaptiveLayout( padding: (isDesktop || kIsWeb
data: currentLayout.copyWith( ? const EdgeInsets.only(top: defaultTitleBarHeight, bottom: 16)
viewSize: selectedViewSize, : mediaQuery.padding),
layoutMode: selectedLayoutMode, viewPadding: isDesktop || kIsWeb ? const EdgeInsets.only(top: defaultTitleBarHeight, bottom: 16) : null,
inputDevice: input, ),
platform: currentPlatform, child: AdaptiveLayout(
isDesktop: isDesktop, data: currentLayout.copyWith(
controller: scrollControllers, viewSize: selectedViewSize,
posterDefaults: posterDefaults, layoutMode: selectedLayoutMode,
), inputDevice: input,
child: Builder( platform: currentPlatform,
builder: (context) => isDesktop isDesktop: isDesktop,
? ResolutionChecker( controller: scrollControllers,
child: posterDefaults: posterDefaults,
widget.adaptiveLayout == null ? DebugBanner(child: widget.child(context)) : widget.child(context), ),
) child: Builder(
: widget.adaptiveLayout == null builder: (context) => isDesktop
? DebugBanner(child: widget.child(context)) ? ResolutionChecker(
: widget.child(context), child: widget.adaptiveLayout == null
), ? DebugBanner(child: widget.child(context))
), : widget.child(context),
)
: widget.adaptiveLayout == null
? DebugBanner(child: widget.child(context))
: widget.child(context),
),
),
);
},
); );
} }
} }

View file

@ -141,7 +141,6 @@ class FocusButtonState extends State<FocusButton> {
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
onEnter: (event) => onHover.value = true, onEnter: (event) => onHover.value = true,
onExit: (event) => onHover.value = false, onExit: (event) => onHover.value = false,
hitTestBehavior: HitTestBehavior.translucent,
child: Focus( child: Focus(
focusNode: focusNode, focusNode: focusNode,
autofocus: widget.autoFocus, autofocus: widget.autoFocus,
@ -160,7 +159,13 @@ class FocusButtonState extends State<FocusButton> {
onTap: widget.onTap, onTap: widget.onTap,
onSecondaryTapDown: widget.onSecondaryTapDown, onSecondaryTapDown: widget.onSecondaryTapDown,
onLongPress: widget.onLongPress, onLongPress: widget.onLongPress,
child: widget.child, child: Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: widget.borderRadius ?? FladderTheme.smallShape.borderRadius,
),
child: widget.child,
),
), ),
Positioned.fill( Positioned.fill(
child: ValueListenableBuilder( child: ValueListenableBuilder(

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart'; import 'package:fladder/screens/shared/animated_fade_size.dart';
@ -9,128 +10,124 @@ import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/keyboard/alpha_numeric_keyboard.dart'; import 'package:fladder/widgets/keyboard/alpha_numeric_keyboard.dart';
class CustomKeyboard extends InheritedWidget { ValueNotifier<bool> isKeyboardOpen = ValueNotifier<bool>(false);
final CustomKeyboardState state;
const CustomKeyboard({ double keyboardWidthFactor = 0.2;
required this.state,
required super.child, class CustomKeyboardWrapper extends StatelessWidget {
final Widget child;
const CustomKeyboardWrapper({
required this.child,
super.key, super.key,
}); });
static CustomKeyboardState of(BuildContext context) {
final inherited = context.dependOnInheritedWidgetOfExactType<CustomKeyboard>();
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<CustomKeyboardWrapper> {
bool _isOpen = false;
TextEditingController? _controller;
TextField? _textField;
bool get isOpen => _isOpen;
VoidCallback? onCloseCall;
FutureOr<List<String>> Function(String query)? searchQuery;
void openKeyboard(
TextField textField, {
VoidCallback? onClosed,
FutureOr<List<String>> 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mq = MediaQuery.of(context); return Container(
return BackButtonListener( color: Theme.of(context).colorScheme.surface,
onBackButtonPressed: () async { child: ValueListenableBuilder(
if (!context.mounted) return false; valueListenable: isKeyboardOpen,
if (_isOpen && context.mounted) { builder: (context, value, _) {
closeKeyboard(); return AnimatedFractionallySizedBox(
return true; duration: const Duration(milliseconds: 175),
} else { widthFactor: value ? 1.0 - keyboardWidthFactor : 1.0,
return false; heightFactor: 1.0,
} alignment: Alignment.centerRight,
}, child: child,
child: CustomKeyboard( );
state: this, },
),
);
}
}
Future<T?> openKeyboard<T>(
BuildContext context,
TextEditingController controller, {
TextInputType? inputType,
TextInputAction? inputAction,
FutureOr<List<String>> Function(String query)? searchQuery,
VoidCallback? onChanged,
}) async {
isKeyboardOpen.value = true;
await showGeneralDialog(
context: context,
transitionDuration: const Duration(milliseconds: 175),
barrierDismissible: true,
barrierColor: Colors.transparent,
barrierLabel: 'Custom keyboard',
useRootNavigator: true,
fullscreenDialog: true,
transitionBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween(begin: const Offset(-1, 0), end: const Offset(0, 0)).animate(
animation,
),
child: child,
);
},
pageBuilder: (context, animation1, animation2) {
return Align(
alignment: Alignment.centerLeft,
child: _SlideInKeyboard(
controller: controller,
onChanged: onChanged ?? () {},
onClose: () {
context.router.pop();
isKeyboardOpen.value = false;
return null;
},
inputType: inputType,
inputAction: inputAction,
searchQuery: searchQuery,
),
);
},
);
isKeyboardOpen.value = false;
return null;
}
class _SlideInKeyboard extends StatefulWidget {
final TextEditingController controller;
final Function() onChanged;
final Function() onClose;
final TextInputType? inputType;
final TextInputAction? inputAction;
final FutureOr<List<String>> Function(String query)? searchQuery;
const _SlideInKeyboard({
required this.controller,
required this.onChanged,
required this.onClose,
this.inputType,
this.inputAction,
this.searchQuery,
});
@override
State<_SlideInKeyboard> createState() => __SlideInKeyboardState();
}
class __SlideInKeyboardState extends State<_SlideInKeyboard> {
@override
Widget build(BuildContext context) {
final padding = MediaQuery.paddingOf(context);
final width = MediaQuery.sizeOf(context).width * keyboardWidthFactor;
return FractionallySizedBox(
widthFactor: keyboardWidthFactor,
heightFactor: 1.0,
child: Padding(
padding: padding.copyWith(left: (padding.left - width).clamp(0, padding.left)),
child: Container( child: Container(
height: double.infinity,
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
alignment: Alignment.center, child: _CustomKeyboardView(
child: Row( controller: widget.controller,
crossAxisAlignment: CrossAxisAlignment.stretch, onChanged: widget.onChanged,
children: [ onClose: widget.onClose,
AnimatedSize( keyboardType: widget.inputType,
duration: const Duration(milliseconds: 125), keyboardActionType: widget.inputAction,
child: _isOpen searchQuery: widget.searchQuery,
? 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),
],
), ),
), ),
), ),
@ -187,7 +184,7 @@ class _CustomKeyboardViewState extends State<_CustomKeyboardView> {
node: scope, node: scope,
autofocus: true, autofocus: true,
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0).add(MediaQuery.paddingOf(context)), padding: const EdgeInsets.all(12.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 16, spacing: 16,
@ -301,7 +298,10 @@ class _SearchResults extends StatelessWidget {
spacing: 8, spacing: 8,
children: [ children: [
const Icon(IconsaxPlusLinear.search_status_1), const Icon(IconsaxPlusLinear.search_status_1),
Text(context.localized.noResults), Text(
context.localized.noResults,
style: Theme.of(context).textTheme.titleLarge,
),
], ],
), ),
), ),

View file

@ -31,7 +31,7 @@ class _BackgroundImageState extends ConsumerState<BackgroundImage> {
@override @override
void didUpdateWidget(covariant BackgroundImage oldWidget) { void didUpdateWidget(covariant BackgroundImage oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (!oldWidget.items.equals(widget.items)) { if (!oldWidget.items.equals(widget.items) || !oldWidget.images.equals(widget.images)) {
updateItems(); updateItems();
} }
} }
@ -46,7 +46,7 @@ class _BackgroundImageState extends ConsumerState<BackgroundImage> {
ImageData? newImage; ImageData? newImage;
if (widget.images.isNotEmpty) { if (widget.images.isNotEmpty) {
newImage = widget.images.shuffled().firstOrNull?.primary; newImage = widget.images.shuffled().firstOrNull?.randomBackDrop;
} else if (widget.items.isNotEmpty) { } else if (widget.items.isNotEmpty) {
final randomItem = widget.items.shuffled().firstOrNull; final randomItem = widget.items.shuffled().firstOrNull;
final itemId = switch (randomItem?.type) { final itemId = switch (randomItem?.type) {