chore: initial release

This commit is contained in:
cogwheel0
2025-08-10 01:20:45 +05:30
commit 758615813f
218 changed files with 67743 additions and 0 deletions

View File

@@ -0,0 +1,281 @@
import 'package:flutter/material.dart';
import '../theme/theme_extensions.dart';
import 'package:flutter/cupertino.dart';
import 'dart:io' show Platform;
import '../theme/app_theme.dart';
/// Centralized service for consistent brand identity throughout the app
/// Uses the hub icon as the primary brand element
class BrandService {
BrandService._();
/// Primary brand icon - the hub icon
static IconData get primaryIcon =>
Platform.isIOS ? CupertinoIcons.link_circle_fill : Icons.hub;
/// Alternative brand icons for different contexts
static IconData get primaryIconOutlined =>
Platform.isIOS ? CupertinoIcons.link_circle : Icons.hub_outlined;
static IconData get connectivityIcon =>
Platform.isIOS ? CupertinoIcons.wifi : Icons.hub;
static IconData get networkIcon =>
Platform.isIOS ? CupertinoIcons.globe : Icons.hub;
/// Brand colors - these should be accessed through context.conduitTheme in UI components
static Color get primaryBrandColor => AppTheme.brandPrimary;
static Color get secondaryBrandColor => AppTheme.brandPrimaryLight;
static Color get accentBrandColor => AppTheme.brandPrimaryDark;
/// Creates a branded icon with consistent styling
static Widget createBrandIcon({
double size = 24,
Color? color,
IconData? icon,
bool useGradient = false,
bool addShadow = false,
}) {
final iconData = icon ?? primaryIcon;
final iconColor = color ?? primaryBrandColor;
Widget iconWidget = Icon(
iconData,
size: size,
color: useGradient ? null : iconColor,
);
if (useGradient) {
iconWidget = ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback: (bounds) => LinearGradient(
colors: [primaryBrandColor, secondaryBrandColor],
).createShader(bounds),
child: Icon(iconData, size: size),
);
}
if (addShadow) {
iconWidget = Container(
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: primaryBrandColor.withValues(alpha: 0.3),
blurRadius: size * 0.3,
offset: Offset(0, size * 0.1),
),
],
),
child: iconWidget,
);
}
return iconWidget;
}
/// Creates a branded avatar with the hub icon
static Widget createBrandAvatar({
double size = 40,
Color? backgroundColor,
Color? iconColor,
bool useGradient = true,
String? fallbackText,
BuildContext? context,
}) {
final bgColor = backgroundColor ?? primaryBrandColor;
final iColor =
iconColor ?? (context?.conduitTheme.textInverse ?? AppTheme.neutral50);
return Container(
width: size,
height: size,
decoration: BoxDecoration(
gradient: useGradient
? LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [primaryBrandColor, secondaryBrandColor],
)
: null,
color: useGradient ? null : bgColor,
borderRadius: BorderRadius.circular(size / 2),
boxShadow: [
BoxShadow(
color: primaryBrandColor.withValues(alpha: 0.3),
blurRadius: size * 0.2,
offset: Offset(0, size * 0.1),
),
],
),
child: fallbackText != null && fallbackText.isNotEmpty
? Center(
child: Text(
fallbackText.toUpperCase(),
style: TextStyle(
color: iColor,
fontSize: size * 0.4,
fontWeight: FontWeight.w600,
),
),
)
: Icon(primaryIcon, size: size * 0.5, color: iColor),
);
}
/// Creates a branded loading indicator
static Widget createBrandLoadingIndicator({
double size = 24,
double strokeWidth = 2,
Color? color,
}) {
return SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(
strokeWidth: strokeWidth,
valueColor: AlwaysStoppedAnimation<Color>(color ?? primaryBrandColor),
),
);
}
/// Creates a branded empty state icon
static Widget createBrandEmptyStateIcon({
double size = 80,
Color? color,
bool showBackground = true,
BuildContext? context,
}) {
final iconColor =
color ?? (context?.conduitTheme.iconSecondary ?? AppTheme.neutral400);
if (!showBackground) {
return createBrandIcon(
size: size,
color: iconColor,
icon: primaryIconOutlined,
);
}
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: context?.conduitTheme.surfaceBackground ?? AppTheme.neutral700,
borderRadius: BorderRadius.circular(size / 2),
border: Border.all(
color: context?.conduitTheme.dividerColor ?? AppTheme.neutral600,
width: 2,
),
),
child: createBrandIcon(
size: size * 0.5,
color: iconColor,
icon: primaryIconOutlined,
),
);
}
/// Creates a branded button with hub icon
static Widget createBrandButton({
required String text,
required VoidCallback? onPressed,
bool isLoading = false,
IconData? icon,
double? width,
bool isSecondary = false,
BuildContext? context,
}) {
return SizedBox(
width: width,
height: 48,
child: ElevatedButton.icon(
onPressed: isLoading ? null : onPressed,
icon: isLoading
? createBrandLoadingIndicator(size: IconSize.sm)
: createBrandIcon(
size: IconSize.md,
icon: icon ?? primaryIcon,
color: context?.conduitTheme.textInverse ?? AppTheme.neutral50,
),
label: Text(text),
style: ElevatedButton.styleFrom(
backgroundColor: isSecondary
? (context?.conduitTheme.buttonSecondary ?? AppTheme.neutral700)
: (context?.conduitTheme.buttonPrimary ?? primaryBrandColor),
foregroundColor:
context?.conduitTheme.buttonPrimaryText ?? AppTheme.neutral50,
disabledBackgroundColor:
context?.conduitTheme.buttonDisabled ?? AppTheme.neutral500,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
),
elevation: Elevation.none,
),
),
);
}
/// Brand-specific semantic labels for accessibility
static String get brandName => 'Conduit';
static String get brandDescription => 'Your AI Conversation Hub';
static String get connectionLabel => 'Hub Connection';
static String get networkLabel => 'Network Hub';
/// Creates branded AppBar with consistent styling
static PreferredSizeWidget createBrandAppBar({
required String title,
List<Widget>? actions,
Widget? leading,
bool centerTitle = true,
double elevation = 0,
BuildContext? context,
}) {
return AppBar(
title: Text(
title,
style: (context != null ? context.conduitTheme.headingSmall : null)
?.copyWith(
color: (context != null
? context.conduitTheme.textPrimary
: null),
fontWeight: FontWeight.w600,
),
),
centerTitle: centerTitle,
elevation: elevation,
backgroundColor: context?.conduitTheme.surfaceBackground,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
leading: leading,
actions: actions,
);
}
/// Creates a branded splash screen logo
static Widget createSplashLogo({
double size = 140,
bool animate = true,
BuildContext? context,
}) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
context?.conduitTheme.buttonPrimary ?? primaryBrandColor,
context?.conduitTheme.buttonPrimary.withValues(alpha: 0.8) ??
secondaryBrandColor,
],
),
borderRadius: BorderRadius.circular(size / 2),
boxShadow: ConduitShadows.glow,
),
child: Icon(
primaryIcon,
size: size * 0.5,
color: context?.conduitTheme.textInverse ?? AppTheme.neutral50,
),
);
}
}

View File

@@ -0,0 +1,414 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'theme_extensions.dart';
class AppTheme {
// Brand accents (ChatGPT aesthetic)
static const Color brandPrimary = Color(0xFF4F46E5); // Indigo
static const Color brandPrimaryLight = Color(0xFF818CF8);
static const Color brandPrimaryDark = Color(0xFF4338CA);
// Enhanced neutral palette for better contrast (WCAG AA compliant)
static const Color neutral900 = Color(0xFF000000); // Pure black
static const Color neutral800 = Color(
0xFF0D0D0D,
); // Darker for better contrast
static const Color neutral700 = Color(0xFF1A1A1A);
static const Color neutral600 = Color(0xFF2D2D2D); // Improved contrast
static const Color neutral500 = Color(0xFF404040); // Better middle gray
static const Color neutral400 = Color(0xFF525252);
static const Color neutral300 = Color(0xFF6B6B6B); // Improved contrast ratio
static const Color neutral200 = Color(0xFF9E9E9E); // Better readability
static const Color neutral100 = Color(0xFFD1D1D1); // Enhanced contrast
static const Color neutral50 = Color(
0xFFF8F8F8,
); // Softer white for reduced eye strain
// Enhanced semantic colors for WCAG AA compliance
static const Color error = Color(0xFFDC2626); // Improved red contrast
static const Color errorDark = Color(0xFFB91C1C); // Darker red for dark theme
static const Color success = Color(0xFF059669); // Better green contrast
static const Color successDark = Color(0xFF047857); // Dark theme green
static const Color warning = Color(0xFFD97706); // Improved orange contrast
static const Color warningDark = Color(0xFFB45309); // Dark theme orange
static const Color info = Color(0xFF0284C7); // Better blue contrast
static const Color infoDark = Color(0xFF0369A1); // Dark theme blue
// Brand aliases
static const Color primaryColor = brandPrimary;
static const Color secondaryColor = brandPrimaryLight;
static const Color surfaceColor = neutral50;
static const Color errorColor = error;
static const Color successColor = success;
// Base Light Theme
static ThemeData lightTheme = ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: const ColorScheme.light(
primary: brandPrimary,
secondary: brandPrimaryLight,
surface: surfaceColor,
error: errorColor,
),
pageTransitionsTheme: const PageTransitionsTheme(
builders: <TargetPlatform, PageTransitionsBuilder>{
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: ZoomPageTransitionsBuilder(),
TargetPlatform.linux: ZoomPageTransitionsBuilder(),
TargetPlatform.macOS: ZoomPageTransitionsBuilder(),
TargetPlatform.windows: ZoomPageTransitionsBuilder(),
},
),
splashFactory: NoSplash.splashFactory,
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: Elevation.none,
backgroundColor: Colors.transparent,
foregroundColor: neutral800,
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: neutral50,
modalBackgroundColor: neutral50,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.modal),
),
showDragHandle: false,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.lg,
vertical: Spacing.xs,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
),
),
),
cardTheme: CardThemeData(
elevation: Elevation.none,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
side: BorderSide(color: neutral200),
),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: neutral900.withValues(alpha: 0.92),
contentTextStyle: GoogleFonts.inter(
color: neutral50,
fontSize: AppTypography.bodyMedium,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.snackbar),
),
elevation: Elevation.high,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: neutral50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: errorColor, width: 1),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
),
textTheme: GoogleFonts.interTextTheme(),
extensions: const [ConduitThemeExtension.auroraLight],
);
// Base Dark Theme
static ThemeData darkTheme = ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: Color(0xFF0A0D0C),
colorScheme: const ColorScheme.dark(
primary: brandPrimary,
secondary: brandPrimaryDark,
surface: Color(0xFF0A0D0C),
surfaceContainerHighest: neutral700,
onSurface: neutral50,
onSurfaceVariant: neutral300,
outline: neutral600,
error: error,
),
pageTransitionsTheme: const PageTransitionsTheme(
builders: <TargetPlatform, PageTransitionsBuilder>{
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: ZoomPageTransitionsBuilder(),
TargetPlatform.linux: ZoomPageTransitionsBuilder(),
TargetPlatform.macOS: ZoomPageTransitionsBuilder(),
TargetPlatform.windows: ZoomPageTransitionsBuilder(),
},
),
splashFactory: NoSplash.splashFactory,
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: Elevation.none,
backgroundColor: Colors.transparent,
foregroundColor: neutral50,
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: neutral900,
modalBackgroundColor: neutral900,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.modal),
),
showDragHandle: false,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.lg,
vertical: Spacing.xs,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
),
),
),
cardTheme: CardThemeData(
elevation: Elevation.none,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
side: BorderSide(color: neutral800),
),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: neutral800.withValues(alpha: 0.92),
contentTextStyle: GoogleFonts.inter(
color: neutral50,
fontSize: AppTypography.bodyMedium,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.snackbar),
),
elevation: Elevation.high,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: neutral700,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: neutral600, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: neutral600, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: brandPrimary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: error, width: 1),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
),
textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme),
extensions: const [ConduitThemeExtension.dark],
);
// Conduit variants using brand colors
static ThemeData conduitLightTheme = lightTheme.copyWith(
colorScheme: lightTheme.colorScheme.copyWith(
primary: brandPrimary,
secondary: brandPrimaryLight,
surface: neutral50,
),
extensions: const [ConduitThemeExtension.light],
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: Elevation.none,
backgroundColor: Colors.transparent,
foregroundColor: neutral800,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: neutral50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: brandPrimary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: error, width: 1),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
),
);
static ThemeData conduitDarkTheme = darkTheme.copyWith(
scaffoldBackgroundColor: const Color(0xFF0A0D0C),
colorScheme: darkTheme.colorScheme.copyWith(
primary: brandPrimary,
secondary: brandPrimaryDark,
surface: const Color(0xFF0A0D0C),
surfaceContainerHighest: neutral700,
),
extensions: const [ConduitThemeExtension.dark],
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: Elevation.none,
backgroundColor: Colors.transparent,
foregroundColor: neutral50,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: neutral700,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: neutral600, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: neutral600, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: brandPrimary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: error, width: 1),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
),
);
// Classic Conduit variants for runtime switching
// Removed classic Conduit variants from public API to keep Aurora only
// Platform-specific theming helpers
static CupertinoThemeData cupertinoTheme(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return CupertinoThemeData(
brightness: isDark ? Brightness.dark : Brightness.light,
primaryColor: brandPrimary,
scaffoldBackgroundColor: isDark ? neutral900 : neutral50,
barBackgroundColor: isDark ? neutral900 : neutral50,
);
}
}
/// Animated theme wrapper for smooth theme transitions
class AnimatedThemeWrapper extends StatefulWidget {
final Widget child;
final ThemeData theme;
final Duration duration;
const AnimatedThemeWrapper({
super.key,
required this.child,
required this.theme,
this.duration = const Duration(milliseconds: 250),
});
@override
State<AnimatedThemeWrapper> createState() => _AnimatedThemeWrapperState();
}
class _AnimatedThemeWrapperState extends State<AnimatedThemeWrapper>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
ThemeData? _previousTheme;
@override
void initState() {
super.initState();
_controller = AnimationController(duration: widget.duration, vsync: this);
_animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOut);
_previousTheme = widget.theme;
}
@override
void didUpdateWidget(AnimatedThemeWrapper oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.theme != widget.theme) {
_previousTheme = oldWidget.theme;
_controller.forward(from: 0);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Theme(
data: ThemeData.lerp(
_previousTheme ?? widget.theme,
widget.theme,
_animation.value,
),
child: widget.child,
);
},
);
}
}
/// Theme transition widget for individual components
class ThemeTransition extends StatelessWidget {
final Widget child;
final Duration duration;
const ThemeTransition({
super.key,
required this.child,
this.duration = const Duration(milliseconds: 200),
});
@override
Widget build(BuildContext context) {
return child.animate().fadeIn(duration: duration);
}
}
// Typography, spacing, and design token classes are now in theme_extensions.dart for consistency

