436 lines
11 KiB
Dart
436 lines
11 KiB
Dart
|
|
import 'package:flutter/material.dart';
|
||
|
|
import '../theme/theme_extensions.dart';
|
||
|
|
import 'package:flutter/services.dart';
|
||
|
|
import 'dart:io' show Platform;
|
||
|
|
|
||
|
|
/// Enhanced keyboard handling utilities for better UX
|
||
|
|
class KeyboardUtils {
|
||
|
|
KeyboardUtils._();
|
||
|
|
|
||
|
|
/// Dismiss keyboard with haptic feedback
|
||
|
|
static void dismissKeyboard(BuildContext context) {
|
||
|
|
final currentFocus = FocusScope.of(context);
|
||
|
|
if (!currentFocus.hasPrimaryFocus && currentFocus.focusedChild != null) {
|
||
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
||
|
|
|
||
|
|
// Add haptic feedback on iOS
|
||
|
|
if (Platform.isIOS) {
|
||
|
|
HapticFeedback.lightImpact();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Force dismiss keyboard immediately
|
||
|
|
static void forceDismissKeyboard() {
|
||
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
||
|
|
SystemChannels.textInput.invokeMethod('TextInput.hide');
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Check if keyboard is currently visible
|
||
|
|
static bool isKeyboardVisible(BuildContext context) {
|
||
|
|
return MediaQuery.of(context).viewInsets.bottom > 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Get keyboard height
|
||
|
|
static double getKeyboardHeight(BuildContext context) {
|
||
|
|
return MediaQuery.of(context).viewInsets.bottom;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Move focus to next field
|
||
|
|
static void nextFocus(BuildContext context) {
|
||
|
|
FocusScope.of(context).nextFocus();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Move focus to previous field
|
||
|
|
static void previousFocus(BuildContext context) {
|
||
|
|
FocusScope.of(context).previousFocus();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Request focus for a specific node
|
||
|
|
static void requestFocus(BuildContext context, FocusNode focusNode) {
|
||
|
|
FocusScope.of(context).requestFocus(focusNode);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Create a tap detector that dismisses keyboard when tapping outside text fields
|
||
|
|
static Widget dismissKeyboardOnTap({
|
||
|
|
required BuildContext context,
|
||
|
|
required Widget child,
|
||
|
|
}) {
|
||
|
|
return GestureDetector(
|
||
|
|
onTap: () => dismissKeyboard(context),
|
||
|
|
// Let children handle taps first (e.g., TextField gains focus)
|
||
|
|
behavior: HitTestBehavior.deferToChild,
|
||
|
|
child: child,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Widget that automatically adjusts for keyboard visibility
|
||
|
|
class KeyboardAware extends StatefulWidget {
|
||
|
|
final Widget child;
|
||
|
|
final EdgeInsets? padding;
|
||
|
|
final bool maintainBottomViewPadding;
|
||
|
|
final Duration animationDuration;
|
||
|
|
final Curve animationCurve;
|
||
|
|
|
||
|
|
const KeyboardAware({
|
||
|
|
super.key,
|
||
|
|
required this.child,
|
||
|
|
this.padding,
|
||
|
|
this.maintainBottomViewPadding = true,
|
||
|
|
this.animationDuration = const Duration(milliseconds: 250),
|
||
|
|
this.animationCurve = Curves.easeInOut,
|
||
|
|
});
|
||
|
|
|
||
|
|
@override
|
||
|
|
State<KeyboardAware> createState() => _KeyboardAwareState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _KeyboardAwareState extends State<KeyboardAware>
|
||
|
|
with WidgetsBindingObserver {
|
||
|
|
double _keyboardHeight = 0;
|
||
|
|
|
||
|
|
@override
|
||
|
|
void initState() {
|
||
|
|
super.initState();
|
||
|
|
WidgetsBinding.instance.addObserver(this);
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void dispose() {
|
||
|
|
WidgetsBinding.instance.removeObserver(this);
|
||
|
|
super.dispose();
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void didChangeMetrics() {
|
||
|
|
super.didChangeMetrics();
|
||
|
|
final newKeyboardHeight = MediaQuery.of(context).viewInsets.bottom;
|
||
|
|
if (newKeyboardHeight != _keyboardHeight) {
|
||
|
|
setState(() {
|
||
|
|
_keyboardHeight = newKeyboardHeight;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
return AnimatedPadding(
|
||
|
|
duration: widget.animationDuration,
|
||
|
|
curve: widget.animationCurve,
|
||
|
|
padding: EdgeInsets.only(
|
||
|
|
bottom: widget.maintainBottomViewPadding ? _keyboardHeight : 0,
|
||
|
|
).add(widget.padding ?? EdgeInsets.zero),
|
||
|
|
child: widget.child,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Enhanced text field with better keyboard handling
|
||
|
|
class EnhancedTextField extends StatefulWidget {
|
||
|
|
final TextEditingController? controller;
|
||
|
|
final FocusNode? focusNode;
|
||
|
|
final String? hintText;
|
||
|
|
final String? labelText;
|
||
|
|
final TextInputType? keyboardType;
|
||
|
|
final TextInputAction? textInputAction;
|
||
|
|
final ValueChanged<String>? onChanged;
|
||
|
|
final ValueChanged<String>? onSubmitted;
|
||
|
|
final VoidCallback? onTap;
|
||
|
|
final bool obscureText;
|
||
|
|
final bool enabled;
|
||
|
|
final int? maxLines;
|
||
|
|
final int? minLines;
|
||
|
|
final EdgeInsets? contentPadding;
|
||
|
|
final Widget? prefixIcon;
|
||
|
|
final Widget? suffixIcon;
|
||
|
|
final bool autofocus;
|
||
|
|
final bool dismissKeyboardOnSubmit;
|
||
|
|
|
||
|
|
const EnhancedTextField({
|
||
|
|
super.key,
|
||
|
|
this.controller,
|
||
|
|
this.focusNode,
|
||
|
|
this.hintText,
|
||
|
|
this.labelText,
|
||
|
|
this.keyboardType,
|
||
|
|
this.textInputAction,
|
||
|
|
this.onChanged,
|
||
|
|
this.onSubmitted,
|
||
|
|
this.onTap,
|
||
|
|
this.obscureText = false,
|
||
|
|
this.enabled = true,
|
||
|
|
this.maxLines = 1,
|
||
|
|
this.minLines,
|
||
|
|
this.contentPadding,
|
||
|
|
this.prefixIcon,
|
||
|
|
this.suffixIcon,
|
||
|
|
this.autofocus = false,
|
||
|
|
this.dismissKeyboardOnSubmit = true,
|
||
|
|
});
|
||
|
|
|
||
|
|
@override
|
||
|
|
State<EnhancedTextField> createState() => _EnhancedTextFieldState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _EnhancedTextFieldState extends State<EnhancedTextField> {
|
||
|
|
late FocusNode _focusNode;
|
||
|
|
bool _hasFocus = false;
|
||
|
|
|
||
|
|
@override
|
||
|
|
void initState() {
|
||
|
|
super.initState();
|
||
|
|
_focusNode = widget.focusNode ?? FocusNode();
|
||
|
|
_focusNode.addListener(_onFocusChanged);
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void dispose() {
|
||
|
|
_focusNode.removeListener(_onFocusChanged);
|
||
|
|
if (widget.focusNode == null) {
|
||
|
|
_focusNode.dispose();
|
||
|
|
}
|
||
|
|
super.dispose();
|
||
|
|
}
|
||
|
|
|
||
|
|
void _onFocusChanged() {
|
||
|
|
setState(() {
|
||
|
|
_hasFocus = _focusNode.hasFocus;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
void _handleSubmitted(String value) {
|
||
|
|
widget.onSubmitted?.call(value);
|
||
|
|
|
||
|
|
if (widget.dismissKeyboardOnSubmit) {
|
||
|
|
KeyboardUtils.dismissKeyboard(context);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add haptic feedback
|
||
|
|
if (Platform.isIOS) {
|
||
|
|
HapticFeedback.lightImpact();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
return AnimatedContainer(
|
||
|
|
duration: const Duration(milliseconds: 200),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
border: Border.all(
|
||
|
|
color: _hasFocus
|
||
|
|
? context.conduitTheme.buttonPrimary
|
||
|
|
: context.conduitTheme.inputBorder,
|
||
|
|
width: _hasFocus ? 2 : 1,
|
||
|
|
),
|
||
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||
|
|
),
|
||
|
|
child: TextField(
|
||
|
|
controller: widget.controller,
|
||
|
|
focusNode: _focusNode,
|
||
|
|
obscureText: widget.obscureText,
|
||
|
|
enabled: widget.enabled,
|
||
|
|
autofocus: widget.autofocus,
|
||
|
|
keyboardType: widget.keyboardType,
|
||
|
|
textInputAction: widget.textInputAction,
|
||
|
|
maxLines: widget.maxLines,
|
||
|
|
minLines: widget.minLines,
|
||
|
|
style: TextStyle(
|
||
|
|
color: context.conduitTheme.textPrimary,
|
||
|
|
fontSize: AppTypography.bodyLarge,
|
||
|
|
),
|
||
|
|
decoration: InputDecoration(
|
||
|
|
hintText: widget.hintText,
|
||
|
|
labelText: widget.labelText,
|
||
|
|
hintStyle: TextStyle(color: context.conduitTheme.inputPlaceholder),
|
||
|
|
labelStyle: TextStyle(
|
||
|
|
color: _hasFocus
|
||
|
|
? context.conduitTheme.buttonPrimary
|
||
|
|
: context.conduitTheme.textSecondary,
|
||
|
|
),
|
||
|
|
prefixIcon: widget.prefixIcon,
|
||
|
|
suffixIcon: widget.suffixIcon,
|
||
|
|
contentPadding:
|
||
|
|
widget.contentPadding ??
|
||
|
|
const EdgeInsets.symmetric(
|
||
|
|
horizontal: Spacing.md,
|
||
|
|
vertical: Spacing.sm,
|
||
|
|
),
|
||
|
|
border: InputBorder.none,
|
||
|
|
enabledBorder: InputBorder.none,
|
||
|
|
focusedBorder: InputBorder.none,
|
||
|
|
errorBorder: InputBorder.none,
|
||
|
|
disabledBorder: InputBorder.none,
|
||
|
|
),
|
||
|
|
onChanged: widget.onChanged,
|
||
|
|
onSubmitted: _handleSubmitted,
|
||
|
|
onTap: widget.onTap,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Smart keyboard handler that manages multiple text fields
|
||
|
|
class SmartKeyboardHandler extends StatefulWidget {
|
||
|
|
final List<FocusNode> focusNodes;
|
||
|
|
final Widget child;
|
||
|
|
final VoidCallback? onDone;
|
||
|
|
|
||
|
|
const SmartKeyboardHandler({
|
||
|
|
super.key,
|
||
|
|
required this.focusNodes,
|
||
|
|
required this.child,
|
||
|
|
this.onDone,
|
||
|
|
});
|
||
|
|
|
||
|
|
@override
|
||
|
|
State<SmartKeyboardHandler> createState() => _SmartKeyboardHandlerState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _SmartKeyboardHandlerState extends State<SmartKeyboardHandler> {
|
||
|
|
int _currentIndex = -1;
|
||
|
|
|
||
|
|
@override
|
||
|
|
void initState() {
|
||
|
|
super.initState();
|
||
|
|
_setupFocusListeners();
|
||
|
|
}
|
||
|
|
|
||
|
|
void _setupFocusListeners() {
|
||
|
|
for (int i = 0; i < widget.focusNodes.length; i++) {
|
||
|
|
widget.focusNodes[i].addListener(() => _onFocusChanged(i));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void _onFocusChanged(int index) {
|
||
|
|
if (widget.focusNodes[index].hasFocus) {
|
||
|
|
setState(() {
|
||
|
|
_currentIndex = index;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void _moveToNext() {
|
||
|
|
if (_currentIndex < widget.focusNodes.length - 1) {
|
||
|
|
KeyboardUtils.requestFocus(context, widget.focusNodes[_currentIndex + 1]);
|
||
|
|
} else {
|
||
|
|
KeyboardUtils.dismissKeyboard(context);
|
||
|
|
widget.onDone?.call();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void _moveToPrevious() {
|
||
|
|
if (_currentIndex > 0) {
|
||
|
|
KeyboardUtils.requestFocus(context, widget.focusNodes[_currentIndex - 1]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
return Focus(
|
||
|
|
onKeyEvent: (node, event) {
|
||
|
|
if (event is KeyDownEvent) {
|
||
|
|
if (event.logicalKey == LogicalKeyboardKey.tab) {
|
||
|
|
if (HardwareKeyboard.instance.isShiftPressed) {
|
||
|
|
_moveToPrevious();
|
||
|
|
} else {
|
||
|
|
_moveToNext();
|
||
|
|
}
|
||
|
|
return KeyEventResult.handled;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return KeyEventResult.ignored;
|
||
|
|
},
|
||
|
|
child: widget.child,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void dispose() {
|
||
|
|
for (final focusNode in widget.focusNodes) {
|
||
|
|
focusNode.removeListener(() {});
|
||
|
|
}
|
||
|
|
super.dispose();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Keyboard-aware scroll view that adjusts scroll position
|
||
|
|
class KeyboardAwareScrollView extends StatefulWidget {
|
||
|
|
final ScrollController? controller;
|
||
|
|
final Widget child;
|
||
|
|
final EdgeInsets? padding;
|
||
|
|
final bool reverse;
|
||
|
|
final Duration animationDuration;
|
||
|
|
|
||
|
|
const KeyboardAwareScrollView({
|
||
|
|
super.key,
|
||
|
|
this.controller,
|
||
|
|
required this.child,
|
||
|
|
this.padding,
|
||
|
|
this.reverse = false,
|
||
|
|
this.animationDuration = const Duration(milliseconds: 300),
|
||
|
|
});
|
||
|
|
|
||
|
|
@override
|
||
|
|
State<KeyboardAwareScrollView> createState() =>
|
||
|
|
_KeyboardAwareScrollViewState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _KeyboardAwareScrollViewState extends State<KeyboardAwareScrollView>
|
||
|
|
with WidgetsBindingObserver {
|
||
|
|
late ScrollController _scrollController;
|
||
|
|
FocusNode? _currentFocus;
|
||
|
|
|
||
|
|
@override
|
||
|
|
void initState() {
|
||
|
|
super.initState();
|
||
|
|
_scrollController = widget.controller ?? ScrollController();
|
||
|
|
WidgetsBinding.instance.addObserver(this);
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void dispose() {
|
||
|
|
WidgetsBinding.instance.removeObserver(this);
|
||
|
|
if (widget.controller == null) {
|
||
|
|
_scrollController.dispose();
|
||
|
|
}
|
||
|
|
super.dispose();
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void didChangeMetrics() {
|
||
|
|
super.didChangeMetrics();
|
||
|
|
_adjustScrollPosition();
|
||
|
|
}
|
||
|
|
|
||
|
|
void _adjustScrollPosition() {
|
||
|
|
final focus = FocusManager.instance.primaryFocus;
|
||
|
|
if (focus != null && focus != _currentFocus) {
|
||
|
|
_currentFocus = focus;
|
||
|
|
|
||
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
|
|
if (_scrollController.hasClients) {
|
||
|
|
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
|
||
|
|
if (keyboardHeight > 0) {
|
||
|
|
_scrollController.animateTo(
|
||
|
|
_scrollController.offset + keyboardHeight / 2,
|
||
|
|
duration: widget.animationDuration,
|
||
|
|
curve: Curves.easeInOut,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
return SingleChildScrollView(
|
||
|
|
controller: _scrollController,
|
||
|
|
reverse: widget.reverse,
|
||
|
|
padding: widget.padding,
|
||
|
|
child: widget.child,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|