chore: initial release
This commit is contained in:
435
lib/shared/utils/keyboard_utils.dart
Normal file
435
lib/shared/utils/keyboard_utils.dart
Normal file
@@ -0,0 +1,435 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
487
lib/shared/utils/platform_utils.dart
Normal file
487
lib/shared/utils/platform_utils.dart
Normal file
@@ -0,0 +1,487 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import '../theme/theme_extensions.dart';
|
||||
|
||||
/// Platform-specific utilities for enhanced user experience
|
||||
class PlatformUtils {
|
||||
PlatformUtils._();
|
||||
|
||||
/// Check if device supports haptic feedback
|
||||
static bool get supportsHaptics => Platform.isIOS || Platform.isAndroid;
|
||||
|
||||
/// Trigger light haptic feedback
|
||||
static void lightHaptic() {
|
||||
if (supportsHaptics) {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger medium haptic feedback
|
||||
static void mediumHaptic() {
|
||||
if (supportsHaptics && Platform.isIOS) {
|
||||
HapticFeedback.mediumImpact();
|
||||
} else if (Platform.isAndroid) {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger heavy haptic feedback
|
||||
static void heavyHaptic() {
|
||||
if (supportsHaptics && Platform.isIOS) {
|
||||
HapticFeedback.heavyImpact();
|
||||
} else if (Platform.isAndroid) {
|
||||
HapticFeedback.vibrate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger selection haptic feedback
|
||||
static void selectionHaptic() {
|
||||
if (supportsHaptics) {
|
||||
HapticFeedback.selectionClick();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get platform-appropriate icon
|
||||
static IconData getIcon({required IconData ios, required IconData android}) {
|
||||
return Platform.isIOS ? ios : android;
|
||||
}
|
||||
|
||||
/// Get platform-appropriate text style
|
||||
static TextStyle getPlatformTextStyle(BuildContext context) {
|
||||
if (Platform.isIOS) {
|
||||
return CupertinoTheme.of(context).textTheme.textStyle;
|
||||
}
|
||||
return Theme.of(context).textTheme.bodyMedium ?? const TextStyle();
|
||||
}
|
||||
|
||||
/// Create platform-specific button
|
||||
static Widget createButton({
|
||||
required String text,
|
||||
required VoidCallback? onPressed,
|
||||
bool isPrimary = true,
|
||||
Color? color,
|
||||
}) {
|
||||
if (Platform.isIOS) {
|
||||
return Builder(
|
||||
builder: (context) => CupertinoButton(
|
||||
onPressed: onPressed,
|
||||
color: isPrimary
|
||||
? (color ?? context.conduitTheme.buttonPrimary)
|
||||
: null,
|
||||
child: Text(text),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return isPrimary
|
||||
? FilledButton(
|
||||
onPressed: onPressed,
|
||||
style: color != null
|
||||
? FilledButton.styleFrom(backgroundColor: color)
|
||||
: null,
|
||||
child: Text(text),
|
||||
)
|
||||
: OutlinedButton(onPressed: onPressed, child: Text(text));
|
||||
}
|
||||
|
||||
/// Create platform-specific switch
|
||||
static Widget createSwitch({
|
||||
required bool value,
|
||||
required ValueChanged<bool>? onChanged,
|
||||
Color? activeColor,
|
||||
}) {
|
||||
if (Platform.isIOS) {
|
||||
return Builder(
|
||||
builder: (context) => CupertinoSwitch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
thumbColor: activeColor ?? context.conduitTheme.buttonPrimary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Switch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeTrackColor: activeColor,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create platform-specific slider
|
||||
static Widget createSlider({
|
||||
required double value,
|
||||
required ValueChanged<double>? onChanged,
|
||||
double min = 0.0,
|
||||
double max = 1.0,
|
||||
int? divisions,
|
||||
Color? activeColor,
|
||||
}) {
|
||||
if (Platform.isIOS) {
|
||||
return Builder(
|
||||
builder: (context) => CupertinoSlider(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
min: min,
|
||||
max: max,
|
||||
divisions: divisions,
|
||||
activeColor: activeColor ?? context.conduitTheme.buttonPrimary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Slider(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
min: min,
|
||||
max: max,
|
||||
divisions: divisions,
|
||||
activeColor: activeColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS-specific enhancements
|
||||
class IOSEnhancements {
|
||||
/// Create iOS-style navigation bar
|
||||
static PreferredSizeWidget createNavigationBar({
|
||||
required String title,
|
||||
VoidCallback? onBack,
|
||||
List<Widget>? actions,
|
||||
Color? backgroundColor,
|
||||
}) {
|
||||
return CupertinoNavigationBar(
|
||||
middle: Text(title),
|
||||
leading: onBack != null
|
||||
? CupertinoNavigationBarBackButton(onPressed: onBack)
|
||||
: null,
|
||||
trailing: actions != null && actions.isNotEmpty
|
||||
? Row(mainAxisSize: MainAxisSize.min, children: actions)
|
||||
: null,
|
||||
backgroundColor: backgroundColor,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create iOS-style context menu
|
||||
static Widget createContextMenu({
|
||||
required Widget child,
|
||||
required List<ContextMenuAction> actions,
|
||||
}) {
|
||||
return CupertinoContextMenu(
|
||||
actions: actions
|
||||
.map(
|
||||
(action) => CupertinoContextMenuAction(
|
||||
onPressed: action.onPressed,
|
||||
isDefaultAction: action.isDefault,
|
||||
isDestructiveAction: action.isDestructive,
|
||||
child: Text(action.title),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create iOS-style action sheet
|
||||
static void showActionSheet({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
String? message,
|
||||
required List<ActionSheetAction> actions,
|
||||
}) {
|
||||
showCupertinoModalPopup(
|
||||
context: context,
|
||||
builder: (context) => CupertinoActionSheet(
|
||||
title: Text(title),
|
||||
message: message != null ? Text(message) : null,
|
||||
actions: actions
|
||||
.map(
|
||||
(action) => CupertinoActionSheetAction(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
action.onPressed();
|
||||
},
|
||||
isDefaultAction: action.isDefault,
|
||||
isDestructiveAction: action.isDestructive,
|
||||
child: Text(action.title),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Android-specific enhancements
|
||||
class AndroidEnhancements {
|
||||
/// Create Material You themed button
|
||||
static Widget createMaterial3Button({
|
||||
required String text,
|
||||
required VoidCallback? onPressed,
|
||||
ButtonType type = ButtonType.filled,
|
||||
IconData? icon,
|
||||
}) {
|
||||
Widget button;
|
||||
|
||||
switch (type) {
|
||||
case ButtonType.filled:
|
||||
button = icon != null
|
||||
? FilledButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon),
|
||||
label: Text(text),
|
||||
)
|
||||
: FilledButton(onPressed: onPressed, child: Text(text));
|
||||
break;
|
||||
case ButtonType.outlined:
|
||||
button = icon != null
|
||||
? OutlinedButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon),
|
||||
label: Text(text),
|
||||
)
|
||||
: OutlinedButton(onPressed: onPressed, child: Text(text));
|
||||
break;
|
||||
case ButtonType.text:
|
||||
button = icon != null
|
||||
? TextButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon),
|
||||
label: Text(text),
|
||||
)
|
||||
: TextButton(onPressed: onPressed, child: Text(text));
|
||||
break;
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
/// Create Material 3 card
|
||||
static Widget createCard({
|
||||
required Widget child,
|
||||
VoidCallback? onTap,
|
||||
EdgeInsetsGeometry? padding,
|
||||
CardType type = CardType.filled,
|
||||
}) {
|
||||
Widget card;
|
||||
|
||||
switch (type) {
|
||||
case CardType.filled:
|
||||
card = Card.filled(
|
||||
child: padding != null
|
||||
? Padding(padding: padding, child: child)
|
||||
: child,
|
||||
);
|
||||
break;
|
||||
case CardType.outlined:
|
||||
card = Card.outlined(
|
||||
child: padding != null
|
||||
? Padding(padding: padding, child: child)
|
||||
: child,
|
||||
);
|
||||
break;
|
||||
case CardType.elevated:
|
||||
card = Card(
|
||||
child: padding != null
|
||||
? Padding(padding: padding, child: child)
|
||||
: child,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (onTap != null) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/// Create floating action button with Material 3 styling
|
||||
static Widget createFAB({
|
||||
required VoidCallback onPressed,
|
||||
required Widget child,
|
||||
bool isExtended = false,
|
||||
String? label,
|
||||
}) {
|
||||
if (isExtended && label != null) {
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: onPressed,
|
||||
icon: child,
|
||||
label: Text(label),
|
||||
);
|
||||
}
|
||||
|
||||
return FloatingActionButton(onPressed: onPressed, child: child);
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform-aware widget that provides different implementations
|
||||
class PlatformWidget extends StatelessWidget {
|
||||
final Widget ios;
|
||||
final Widget android;
|
||||
final Widget? fallback;
|
||||
|
||||
const PlatformWidget({
|
||||
super.key,
|
||||
required this.ios,
|
||||
required this.android,
|
||||
this.fallback,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (Platform.isIOS) {
|
||||
return ios;
|
||||
} else if (Platform.isAndroid) {
|
||||
return android;
|
||||
} else {
|
||||
return fallback ?? android;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enhanced button with platform-specific haptics
|
||||
class HapticButton extends StatelessWidget {
|
||||
final Widget child;
|
||||
final VoidCallback? onPressed;
|
||||
final HapticType hapticType;
|
||||
final ButtonStyle? style;
|
||||
|
||||
const HapticButton({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.onPressed,
|
||||
this.hapticType = HapticType.light,
|
||||
this.style,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FilledButton(
|
||||
onPressed: onPressed != null
|
||||
? () {
|
||||
_triggerHaptic();
|
||||
onPressed!();
|
||||
}
|
||||
: null,
|
||||
style: style,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
void _triggerHaptic() {
|
||||
switch (hapticType) {
|
||||
case HapticType.light:
|
||||
PlatformUtils.lightHaptic();
|
||||
break;
|
||||
case HapticType.medium:
|
||||
PlatformUtils.mediumHaptic();
|
||||
break;
|
||||
case HapticType.heavy:
|
||||
PlatformUtils.heavyHaptic();
|
||||
break;
|
||||
case HapticType.selection:
|
||||
PlatformUtils.selectionHaptic();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enhanced list tile with platform-specific styling
|
||||
class PlatformListTile extends StatelessWidget {
|
||||
final Widget? leading;
|
||||
final Widget? title;
|
||||
final Widget? subtitle;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
final bool enableHaptic;
|
||||
|
||||
const PlatformListTile({
|
||||
super.key,
|
||||
this.leading,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
this.enableHaptic = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tile = ListTile(
|
||||
leading: leading,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
trailing: trailing,
|
||||
onTap: onTap != null && enableHaptic
|
||||
? () {
|
||||
PlatformUtils.selectionHaptic();
|
||||
onTap!();
|
||||
}
|
||||
: onTap,
|
||||
);
|
||||
|
||||
if (Platform.isIOS) {
|
||||
return Builder(
|
||||
builder: (context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceBackground,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: context.conduitTheme.dividerColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: tile,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return tile;
|
||||
}
|
||||
}
|
||||
|
||||
// Enums and supporting classes
|
||||
enum HapticType { light, medium, heavy, selection }
|
||||
|
||||
enum ButtonType { filled, outlined, text }
|
||||
|
||||
enum CardType { filled, outlined, elevated }
|
||||
|
||||
class ContextMenuAction {
|
||||
final String title;
|
||||
final VoidCallback onPressed;
|
||||
final bool isDefault;
|
||||
final bool isDestructive;
|
||||
|
||||
const ContextMenuAction({
|
||||
required this.title,
|
||||
required this.onPressed,
|
||||
this.isDefault = false,
|
||||
this.isDestructive = false,
|
||||
});
|
||||
}
|
||||
|
||||
class ActionSheetAction {
|
||||
final String title;
|
||||
final VoidCallback onPressed;
|
||||
final bool isDefault;
|
||||
final bool isDestructive;
|
||||
|
||||
const ActionSheetAction({
|
||||
required this.title,
|
||||
required this.onPressed,
|
||||
this.isDefault = false,
|
||||
this.isDestructive = false,
|
||||
});
|
||||
}
|
||||
221
lib/shared/utils/ui_utils.dart
Normal file
221
lib/shared/utils/ui_utils.dart
Normal file
@@ -0,0 +1,221 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import '../theme/theme_extensions.dart';
|
||||
|
||||
/// Utility functions for common UI patterns and helpers
|
||||
/// Following Conduit design principles
|
||||
|
||||
class UiUtils {
|
||||
static bool get isIOS => Platform.isIOS;
|
||||
|
||||
/// Returns platform-appropriate icon
|
||||
static IconData platformIcon({
|
||||
required IconData ios,
|
||||
required IconData android,
|
||||
}) {
|
||||
return isIOS ? ios : android;
|
||||
}
|
||||
|
||||
/// Common platform icons used throughout the app
|
||||
static IconData get chatIcon =>
|
||||
platformIcon(ios: CupertinoIcons.chat_bubble_2, android: Icons.chat);
|
||||
|
||||
static IconData get searchIcon =>
|
||||
platformIcon(ios: CupertinoIcons.search, android: Icons.search);
|
||||
|
||||
static IconData get deleteIcon =>
|
||||
platformIcon(ios: CupertinoIcons.delete, android: Icons.delete);
|
||||
|
||||
static IconData get archiveIcon =>
|
||||
platformIcon(ios: CupertinoIcons.archivebox, android: Icons.archive);
|
||||
|
||||
static IconData get shareIcon =>
|
||||
platformIcon(ios: CupertinoIcons.share, android: Icons.share);
|
||||
|
||||
static IconData get settingsIcon =>
|
||||
platformIcon(ios: CupertinoIcons.gear, android: Icons.settings);
|
||||
|
||||
static IconData get editIcon =>
|
||||
platformIcon(ios: CupertinoIcons.pencil, android: Icons.edit_outlined);
|
||||
|
||||
static IconData get menuIcon =>
|
||||
platformIcon(ios: CupertinoIcons.line_horizontal_3, android: Icons.menu);
|
||||
|
||||
static IconData get addIcon =>
|
||||
platformIcon(ios: CupertinoIcons.plus_circle, android: Icons.add);
|
||||
|
||||
static IconData get attachIcon =>
|
||||
platformIcon(ios: CupertinoIcons.paperclip, android: Icons.attach_file);
|
||||
|
||||
static IconData get micIcon =>
|
||||
platformIcon(ios: CupertinoIcons.mic, android: Icons.mic);
|
||||
|
||||
static IconData get sendIcon => platformIcon(
|
||||
ios: CupertinoIcons.arrow_up_circle_fill,
|
||||
android: Icons.send,
|
||||
);
|
||||
|
||||
static IconData get moreIcon => platformIcon(
|
||||
ios: CupertinoIcons.ellipsis_vertical,
|
||||
android: Icons.more_vert,
|
||||
);
|
||||
|
||||
static IconData get closeIcon =>
|
||||
platformIcon(ios: CupertinoIcons.xmark, android: Icons.close);
|
||||
|
||||
static IconData get checkIcon =>
|
||||
platformIcon(ios: CupertinoIcons.check_mark, android: Icons.check);
|
||||
|
||||
static IconData get globeIcon =>
|
||||
platformIcon(ios: CupertinoIcons.globe, android: Icons.public);
|
||||
|
||||
static IconData get folderIcon =>
|
||||
platformIcon(ios: CupertinoIcons.folder, android: Icons.folder);
|
||||
|
||||
static IconData get tagIcon =>
|
||||
platformIcon(ios: CupertinoIcons.tag, android: Icons.label);
|
||||
|
||||
static IconData get copyIcon =>
|
||||
platformIcon(ios: CupertinoIcons.doc_on_doc, android: Icons.copy);
|
||||
|
||||
static IconData get pinIcon =>
|
||||
platformIcon(ios: CupertinoIcons.pin_fill, android: Icons.push_pin);
|
||||
|
||||
static IconData get unpinIcon => platformIcon(
|
||||
ios: CupertinoIcons.pin_slash,
|
||||
android: Icons.push_pin_outlined,
|
||||
);
|
||||
|
||||
/// Shows a Conduit-styled snackbar with conversational messaging
|
||||
static void showMessage(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
bool isError = false,
|
||||
VoidCallback? onRetry,
|
||||
Duration? duration,
|
||||
}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: isError
|
||||
? context.conduitTheme.error
|
||||
: context.conduitTheme.buttonPrimary,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
action: onRetry != null
|
||||
? SnackBarAction(
|
||||
label: 'Try again',
|
||||
textColor: context.conduitTheme.textInverse,
|
||||
onPressed: onRetry,
|
||||
)
|
||||
: null,
|
||||
duration: duration ?? const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows a Conduit-styled confirmation dialog
|
||||
static Future<bool> showConfirmationDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmText = 'Confirm',
|
||||
String cancelText = 'Cancel',
|
||||
bool isDestructive = false,
|
||||
}) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(color: context.conduitTheme.textPrimary),
|
||||
),
|
||||
content: Text(
|
||||
message,
|
||||
style: TextStyle(color: context.conduitTheme.textSecondary),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(cancelText),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: isDestructive
|
||||
? TextButton.styleFrom(
|
||||
foregroundColor: context.conduitTheme.error,
|
||||
)
|
||||
: null,
|
||||
child: Text(confirmText),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
/// Formats dates in a conversational way following Conduit patterns
|
||||
static String formatDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(date);
|
||||
|
||||
if (difference.inDays == 0) {
|
||||
if (difference.inHours == 0) {
|
||||
if (difference.inMinutes == 0) {
|
||||
return 'Just now';
|
||||
}
|
||||
return '${difference.inMinutes}m ago';
|
||||
}
|
||||
return '${difference.inHours}h ago';
|
||||
} else if (difference.inDays == 1) {
|
||||
return 'Yesterday';
|
||||
} else if (difference.inDays < 7) {
|
||||
return '${difference.inDays} days ago';
|
||||
} else if (difference.inDays < 30) {
|
||||
final weeks = (difference.inDays / 7).floor();
|
||||
return weeks == 1 ? '1 week ago' : '$weeks weeks ago';
|
||||
} else if (difference.inDays < 365) {
|
||||
final months = (difference.inDays / 30).floor();
|
||||
return months == 1 ? '1 month ago' : '$months months ago';
|
||||
} else {
|
||||
return '${date.month}/${date.day}/${date.year}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a smooth haptic feedback on iOS
|
||||
static void hapticFeedback() {
|
||||
if (isIOS) {
|
||||
// iOS haptic feedback would be implemented here
|
||||
// For now, we'll leave this as a placeholder
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe area padding helper
|
||||
static EdgeInsets safeAreaPadding(BuildContext context) {
|
||||
return MediaQuery.of(context).padding;
|
||||
}
|
||||
|
||||
/// Screen size helpers
|
||||
static Size screenSize(BuildContext context) {
|
||||
return MediaQuery.of(context).size;
|
||||
}
|
||||
|
||||
static bool isSmallScreen(BuildContext context) {
|
||||
return screenSize(context).width < 375;
|
||||
}
|
||||
|
||||
static bool isLargeScreen(BuildContext context) {
|
||||
return screenSize(context).width > 414;
|
||||
}
|
||||
|
||||
/// Keyboard handling
|
||||
static bool isKeyboardOpen(BuildContext context) {
|
||||
return MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
}
|
||||
|
||||
/// Focus management
|
||||
static void unfocus(BuildContext context) {
|
||||
FocusScope.of(context).unfocus();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user