File diff suppressed because it is too large Load Diff

View 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,
);
}
}

View 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,
});
}

View 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();
}
}

View File

@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../theme/theme_extensions.dart';
import 'improved_loading_states.dart';
/// Cached network image widget with progressive loading and error handling
class CachedImage extends StatelessWidget {
final String imageUrl;
final double? width;
final double? height;
final BoxFit fit;
final Widget? placeholder;
final Widget? errorWidget;
final Duration fadeInDuration;
final Duration fadeOutDuration;
final bool enableMemoryCache;
final int? maxWidthDiskCache;
final int? maxHeightDiskCache;
const CachedImage({
super.key,
required this.imageUrl,
this.width,
this.height,
this.fit = BoxFit.cover,
this.placeholder,
this.errorWidget,
this.fadeInDuration = const Duration(milliseconds: 300),
this.fadeOutDuration = const Duration(milliseconds: 100),
this.enableMemoryCache = true,
this.maxWidthDiskCache,
this.maxHeightDiskCache,
});
@override
Widget build(BuildContext context) {
return CachedNetworkImage(
imageUrl: imageUrl,
width: width,
height: height,
fit: fit,
fadeInDuration: fadeInDuration,
fadeOutDuration: fadeOutDuration,
placeholder: placeholder != null
? (context, url) => placeholder!
: _buildDefaultPlaceholder,
errorWidget: errorWidget != null
? (context, url, error) => errorWidget!
: _buildDefaultErrorWidget,
memCacheWidth: enableMemoryCache ? width?.toInt() : null,
memCacheHeight: enableMemoryCache ? height?.toInt() : null,
maxWidthDiskCache: maxWidthDiskCache,
maxHeightDiskCache: maxHeightDiskCache,
useOldImageOnUrlChange: true,
filterQuality: FilterQuality.medium,
);
}
Widget _buildDefaultPlaceholder(BuildContext context, String url) {
return ShimmerLoader(
width: width ?? double.infinity,
height: height ?? 200,
borderRadius: BorderRadius.circular(8),
);
}
Widget _buildDefaultErrorWidget(
BuildContext context,
String url,
dynamic error,
) {
return Container(
width: width,
height: height,
color: context.conduitTheme.shimmerBase,
child: Icon(
Icons.broken_image,
color: context.conduitTheme.iconSecondary,
size: (width != null && height != null)
? (width! < height! ? width! * 0.5 : height! * 0.5)
: 24,
),
);
}
}
/// Cached circular avatar with progressive loading
class CachedAvatar extends StatelessWidget {
final String? imageUrl;
final String fallbackText;
final double radius;
final Color? backgroundColor;
final Color? textColor;
const CachedAvatar({
super.key,
this.imageUrl,
required this.fallbackText,
this.radius = 20,
this.backgroundColor,
this.textColor,
});
@override
Widget build(BuildContext context) {
return CircleAvatar(
radius: radius,
backgroundColor:
backgroundColor ?? context.conduitTheme.surfaceBackground,
child: imageUrl != null
? ClipOval(
child: CachedNetworkImage(
imageUrl: imageUrl!,
width: radius * 2,
height: radius * 2,
fit: BoxFit.cover,
placeholder: (context, url) => CircularProgressIndicator(
strokeWidth: 2,
color: textColor ?? context.conduitTheme.iconSecondary,
),
errorWidget: (context, url, error) => Text(
fallbackText.isNotEmpty ? fallbackText[0].toUpperCase() : '?',
style: TextStyle(
color: textColor ?? context.conduitTheme.textPrimary,
fontWeight: FontWeight.bold,
fontSize: radius * 0.6,
),
),
memCacheWidth: (radius * 2).toInt(),
memCacheHeight: (radius * 2).toInt(),
),
)
: Text(
fallbackText.isNotEmpty ? fallbackText[0].toUpperCase() : '?',
style: TextStyle(
color: textColor ?? context.conduitTheme.textPrimary,
fontWeight: FontWeight.bold,
fontSize: radius * 0.6,
),
),
);
}
}

View File

