feat: Implement custom keyboard for Android TV (#523)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2025-10-09 09:59:30 +02:00 committed by GitHub
parent 721fc28060
commit 75c2f958b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 927 additions and 157 deletions

View file

@ -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<AlphaNumericKeyboard> createState() => _AlphaNumericKeyboardState();
}
class _AlphaNumericKeyboardState extends ConsumerState<AlphaNumericKeyboard> {
bool usingAlpha = true;
bool shift = false;
late TextInputType type = widget.keyboardType;
late TextInputAction actionType = widget.keyboardActionType;
List<List<String>> 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",
};
}
}

View file

@ -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<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
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<List<String>> 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<List<String>> searchQueryResults = ValueNotifier([]);
Future<void> 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<String> 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),
],
),
),
);
}
}

View file

@ -0,0 +1,92 @@
class KeyboardLayouts {
static const Map<String, Map<KeyboardLayer, List<List<String>>>> 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,
}