824 lines
27 KiB
Dart
824 lines
27 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:forui/forui.dart';
|
||
|
||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||
|
||
/// 自研软键盘的初始面板类型。
|
||
enum AppSoftKeyboardMode {
|
||
/// 数字面板(1-9、0、退格、清空)。
|
||
digits,
|
||
|
||
/// 字母面板(QWERTY 布局,支持大小写切换与空格)。
|
||
letters,
|
||
|
||
/// 符号面板(常用标点与特殊符号)。
|
||
symbols,
|
||
}
|
||
|
||
/// 浮层软键盘的默认停靠位置。
|
||
enum AppSoftKeyboardDock {
|
||
/// 屏幕底部水平居中;适配表单位于页面上方的常规场景。
|
||
bottomCenter,
|
||
|
||
/// 屏幕左侧垂直居中;适配表单在右侧的页面(如登录页),键盘停靠品牌区
|
||
/// 一侧,不再遮挡输入框与登录按钮。
|
||
centerLeft,
|
||
}
|
||
|
||
/// 自研软键盘面板:数字/字母/符号三面板 + 顶部切换 tab。
|
||
///
|
||
/// 定制 ROM 在检测到物理键盘连接时不渲染系统软键盘窗口,本面板以确定性
|
||
/// 自绘接管触摸输入:显式绑定目标 [TextEditingController],在光标位置插入、
|
||
/// 退格或清空,字符白名单与长度上限由调用场景声明。两种使用方式:
|
||
///
|
||
/// - 页面输入框:[AppSoftKeyboard.show] 弹出可拖动的浮层键盘;
|
||
/// - 矮弹框内的输入(如终端操作验证):直接把 [AppSoftKeyboardPanel] 放进
|
||
/// 弹框 content,键盘跟随弹框而不与弹框分离。
|
||
///
|
||
/// 目标输入框仍应保持物理输入能力:设置 [TextInputType.none] 只抑制系统
|
||
/// 软键盘,实体键盘与扫码枪的按键事件不受影响。
|
||
class AppSoftKeyboardPanel extends StatefulWidget {
|
||
/// 创建内嵌键盘面板;宽度默认跟随父级约束。
|
||
const AppSoftKeyboardPanel({
|
||
super.key,
|
||
required this.controller,
|
||
this.mode = AppSoftKeyboardMode.digits,
|
||
this.allowPattern,
|
||
this.maxLength,
|
||
this.semanticLabel = '终端软键盘',
|
||
this.onCloseRequested,
|
||
this.contentWidth,
|
||
});
|
||
|
||
/// 绑定的目标输入控制器;按键在光标位置插入或删除。
|
||
final TextEditingController controller;
|
||
|
||
/// 初始面板类型。
|
||
final AppSoftKeyboardMode mode;
|
||
|
||
/// 可插入字符白名单(正则逐字符匹配);为空时不限制。
|
||
final RegExp? allowPattern;
|
||
|
||
/// 目标文本总长度上限;超限时新插入被忽略。
|
||
final int? maxLength;
|
||
|
||
/// 无障碍标签,用于区分宿主场景中的多个键盘实例。
|
||
final String semanticLabel;
|
||
|
||
/// 请求收起键盘的回调;非空时面板显示通栏“确认”键(浮层模式),
|
||
/// 为空表示内嵌在弹框中,提交由弹框自身按钮承担。
|
||
final VoidCallback? onCloseRequested;
|
||
|
||
/// 面板内容区宽度;为空时跟随父级约束(内嵌弹框场景)。
|
||
final double? contentWidth;
|
||
|
||
@override
|
||
State<AppSoftKeyboardPanel> createState() => _AppSoftKeyboardPanelState();
|
||
}
|
||
|
||
class _AppSoftKeyboardPanelState extends State<AppSoftKeyboardPanel> {
|
||
/// 当前面板类型;由顶部 tab 与字母面板的 123 键切换。
|
||
AppSoftKeyboardMode _mode = AppSoftKeyboardMode.digits;
|
||
|
||
/// 字母面板当前是否大写;默认小写,按 ⇧ 切换。
|
||
bool _uppercase = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_mode = widget.mode;
|
||
}
|
||
|
||
/// 校验并插入文本到光标位置;替换当前选区,插入后光标落在文本末尾。
|
||
void _insert(String text) {
|
||
if (text.isEmpty) return;
|
||
final allowPattern = widget.allowPattern;
|
||
if (allowPattern != null &&
|
||
text.runes
|
||
.any((code) => !allowPattern.hasMatch(String.fromCharCode(code)))) {
|
||
return;
|
||
}
|
||
final value = widget.controller.value;
|
||
final selection = value.selection.isValid
|
||
? value.selection
|
||
: TextSelection.collapsed(offset: value.text.length);
|
||
final removedLength = selection.end - selection.start;
|
||
final maxLength = widget.maxLength;
|
||
if (maxLength != null &&
|
||
value.text.length - removedLength + text.length > maxLength) {
|
||
return;
|
||
}
|
||
widget.controller.value = value.replaced(selection, text);
|
||
}
|
||
|
||
/// 删除选区或光标前一位字符。
|
||
void _backspace() {
|
||
final value = widget.controller.value;
|
||
if (value.text.isEmpty) return;
|
||
final selection = value.selection.isValid
|
||
? value.selection
|
||
: TextSelection.collapsed(offset: value.text.length);
|
||
final range = selection.isCollapsed
|
||
? TextRange(start: selection.start - 1, end: selection.end)
|
||
: selection;
|
||
widget.controller.value = value.replaced(range, '');
|
||
}
|
||
|
||
/// 清空全部文本。
|
||
void _clear() {
|
||
if (widget.controller.text.isEmpty) return;
|
||
widget.controller.clear();
|
||
}
|
||
|
||
void _switchMode(AppSoftKeyboardMode mode) {
|
||
if (mode == _mode) return;
|
||
setState(() => _mode = mode);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Semantics(
|
||
label: widget.semanticLabel,
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
// 内嵌场景宽度跟随弹框;浮层场景使用固定面板宽。
|
||
final contentWidth = widget.contentWidth ?? constraints.maxWidth;
|
||
return <Widget>[
|
||
_buildTabRow(FTheme.of(context)),
|
||
SizedBox(height: 10.h),
|
||
switch (_mode) {
|
||
AppSoftKeyboardMode.digits =>
|
||
_buildDigitsPanel(context, contentWidth),
|
||
AppSoftKeyboardMode.letters =>
|
||
_buildLettersPanel(context, contentWidth),
|
||
AppSoftKeyboardMode.symbols =>
|
||
_buildSymbolsPanel(context, contentWidth),
|
||
},
|
||
].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 顶部面板切换 tab 行。
|
||
Widget _buildTabRow(FThemeData theme) {
|
||
Widget tab(String label, AppSoftKeyboardMode mode) {
|
||
final active = _mode == mode;
|
||
return InkWell(
|
||
onTap: () => _switchMode(mode),
|
||
borderRadius: BorderRadius.circular(20.w),
|
||
child: Text(
|
||
label,
|
||
style: theme.typography.sm.copyWith(
|
||
color: active ? XfjDesignTokens.primary : XfjDesignTokens.muted,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
).center().tight(width: 84.w, height: 44.h).decorated(
|
||
color: active
|
||
? XfjDesignTokens.primarySoft
|
||
: XfjDesignTokens.control,
|
||
borderRadius: BorderRadius.circular(20.w),
|
||
),
|
||
);
|
||
}
|
||
|
||
return <Widget>[
|
||
const Spacer(),
|
||
tab('123', AppSoftKeyboardMode.digits),
|
||
tab('ABC', AppSoftKeyboardMode.letters),
|
||
tab('#+=', AppSoftKeyboardMode.symbols),
|
||
].toRowSpace(space: 8.w).tight(height: 44.h).padding(horizontal: 4.w);
|
||
}
|
||
|
||
/// 数字面板:三行数字(1-9)+ 操作行(清空/0/退格),浮层模式追加通栏确认。
|
||
Widget _buildDigitsPanel(BuildContext context, double contentWidth) {
|
||
final theme = FTheme.of(context);
|
||
final keyWidth = (contentWidth - 20.w) / 3 - 1;
|
||
Widget key(
|
||
String semantic, {
|
||
Widget? content,
|
||
VoidCallback? onTap,
|
||
}) =>
|
||
_Key(
|
||
semanticLabel: semantic,
|
||
onTap: onTap ?? () {},
|
||
width: keyWidth,
|
||
child: content,
|
||
);
|
||
Widget digitRow(List<String> digits) => <Widget>[
|
||
for (final value in digits)
|
||
key(
|
||
'输入$value',
|
||
onTap: () => _insert(value),
|
||
content: Text(
|
||
value,
|
||
style: theme.typography.xl.copyWith(
|
||
color: XfjDesignTokens.ink,
|
||
fontWeight: FontWeight.w800,
|
||
fontFeatures: const [FontFeature.tabularFigures()],
|
||
),
|
||
),
|
||
),
|
||
].toRowSpace(space: 10.w);
|
||
return <Widget>[
|
||
digitRow(['1', '2', '3']),
|
||
digitRow(['4', '5', '6']),
|
||
digitRow(['7', '8', '9']),
|
||
<Widget>[
|
||
key(
|
||
'清空全部输入',
|
||
onTap: _clear,
|
||
content: Text(
|
||
'清空',
|
||
style: theme.typography.sm.copyWith(
|
||
color: XfjDesignTokens.muted,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
key(
|
||
'输入0',
|
||
onTap: () => _insert('0'),
|
||
content: Text(
|
||
'0',
|
||
style: theme.typography.xl.copyWith(
|
||
color: XfjDesignTokens.ink,
|
||
fontWeight: FontWeight.w800,
|
||
fontFeatures: const [FontFeature.tabularFigures()],
|
||
),
|
||
),
|
||
),
|
||
// 退格是全键盘唯一的长按键:点按删一位,长按连发持续删除。
|
||
_Key(
|
||
semanticLabel: '退格删除,长按连续删除',
|
||
onTap: _backspace,
|
||
onLongPressRepeat: _backspace,
|
||
width: keyWidth,
|
||
child: FIcon(
|
||
FAssets.icons.delete,
|
||
color: XfjDesignTokens.muted,
|
||
size: 22.sp,
|
||
),
|
||
),
|
||
].toRowSpace(space: 10.w),
|
||
if (widget.onCloseRequested != null)
|
||
_Key(
|
||
semanticLabel: '确认并收起键盘',
|
||
onTap: widget.onCloseRequested!,
|
||
width: contentWidth,
|
||
height: 54.h,
|
||
emphasized: true,
|
||
child: Text(
|
||
'确认',
|
||
style: theme.typography.base.copyWith(
|
||
color: Colors.white,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
].toColumnSpace(space: 10.h);
|
||
}
|
||
|
||
/// 字母面板:QWERTY 三行(键等宽、整行居中)+ 操作行(⇧/123/空格/退格/确认)。
|
||
Widget _buildLettersPanel(BuildContext context, double contentWidth) {
|
||
final theme = FTheme.of(context);
|
||
final keyWidth = (contentWidth - 9 * 8.w) / 10 - 1;
|
||
// 操作行按比例分配“内容宽 - 4 个间隙”,杜绝固定宽度在不同弹宽下溢出。
|
||
final avail = contentWidth - 4 * 8.w - 2;
|
||
Widget letter(String value) => _Key(
|
||
semanticLabel: '输入字母$value',
|
||
onTap: () => _insert(_uppercase ? value.toUpperCase() : value),
|
||
width: keyWidth,
|
||
child: Text(
|
||
_uppercase ? value.toUpperCase() : value,
|
||
style: theme.typography.lg.copyWith(
|
||
color: XfjDesignTokens.ink,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
);
|
||
Widget letterRow(List<String> letters) => <Widget>[
|
||
for (final value in letters) letter(value),
|
||
].toRowSpace(
|
||
space: 8.w,
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
mainAxisSize: MainAxisSize.min,
|
||
);
|
||
final actionStyle = theme.typography.sm.copyWith(
|
||
color: XfjDesignTokens.muted,
|
||
fontWeight: FontWeight.w700,
|
||
);
|
||
final whiteActionStyle = theme.typography.sm.copyWith(
|
||
color: Colors.white,
|
||
fontWeight: FontWeight.w700,
|
||
);
|
||
Widget actionKey(
|
||
String semantic, {
|
||
required double width,
|
||
bool emphasized = false,
|
||
Widget? content,
|
||
required VoidCallback onTap,
|
||
}) =>
|
||
_Key(
|
||
semanticLabel: semantic,
|
||
onTap: onTap,
|
||
width: width,
|
||
emphasized: emphasized,
|
||
child: content,
|
||
);
|
||
return <Widget>[
|
||
letterRow(['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p']),
|
||
letterRow(['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l']),
|
||
letterRow(['z', 'x', 'c', 'v', 'b', 'n', 'm']),
|
||
<Widget>[
|
||
actionKey(
|
||
_uppercase ? '切换为小写输入' : '切换为大写输入',
|
||
width: avail * 1 / 9.5,
|
||
emphasized: _uppercase,
|
||
content: Text(
|
||
_uppercase ? '⇧ 大' : '⇧ 小',
|
||
style: _uppercase ? whiteActionStyle : actionStyle,
|
||
),
|
||
onTap: () => setState(() => _uppercase = !_uppercase),
|
||
),
|
||
actionKey(
|
||
'切到数字面板',
|
||
width: avail * 1 / 9.5,
|
||
content: Text('123', style: actionStyle),
|
||
onTap: () => _switchMode(AppSoftKeyboardMode.digits),
|
||
),
|
||
actionKey(
|
||
'输入空格',
|
||
width: avail * 3.5 / 9.5,
|
||
content: Text('空格', style: actionStyle),
|
||
onTap: () => _insert(' '),
|
||
),
|
||
// 退格是全键盘唯一的长按键:点按删一位,长按连发持续删除。
|
||
_Key(
|
||
semanticLabel: '退格删除,长按连续删除',
|
||
onTap: _backspace,
|
||
onLongPressRepeat: _backspace,
|
||
width: avail * 2 / 9.5,
|
||
child: FIcon(
|
||
FAssets.icons.delete,
|
||
color: XfjDesignTokens.muted,
|
||
size: 22.sp,
|
||
),
|
||
),
|
||
if (widget.onCloseRequested != null)
|
||
actionKey(
|
||
'确认并收起键盘',
|
||
width: avail * 2 / 9.5,
|
||
emphasized: true,
|
||
content: Text('确认', style: whiteActionStyle),
|
||
onTap: widget.onCloseRequested!,
|
||
),
|
||
].toRowSpace(space: 8.w),
|
||
].toColumnSpace(space: 10.h);
|
||
}
|
||
|
||
/// 符号面板:常用符号五行五列 + 操作行(空格/退格/确认)。
|
||
Widget _buildSymbolsPanel(BuildContext context, double contentWidth) {
|
||
final theme = FTheme.of(context);
|
||
// 操作行按比例分配“内容宽 - 2 个间隙”,杜绝宽度求和溢出。
|
||
final avail = contentWidth - 2 * 8.w - 2;
|
||
const symbols = [
|
||
'-',
|
||
'.',
|
||
'_',
|
||
'/',
|
||
'*',
|
||
',',
|
||
':',
|
||
';',
|
||
'%',
|
||
'#',
|
||
'+',
|
||
'@',
|
||
'&',
|
||
'?',
|
||
'!',
|
||
'(',
|
||
')',
|
||
'[',
|
||
']',
|
||
'=',
|
||
];
|
||
final keyWidth = (contentWidth - 4 * 8.w) / 5 - 1;
|
||
Widget symbol(String value) => _Key(
|
||
semanticLabel: '输入符号$value',
|
||
onTap: () => _insert(value),
|
||
width: keyWidth,
|
||
child: Text(
|
||
value,
|
||
style: theme.typography.lg.copyWith(
|
||
color: XfjDesignTokens.ink,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
);
|
||
final actionStyle = theme.typography.sm.copyWith(
|
||
color: XfjDesignTokens.muted,
|
||
fontWeight: FontWeight.w700,
|
||
);
|
||
final whiteActionStyle = theme.typography.sm.copyWith(
|
||
color: Colors.white,
|
||
fontWeight: FontWeight.w700,
|
||
);
|
||
return <Widget>[
|
||
for (var index = 0; index < symbols.length; index += 5)
|
||
<Widget>[
|
||
for (final value in symbols.skip(index).take(5)) symbol(value),
|
||
].toRowSpace(space: 8.w),
|
||
<Widget>[
|
||
_Key(
|
||
semanticLabel: '输入空格',
|
||
onTap: () => _insert(' '),
|
||
width: avail * 2 / 5,
|
||
child: Text('空格', style: actionStyle),
|
||
),
|
||
// 退格是全键盘唯一的长按键:点按删一位,长按连发持续删除。
|
||
_Key(
|
||
semanticLabel: '退格删除,长按连续删除',
|
||
onTap: _backspace,
|
||
onLongPressRepeat: _backspace,
|
||
width: avail * 1.5 / 5,
|
||
child: FIcon(
|
||
FAssets.icons.delete,
|
||
color: XfjDesignTokens.muted,
|
||
size: 22.sp,
|
||
),
|
||
),
|
||
if (widget.onCloseRequested != null)
|
||
_Key(
|
||
semanticLabel: '确认并收起键盘',
|
||
onTap: widget.onCloseRequested!,
|
||
width: avail * 1.5 / 5,
|
||
emphasized: true,
|
||
child: Text('确认', style: whiteActionStyle),
|
||
),
|
||
].toRowSpace(space: 8.w),
|
||
].toColumnSpace(space: 10.h);
|
||
}
|
||
}
|
||
|
||
/// 自研可拖动软键盘弹出框。
|
||
///
|
||
/// 通过 [AppSoftKeyboard.show] 弹出:键盘浮在页面内容之上(透明遮罩),
|
||
/// 默认停靠屏幕底部水平居中,需要避开页面内容时可用 [AppSoftKeyboardDock]
|
||
/// 自定义停靠位(如登录页停靠左侧垂直居中);弹出后仍可按住顶部手柄行
|
||
/// 自由拖动,点击键盘外任意处或按“确认”收起。矮弹框内的输入场景应改用
|
||
/// [AppSoftKeyboardPanel] 内嵌。
|
||
abstract final class AppSoftKeyboard {
|
||
/// 浮层键盘的面板宽度(内容区,含左右内边距共 760)。
|
||
static final double floatingContentWidth = 736.w;
|
||
|
||
/// 弹出浮层键盘并绑定目标控制器;返回的 Future 在键盘关闭时完成。
|
||
///
|
||
/// 浮层以 [RawDialogRoute](requestFocus: false)实现:键盘是输入法而非
|
||
/// 模态任务,路由入栈不抢主焦点,被点击的输入框保持聚焦描边,实体键盘
|
||
/// 与扫码枪的输入继续进入该输入框。
|
||
static Future<void> show(
|
||
BuildContext context, {
|
||
required TextEditingController controller,
|
||
AppSoftKeyboardMode mode = AppSoftKeyboardMode.digits,
|
||
AppSoftKeyboardDock dock = AppSoftKeyboardDock.bottomCenter,
|
||
RegExp? allowPattern,
|
||
int? maxLength,
|
||
String semanticLabel = '终端软键盘',
|
||
}) {
|
||
return Navigator.of(context, rootNavigator: true).push<void>(
|
||
RawDialogRoute<void>(
|
||
// 不抢焦点:模态路由默认把主焦点移入弹层 FocusScope,会导致底层
|
||
// 输入框失去聚焦描边、实体输入无法进入目标框。
|
||
requestFocus: false,
|
||
pageBuilder: (dialogContext, _, __) => _SoftKeyboardDialog(
|
||
controller: controller,
|
||
mode: mode,
|
||
dock: dock,
|
||
allowPattern: allowPattern,
|
||
maxLength: maxLength,
|
||
semanticLabel: semanticLabel,
|
||
),
|
||
barrierDismissible: true,
|
||
// 透明遮罩:键盘期间页面内容可见,点击键盘外任意处即收起。
|
||
barrierColor: Colors.transparent,
|
||
// barrierDismissible 开启时框架要求提供遮罩语义标签。
|
||
barrierLabel: '收起软键盘',
|
||
// 键盘要求即时出现与消失,不做转场动画。
|
||
transitionDuration: Duration.zero,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 浮层键盘宿主:按停靠位对齐面板、承载拖动手柄行与内嵌面板。
|
||
class _SoftKeyboardDialog extends StatefulWidget {
|
||
const _SoftKeyboardDialog({
|
||
required this.controller,
|
||
required this.mode,
|
||
required this.dock,
|
||
required this.semanticLabel,
|
||
this.allowPattern,
|
||
this.maxLength,
|
||
});
|
||
|
||
final TextEditingController controller;
|
||
final AppSoftKeyboardMode mode;
|
||
|
||
/// 弹出时的默认停靠位;拖动偏移在此基础上叠加。
|
||
final AppSoftKeyboardDock dock;
|
||
|
||
final RegExp? allowPattern;
|
||
final int? maxLength;
|
||
final String semanticLabel;
|
||
|
||
@override
|
||
State<_SoftKeyboardDialog> createState() => _SoftKeyboardDialogState();
|
||
}
|
||
|
||
class _SoftKeyboardDialogState extends State<_SoftKeyboardDialog> {
|
||
/// 拖动偏移;相对当前停靠位的默认位置。
|
||
Offset _dragOffset = Offset.zero;
|
||
|
||
/// 拖动手势更新偏移;按屏幕半宽钳制,保证键盘不会被完全拖出屏幕。
|
||
void _onDragUpdate(DragUpdateDetails details) {
|
||
final halfScreen = MediaQuery.sizeOf(context) / 2;
|
||
setState(() {
|
||
_dragOffset += details.delta;
|
||
_dragOffset = Offset(
|
||
_dragOffset.dx.clamp(-halfScreen.width, halfScreen.width),
|
||
_dragOffset.dy.clamp(-halfScreen.height, halfScreen.height),
|
||
);
|
||
});
|
||
}
|
||
|
||
void _close() => Navigator.of(context).pop();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
// 弹层约束为全屏;按停靠位对齐面板,停靠侧留出安全边距。
|
||
final (alignment, margin) = switch (widget.dock) {
|
||
AppSoftKeyboardDock.bottomCenter => (
|
||
Alignment.bottomCenter,
|
||
EdgeInsets.only(bottom: 16.h),
|
||
),
|
||
AppSoftKeyboardDock.centerLeft => (
|
||
Alignment.centerLeft,
|
||
EdgeInsets.only(left: 16.w),
|
||
),
|
||
};
|
||
return SizedBox.expand(
|
||
child: Padding(
|
||
padding: margin,
|
||
child: Transform.translate(
|
||
offset: _dragOffset,
|
||
child: Align(
|
||
alignment: alignment,
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: Container(
|
||
width: 760.w,
|
||
padding: EdgeInsets.all(12.w),
|
||
decoration: BoxDecoration(
|
||
color: XfjDesignTokens.canvas,
|
||
borderRadius:
|
||
BorderRadius.circular(XfjDesignTokens.cardRadius),
|
||
border: Border.all(color: XfjDesignTokens.line),
|
||
boxShadow: XfjDesignTokens.surfaceShadow,
|
||
),
|
||
// Align 传入的是有界约束,列必须显式收缩,否则面板被拉满全屏高。
|
||
child: <Widget>[
|
||
_buildDragHandle(),
|
||
SizedBox(height: 10.h),
|
||
AppSoftKeyboardPanel(
|
||
controller: widget.controller,
|
||
mode: widget.mode,
|
||
allowPattern: widget.allowPattern,
|
||
maxLength: widget.maxLength,
|
||
semanticLabel: widget.semanticLabel,
|
||
onCloseRequested: _close,
|
||
contentWidth: AppSoftKeyboard.floatingContentWidth,
|
||
),
|
||
].toColumn(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 拖动手柄行:右侧拖动指示,按住整行自由拖动键盘。
|
||
Widget _buildDragHandle() {
|
||
return GestureDetector(
|
||
onPanUpdate: _onDragUpdate,
|
||
child: <Widget>[
|
||
const Spacer(),
|
||
Icon(
|
||
Icons.drag_indicator_rounded,
|
||
color: XfjDesignTokens.muted,
|
||
size: 26.w,
|
||
semanticLabel: '拖动键盘',
|
||
),
|
||
].toRowSpace(space: 8.w).tight(height: 32.h).padding(horizontal: 4.w),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 单个键盘按键;固定宽度布局(弹层处于无界高度约束,禁用 Expanded),
|
||
/// 主操作键使用主题色底白字。
|
||
///
|
||
/// 按压反馈走“整键缩放 + 主题色渐变”而非涟漪:Listener 直接监听指针按下/
|
||
/// 抬起,只触发本键的一次 Transform 与 decoration 隐式动画,不参与手势
|
||
/// 竞技场、不引起布局重排,是触摸反馈里开销最低的实现;透明 Material 上
|
||
/// 涟漪本就不可见。
|
||
class _Key extends StatefulWidget {
|
||
const _Key({
|
||
required this.semanticLabel,
|
||
required this.onTap,
|
||
required this.width,
|
||
required this.child,
|
||
this.emphasized = false,
|
||
this.height,
|
||
this.onLongPressRepeat,
|
||
});
|
||
|
||
final String semanticLabel;
|
||
final VoidCallback onTap;
|
||
|
||
/// 按键宽度;由各面板按列数与间距计算。
|
||
final double width;
|
||
|
||
/// 按键内容(数字文字或图标)。
|
||
final Widget? child;
|
||
|
||
/// 是否主操作键(确认/大写锁定态),使用主题色底与白字。
|
||
final bool emphasized;
|
||
|
||
/// 按键高度;默认 62。
|
||
final double? height;
|
||
|
||
/// 长按连发回调;非空时按住超过系统长按阈值后先触发一次,随后周期连发。
|
||
/// 整个键盘目前只有退格键使用(长按持续删除)。
|
||
final VoidCallback? onLongPressRepeat;
|
||
|
||
@override
|
||
State<_Key> createState() => _KeyState();
|
||
}
|
||
|
||
class _KeyState extends State<_Key> {
|
||
/// 按压目标缩放:模拟实体键帽下沉,幅度保证一眼可辨。
|
||
static const double _pressedScale = 0.92;
|
||
|
||
/// 缩放与渐变共用同一时长曲线,让按压表现为一次同步手势。
|
||
static const Duration _pressDuration = Duration(milliseconds: 90);
|
||
|
||
/// 长按连发周期:10 次/秒,短按删一位、长按可控地连续清空。
|
||
static const Duration _longPressRepeatInterval = Duration(milliseconds: 100);
|
||
|
||
/// 普通键按压渐变:品牌浅蓝自上而下过渡到主题蓝浅调。
|
||
static final LinearGradient _normalPressedGradient = LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [
|
||
XfjDesignTokens.primarySoft,
|
||
Color.alphaBlend(
|
||
XfjDesignTokens.primary.withValues(alpha: 0.18),
|
||
XfjDesignTokens.surface,
|
||
),
|
||
],
|
||
);
|
||
|
||
/// 主操作键按压渐变:主题蓝自上而下加深一档,替代抬起的纯色底。
|
||
static final LinearGradient _emphasizedPressedGradient = LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [
|
||
XfjDesignTokens.primary,
|
||
Color.alphaBlend(
|
||
Colors.black.withValues(alpha: 0.14),
|
||
XfjDesignTokens.primary,
|
||
),
|
||
],
|
||
);
|
||
|
||
/// 当前是否有指针按在本键上;驱动按压缩放与主题色渐变。
|
||
bool _pressed = false;
|
||
|
||
/// 长按连发定时器;只在按住期间存在。
|
||
Timer? _repeatTimer;
|
||
|
||
void _setPressed(bool value) {
|
||
if (!mounted || _pressed == value) return;
|
||
setState(() => _pressed = value);
|
||
}
|
||
|
||
/// 长按阈值到达:先删一次,再以固定周期连发,直到指针抬起或取消。
|
||
void _startLongPressRepeat() {
|
||
widget.onLongPressRepeat?.call();
|
||
_repeatTimer?.cancel();
|
||
_repeatTimer = Timer.periodic(_longPressRepeatInterval, (_) {
|
||
if (!mounted) return;
|
||
widget.onLongPressRepeat?.call();
|
||
});
|
||
}
|
||
|
||
void _stopLongPressRepeat() {
|
||
_repeatTimer?.cancel();
|
||
_repeatTimer = null;
|
||
}
|
||
|
||
/// 指针结束的统一收尾:松开按压视觉并停掉连发。
|
||
void _pointerEnd() {
|
||
_setPressed(false);
|
||
_stopLongPressRepeat();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_stopLongPressRepeat();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final keyHeight = widget.height ?? 62.h;
|
||
final radius = BorderRadius.circular(XfjDesignTokens.controlRadius);
|
||
final BoxDecoration decoration;
|
||
if (_pressed) {
|
||
decoration = BoxDecoration(
|
||
gradient: widget.emphasized
|
||
? _emphasizedPressedGradient
|
||
: _normalPressedGradient,
|
||
border: Border.all(
|
||
color: widget.emphasized
|
||
? XfjDesignTokens.primary
|
||
: XfjDesignTokens.primary.withValues(alpha: 0.45),
|
||
),
|
||
borderRadius: radius,
|
||
);
|
||
} else {
|
||
decoration = BoxDecoration(
|
||
color: widget.emphasized
|
||
? XfjDesignTokens.primary
|
||
: XfjDesignTokens.surface,
|
||
border: Border.all(
|
||
color: widget.emphasized
|
||
? XfjDesignTokens.primary
|
||
: XfjDesignTokens.line,
|
||
),
|
||
borderRadius: radius,
|
||
);
|
||
}
|
||
return Semantics(
|
||
button: true,
|
||
label: widget.semanticLabel,
|
||
child: Listener(
|
||
onPointerDown: (_) => _setPressed(true),
|
||
onPointerUp: (_) => _pointerEnd(),
|
||
onPointerCancel: (_) => _pointerEnd(),
|
||
child: AnimatedScale(
|
||
scale: _pressed ? _pressedScale : 1,
|
||
duration: _pressDuration,
|
||
curve: Curves.easeOut,
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
onTap: widget.onTap,
|
||
onLongPress: widget.onLongPressRepeat == null
|
||
? null
|
||
: _startLongPressRepeat,
|
||
borderRadius: radius,
|
||
// 渐变走 AnimatedContainer 隐式动画:只重绘本键 decoration,
|
||
// 不触发布局,与外层缩放共同构成零重排的按压反馈。
|
||
// 尺寸放在外层 SizedBox、容器内部用恒定 infinity 撑满:
|
||
// 面板切换会按位置复用 _Key 元素,键宽变化若参与隐式动画,
|
||
// 行布局会在过渡帧溢出。
|
||
child: SizedBox(
|
||
width: widget.width,
|
||
height: keyHeight,
|
||
child: AnimatedContainer(
|
||
duration: _pressDuration,
|
||
curve: Curves.easeOut,
|
||
width: double.infinity,
|
||
height: double.infinity,
|
||
alignment: Alignment.center,
|
||
decoration: decoration,
|
||
child: widget.child ?? const SizedBox.shrink(),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|