@@ -0,0 +1,958 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../theme/theme_extensions.dart';
import '../services/brand_service.dart';
import '../../core/services/enhanced_accessibility_service.dart';
import '../../core/services/platform_service.dart';
import '../../core/services/settings_service.dart';
/// Unified component library following Conduit design patterns
/// This provides consistent, reusable UI components throughout the app
class ConduitButton extends ConsumerWidget {
final String text;
final VoidCallback? onPressed;
final bool isLoading;
final bool isDestructive;
final bool isSecondary;
final IconData? icon;
final double? width;
final bool isFullWidth;
final bool isCompact;
const ConduitButton({
super.key,
required this.text,
this.onPressed,
this.isLoading = false,
this.isDestructive = false,
this.isSecondary = false,
this.icon,
this.width,
this.isFullWidth = false,
this.isCompact = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hapticEnabled = ref.watch(hapticEnabledProvider);
Color backgroundColor;
Color textColor;
if (isDestructive) {
backgroundColor = context.conduitTheme.error;
textColor = context.conduitTheme.buttonPrimaryText;
} else if (isSecondary) {
backgroundColor = context.conduitTheme.buttonSecondary;
textColor = context.conduitTheme.buttonSecondaryText;
} else {
backgroundColor = context.conduitTheme.buttonPrimary;
textColor = context.conduitTheme.buttonPrimaryText;
}
// Build semantic label
String semanticLabel = text;
if (isLoading) {
semanticLabel = 'Loading: $text';
} else if (isDestructive) {
semanticLabel = 'Warning: $text';
}
return Semantics(
label: semanticLabel,
button: true,
enabled: !isLoading && onPressed != null,
child: SizedBox(
width: isFullWidth ? double.infinity : width,
height: isCompact ? TouchTarget.medium : TouchTarget.comfortable,
child: ElevatedButton(
onPressed: isLoading
? null
: () {
if (onPressed != null) {
PlatformService.hapticFeedbackWithSettings(
type: isDestructive
? HapticType.warning
: HapticType.light,
hapticEnabled: hapticEnabled,
);
onPressed!();
}
},
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor,
foregroundColor: textColor,
disabledBackgroundColor: context.conduitTheme.buttonDisabled,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.button),
),
elevation: Elevation.none,
shadowColor: backgroundColor.withValues(alpha: Alpha.standard),
minimumSize: Size(
TouchTarget.minimum,
isCompact ? TouchTarget.medium : TouchTarget.comfortable,
),
padding: EdgeInsets.symmetric(
horizontal: isCompact ? Spacing.md : Spacing.buttonPadding,
vertical: isCompact ? Spacing.sm : Spacing.sm,
),
),
child: isLoading
? Semantics(
label: 'Loading',
excludeSemantics: true,
child: SizedBox(
width: IconSize.small,
height: IconSize.small,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(textColor),
),
),
)
: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: IconSize.small),
SizedBox(width: Spacing.iconSpacing),
],
Flexible(
child: EnhancedAccessibilityService.createAccessibleText(
text,
style: AppTypography.standard.copyWith(
fontWeight: FontWeight.w600,
color: textColor,
),
maxLines: 1,
),
),
],
),
),
),
);
}
}
class ConduitInput extends StatelessWidget {
final String? label;
final String? hint;
final TextEditingController? controller;
final ValueChanged<String>? onChanged;
final VoidCallback? onTap;
final bool obscureText;
final bool enabled;
final String? errorText;
final int? maxLines;
final Widget? suffixIcon;
final Widget? prefixIcon;
final TextInputType? keyboardType;
final bool autofocus;
final String? semanticLabel;
final ValueChanged<String>? onSubmitted;
final bool isRequired;
const ConduitInput({
super.key,
this.label,
this.hint,
this.controller,
this.onChanged,
this.onTap,
this.obscureText = false,
this.enabled = true,
this.errorText,
this.maxLines = 1,
this.suffixIcon,
this.prefixIcon,
this.keyboardType,
this.autofocus = false,
this.semanticLabel,
this.onSubmitted,
this.isRequired = false,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (label != null) ...[
Row(
children: [
Text(
label!,
style: AppTypography.standard.copyWith(
fontWeight: FontWeight.w500,
color: context.conduitTheme.textPrimary,
),
),
if (isRequired) ...[
SizedBox(width: Spacing.textSpacing),
Text(
'*',
style: AppTypography.standard.copyWith(
color: context.conduitTheme.error,
fontWeight: FontWeight.w600,
),
),
],
],
),
SizedBox(height: Spacing.sm),
],
Semantics(
label: semanticLabel ?? label ?? 'Input field',
textField: true,
child: TextField(
controller: controller,
onChanged: onChanged,
onTap: onTap,
onSubmitted: onSubmitted,
obscureText: obscureText,
enabled: enabled,
maxLines: maxLines,
keyboardType: keyboardType,
autofocus: autofocus,
style: AppTypography.standard.copyWith(
color: context.conduitTheme.textPrimary,
),
decoration: InputDecoration(
hintText: hint,
hintStyle: AppTypography.standard.copyWith(
color: context.conduitTheme.inputPlaceholder,
),
filled: true,
fillColor: context.conduitTheme.inputBackground,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.input),
borderSide: BorderSide(
color: context.conduitTheme.inputBorder,
width: BorderWidth.standard,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.input),
borderSide: BorderSide(
color: context.conduitTheme.inputBorder,
width: BorderWidth.standard,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.input),
borderSide: BorderSide(
color: context.conduitTheme.buttonPrimary,
width: BorderWidth.thick,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.input),
borderSide: BorderSide(
color: context.conduitTheme.error,
width: BorderWidth.standard,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.input),
borderSide: BorderSide(
color: context.conduitTheme.error,
width: BorderWidth.thick,
),
),
contentPadding: EdgeInsets.symmetric(
horizontal: Spacing.inputPadding,
vertical: Spacing.md,
),
suffixIcon: suffixIcon,
prefixIcon: prefixIcon,
errorText: errorText,
errorStyle: AppTypography.small.copyWith(
color: context.conduitTheme.error,
),
),
),
),
],
);
}
}
class ConduitCard extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
final VoidCallback? onTap;
final bool isSelected;
final bool isElevated;
final bool isCompact;
const ConduitCard({
super.key,
required this.child,
this.padding,
this.onTap,
this.isSelected = false,
this.isElevated = false,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding:
padding ??
EdgeInsets.all(isCompact ? Spacing.md : Spacing.cardPadding),
decoration: BoxDecoration(
color: isSelected
? context.conduitTheme.buttonPrimary.withValues(
alpha: Alpha.highlight,
)
: context.conduitTheme.cardBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.card),
border: Border.all(
color: isSelected
? context.conduitTheme.buttonPrimary.withValues(
alpha: Alpha.standard,
)
: context.conduitTheme.cardBorder,
width: BorderWidth.standard,
),
boxShadow: isElevated ? ConduitShadows.card : null,
),
child: child,
),
);
}
}
class ConduitIconButton extends ConsumerWidget {
final IconData icon;
final VoidCallback? onPressed;
final String? tooltip;
final bool isActive;
final Color? backgroundColor;
final Color? iconColor;
final bool isCompact;
final bool isCircular;
const ConduitIconButton({
super.key,
required this.icon,
this.onPressed,
this.tooltip,
this.isActive = false,
this.backgroundColor,
this.iconColor,
this.isCompact = false,
this.isCircular = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hapticEnabled = ref.watch(hapticEnabledProvider);
final effectiveBackgroundColor =
backgroundColor ??
(isActive
? context.conduitTheme.buttonPrimary.withValues(
alpha: Alpha.highlight,
)
: Colors.transparent);
final effectiveIconColor =
iconColor ??
(isActive
? context.conduitTheme.buttonPrimary
: context.conduitTheme.iconSecondary);
// Build semantic label with context
String semanticLabel = tooltip ?? 'Button';
if (isActive) {
semanticLabel = '$semanticLabel, active';
}
return Semantics(
label: semanticLabel,
button: true,
enabled: onPressed != null,
child: Tooltip(
message: tooltip ?? '',
child: GestureDetector(
onTap: () {
if (onPressed != null) {
PlatformService.hapticFeedbackWithSettings(
type: HapticType.selection,
hapticEnabled: hapticEnabled,
);
onPressed!();
}
},
child: Container(
width: isCompact ? TouchTarget.medium : TouchTarget.minimum,
height: isCompact ? TouchTarget.medium : TouchTarget.minimum,
decoration: BoxDecoration(
color: effectiveBackgroundColor,
borderRadius: BorderRadius.circular(
isCircular
? AppBorderRadius.circular
: AppBorderRadius.standard,
),
border: isActive
? Border.all(
color: context.conduitTheme.buttonPrimary.withValues(
alpha: Alpha.standard,
),
width: BorderWidth.standard,
)
: null,
),
child: Icon(
icon,
size: isCompact ? IconSize.small : IconSize.medium,
color: effectiveIconColor,
semanticLabel: tooltip,
),
),
),
),
);
}
}
class ConduitLoadingIndicator extends StatelessWidget {
final String? message;
final double size;
final bool isCompact;
const ConduitLoadingIndicator({
super.key,
this.message,
this.size = 24,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(
strokeWidth: isCompact ? 2 : 3,
valueColor: AlwaysStoppedAnimation<Color>(
context.conduitTheme.buttonPrimary,
),
),
),
if (message != null) ...[
SizedBox(height: isCompact ? Spacing.sm : Spacing.md),
Text(
message!,
style: AppTypography.standard.copyWith(
color: context.conduitTheme.textSecondary,
),
textAlign: TextAlign.center,
),
],
],
);
}
}
class ConduitEmptyState extends StatelessWidget {
final IconData icon;
final String title;
final String message;
final Widget? action;
final bool isCompact;
const ConduitEmptyState({
super.key,
required this.icon,
required this.title,
required this.message,
this.action,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: EdgeInsets.all(isCompact ? Spacing.md : Spacing.lg),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: isCompact ? IconSize.xxl : IconSize.xxl + Spacing.md,
height: isCompact ? IconSize.xxl : IconSize.xxl + Spacing.md,
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.circular),
),
child: Icon(
icon,
size: isCompact ? IconSize.xl : TouchTarget.minimum,
color: context.conduitTheme.iconSecondary,
),
),
SizedBox(height: isCompact ? Spacing.sm : Spacing.md),
Text(
title,
style: AppTypography.headlineSmallStyle.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
SizedBox(height: Spacing.sm),
Text(
message,
style: AppTypography.standard.copyWith(
color: context.conduitTheme.textSecondary,
),
textAlign: TextAlign.center,
),
if (action != null) ...[
SizedBox(height: isCompact ? Spacing.md : Spacing.lg),
action!,
],
],
),
),
);
}
}
class ConduitAvatar extends StatelessWidget {
final double size;
final IconData? icon;
final String? text;
final bool isCompact;
const ConduitAvatar({
super.key,
this.size = 32,
this.icon,
this.text,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return BrandService.createBrandAvatar(
size: isCompact ? size * 0.8 : size,
fallbackText: text,
);
}
}
class ConduitBadge extends StatelessWidget {
final String text;
final Color? backgroundColor;
final Color? textColor;
final bool isCompact;
const ConduitBadge({
super.key,
required this.text,
this.backgroundColor,
this.textColor,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.symmetric(
horizontal: isCompact ? Spacing.sm : Spacing.md,
vertical: isCompact ? Spacing.xs : Spacing.sm,
),
decoration: BoxDecoration(
color:
backgroundColor ??
context.conduitTheme.buttonPrimary.withValues(
alpha: Alpha.badgeBackground,
),
borderRadius: BorderRadius.circular(AppBorderRadius.badge),
),
child: Text(
text,
style: AppTypography.small.copyWith(
color: textColor ?? context.conduitTheme.buttonPrimary,
fontWeight: FontWeight.w600,
),
),
);
}
}
class ConduitChip extends StatelessWidget {
final String label;
final VoidCallback? onTap;
final bool isSelected;
final IconData? icon;
final bool isCompact;
const ConduitChip({
super.key,
required this.label,
this.onTap,
this.isSelected = false,
this.icon,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: isCompact ? Spacing.sm : Spacing.md,
vertical: isCompact ? Spacing.xs : Spacing.sm,
),
decoration: BoxDecoration(
color: isSelected
? context.conduitTheme.buttonPrimary.withValues(
alpha: Alpha.highlight,
)
: context.conduitTheme.surfaceContainer,
borderRadius: BorderRadius.circular(AppBorderRadius.chip),
border: Border.all(
color: isSelected
? context.conduitTheme.buttonPrimary.withValues(
alpha: Alpha.standard,
)
: context.conduitTheme.dividerColor,
width: BorderWidth.standard,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
size: isCompact ? IconSize.xs : IconSize.small,
color: isSelected
? context.conduitTheme.buttonPrimary
: context.conduitTheme.iconSecondary,
),
SizedBox(width: Spacing.iconSpacing),
],
Text(
label,
style: AppTypography.small.copyWith(
color: isSelected
? context.conduitTheme.buttonPrimary
: context.conduitTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
}
class ConduitDivider extends StatelessWidget {
final bool isCompact;
final Color? color;
const ConduitDivider({super.key, this.isCompact = false, this.color});
@override
Widget build(BuildContext context) {
return Container(
height: BorderWidth.standard,
color: color ?? context.conduitTheme.dividerColor,
margin: EdgeInsets.symmetric(
vertical: isCompact ? Spacing.sm : Spacing.md,
),
);
}
}
class ConduitSpacer extends StatelessWidget {
final double height;
final bool isCompact;
const ConduitSpacer({super.key, this.height = 16, this.isCompact = false});
@override
Widget build(BuildContext context) {
return SizedBox(height: isCompact ? height * 0.5 : height);
}
}
/// Enhanced form field with better accessibility and validation
class AccessibleFormField extends StatelessWidget {
final String? label;
final String? hint;
final TextEditingController? controller;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final VoidCallback? onTap;
final bool obscureText;
final bool enabled;
final String? errorText;
final int? maxLines;
final Widget? suffixIcon;
final Widget? prefixIcon;
final TextInputType? keyboardType;
final bool autofocus;
final String? semanticLabel;
final String? Function(String?)? validator;
final bool isRequired;
final bool isCompact;
const AccessibleFormField({
super.key,
this.label,
this.hint,
this.controller,
this.onChanged,
this.onSubmitted,
this.onTap,
this.obscureText = false,
this.enabled = true,
this.errorText,
this.maxLines = 1,
this.suffixIcon,
this.prefixIcon,
this.keyboardType,
this.autofocus = false,
this.semanticLabel,
this.validator,
this.isRequired = false,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (label != null) ...[
Row(
children: [
Text(
label!,
style: AppTypography.standard.copyWith(
fontWeight: FontWeight.w500,
color: context.conduitTheme.textPrimary,
),
),
if (isRequired) ...[
SizedBox(width: Spacing.textSpacing),
Text(
'*',
style: AppTypography.standard.copyWith(
color: context.conduitTheme.error,
fontWeight: FontWeight.w600,
),
),
],
],
),
SizedBox(height: isCompact ? Spacing.xs : Spacing.sm),
],
Semantics(
label: semanticLabel ?? label ?? 'Input field',
textField: true,
child: TextFormField(
controller: controller,
onChanged: onChanged,
onTap: onTap,
onFieldSubmitted: onSubmitted,
obscureText: obscureText,
enabled: enabled,
maxLines: maxLines,
keyboardType: keyboardType,
autofocus: autofocus,
validator: validator,
style: AppTypography.standard.copyWith(
color: context.conduitTheme.textPrimary,
),
decoration: InputDecoration(
hintText: hint,
hintStyle: AppTypography.standard.copyWith(
color: context.conduitTheme.inputPlaceholder,
),
filled: true,
fillColor: context.conduitTheme.inputBackground,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.input),
borderSide: BorderSide(
color: context.conduitTheme.inputBorder,
width: BorderWidth.standard,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.input),
borderSide: BorderSide(
color: context.conduitTheme.inputBorder,
width: BorderWidth.standard,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.input),
borderSide: BorderSide(
color: context.conduitTheme.buttonPrimary,
width: BorderWidth.thick,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.input),
borderSide: BorderSide(
color: context.conduitTheme.error,
width: BorderWidth.standard,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.input),
borderSide: BorderSide(
color: context.conduitTheme.error,
width: BorderWidth.thick,
),
),
contentPadding: EdgeInsets.symmetric(
horizontal: isCompact ? Spacing.md : Spacing.inputPadding,
vertical: isCompact ? Spacing.sm : Spacing.md,
),
suffixIcon: suffixIcon,
prefixIcon: prefixIcon,
errorText: errorText,
errorStyle: AppTypography.small.copyWith(
color: context.conduitTheme.error,
),
),
),
),
],
);
}
}
/// Enhanced section header with better typography
class ConduitSectionHeader extends StatelessWidget {
final String title;
final String? subtitle;
final Widget? action;
final bool isCompact;
const ConduitSectionHeader({
super.key,
required this.title,
this.subtitle,
this.action,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(
horizontal: isCompact ? Spacing.md : Spacing.pagePadding,
vertical: isCompact ? Spacing.sm : Spacing.md,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppTypography.headlineSmallStyle.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
),
),
if (subtitle != null) ...[
SizedBox(height: Spacing.textSpacing),
Text(
subtitle!,
style: AppTypography.standard.copyWith(
color: context.conduitTheme.textSecondary,
),
),
],
],
),
),
if (action != null) ...[SizedBox(width: Spacing.md), action!],
],
),
);
}
}
/// Enhanced list item with better consistency
class ConduitListItem extends StatelessWidget {
final Widget leading;
final Widget title;
final Widget? subtitle;
final Widget? trailing;
final VoidCallback? onTap;
final bool isSelected;
final bool isCompact;
const ConduitListItem({
super.key,
required this.leading,
required this.title,
this.subtitle,
this.trailing,
this.onTap,
this.isSelected = false,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: EdgeInsets.all(
isCompact ? Spacing.sm : Spacing.listItemPadding,
),
decoration: BoxDecoration(
color: isSelected
? context.conduitTheme.buttonPrimary.withValues(
alpha: Alpha.highlight,
)
: Colors.transparent,
borderRadius: BorderRadius.circular(AppBorderRadius.standard),
),
child: Row(
children: [
leading,
SizedBox(width: isCompact ? Spacing.sm : Spacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title,
if (subtitle != null) ...[
SizedBox(height: Spacing.textSpacing),
subtitle!,
],
],
),
),
if (trailing != null) ...[
SizedBox(width: isCompact ? Spacing.sm : Spacing.md),
trailing!,
],
],
),
),
);
}
}

View File

@@ -0,0 +1,448 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'dart:io' show Platform;
import '../theme/theme_extensions.dart';
import '../services/brand_service.dart';
/// Enhanced empty state widgets with illustrations and actions
class ConduitEmptyState extends StatelessWidget {
final String title;
final String? subtitle;
final IconData? icon;
final Widget? illustration;
final List<EmptyStateAction>? actions;
final bool isLoading;
const ConduitEmptyState({
super.key,
required this.title,
this.subtitle,
this.icon,
this.illustration,
this.actions,
this.isLoading = false,
});
@override
Widget build(BuildContext context) {
final conduitTheme = context.conduitTheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(Spacing.xl),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Illustration or icon
if (illustration != null)
illustration!
else if (icon != null)
Container(
width: IconSize.xxl * 2.5, // 120px equivalent
height: IconSize.xxl * 2.5, // 120px equivalent
decoration: BoxDecoration(
color: conduitTheme.cardBackground,
shape: BoxShape.circle,
border: Border.all(color: conduitTheme.cardBorder, width: 2),
),
child: Icon(
icon!,
size: IconSize.xxl,
color: context.conduitTheme.iconSecondary,
),
)
else
// Default to brand icon when no specific icon or illustration provided
BrandService.createBrandEmptyStateIcon(
size: IconSize.xxl * 2.5, // 120px equivalent
showBackground: true,
),
const SizedBox(height: Spacing.xl),
// Title
Text(
title,
style: conduitTheme.headingMedium,
textAlign: TextAlign.center,
),
// Subtitle
if (subtitle != null) ...[
const SizedBox(height: Spacing.xs),
Text(
subtitle!,
style: conduitTheme.bodyMedium?.copyWith(
color: context.conduitTheme.textSecondary,
),
textAlign: TextAlign.center,
),
],
// Actions
if (actions != null && actions!.isNotEmpty) ...[
const SizedBox(height: Spacing.xl),
...actions!.map(
(action) => Padding(
padding: const EdgeInsets.only(bottom: Spacing.xs),
child: _buildActionButton(context, action),
),
),
],
],
),
),
).animate().fadeIn(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
Widget _buildActionButton(BuildContext context, EmptyStateAction action) {
return SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: action.onPressed,
style: action.isPrimary
? FilledButton.styleFrom(
backgroundColor: context.conduitTheme.buttonPrimary,
foregroundColor: context.conduitTheme.buttonPrimaryText,
)
: FilledButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: context.conduitTheme.textSecondary,
side: BorderSide(
color: context.conduitTheme.dividerColor,
width: 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (action.icon != null) ...[
Icon(action.icon, size: IconSize.md),
const SizedBox(width: Spacing.sm),
],
Text(action.label),
],
),
),
);
}
}
/// Action for empty states
class EmptyStateAction {
final String label;
final VoidCallback onPressed;
final IconData? icon;
final bool isPrimary;
const EmptyStateAction({
required this.label,
required this.onPressed,
this.icon,
this.isPrimary = true,
});
}
/// Chat-specific empty state
class ChatEmptyState extends StatelessWidget {
final VoidCallback? onStartChat;
const ChatEmptyState({super.key, this.onStartChat});
@override
Widget build(BuildContext context) {
return ConduitEmptyState(
title: 'Start a conversation',
subtitle:
'Ask me anything! I\'m here to help with questions, creative tasks, analysis, and more.',
// Remove custom illustration to use default brand icon
icon: BrandService.primaryIcon,
actions: onStartChat != null
? [
EmptyStateAction(
label: 'Start chatting',
icon: BrandService.primaryIcon,
onPressed: onStartChat!,
),
]
: null,
);
}
}
/// Files empty state
class FilesEmptyState extends StatelessWidget {
final VoidCallback? onUploadFile;
const FilesEmptyState({super.key, this.onUploadFile});
@override
Widget build(BuildContext context) {
return ConduitEmptyState(
title: 'No files yet',
subtitle:
'Upload documents, images, or other files to get started with your knowledge base.',
illustration: Builder(
builder: (context) => _buildFilesIllustration(context),
),
actions: onUploadFile != null
? [
EmptyStateAction(
label: 'Upload files',
icon: Platform.isIOS
? CupertinoIcons.doc_on_doc
: Icons.upload_file,
onPressed: onUploadFile!,
),
]
: null,
);
}
Widget _buildFilesIllustration(BuildContext context) {
return SizedBox(
width: 120,
height: 120,
child: Stack(
alignment: Alignment.center,
children: [
// Background circle
Container(
width: IconSize.xxl * 2.5, // 120px equivalent
height: IconSize.xxl * 2.5, // 120px equivalent
decoration: BoxDecoration(
color: context.conduitTheme.info.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
),
// File stack
...List.generate(3, (index) {
return Positioned(
top: 30 + (index * 8.0),
left: 30 + (index * 4.0),
child:
Container(
width: TouchTarget.minimum,
height: 50,
decoration: BoxDecoration(
color: [
context.conduitTheme.info,
context.conduitTheme.success,
context.conduitTheme.warning,
][index],
borderRadius: BorderRadius.circular(
AppBorderRadius.xs,
),
),
child: Icon(
[Icons.description, Icons.image, Icons.folder][index],
color: context.conduitTheme.textInverse,
size: IconSize.md,
),
)
.animate(delay: Duration(milliseconds: index * 200))
.fadeIn()
.slideY(begin: 0.3, end: 0),
);
}),
],
),
);
}
}
/// Tools empty state
class ToolsEmptyState extends StatelessWidget {
final VoidCallback? onExploreTools;
const ToolsEmptyState({super.key, this.onExploreTools});
@override
Widget build(BuildContext context) {
return ConduitEmptyState(
title: 'Powerful tools await',
subtitle: 'Discover tools to enhance your productivity and creativity.',
illustration: Builder(
builder: (context) => _buildToolsIllustration(context),
),
actions: onExploreTools != null
? [
EmptyStateAction(
label: 'Explore tools',
icon: Platform.isIOS
? CupertinoIcons.wand_stars
: Icons.auto_awesome,
onPressed: onExploreTools!,
),
]
: null,
);
}
Widget _buildToolsIllustration(BuildContext context) {
return SizedBox(
width: 120,
height: 120,
child: Stack(
alignment: Alignment.center,
children: [
// Background circle
Container(
width: IconSize.xxl * 2.5, // 120px equivalent
height: IconSize.xxl * 2.5, // 120px equivalent
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
),
// Tools arrangement
...List.generate(6, (index) {
final angle = (index * 60) * (3.14159 / 180);
final radius = 35.0;
return Positioned(
top: 60 + (radius * -cos(angle)) - 15,
left: 60 + (radius * sin(angle)) - 15,
child:
Container(
width: Spacing.xl - Spacing.xxs, // 30px equivalent
height: Spacing.xl - Spacing.xxs, // 30px equivalent
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary,
shape: BoxShape.circle,
),
child: Icon(
[
Icons.palette,
Icons.calculate,
Icons.code,
Icons.translate,
Icons.music_note,
Icons.analytics,
][index],
color: context.conduitTheme.textInverse,
size: IconSize.sm,
),
)
.animate(delay: Duration(milliseconds: index * 100))
.fadeIn()
.scale(
begin: const Offset(0.5, 0.5),
end: const Offset(1.0, 1.0),
),
);
}),
],
),
);
}
}
/// Search results empty state
class SearchEmptyState extends StatelessWidget {
final String query;
final VoidCallback? onClearSearch;
const SearchEmptyState({super.key, required this.query, this.onClearSearch});
@override
Widget build(BuildContext context) {
return ConduitEmptyState(
title: 'No results found',
subtitle: 'No results for "$query". Try adjusting your search terms.',
icon: Platform.isIOS ? CupertinoIcons.search : Icons.search_off,
actions: onClearSearch != null
? [
EmptyStateAction(
label: 'Clear search',
icon: Platform.isIOS ? CupertinoIcons.clear : Icons.clear,
onPressed: onClearSearch!,
isPrimary: false,
),
]
: null,
);
}
}
/// Connection error empty state
class ConnectionEmptyState extends StatelessWidget {
final VoidCallback? onRetry;
const ConnectionEmptyState({super.key, this.onRetry});
@override
Widget build(BuildContext context) {
return ConduitEmptyState(
title: 'Connection problem',
subtitle:
'Unable to load content. Please check your connection and try again.',
icon: Platform.isIOS ? CupertinoIcons.wifi_slash : Icons.wifi_off,
actions: onRetry != null
? [
EmptyStateAction(
label: 'Try again',
icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh,
onPressed: onRetry!,
),
]
: null,
);
}
}
/// Generic empty state with custom illustration
class CustomEmptyState extends StatelessWidget {
final String title;
final String subtitle;
final Widget illustration;
final List<EmptyStateAction>? actions;
const CustomEmptyState({
super.key,
required this.title,
required this.subtitle,
required this.illustration,
this.actions,
});
@override
Widget build(BuildContext context) {
return ConduitEmptyState(
title: title,
subtitle: subtitle,
illustration: illustration,
actions: actions,
);
}
}
// Helper function to get cosine
double cos(double radians) {
// Simple cosine approximation for illustration positioning
if (radians == 0) return 1.0;
if (radians == 1.5708) return 0.0; // π/2
if (radians == 3.14159) return -1.0; // π
if (radians == 4.71239) return 0.0; // 3π/2
// Taylor series approximation for other values
double x2 = radians * radians;
return 1 - x2 / 2 + x2 * x2 / 24 - x2 * x2 * x2 / 720;
}
// Helper function to get sine
double sin(double radians) {
// Simple sine approximation for illustration positioning
if (radians == 0) return 0.0;
if (radians == 1.5708) return 1.0; // π/2
if (radians == 3.14159) return 0.0; // π
if (radians == 4.71239) return -1.0; // 3π/2
// Taylor series approximation for other values
double x2 = radians * radians;
return radians - radians * x2 / 6 + radians * x2 * x2 / 120;
}

View File

@@ -0,0 +1,397 @@
import 'package:flutter/material.dart';
import '../theme/theme_extensions.dart';
import 'conduit_components.dart';
/// Enhanced error widget with production-grade design and better hierarchy
class ConduitErrorWidget extends StatelessWidget {
final String title;
final String message;
final String? actionLabel;
final VoidCallback? onAction;
final IconData? icon;
final bool isCompact;
const ConduitErrorWidget({
super.key,
required this.title,
required this.message,
this.actionLabel,
this.onAction,
this.icon,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(isCompact ? Spacing.md : Spacing.cardPadding),
decoration: BoxDecoration(
color: context.conduitTheme.errorBackground.withValues(
alpha: Alpha.badgeBackground,
),
borderRadius: BorderRadius.circular(AppBorderRadius.card),
border: Border.all(
color: context.conduitTheme.error.withValues(alpha: Alpha.subtle),
width: BorderWidth.standard,
),
boxShadow: ConduitShadows.card,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon ?? Icons.error_outline,
size: isCompact ? IconSize.large : IconSize.xl,
color: context.conduitTheme.error,
),
SizedBox(height: isCompact ? Spacing.sm : Spacing.md),
Text(
title,
style: AppTypography.headlineSmallStyle.copyWith(
color: context.conduitTheme.error,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
SizedBox(height: isCompact ? Spacing.xs : Spacing.sm),
Text(
message,
style: AppTypography.standard.copyWith(
color: context.conduitTheme.textSecondary,
),
textAlign: TextAlign.center,
),
if (actionLabel != null && onAction != null) ...[
SizedBox(height: isCompact ? Spacing.md : Spacing.lg),
SizedBox(
width: double.infinity,
child: ConduitButton(
text: actionLabel!,
onPressed: onAction,
isDestructive: true,
isCompact: isCompact,
),
),
],
],
),
);
}
}
/// Enhanced network error widget with better hierarchy
class NetworkErrorWidget extends StatelessWidget {
final VoidCallback? onRetry;
final String? customMessage;
final bool isCompact;
const NetworkErrorWidget({
super.key,
this.onRetry,
this.customMessage,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return ConduitErrorWidget(
title: 'Connection Error',
message:
customMessage ??
'Unable to connect to the server. Please check your internet connection and try again.',
actionLabel: 'Retry',
onAction: onRetry,
icon: Icons.wifi_off,
isCompact: isCompact,
);
}
}
/// Enhanced empty state widget with better hierarchy
class EmptyStateWidget extends StatelessWidget {
final String title;
final String message;
final IconData? icon;
final String? actionLabel;
final VoidCallback? onAction;
final bool isCompact;
const EmptyStateWidget({
super.key,
required this.title,
required this.message,
this.icon,
this.actionLabel,
this.onAction,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(isCompact ? Spacing.md : Spacing.cardPadding),
decoration: BoxDecoration(
color: context.conduitTheme.cardBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.card),
border: Border.all(
color: context.conduitTheme.cardBorder,
width: BorderWidth.standard,
),
boxShadow: ConduitShadows.card,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon ?? Icons.inbox_outlined,
size: isCompact ? IconSize.large : IconSize.xxl,
color: context.conduitTheme.iconSecondary,
),
SizedBox(height: isCompact ? Spacing.sm : Spacing.md),
Text(
title,
style: AppTypography.headlineSmallStyle.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
SizedBox(height: isCompact ? Spacing.xs : Spacing.sm),
Text(
message,
style: AppTypography.standard.copyWith(
color: context.conduitTheme.textSecondary,
),
textAlign: TextAlign.center,
),
if (actionLabel != null && onAction != null) ...[
SizedBox(height: isCompact ? Spacing.md : Spacing.lg),
SizedBox(
width: double.infinity,
child: ConduitButton(
text: actionLabel!,
onPressed: onAction,
isCompact: isCompact,
),
),
],
],
),
);
}
}
/// Enhanced loading error widget with better hierarchy
class LoadingErrorWidget extends StatelessWidget {
final String message;
final VoidCallback? onRetry;
final bool isCompact;
const LoadingErrorWidget({
super.key,
required this.message,
this.onRetry,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return ConduitErrorWidget(
title: 'Loading Failed',
message: message,
actionLabel: onRetry != null ? 'Try Again' : null,
onAction: onRetry,
icon: Icons.error_outline,
isCompact: isCompact,
);
}
}
/// Enhanced validation error widget with better hierarchy
class ValidationErrorWidget extends StatelessWidget {
final String fieldName;
final String message;
final VoidCallback? onFix;
final bool isCompact;
const ValidationErrorWidget({
super.key,
required this.fieldName,
required this.message,
this.onFix,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return ConduitErrorWidget(
title: 'Invalid $fieldName',
message: message,
actionLabel: onFix != null ? 'Fix Now' : null,
onAction: onFix,
icon: Icons.warning_amber_outlined,
isCompact: isCompact,
);
}
}
/// Enhanced permission error widget with better hierarchy
class PermissionErrorWidget extends StatelessWidget {
final String permission;
final String message;
final VoidCallback? onGrant;
final bool isCompact;
const PermissionErrorWidget({
super.key,
required this.permission,
required this.message,
this.onGrant,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return ConduitErrorWidget(
title: 'Permission Required',
message: 'This app needs $permission permission to $message.',
actionLabel: onGrant != null ? 'Grant Permission' : null,
onAction: onGrant,
icon: Icons.security,
isCompact: isCompact,
);
}
}
/// Enhanced server error widget with better hierarchy
class ServerErrorWidget extends StatelessWidget {
final String error;
final VoidCallback? onRetry;
final bool isCompact;
const ServerErrorWidget({
super.key,
required this.error,
this.onRetry,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return ConduitErrorWidget(
title: 'Server Error',
message: error,
actionLabel: onRetry != null ? 'Retry' : null,
onAction: onRetry,
icon: Icons.cloud_off,
isCompact: isCompact,
);
}
}
/// Enhanced file error widget with better hierarchy
class FileErrorWidget extends StatelessWidget {
final String fileName;
final String error;
final VoidCallback? onRetry;
final bool isCompact;
const FileErrorWidget({
super.key,
required this.fileName,
required this.error,
this.onRetry,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return ConduitErrorWidget(
title: 'File Error',
message: 'Failed to process $fileName: $error',
actionLabel: onRetry != null ? 'Try Again' : null,
onAction: onRetry,
icon: Icons.file_present,
isCompact: isCompact,
);
}
}
/// Enhanced authentication error widget with better hierarchy
class AuthErrorWidget extends StatelessWidget {
final String message;
final VoidCallback? onLogin;
final bool isCompact;
const AuthErrorWidget({
super.key,
required this.message,
this.onLogin,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return ConduitErrorWidget(
title: 'Authentication Required',
message: message,
actionLabel: onLogin != null ? 'Sign In' : null,
onAction: onLogin,
icon: Icons.lock_outline,
isCompact: isCompact,
);
}
}
/// Enhanced offline error widget with better hierarchy
class OfflineErrorWidget extends StatelessWidget {
final String message;
final VoidCallback? onRetry;
final bool isCompact;
const OfflineErrorWidget({
super.key,
this.message =
'You\'re currently offline. Please check your internet connection.',
this.onRetry,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return ConduitErrorWidget(
title: 'Offline',
message: message,
actionLabel: onRetry != null ? 'Retry' : null,
onAction: onRetry,
icon: Icons.wifi_off,
isCompact: isCompact,
);
}
}
/// Enhanced timeout error widget with better hierarchy
class TimeoutErrorWidget extends StatelessWidget {
final String operation;
final VoidCallback? onRetry;
final bool isCompact;
const TimeoutErrorWidget({
super.key,
required this.operation,
this.onRetry,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return ConduitErrorWidget(
title: 'Request Timeout',
message: 'The $operation request timed out. Please try again.',
actionLabel: onRetry != null ? 'Retry' : null,
onAction: onRetry,
icon: Icons.timer_off,
isCompact: isCompact,
);
}
}

View File

@@ -0,0 +1,666 @@
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'skeleton_loader.dart';
import '../theme/theme_extensions.dart';
import 'conduit_components.dart';
/// Improved loading state widget with accessibility and better hierarchy
class ImprovedLoadingState extends StatefulWidget {
final String? message;
final bool showProgress;
final double? progress;
final Widget? customWidget;
final bool useSkeletonLoader;
final int skeletonCount;
final double skeletonHeight;
final bool isCompact;
const ImprovedLoadingState({
super.key,
this.message,
this.showProgress = false,
this.progress,
this.customWidget,
this.useSkeletonLoader = false,
this.skeletonCount = 3,
this.skeletonHeight = 100,
this.isCompact = false,
});
@override
State<ImprovedLoadingState> createState() => _ImprovedLoadingStateState();
}
class _ImprovedLoadingStateState extends State<ImprovedLoadingState>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: AnimationDuration.standard,
vsync: this,
);
_fadeAnimation = CurvedAnimation(
parent: _animationController,
curve: AnimationCurves.standard,
);
_animationController.forward();
// Announce loading state for screen readers
if (widget.message != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
SemanticsService.announce(
'Loading: ${widget.message}',
TextDirection.ltr,
);
});
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.customWidget != null) {
return widget.customWidget!;
}
if (widget.useSkeletonLoader) {
return _buildSkeletonLoader();
}
return FadeTransition(
opacity: _fadeAnimation,
child: Center(
child: Semantics(
label: widget.message ?? 'Loading content',
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.showProgress && widget.progress != null)
_buildProgressIndicator()
else
_buildCircularIndicator(),
if (widget.message != null) ...[
SizedBox(height: widget.isCompact ? Spacing.sm : Spacing.md),
Text(
widget.message!,
style: AppTypography.standard.copyWith(
color: context.conduitTheme.textSecondary,
),
textAlign: TextAlign.center,
),
],
],
),
),
),
);
}
Widget _buildCircularIndicator() {
return SizedBox(
width: widget.isCompact ? IconSize.large : IconSize.xxl,
height: widget.isCompact ? IconSize.large : IconSize.xxl,
child: CircularProgressIndicator(
strokeWidth: widget.isCompact ? 2 : 3,
valueColor: AlwaysStoppedAnimation<Color>(
context.conduitTheme.buttonPrimary,
),
),
);
}
Widget _buildProgressIndicator() {
return Column(
children: [
SizedBox(
width: widget.isCompact ? 150 : 200,
child: LinearProgressIndicator(
value: widget.progress,
minHeight: widget.isCompact ? 3 : 4,
backgroundColor: context.conduitTheme.dividerColor,
valueColor: AlwaysStoppedAnimation<Color>(
context.conduitTheme.buttonPrimary,
),
),
),
SizedBox(height: widget.isCompact ? Spacing.xs : Spacing.sm),
Text(
'${(widget.progress! * 100).toInt()}%',
style: AppTypography.small.copyWith(
color: context.conduitTheme.textSecondary,
),
),
],
);
}
Widget _buildSkeletonLoader() {
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: widget.skeletonCount,
itemBuilder: (context, index) => Padding(
padding: EdgeInsets.symmetric(
horizontal: widget.isCompact ? Spacing.sm : Spacing.md,
vertical: widget.isCompact ? Spacing.xs : Spacing.sm,
),
child: SkeletonLoader(
height: widget.skeletonHeight,
isCompact: widget.isCompact,
),
),
);
}
}
/// Improved empty state with better UX and hierarchy
class ImprovedEmptyState extends StatelessWidget {
final String title;
final String? subtitle;
final IconData? icon;
final Widget? customIcon;
final VoidCallback? onAction;
final String? actionLabel;
final bool showAnimation;
final bool isCompact;
const ImprovedEmptyState({
super.key,
required this.title,
this.subtitle,
this.icon,
this.customIcon,
this.onAction,
this.actionLabel,
this.showAnimation = true,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
Widget content = Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icon or custom widget
if (customIcon != null)
customIcon!
else if (icon != null)
showAnimation
? TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: AnimationDuration.standard,
curve: AnimationCurves.elastic,
builder: (context, value, child) => Transform.scale(
scale: value,
child: Icon(
icon,
size: isCompact ? IconSize.large : IconSize.xxl,
color: theme.iconSecondary,
),
),
)
: Icon(
icon,
size: isCompact ? IconSize.large : IconSize.xxl,
color: theme.iconSecondary,
),
SizedBox(height: isCompact ? Spacing.md : Spacing.lg),
// Title
Text(
title,
style: AppTypography.headlineSmallStyle.copyWith(
color: theme.textPrimary,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
// Subtitle
if (subtitle != null) ...[
SizedBox(height: isCompact ? Spacing.xs : Spacing.sm),
Text(
subtitle!,
style: AppTypography.standard.copyWith(color: theme.textSecondary),
textAlign: TextAlign.center,
),
],
// Action button
if (actionLabel != null && onAction != null) ...[
SizedBox(height: isCompact ? Spacing.md : Spacing.lg),
ConduitButton(
text: actionLabel!,
onPressed: onAction,
isCompact: isCompact,
),
],
],
);
return Center(
child: Padding(
padding: EdgeInsets.all(isCompact ? Spacing.md : Spacing.lg),
child: showAnimation
? content.animate().fadeIn(
duration: AnimationDuration.standard,
curve: AnimationCurves.standard,
)
: content,
),
);
}
}
/// Enhanced loading overlay with better hierarchy
class LoadingOverlay extends StatelessWidget {
final Widget child;
final bool isLoading;
final String? message;
final bool isCompact;
const LoadingOverlay({
super.key,
required this.child,
required this.isLoading,
this.message,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return Stack(
children: [
child,
if (isLoading)
Container(
color: context.conduitTheme.surfaceBackground.withValues(
alpha: Alpha.overlay,
),
child: Center(
child: Container(
padding: EdgeInsets.all(isCompact ? Spacing.md : Spacing.lg),
decoration: BoxDecoration(
color: context.conduitTheme.cardBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.card),
boxShadow: ConduitShadows.card,
),
child: ImprovedLoadingState(
message: message,
isCompact: isCompact,
),
),
),
),
],
);
}
}
/// Enhanced loading button with better hierarchy
class LoadingButton extends StatefulWidget {
final String text;
final VoidCallback? onPressed;
final bool isLoading;
final bool isDestructive;
final bool isSecondary;
final IconData? icon;
final double? width;
final bool isFullWidth;
final bool isCompact;
const LoadingButton({
super.key,
required this.text,
this.onPressed,
this.isLoading = false,
this.isDestructive = false,
this.isSecondary = false,
this.icon,
this.width,
this.isFullWidth = false,
this.isCompact = false,
});
@override
State<LoadingButton> createState() => _LoadingButtonState();
}
class _LoadingButtonState extends State<LoadingButton> {
@override
Widget build(BuildContext context) {
return ConduitButton(
text: widget.text,
onPressed: widget.isLoading ? null : widget.onPressed,
isLoading: widget.isLoading,
isDestructive: widget.isDestructive,
isSecondary: widget.isSecondary,
icon: widget.icon,
width: widget.width,
isFullWidth: widget.isFullWidth,
isCompact: widget.isCompact,
);
}
}
/// Enhanced loading list with better hierarchy
class LoadingList extends StatelessWidget {
final bool isLoading;
final Widget child;
final int skeletonCount;
final double skeletonHeight;
final bool isCompact;
const LoadingList({
super.key,
required this.isLoading,
required this.child,
this.skeletonCount = 5,
this.skeletonHeight = 80,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
if (isLoading) {
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: skeletonCount,
itemBuilder: (context, index) => Padding(
padding: EdgeInsets.symmetric(
horizontal: isCompact ? Spacing.sm : Spacing.md,
vertical: isCompact ? Spacing.xs : Spacing.sm,
),
child: SkeletonLoader(height: skeletonHeight, isCompact: isCompact),
),
);
}
return child;
}
}
/// Enhanced loading card with better hierarchy
class LoadingCard extends StatelessWidget {
final bool isLoading;
final Widget child;
final bool isCompact;
const LoadingCard({
super.key,
required this.isLoading,
required this.child,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
if (isLoading) {
return ConduitCard(
isCompact: isCompact,
child: ImprovedLoadingState(
message: 'Loading...',
isCompact: isCompact,
),
);
}
return child;
}
}
/// Shimmer loading effect
class ShimmerLoader extends StatefulWidget {
final double width;
final double height;
final BorderRadius? borderRadius;
final EdgeInsetsGeometry? margin;
const ShimmerLoader({
super.key,
this.width = double.infinity,
this.height = 20,
this.borderRadius,
this.margin,
});
@override
State<ShimmerLoader> createState() => _ShimmerLoaderState();
}
class _ShimmerLoaderState extends State<ShimmerLoader>
with SingleTickerProviderStateMixin {
late AnimationController _shimmerController;
late Animation<double> _shimmerAnimation;
@override
void initState() {
super.initState();
_shimmerController = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
)..repeat();
_shimmerAnimation = Tween<double>(begin: -1.0, end: 2.0).animate(
CurvedAnimation(parent: _shimmerController, curve: Curves.linear),
);
}
@override
void dispose() {
_shimmerController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
return Container(
width: widget.width,
height: widget.height,
margin: widget.margin,
decoration: BoxDecoration(
borderRadius: widget.borderRadius ?? BorderRadius.circular(4),
color: theme.surfaceContainer,
),
child: AnimatedBuilder(
animation: _shimmerAnimation,
builder: (context, child) {
return Container(
decoration: BoxDecoration(
borderRadius: widget.borderRadius ?? BorderRadius.circular(4),
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
theme.shimmerBase,
theme.shimmerHighlight,
theme.shimmerBase,
],
stops: [
_shimmerAnimation.value - 0.3,
_shimmerAnimation.value,
_shimmerAnimation.value + 0.3,
],
),
),
);
},
),
);
}
}
/// Content placeholder for loading states
class ContentPlaceholder extends StatelessWidget {
final int lineCount;
final double lineHeight;
final double spacing;
final EdgeInsetsGeometry? padding;
final bool showAvatar;
final bool showActions;
const ContentPlaceholder({
super.key,
this.lineCount = 3,
this.lineHeight = 16,
this.spacing = 8,
this.padding,
this.showAvatar = false,
this.showActions = false,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: padding ?? const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAvatar)
Row(
children: [
const ShimmerLoader(
width: 48,
height: 48,
borderRadius: BorderRadius.all(Radius.circular(24)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShimmerLoader(width: 120, height: lineHeight),
SizedBox(height: spacing / 2),
ShimmerLoader(width: 80, height: lineHeight * 0.8),
],
),
),
],
),
if (showAvatar) SizedBox(height: spacing * 2),
...List.generate(lineCount, (index) {
final isLast = index == lineCount - 1;
return Padding(
padding: EdgeInsets.only(bottom: isLast ? 0 : spacing),
child: ShimmerLoader(
width: isLast ? 200 : double.infinity,
height: lineHeight,
),
);
}),
if (showActions) ...[
SizedBox(height: spacing * 2),
Row(
children: [
ShimmerLoader(
width: 80,
height: 32,
borderRadius: BorderRadius.circular(16),
),
const SizedBox(width: 8),
ShimmerLoader(
width: 80,
height: 32,
borderRadius: BorderRadius.circular(16),
),
],
),
],
],
),
);
}
}
/// Error state widget with retry
class ErrorStateWidget extends StatelessWidget {
final String message;
final VoidCallback? onRetry;
final Object? error;
final bool showDetails;
const ErrorStateWidget({
super.key,
required this.message,
this.onRetry,
this.error,
this.showDetails = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: theme.colorScheme.error),
const SizedBox(height: 16),
Text(
'Oops! Something went wrong',
style: theme.textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
message,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
if (showDetails && error != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
error.toString(),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
],
if (onRetry != null) ...[
const SizedBox(height: 24),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Try Again'),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,429 @@
import 'package:flutter/material.dart';
import '../theme/theme_extensions.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:io' show Platform;
import '../services/brand_service.dart';
import '../theme/app_theme.dart';
/// Standard loading indicators following Conduit design patterns
class ConduitLoading {
// Private constructor to prevent instantiation
ConduitLoading._();
/// Primary loading indicator
static Widget primary({
double size = IconSize.lg,
Color? color,
String? message,
}) {
return _LoadingIndicator(
size: size,
color: color ?? BrandService.primaryBrandColor,
message: message,
type: _LoadingType.primary,
);
}
/// Inline loading for content areas
static Widget inline({
double size = IconSize.md,
Color? color,
String? message,
BuildContext? context,
}) {
return _LoadingIndicator(
size: size,
color:
color ??
(context?.conduitTheme.loadingIndicator ??
context?.conduitTheme.buttonPrimary ??
AppTheme.brandPrimary),
message: message,
type: _LoadingType.inline,
);
}
/// Button loading state
static Widget button({
double size = IconSize.sm,
Color? color,
BuildContext? context,
}) {
return _LoadingIndicator(
size: size,
color:
color ??
(context?.conduitTheme.buttonPrimaryText ??
context?.conduitTheme.textPrimary ??
AppTheme.neutral50),
type: _LoadingType.button,
);
}
/// Overlay loading for full screen
static Widget overlay({String? message, bool darkBackground = true}) {
return _LoadingOverlay(message: message, darkBackground: darkBackground);
}
/// Skeleton loading for content placeholders
static Widget skeleton({
double width = double.infinity,
double height = 20,
BorderRadius? borderRadius,
}) {
return _SkeletonLoader(
width: width,
height: height,
borderRadius: borderRadius ?? BorderRadius.circular(AppBorderRadius.xs),
);
}
/// List item skeleton
static Widget listItemSkeleton({bool showAvatar = true, int lines = 2}) {
return _ListItemSkeleton(showAvatar: showAvatar, lines: lines);
}
}
enum _LoadingType { primary, inline, button }
class _LoadingIndicator extends StatelessWidget {
final double size;
final Color color;
final String? message;
final _LoadingType type;
const _LoadingIndicator({
required this.size,
required this.color,
this.message,
required this.type,
});
@override
Widget build(BuildContext context) {
Widget indicator;
if (Platform.isIOS) {
indicator = CupertinoActivityIndicator(color: color, radius: size / 2);
} else {
indicator = SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(
strokeWidth: size / 8,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
);
}
if (message == null) {
return indicator;
}
final spacing = type == _LoadingType.button ? Spacing.sm : Spacing.xs;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
indicator,
SizedBox(height: spacing),
Text(
message!,
style: TextStyle(
color: color,
fontSize: type == _LoadingType.button
? AppTypography.bodySmall
: AppTypography.bodyLarge,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
);
}
}
class _LoadingOverlay extends StatelessWidget {
final String? message;
final bool darkBackground;
const _LoadingOverlay({this.message, required this.darkBackground});
@override
Widget build(BuildContext context) {
return Container(
color: darkBackground
? context.conduitTheme.surfaceBackground.withValues(
alpha: Alpha.strong,
)
: context.conduitTheme.surfaceBackground.withValues(
alpha: Alpha.intense,
),
child: Center(
child: Container(
padding: const EdgeInsets.all(Spacing.lg),
decoration: BoxDecoration(
color: darkBackground
? context.conduitTheme.surfaceBackground
: context.conduitTheme.surfaceBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
boxShadow: ConduitShadows.high,
),
child: ConduitLoading.primary(
size: IconSize.xl,
color: context.conduitTheme.buttonPrimary,
message: message,
),
),
),
).animate().fadeIn(duration: AnimationDuration.fast);
}
}
class _SkeletonLoader extends StatefulWidget {
final double width;
final double height;
final BorderRadius borderRadius;
const _SkeletonLoader({
required this.width,
required this.height,
required this.borderRadius,
});
@override
State<_SkeletonLoader> createState() => _SkeletonLoaderState();
}
class _SkeletonLoaderState extends State<_SkeletonLoader>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: AnimationDuration.ultra,
vsync: this,
);
_animation =
Tween<double>(
begin: AnimationValues.shimmerBegin,
end: AnimationValues.shimmerEnd,
).animate(
CurvedAnimation(
parent: _controller,
curve: AnimationCurves.easeInOut,
),
);
_controller.repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
borderRadius: widget.borderRadius,
color: context.conduitTheme.shimmerBase,
),
child: AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
decoration: BoxDecoration(
borderRadius: widget.borderRadius,
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Colors.transparent,
context.conduitTheme.shimmerHighlight,
Colors.transparent,
],
stops: [
(_animation.value - 0.3).clamp(0.0, 1.0),
_animation.value.clamp(0.0, 1.0),
(_animation.value + 0.3).clamp(0.0, 1.0),
],
),
),
);
},
),
);
}
}
class _ListItemSkeleton extends StatelessWidget {
final bool showAvatar;
final int lines;
const _ListItemSkeleton({required this.showAvatar, required this.lines});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.xs,
),
child: Row(
children: [
if (showAvatar) ...[
ConduitLoading.skeleton(
width: TouchTarget.minimum,
height: TouchTarget.minimum,
borderRadius: BorderRadius.circular(AppBorderRadius.xl),
),
const SizedBox(width: Spacing.xs),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(lines, (index) {
return Padding(
padding: EdgeInsets.only(
bottom: index < lines - 1 ? Spacing.sm : 0,
),
child: ConduitLoading.skeleton(
width: index == lines - 1 ? 150 : double.infinity,
height: index == 0 ? 16 : 14,
),
);
}),
),
),
],
),
);
}
}
/// Loading state wrapper for async operations
class LoadingStateWrapper<T> extends StatelessWidget {
final AsyncValue<T> asyncValue;
final Widget Function(T data) builder;
final Widget? loadingWidget;
final Widget Function(Object error, StackTrace stackTrace)? errorBuilder;
final bool showLoadingOverlay;
const LoadingStateWrapper({
super.key,
required this.asyncValue,
required this.builder,
this.loadingWidget,
this.errorBuilder,
this.showLoadingOverlay = false,
});
@override
Widget build(BuildContext context) {
return asyncValue.when(
data: builder,
loading: () => showLoadingOverlay
? ConduitLoading.overlay(message: 'Loading...')
: loadingWidget ?? ConduitLoading.primary(message: 'Loading...'),
error: (error, stackTrace) {
if (errorBuilder != null) {
return errorBuilder!(error, stackTrace);
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Platform.isIOS
? CupertinoIcons.exclamationmark_triangle
: Icons.error_outline,
size: IconSize.xxl,
color: context.conduitTheme.error,
),
const SizedBox(height: Spacing.md),
Text(
'Something went wrong',
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontSize: AppTypography.headlineSmall,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: Spacing.sm),
Text(
error.toString(),
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontSize: AppTypography.bodySmall,
),
textAlign: TextAlign.center,
),
],
),
);
},
);
}
}
/// Button with loading state
class LoadingButton extends StatelessWidget {
final VoidCallback? onPressed;
final Widget child;
final bool isLoading;
final bool isPrimary;
const LoadingButton({
super.key,
required this.onPressed,
required this.child,
this.isLoading = false,
this.isPrimary = true,
});
@override
Widget build(BuildContext context) {
return FilledButton(
onPressed: isLoading ? null : onPressed,
style: isPrimary
? FilledButton.styleFrom(
backgroundColor: context.conduitTheme.buttonPrimary,
foregroundColor: context.conduitTheme.buttonPrimaryText,
)
: null,
child: isLoading ? ConduitLoading.button(context: context) : child,
);
}
}
/// Refresh indicator with Conduit styling
class ConduitRefreshIndicator extends StatelessWidget {
final Widget child;
final Future<void> Function() onRefresh;
const ConduitRefreshIndicator({
super.key,
required this.child,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: onRefresh,
color: context.conduitTheme.buttonPrimary,
backgroundColor: context.conduitTheme.surfaceBackground,
child: child,
);
}
}

View File

@@ -0,0 +1,231 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'dart:io' show Platform;
import '../../core/services/connectivity_service.dart';
import '../theme/theme_extensions.dart';
class OfflineIndicator extends ConsumerWidget {
final Widget child;
final bool showBanner;
const OfflineIndicator({
super.key,
required this.child,
this.showBanner = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final connectivityStatus = ref.watch(connectivityStatusProvider);
return Stack(
children: [
child,
if (showBanner)
connectivityStatus.when(
data: (status) {
if (status == ConnectivityStatus.offline) {
return _OfflineBanner();
}
return const SizedBox.shrink();
},
loading: () => const SizedBox.shrink(),
error: (_, _) => _OfflineBanner(),
),
],
);
}
}
class _OfflineBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
bottom: false,
child:
Container(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.xs,
),
decoration: BoxDecoration(
color: context.conduitTheme.warning,
boxShadow: ConduitShadows.low,
),
child: Row(
children: [
Icon(
Platform.isIOS
? CupertinoIcons.wifi_slash
: Icons.wifi_off,
color: context.conduitTheme.textInverse,
size: AppTypography.headlineMedium,
),
const SizedBox(width: Spacing.xs),
Expanded(
child: Text(
'You\'re offline. Some features may be limited.',
style: TextStyle(
color: context.conduitTheme.textInverse,
fontSize: AppTypography.labelLarge,
fontWeight: FontWeight.w500,
),
),
),
],
),
)
.animate(onPlay: (controller) => controller.forward())
.slideY(
begin: -1,
end: 0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
),
),
);
}
}
// Inline offline indicator for specific features
class InlineOfflineIndicator extends ConsumerWidget {
final String message;
final IconData? icon;
final Color? backgroundColor;
const InlineOfflineIndicator({
super.key,
this.message = 'This feature requires an internet connection',
this.icon,
this.backgroundColor,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isOnline = ref.watch(isOnlineProvider);
if (isOnline) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.all(Spacing.md),
padding: const EdgeInsets.all(Spacing.md),
decoration: BoxDecoration(
color:
backgroundColor ??
context.conduitTheme.warning.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(
color: context.conduitTheme.warning.withValues(alpha: 0.3),
width: BorderWidth.regular,
),
),
child: Row(
children: [
Icon(
icon ??
(Platform.isIOS ? CupertinoIcons.wifi_slash : Icons.wifi_off),
color: context.conduitTheme.warning,
size: Spacing.lg,
),
const SizedBox(width: Spacing.xs),
Expanded(
child: Text(
message,
style: TextStyle(
color: context.conduitTheme.warning,
fontSize: AppTypography.labelLarge,
fontWeight: FontWeight.w500,
),
),
),
],
),
).animate().fadeIn(duration: const Duration(milliseconds: 300));
}
}
// Offline-aware button that disables when offline
class OfflineAwareButton extends ConsumerWidget {
final VoidCallback? onPressed;
final Widget child;
final bool requiresConnection;
final String? offlineTooltip;
const OfflineAwareButton({
super.key,
required this.onPressed,
required this.child,
this.requiresConnection = true,
this.offlineTooltip,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isOnline = ref.watch(isOnlineProvider);
final enabled = !requiresConnection || isOnline;
return Tooltip(
message: !enabled
? (offlineTooltip ?? 'This action requires an internet connection')
: '',
child: FilledButton(onPressed: enabled ? onPressed : null, child: child),
);
}
}
// Chat-specific offline indicator
class ChatOfflineOverlay extends ConsumerWidget {
const ChatOfflineOverlay({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isOnline = ref.watch(isOnlineProvider);
if (isOnline) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
decoration: BoxDecoration(
color: context.conduitTheme.warning.withValues(alpha: 0.2),
border: Border(
top: BorderSide(
color: context.conduitTheme.warning.withValues(alpha: 0.5),
width: BorderWidth.regular,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Platform.isIOS ? CupertinoIcons.wifi_slash : Icons.wifi_off,
color: context.conduitTheme.warning,
size: Spacing.md,
),
const SizedBox(width: Spacing.sm),
Text(
'Messages will be sent when you\'re back online',
style: TextStyle(
color: context.conduitTheme.warning,
fontSize: AppTypography.bodySmall,
fontWeight: FontWeight.w500,
),
),
],
),
).animate().fadeIn(duration: const Duration(milliseconds: 300));
}
}

View File

@@ -0,0 +1,414 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'skeleton_loader.dart';
import 'improved_loading_states.dart';
/// Optimized list widget with virtualization and performance enhancements
class OptimizedList<T> extends ConsumerStatefulWidget {
final List<T> items;
final Widget Function(BuildContext context, T item, int index) itemBuilder;
final Widget? separatorBuilder;
final Widget? loadingWidget;
final Widget? emptyWidget;
final String? emptyMessage;
final Future<void> Function()? onRefresh;
final VoidCallback? onLoadMore;
final bool hasMore;
final bool isLoading;
final EdgeInsetsGeometry? padding;
final ScrollController? scrollController;
final ScrollPhysics? physics;
final bool shrinkWrap;
final Axis scrollDirection;
final bool reverse;
final double? cacheExtent;
final int? itemExtent;
final bool addAutomaticKeepAlives;
final bool addRepaintBoundaries;
final bool enablePagination;
final double paginationThreshold;
const OptimizedList({
super.key,
required this.items,
required this.itemBuilder,
this.separatorBuilder,
this.loadingWidget,
this.emptyWidget,
this.emptyMessage,
this.onRefresh,
this.onLoadMore,
this.hasMore = false,
this.isLoading = false,
this.padding,
this.scrollController,
this.physics,
this.shrinkWrap = false,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.cacheExtent,
this.itemExtent,
this.addAutomaticKeepAlives = true,
this.addRepaintBoundaries = true,
this.enablePagination = false,
this.paginationThreshold = 0.8,
});
@override
ConsumerState<OptimizedList<T>> createState() => _OptimizedListState<T>();
}
class _OptimizedListState<T> extends ConsumerState<OptimizedList<T>> {
late ScrollController _scrollController;
bool _isLoadingMore = false;
final Set<int> _visibleIndices = {};
@override
void initState() {
super.initState();
_scrollController = widget.scrollController ?? ScrollController();
if (widget.enablePagination) {
_scrollController.addListener(_onScroll);
}
}
@override
void dispose() {
if (widget.scrollController == null) {
_scrollController.dispose();
}
super.dispose();
}
void _onScroll() {
if (!widget.enablePagination ||
_isLoadingMore ||
!widget.hasMore ||
widget.onLoadMore == null) {
return;
}
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.position.pixels;
final threshold = maxScroll * widget.paginationThreshold;
if (currentScroll >= threshold) {
_loadMore();
}
}
Future<void> _loadMore() async {
if (_isLoadingMore) return;
setState(() {
_isLoadingMore = true;
});
try {
widget.onLoadMore?.call();
} finally {
if (mounted) {
setState(() {
_isLoadingMore = false;
});
}
}
}
@override
Widget build(BuildContext context) {
// Show loading state
if (widget.isLoading && widget.items.isEmpty) {
return widget.loadingWidget ?? _buildDefaultLoadingWidget();
}
// Show empty state
if (widget.items.isEmpty) {
return widget.emptyWidget ??
ImprovedEmptyState(
title: 'No items',
subtitle: widget.emptyMessage ?? 'No items to display',
icon: Icons.inbox_outlined,
);
}
// Build the list
Widget listWidget;
if (widget.separatorBuilder != null) {
listWidget = ListView.separated(
controller: _scrollController,
padding: widget.padding,
physics: widget.physics ?? const AlwaysScrollableScrollPhysics(),
shrinkWrap: widget.shrinkWrap,
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,
cacheExtent: widget.cacheExtent ?? 250.0,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
addRepaintBoundaries: widget.addRepaintBoundaries,
itemCount: widget.items.length + (widget.hasMore ? 1 : 0),
separatorBuilder: (context, index) => widget.separatorBuilder!,
itemBuilder: (context, index) {
if (index >= widget.items.length) {
return _buildLoadMoreIndicator();
}
return _buildOptimizedItem(context, index);
},
);
} else {
listWidget = ListView.builder(
controller: _scrollController,
padding: widget.padding,
physics: widget.physics ?? const AlwaysScrollableScrollPhysics(),
shrinkWrap: widget.shrinkWrap,
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,
cacheExtent: widget.cacheExtent ?? 250.0,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
addRepaintBoundaries: widget.addRepaintBoundaries,
itemCount: widget.items.length + (widget.hasMore ? 1 : 0),
itemExtent: widget.itemExtent?.toDouble(),
itemBuilder: (context, index) {
if (index >= widget.items.length) {
return _buildLoadMoreIndicator();
}
return _buildOptimizedItem(context, index);
},
);
}
// Add refresh indicator if enabled
if (widget.onRefresh != null) {
return RefreshIndicator(onRefresh: widget.onRefresh!, child: listWidget);
}
return listWidget;
}
Widget _buildOptimizedItem(BuildContext context, int index) {
final item = widget.items[index];
// Track visible items for analytics
_visibleIndices.add(index);
// Wrap in repaint boundary for performance
if (widget.addRepaintBoundaries) {
return RepaintBoundary(child: widget.itemBuilder(context, item, index));
}
return widget.itemBuilder(context, item, index);
}
Widget _buildLoadMoreIndicator() {
return Container(
padding: const EdgeInsets.all(16.0),
alignment: Alignment.center,
child: _isLoadingMore
? const CircularProgressIndicator()
: TextButton(onPressed: _loadMore, child: const Text('Load More')),
);
}
Widget _buildDefaultLoadingWidget() {
return ListView.builder(
padding: widget.padding,
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: 5,
itemBuilder: (context, index) => const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: SkeletonLoader(height: 80),
),
);
}
}
/// Sliver version of OptimizedList for use in CustomScrollView
class OptimizedSliverList<T> extends ConsumerWidget {
final List<T> items;
final Widget Function(BuildContext context, T item, int index) itemBuilder;
final Widget? loadingWidget;
final Widget? emptyWidget;
final String? emptyMessage;
final bool isLoading;
final bool hasMore;
final VoidCallback? onLoadMore;
final bool addAutomaticKeepAlives;
final bool addRepaintBoundaries;
const OptimizedSliverList({
super.key,
required this.items,
required this.itemBuilder,
this.loadingWidget,
this.emptyWidget,
this.emptyMessage,
this.isLoading = false,
this.hasMore = false,
this.onLoadMore,
this.addAutomaticKeepAlives = true,
this.addRepaintBoundaries = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Show loading state
if (isLoading && items.isEmpty) {
return SliverToBoxAdapter(
child: loadingWidget ?? _buildDefaultLoadingWidget(),
);
}
// Show empty state
if (items.isEmpty) {
return SliverToBoxAdapter(
child:
emptyWidget ??
ImprovedEmptyState(
title: 'No items',
subtitle: emptyMessage ?? 'No items to display',
icon: Icons.inbox_outlined,
),
);
}
// Build the list
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= items.length) {
if (hasMore) {
// Trigger load more
WidgetsBinding.instance.addPostFrameCallback((_) {
onLoadMore?.call();
});
return Container(
padding: const EdgeInsets.all(16.0),
alignment: Alignment.center,
child: const CircularProgressIndicator(),
);
}
return null;
}
final item = items[index];
final widget = itemBuilder(context, item, index);
// Wrap in repaint boundary for performance
if (addRepaintBoundaries) {
return RepaintBoundary(child: widget);
}
return widget;
},
childCount: items.length + (hasMore ? 1 : 0),
addAutomaticKeepAlives: addAutomaticKeepAlives,
addRepaintBoundaries: addRepaintBoundaries,
),
);
}
Widget _buildDefaultLoadingWidget() {
return Column(
children: List.generate(
5,
(index) => const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: SkeletonLoader(height: 80),
),
),
);
}
}
/// Animated list with optimizations
class OptimizedAnimatedList<T> extends ConsumerStatefulWidget {
final List<T> items;
final Widget Function(
BuildContext context,
T item,
int index,
Animation<double> animation,
)
itemBuilder;
final Duration animationDuration;
final Curve animationCurve;
final EdgeInsetsGeometry? padding;
final ScrollController? scrollController;
final bool shrinkWrap;
const OptimizedAnimatedList({
super.key,
required this.items,
required this.itemBuilder,
this.animationDuration = const Duration(milliseconds: 300),
this.animationCurve = Curves.easeInOut,
this.padding,
this.scrollController,
this.shrinkWrap = false,
});
@override
ConsumerState<OptimizedAnimatedList<T>> createState() =>
_OptimizedAnimatedListState<T>();
}
class _OptimizedAnimatedListState<T>
extends ConsumerState<OptimizedAnimatedList<T>> {
final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();
late List<T> _items;
@override
void initState() {
super.initState();
_items = List.from(widget.items);
}
@override
void didUpdateWidget(OptimizedAnimatedList<T> oldWidget) {
super.didUpdateWidget(oldWidget);
// Handle item additions
for (int i = 0; i < widget.items.length; i++) {
if (i >= _items.length || widget.items[i] != _items[i]) {
_items.insert(i, widget.items[i]);
_listKey.currentState?.insertItem(
i,
duration: widget.animationDuration,
);
}
}
// Handle item removals
for (int i = _items.length - 1; i >= widget.items.length; i--) {
final removedItem = _items[i];
_items.removeAt(i);
_listKey.currentState?.removeItem(
i,
(context, animation) =>
widget.itemBuilder(context, removedItem, i, animation),
duration: widget.animationDuration,
);
}
}
@override
Widget build(BuildContext context) {
return AnimatedList(
key: _listKey,
controller: widget.scrollController,
padding: widget.padding,
shrinkWrap: widget.shrinkWrap,
initialItemCount: _items.length,
itemBuilder: (context, index, animation) {
if (index >= _items.length) return const SizedBox.shrink();
return widget.itemBuilder(context, _items[index], index, animation);
},
);
}
}

View File

@@ -0,0 +1,361 @@
import 'package:flutter/material.dart';
import '../theme/theme_extensions.dart';
/// Enhanced skeleton loader with production-grade animations and better hierarchy
class SkeletonLoader extends StatefulWidget {
final double? width;
final double? height;
final BorderRadius? borderRadius;
final Duration? duration;
final Color? baseColor;
final Color? highlightColor;
final bool isCompact;
const SkeletonLoader({
super.key,
this.width,
this.height,
this.borderRadius,
this.duration,
this.baseColor,
this.highlightColor,
this.isCompact = false,
});
@override
State<SkeletonLoader> createState() => _SkeletonLoaderState();
}
class _SkeletonLoaderState extends State<SkeletonLoader>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.duration ?? AnimationDuration.typingIndicator,
vsync: this,
);
_animation =
Tween<double>(
begin: AnimationValues.shimmerBegin,
end: AnimationValues.shimmerEnd,
).animate(
CurvedAnimation(parent: _controller, curve: AnimationCurves.linear),
);
_controller.repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
borderRadius:
widget.borderRadius ??
BorderRadius.circular(
widget.isCompact ? AppBorderRadius.xs : AppBorderRadius.sm,
),
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
widget.baseColor ?? context.conduitTheme.shimmerBase,
widget.highlightColor ?? context.conduitTheme.shimmerHighlight,
widget.baseColor ?? context.conduitTheme.shimmerBase,
],
stops: [
_animation.value - 0.3,
_animation.value,
_animation.value + 0.3,
],
),
),
);
},
);
}
}
/// Enhanced skeleton for chat messages with better hierarchy
class SkeletonChatMessage extends StatelessWidget {
final bool isUser;
final int lines;
final bool isCompact;
const SkeletonChatMessage({
super.key,
this.isUser = false,
this.lines = 2,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(
horizontal: isCompact ? Spacing.sm : Spacing.messagePadding,
vertical: isCompact ? Spacing.xs : Spacing.sm,
),
child: Row(
mainAxisAlignment: isUser
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isUser) ...[
SkeletonLoader(
width: isCompact ? 32 : 40,
height: isCompact ? 32 : 40,
borderRadius: BorderRadius.circular(AppBorderRadius.avatar),
),
SizedBox(width: isCompact ? Spacing.xs : Spacing.sm),
],
Expanded(
child: Column(
crossAxisAlignment: isUser
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
for (int i = 0; i < lines; i++)
Padding(
padding: EdgeInsets.only(
bottom: i < lines - 1
? (isCompact ? Spacing.xs : Spacing.sm)
: 0,
),
child: SkeletonLoader(
width: isUser
? null
: (MediaQuery.of(context).size.width * 0.6),
height: isCompact ? 12 : 16,
borderRadius: BorderRadius.circular(
isCompact ? AppBorderRadius.xs : AppBorderRadius.sm,
),
),
),
],
),
),
if (isUser) ...[
SizedBox(width: isCompact ? Spacing.xs : Spacing.sm),
SkeletonLoader(
width: isCompact ? 32 : 40,
height: isCompact ? 32 : 40,
borderRadius: BorderRadius.circular(AppBorderRadius.avatar),
),
],
],
),
);
}
}
/// Enhanced skeleton for list items with better hierarchy
class SkeletonListItem extends StatelessWidget {
final bool showAvatar;
final bool showSubtitle;
final bool isCompact;
const SkeletonListItem({
super.key,
this.showAvatar = true,
this.showSubtitle = true,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(isCompact ? Spacing.sm : Spacing.listItemPadding),
child: Row(
children: [
if (showAvatar) ...[
SkeletonLoader(
width: isCompact ? 32 : 40,
height: isCompact ? 32 : 40,
borderRadius: BorderRadius.circular(AppBorderRadius.avatar),
),
SizedBox(width: isCompact ? Spacing.sm : Spacing.md),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SkeletonLoader(
width: double.infinity,
height: isCompact ? 14 : 16,
borderRadius: BorderRadius.circular(
isCompact ? AppBorderRadius.xs : AppBorderRadius.sm,
),
),
if (showSubtitle) ...[
SizedBox(height: isCompact ? Spacing.xs : Spacing.sm),
SkeletonLoader(
width: MediaQuery.of(context).size.width * 0.7,
height: isCompact ? 12 : 14,
borderRadius: BorderRadius.circular(
isCompact ? AppBorderRadius.xs : AppBorderRadius.sm,
),
),
],
],
),
),
],
),
);
}
}
/// Enhanced skeleton for cards with better hierarchy
class SkeletonCard extends StatelessWidget {
final bool showTitle;
final bool showContent;
final bool showActions;
final bool isCompact;
const SkeletonCard({
super.key,
this.showTitle = true,
this.showContent = true,
this.showActions = false,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(isCompact ? Spacing.sm : Spacing.cardPadding),
decoration: BoxDecoration(
color: context.conduitTheme.cardBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.card),
border: Border.all(
color: context.conduitTheme.cardBorder,
width: BorderWidth.standard,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showTitle) ...[
SkeletonLoader(
width: MediaQuery.of(context).size.width * 0.8,
height: isCompact ? 16 : 20,
borderRadius: BorderRadius.circular(
isCompact ? AppBorderRadius.xs : AppBorderRadius.sm,
),
),
SizedBox(height: isCompact ? Spacing.sm : Spacing.md),
],
if (showContent) ...[
SkeletonLoader(
width: double.infinity,
height: isCompact ? 12 : 14,
borderRadius: BorderRadius.circular(
isCompact ? AppBorderRadius.xs : AppBorderRadius.sm,
),
),
SizedBox(height: isCompact ? Spacing.xs : Spacing.sm),
SkeletonLoader(
width: MediaQuery.of(context).size.width * 0.6,
height: isCompact ? 12 : 14,
borderRadius: BorderRadius.circular(
isCompact ? AppBorderRadius.xs : AppBorderRadius.sm,
),
),
if (showActions) ...[
SizedBox(height: isCompact ? Spacing.md : Spacing.lg),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
SkeletonLoader(
width: isCompact ? 60 : 80,
height: isCompact ? 32 : 40,
borderRadius: BorderRadius.circular(AppBorderRadius.button),
),
SizedBox(width: isCompact ? Spacing.sm : Spacing.md),
SkeletonLoader(
width: isCompact ? 60 : 80,
height: isCompact ? 32 : 40,
borderRadius: BorderRadius.circular(AppBorderRadius.button),
),
],
),
],
],
],
),
);
}
}
/// Enhanced skeleton for input fields with better hierarchy
class SkeletonInput extends StatelessWidget {
final bool showLabel;
final bool isCompact;
const SkeletonInput({
super.key,
this.showLabel = true,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showLabel) ...[
SkeletonLoader(
width: 80,
height: isCompact ? 14 : 16,
borderRadius: BorderRadius.circular(
isCompact ? AppBorderRadius.xs : AppBorderRadius.sm,
),
),
SizedBox(height: isCompact ? Spacing.xs : Spacing.sm),
],
SkeletonLoader(
width: double.infinity,
height: isCompact ? 40 : 48,
borderRadius: BorderRadius.circular(AppBorderRadius.input),
),
],
);
}
}
/// Enhanced skeleton for buttons with better hierarchy
class SkeletonButton extends StatelessWidget {
final bool isFullWidth;
final bool isCompact;
const SkeletonButton({
super.key,
this.isFullWidth = false,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return SkeletonLoader(
width: isFullWidth ? double.infinity : (isCompact ? 80 : 120),
height: isCompact ? TouchTarget.medium : TouchTarget.comfortable,
borderRadius: BorderRadius.circular(AppBorderRadius.button),
);
}
}

View File

@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import '../theme/theme_extensions.dart';
/// Centralized helper for building themed dialogs consistently
class ThemedDialogs {
ThemedDialogs._();
/// Build a base themed AlertDialog
static AlertDialog buildBase({
required BuildContext context,
required String title,
Widget? content,
List<Widget>? actions,
}) {
return AlertDialog(
backgroundColor: context.conduitTheme.surfaceBackground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.dialog),
),
title: Text(
title,
style: TextStyle(color: context.conduitTheme.textPrimary),
),
content: content,
actions: actions,
);
}
/// Show a simple confirmation dialog with Cancel/Confirm actions
static Future<bool> confirm(
BuildContext context, {
required String title,
required String message,
String confirmText = 'Confirm',
String cancelText = 'Cancel',
bool isDestructive = false,
bool barrierDismissible = true,
}) async {
final result = await showDialog<bool>(
context: context,
barrierDismissible: barrierDismissible,
builder: (ctx) => buildBase(
context: ctx,
title: title,
content: Text(
message,
style: TextStyle(color: ctx.conduitTheme.textSecondary),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(
cancelText,
style: TextStyle(color: ctx.conduitTheme.textSecondary),
),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: TextButton.styleFrom(
foregroundColor: isDestructive
? ctx.conduitTheme.error
: ctx.conduitTheme.buttonPrimary,
),
child: Text(
confirmText,
style: TextStyle(
color: isDestructive
? ctx.conduitTheme.error
: ctx.conduitTheme.buttonPrimary,
),
),
),
],
),
);
return result ?? false;
}
/// Show a generic themed dialog
static Future<T?> show<T>(
BuildContext context, {
required String title,
required Widget content,
List<Widget>? actions,
bool barrierDismissible = true,
}) {
return showDialog<T>(
context: context,
barrierDismissible: barrierDismissible,
builder: (ctx) => buildBase(
context: ctx,
title: title,
content: content,
actions: actions,
),
);
}
}