chore: initial release
This commit is contained in:
281
lib/shared/services/brand_service.dart
Normal file
281
lib/shared/services/brand_service.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
414
lib/shared/theme/app_theme.dart
Normal file
414
lib/shared/theme/app_theme.dart
Normal 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
|
||||
1808
lib/shared/theme/theme_extensions.dart
Normal file
1808
lib/shared/theme/theme_extensions.dart
Normal file
File diff suppressed because it is too large
Load Diff
435
lib/shared/utils/keyboard_utils.dart
Normal file
435
lib/shared/utils/keyboard_utils.dart
Normal file
@@ -0,0 +1,435 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/theme_extensions.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
/// Enhanced keyboard handling utilities for better UX
|
||||
class KeyboardUtils {
|
||||
KeyboardUtils._();
|
||||
|
||||
/// Dismiss keyboard with haptic feedback
|
||||
static void dismissKeyboard(BuildContext context) {
|
||||
final currentFocus = FocusScope.of(context);
|
||||
if (!currentFocus.hasPrimaryFocus && currentFocus.focusedChild != null) {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
|
||||
// Add haptic feedback on iOS
|
||||
if (Platform.isIOS) {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Force dismiss keyboard immediately
|
||||
static void forceDismissKeyboard() {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
SystemChannels.textInput.invokeMethod('TextInput.hide');
|
||||
}
|
||||
|
||||
/// Check if keyboard is currently visible
|
||||
static bool isKeyboardVisible(BuildContext context) {
|
||||
return MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
}
|
||||
|
||||
/// Get keyboard height
|
||||
static double getKeyboardHeight(BuildContext context) {
|
||||
return MediaQuery.of(context).viewInsets.bottom;
|
||||
}
|
||||
|
||||
/// Move focus to next field
|
||||
static void nextFocus(BuildContext context) {
|
||||
FocusScope.of(context).nextFocus();
|
||||
}
|
||||
|
||||
/// Move focus to previous field
|
||||
static void previousFocus(BuildContext context) {
|
||||
FocusScope.of(context).previousFocus();
|
||||
}
|
||||
|
||||
/// Request focus for a specific node
|
||||
static void requestFocus(BuildContext context, FocusNode focusNode) {
|
||||
FocusScope.of(context).requestFocus(focusNode);
|
||||
}
|
||||
|
||||
/// Create a tap detector that dismisses keyboard when tapping outside text fields
|
||||
static Widget dismissKeyboardOnTap({
|
||||
required BuildContext context,
|
||||
required Widget child,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: () => dismissKeyboard(context),
|
||||
// Let children handle taps first (e.g., TextField gains focus)
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget that automatically adjusts for keyboard visibility
|
||||
class KeyboardAware extends StatefulWidget {
|
||||
final Widget child;
|
||||
final EdgeInsets? padding;
|
||||
final bool maintainBottomViewPadding;
|
||||
final Duration animationDuration;
|
||||
final Curve animationCurve;
|
||||
|
||||
const KeyboardAware({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding,
|
||||
this.maintainBottomViewPadding = true,
|
||||
this.animationDuration = const Duration(milliseconds: 250),
|
||||
this.animationCurve = Curves.easeInOut,
|
||||
});
|
||||
|
||||
@override
|
||||
State<KeyboardAware> createState() => _KeyboardAwareState();
|
||||
}
|
||||
|
||||
class _KeyboardAwareState extends State<KeyboardAware>
|
||||
with WidgetsBindingObserver {
|
||||
double _keyboardHeight = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
super.didChangeMetrics();
|
||||
final newKeyboardHeight = MediaQuery.of(context).viewInsets.bottom;
|
||||
if (newKeyboardHeight != _keyboardHeight) {
|
||||
setState(() {
|
||||
_keyboardHeight = newKeyboardHeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedPadding(
|
||||
duration: widget.animationDuration,
|
||||
curve: widget.animationCurve,
|
||||
padding: EdgeInsets.only(
|
||||
bottom: widget.maintainBottomViewPadding ? _keyboardHeight : 0,
|
||||
).add(widget.padding ?? EdgeInsets.zero),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enhanced text field with better keyboard handling
|
||||
class EnhancedTextField extends StatefulWidget {
|
||||
final TextEditingController? controller;
|
||||
final FocusNode? focusNode;
|
||||
final String? hintText;
|
||||
final String? labelText;
|
||||
final TextInputType? keyboardType;
|
||||
final TextInputAction? textInputAction;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final VoidCallback? onTap;
|
||||
final bool obscureText;
|
||||
final bool enabled;
|
||||
final int? maxLines;
|
||||
final int? minLines;
|
||||
final EdgeInsets? contentPadding;
|
||||
final Widget? prefixIcon;
|
||||
final Widget? suffixIcon;
|
||||
final bool autofocus;
|
||||
final bool dismissKeyboardOnSubmit;
|
||||
|
||||
const EnhancedTextField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.focusNode,
|
||||
this.hintText,
|
||||
this.labelText,
|
||||
this.keyboardType,
|
||||
this.textInputAction,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.onTap,
|
||||
this.obscureText = false,
|
||||
this.enabled = true,
|
||||
this.maxLines = 1,
|
||||
this.minLines,
|
||||
this.contentPadding,
|
||||
this.prefixIcon,
|
||||
this.suffixIcon,
|
||||
this.autofocus = false,
|
||||
this.dismissKeyboardOnSubmit = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EnhancedTextField> createState() => _EnhancedTextFieldState();
|
||||
}
|
||||
|
||||
class _EnhancedTextFieldState extends State<EnhancedTextField> {
|
||||
late FocusNode _focusNode;
|
||||
bool _hasFocus = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = widget.focusNode ?? FocusNode();
|
||||
_focusNode.addListener(_onFocusChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.removeListener(_onFocusChanged);
|
||||
if (widget.focusNode == null) {
|
||||
_focusNode.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChanged() {
|
||||
setState(() {
|
||||
_hasFocus = _focusNode.hasFocus;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleSubmitted(String value) {
|
||||
widget.onSubmitted?.call(value);
|
||||
|
||||
if (widget.dismissKeyboardOnSubmit) {
|
||||
KeyboardUtils.dismissKeyboard(context);
|
||||
}
|
||||
|
||||
// Add haptic feedback
|
||||
if (Platform.isIOS) {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: _hasFocus
|
||||
? context.conduitTheme.buttonPrimary
|
||||
: context.conduitTheme.inputBorder,
|
||||
width: _hasFocus ? 2 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
),
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
obscureText: widget.obscureText,
|
||||
enabled: widget.enabled,
|
||||
autofocus: widget.autofocus,
|
||||
keyboardType: widget.keyboardType,
|
||||
textInputAction: widget.textInputAction,
|
||||
maxLines: widget.maxLines,
|
||||
minLines: widget.minLines,
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hintText,
|
||||
labelText: widget.labelText,
|
||||
hintStyle: TextStyle(color: context.conduitTheme.inputPlaceholder),
|
||||
labelStyle: TextStyle(
|
||||
color: _hasFocus
|
||||
? context.conduitTheme.buttonPrimary
|
||||
: context.conduitTheme.textSecondary,
|
||||
),
|
||||
prefixIcon: widget.prefixIcon,
|
||||
suffixIcon: widget.suffixIcon,
|
||||
contentPadding:
|
||||
widget.contentPadding ??
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.md,
|
||||
vertical: Spacing.sm,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
),
|
||||
onChanged: widget.onChanged,
|
||||
onSubmitted: _handleSubmitted,
|
||||
onTap: widget.onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Smart keyboard handler that manages multiple text fields
|
||||
class SmartKeyboardHandler extends StatefulWidget {
|
||||
final List<FocusNode> focusNodes;
|
||||
final Widget child;
|
||||
final VoidCallback? onDone;
|
||||
|
||||
const SmartKeyboardHandler({
|
||||
super.key,
|
||||
required this.focusNodes,
|
||||
required this.child,
|
||||
this.onDone,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SmartKeyboardHandler> createState() => _SmartKeyboardHandlerState();
|
||||
}
|
||||
|
||||
class _SmartKeyboardHandlerState extends State<SmartKeyboardHandler> {
|
||||
int _currentIndex = -1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupFocusListeners();
|
||||
}
|
||||
|
||||
void _setupFocusListeners() {
|
||||
for (int i = 0; i < widget.focusNodes.length; i++) {
|
||||
widget.focusNodes[i].addListener(() => _onFocusChanged(i));
|
||||
}
|
||||
}
|
||||
|
||||
void _onFocusChanged(int index) {
|
||||
if (widget.focusNodes[index].hasFocus) {
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _moveToNext() {
|
||||
if (_currentIndex < widget.focusNodes.length - 1) {
|
||||
KeyboardUtils.requestFocus(context, widget.focusNodes[_currentIndex + 1]);
|
||||
} else {
|
||||
KeyboardUtils.dismissKeyboard(context);
|
||||
widget.onDone?.call();
|
||||
}
|
||||
}
|
||||
|
||||
void _moveToPrevious() {
|
||||
if (_currentIndex > 0) {
|
||||
KeyboardUtils.requestFocus(context, widget.focusNodes[_currentIndex - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Focus(
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is KeyDownEvent) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.tab) {
|
||||
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||
_moveToPrevious();
|
||||
} else {
|
||||
_moveToNext();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final focusNode in widget.focusNodes) {
|
||||
focusNode.removeListener(() {});
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyboard-aware scroll view that adjusts scroll position
|
||||
class KeyboardAwareScrollView extends StatefulWidget {
|
||||
final ScrollController? controller;
|
||||
final Widget child;
|
||||
final EdgeInsets? padding;
|
||||
final bool reverse;
|
||||
final Duration animationDuration;
|
||||
|
||||
const KeyboardAwareScrollView({
|
||||
super.key,
|
||||
this.controller,
|
||||
required this.child,
|
||||
this.padding,
|
||||
this.reverse = false,
|
||||
this.animationDuration = const Duration(milliseconds: 300),
|
||||
});
|
||||
|
||||
@override
|
||||
State<KeyboardAwareScrollView> createState() =>
|
||||
_KeyboardAwareScrollViewState();
|
||||
}
|
||||
|
||||
class _KeyboardAwareScrollViewState extends State<KeyboardAwareScrollView>
|
||||
with WidgetsBindingObserver {
|
||||
late ScrollController _scrollController;
|
||||
FocusNode? _currentFocus;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController = widget.controller ?? ScrollController();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
if (widget.controller == null) {
|
||||
_scrollController.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
super.didChangeMetrics();
|
||||
_adjustScrollPosition();
|
||||
}
|
||||
|
||||
void _adjustScrollPosition() {
|
||||
final focus = FocusManager.instance.primaryFocus;
|
||||
if (focus != null && focus != _currentFocus) {
|
||||
_currentFocus = focus;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
|
||||
if (keyboardHeight > 0) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.offset + keyboardHeight / 2,
|
||||
duration: widget.animationDuration,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
reverse: widget.reverse,
|
||||
padding: widget.padding,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
487
lib/shared/utils/platform_utils.dart
Normal file
487
lib/shared/utils/platform_utils.dart
Normal file
@@ -0,0 +1,487 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import '../theme/theme_extensions.dart';
|
||||
|
||||
/// Platform-specific utilities for enhanced user experience
|
||||
class PlatformUtils {
|
||||
PlatformUtils._();
|
||||
|
||||
/// Check if device supports haptic feedback
|
||||
static bool get supportsHaptics => Platform.isIOS || Platform.isAndroid;
|
||||
|
||||
/// Trigger light haptic feedback
|
||||
static void lightHaptic() {
|
||||
if (supportsHaptics) {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger medium haptic feedback
|
||||
static void mediumHaptic() {
|
||||
if (supportsHaptics && Platform.isIOS) {
|
||||
HapticFeedback.mediumImpact();
|
||||
} else if (Platform.isAndroid) {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger heavy haptic feedback
|
||||
static void heavyHaptic() {
|
||||
if (supportsHaptics && Platform.isIOS) {
|
||||
HapticFeedback.heavyImpact();
|
||||
} else if (Platform.isAndroid) {
|
||||
HapticFeedback.vibrate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger selection haptic feedback
|
||||
static void selectionHaptic() {
|
||||
if (supportsHaptics) {
|
||||
HapticFeedback.selectionClick();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get platform-appropriate icon
|
||||
static IconData getIcon({required IconData ios, required IconData android}) {
|
||||
return Platform.isIOS ? ios : android;
|
||||
}
|
||||
|
||||
/// Get platform-appropriate text style
|
||||
static TextStyle getPlatformTextStyle(BuildContext context) {
|
||||
if (Platform.isIOS) {
|
||||
return CupertinoTheme.of(context).textTheme.textStyle;
|
||||
}
|
||||
return Theme.of(context).textTheme.bodyMedium ?? const TextStyle();
|
||||
}
|
||||
|
||||
/// Create platform-specific button
|
||||
static Widget createButton({
|
||||
required String text,
|
||||
required VoidCallback? onPressed,
|
||||
bool isPrimary = true,
|
||||
Color? color,
|
||||
}) {
|
||||
if (Platform.isIOS) {
|
||||
return Builder(
|
||||
builder: (context) => CupertinoButton(
|
||||
onPressed: onPressed,
|
||||
color: isPrimary
|
||||
? (color ?? context.conduitTheme.buttonPrimary)
|
||||
: null,
|
||||
child: Text(text),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return isPrimary
|
||||
? FilledButton(
|
||||
onPressed: onPressed,
|
||||
style: color != null
|
||||
? FilledButton.styleFrom(backgroundColor: color)
|
||||
: null,
|
||||
child: Text(text),
|
||||
)
|
||||
: OutlinedButton(onPressed: onPressed, child: Text(text));
|
||||
}
|
||||
|
||||
/// Create platform-specific switch
|
||||
static Widget createSwitch({
|
||||
required bool value,
|
||||
required ValueChanged<bool>? onChanged,
|
||||
Color? activeColor,
|
||||
}) {
|
||||
if (Platform.isIOS) {
|
||||
return Builder(
|
||||
builder: (context) => CupertinoSwitch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
thumbColor: activeColor ?? context.conduitTheme.buttonPrimary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Switch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeTrackColor: activeColor,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create platform-specific slider
|
||||
static Widget createSlider({
|
||||
required double value,
|
||||
required ValueChanged<double>? onChanged,
|
||||
double min = 0.0,
|
||||
double max = 1.0,
|
||||
int? divisions,
|
||||
Color? activeColor,
|
||||
}) {
|
||||
if (Platform.isIOS) {
|
||||
return Builder(
|
||||
builder: (context) => CupertinoSlider(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
min: min,
|
||||
max: max,
|
||||
divisions: divisions,
|
||||
activeColor: activeColor ?? context.conduitTheme.buttonPrimary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Slider(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
min: min,
|
||||
max: max,
|
||||
divisions: divisions,
|
||||
activeColor: activeColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS-specific enhancements
|
||||
class IOSEnhancements {
|
||||
/// Create iOS-style navigation bar
|
||||
static PreferredSizeWidget createNavigationBar({
|
||||
required String title,
|
||||
VoidCallback? onBack,
|
||||
List<Widget>? actions,
|
||||
Color? backgroundColor,
|
||||
}) {
|
||||
return CupertinoNavigationBar(
|
||||
middle: Text(title),
|
||||
leading: onBack != null
|
||||
? CupertinoNavigationBarBackButton(onPressed: onBack)
|
||||
: null,
|
||||
trailing: actions != null && actions.isNotEmpty
|
||||
? Row(mainAxisSize: MainAxisSize.min, children: actions)
|
||||
: null,
|
||||
backgroundColor: backgroundColor,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create iOS-style context menu
|
||||
static Widget createContextMenu({
|
||||
required Widget child,
|
||||
required List<ContextMenuAction> actions,
|
||||
}) {
|
||||
return CupertinoContextMenu(
|
||||
actions: actions
|
||||
.map(
|
||||
(action) => CupertinoContextMenuAction(
|
||||
onPressed: action.onPressed,
|
||||
isDefaultAction: action.isDefault,
|
||||
isDestructiveAction: action.isDestructive,
|
||||
child: Text(action.title),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create iOS-style action sheet
|
||||
static void showActionSheet({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
String? message,
|
||||
required List<ActionSheetAction> actions,
|
||||
}) {
|
||||
showCupertinoModalPopup(
|
||||
context: context,
|
||||
builder: (context) => CupertinoActionSheet(
|
||||
title: Text(title),
|
||||
message: message != null ? Text(message) : null,
|
||||
actions: actions
|
||||
.map(
|
||||
(action) => CupertinoActionSheetAction(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
action.onPressed();
|
||||
},
|
||||
isDefaultAction: action.isDefault,
|
||||
isDestructiveAction: action.isDestructive,
|
||||
child: Text(action.title),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Android-specific enhancements
|
||||
class AndroidEnhancements {
|
||||
/// Create Material You themed button
|
||||
static Widget createMaterial3Button({
|
||||
required String text,
|
||||
required VoidCallback? onPressed,
|
||||
ButtonType type = ButtonType.filled,
|
||||
IconData? icon,
|
||||
}) {
|
||||
Widget button;
|
||||
|
||||
switch (type) {
|
||||
case ButtonType.filled:
|
||||
button = icon != null
|
||||
? FilledButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon),
|
||||
label: Text(text),
|
||||
)
|
||||
: FilledButton(onPressed: onPressed, child: Text(text));
|
||||
break;
|
||||
case ButtonType.outlined:
|
||||
button = icon != null
|
||||
? OutlinedButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon),
|
||||
label: Text(text),
|
||||
)
|
||||
: OutlinedButton(onPressed: onPressed, child: Text(text));
|
||||
break;
|
||||
case ButtonType.text:
|
||||
button = icon != null
|
||||
? TextButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon),
|
||||
label: Text(text),
|
||||
)
|
||||
: TextButton(onPressed: onPressed, child: Text(text));
|
||||
break;
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
/// Create Material 3 card
|
||||
static Widget createCard({
|
||||
required Widget child,
|
||||
VoidCallback? onTap,
|
||||
EdgeInsetsGeometry? padding,
|
||||
CardType type = CardType.filled,
|
||||
}) {
|
||||
Widget card;
|
||||
|
||||
switch (type) {
|
||||
case CardType.filled:
|
||||
card = Card.filled(
|
||||
child: padding != null
|
||||
? Padding(padding: padding, child: child)
|
||||
: child,
|
||||
);
|
||||
break;
|
||||
case CardType.outlined:
|
||||
card = Card.outlined(
|
||||
child: padding != null
|
||||
? Padding(padding: padding, child: child)
|
||||
: child,
|
||||
);
|
||||
break;
|
||||
case CardType.elevated:
|
||||
card = Card(
|
||||
child: padding != null
|
||||
? Padding(padding: padding, child: child)
|
||||
: child,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (onTap != null) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/// Create floating action button with Material 3 styling
|
||||
static Widget createFAB({
|
||||
required VoidCallback onPressed,
|
||||
required Widget child,
|
||||
bool isExtended = false,
|
||||
String? label,
|
||||
}) {
|
||||
if (isExtended && label != null) {
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: onPressed,
|
||||
icon: child,
|
||||
label: Text(label),
|
||||
);
|
||||
}
|
||||
|
||||
return FloatingActionButton(onPressed: onPressed, child: child);
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform-aware widget that provides different implementations
|
||||
class PlatformWidget extends StatelessWidget {
|
||||
final Widget ios;
|
||||
final Widget android;
|
||||
final Widget? fallback;
|
||||
|
||||
const PlatformWidget({
|
||||
super.key,
|
||||
required this.ios,
|
||||
required this.android,
|
||||
this.fallback,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (Platform.isIOS) {
|
||||
return ios;
|
||||
} else if (Platform.isAndroid) {
|
||||
return android;
|
||||
} else {
|
||||
return fallback ?? android;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enhanced button with platform-specific haptics
|
||||
class HapticButton extends StatelessWidget {
|
||||
final Widget child;
|
||||
final VoidCallback? onPressed;
|
||||
final HapticType hapticType;
|
||||
final ButtonStyle? style;
|
||||
|
||||
const HapticButton({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.onPressed,
|
||||
this.hapticType = HapticType.light,
|
||||
this.style,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FilledButton(
|
||||
onPressed: onPressed != null
|
||||
? () {
|
||||
_triggerHaptic();
|
||||
onPressed!();
|
||||
}
|
||||
: null,
|
||||
style: style,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
void _triggerHaptic() {
|
||||
switch (hapticType) {
|
||||
case HapticType.light:
|
||||
PlatformUtils.lightHaptic();
|
||||
break;
|
||||
case HapticType.medium:
|
||||
PlatformUtils.mediumHaptic();
|
||||
break;
|
||||
case HapticType.heavy:
|
||||
PlatformUtils.heavyHaptic();
|
||||
break;
|
||||
case HapticType.selection:
|
||||
PlatformUtils.selectionHaptic();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enhanced list tile with platform-specific styling
|
||||
class PlatformListTile extends StatelessWidget {
|
||||
final Widget? leading;
|
||||
final Widget? title;
|
||||
final Widget? subtitle;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
final bool enableHaptic;
|
||||
|
||||
const PlatformListTile({
|
||||
super.key,
|
||||
this.leading,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
this.enableHaptic = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tile = ListTile(
|
||||
leading: leading,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
trailing: trailing,
|
||||
onTap: onTap != null && enableHaptic
|
||||
? () {
|
||||
PlatformUtils.selectionHaptic();
|
||||
onTap!();
|
||||
}
|
||||
: onTap,
|
||||
);
|
||||
|
||||
if (Platform.isIOS) {
|
||||
return Builder(
|
||||
builder: (context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceBackground,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: context.conduitTheme.dividerColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: tile,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return tile;
|
||||
}
|
||||
}
|
||||
|
||||
// Enums and supporting classes
|
||||
enum HapticType { light, medium, heavy, selection }
|
||||
|
||||
enum ButtonType { filled, outlined, text }
|
||||
|
||||
enum CardType { filled, outlined, elevated }
|
||||
|
||||
class ContextMenuAction {
|
||||
final String title;
|
||||
final VoidCallback onPressed;
|
||||
final bool isDefault;
|
||||
final bool isDestructive;
|
||||
|
||||
const ContextMenuAction({
|
||||
required this.title,
|
||||
required this.onPressed,
|
||||
this.isDefault = false,
|
||||
this.isDestructive = false,
|
||||
});
|
||||
}
|
||||
|
||||
class ActionSheetAction {
|
||||
final String title;
|
||||
final VoidCallback onPressed;
|
||||
final bool isDefault;
|
||||
final bool isDestructive;
|
||||
|
||||
const ActionSheetAction({
|
||||
required this.title,
|
||||
required this.onPressed,
|
||||
this.isDefault = false,
|
||||
this.isDestructive = false,
|
||||
});
|
||||
}
|
||||
221
lib/shared/utils/ui_utils.dart
Normal file
221
lib/shared/utils/ui_utils.dart
Normal file
@@ -0,0 +1,221 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import '../theme/theme_extensions.dart';
|
||||
|
||||
/// Utility functions for common UI patterns and helpers
|
||||
/// Following Conduit design principles
|
||||
|
||||
class UiUtils {
|
||||
static bool get isIOS => Platform.isIOS;
|
||||
|
||||
/// Returns platform-appropriate icon
|
||||
static IconData platformIcon({
|
||||
required IconData ios,
|
||||
required IconData android,
|
||||
}) {
|
||||
return isIOS ? ios : android;
|
||||
}
|
||||
|
||||
/// Common platform icons used throughout the app
|
||||
static IconData get chatIcon =>
|
||||
platformIcon(ios: CupertinoIcons.chat_bubble_2, android: Icons.chat);
|
||||
|
||||
static IconData get searchIcon =>
|
||||
platformIcon(ios: CupertinoIcons.search, android: Icons.search);
|
||||
|
||||
static IconData get deleteIcon =>
|
||||
platformIcon(ios: CupertinoIcons.delete, android: Icons.delete);
|
||||
|
||||
static IconData get archiveIcon =>
|
||||
platformIcon(ios: CupertinoIcons.archivebox, android: Icons.archive);
|
||||
|
||||
static IconData get shareIcon =>
|
||||
platformIcon(ios: CupertinoIcons.share, android: Icons.share);
|
||||
|
||||
static IconData get settingsIcon =>
|
||||
platformIcon(ios: CupertinoIcons.gear, android: Icons.settings);
|
||||
|
||||
static IconData get editIcon =>
|
||||
platformIcon(ios: CupertinoIcons.pencil, android: Icons.edit_outlined);
|
||||
|
||||
static IconData get menuIcon =>
|
||||
platformIcon(ios: CupertinoIcons.line_horizontal_3, android: Icons.menu);
|
||||
|
||||
static IconData get addIcon =>
|
||||
platformIcon(ios: CupertinoIcons.plus_circle, android: Icons.add);
|
||||
|
||||
static IconData get attachIcon =>
|
||||
platformIcon(ios: CupertinoIcons.paperclip, android: Icons.attach_file);
|
||||
|
||||
static IconData get micIcon =>
|
||||
platformIcon(ios: CupertinoIcons.mic, android: Icons.mic);
|
||||
|
||||
static IconData get sendIcon => platformIcon(
|
||||
ios: CupertinoIcons.arrow_up_circle_fill,
|
||||
android: Icons.send,
|
||||
);
|
||||
|
||||
static IconData get moreIcon => platformIcon(
|
||||
ios: CupertinoIcons.ellipsis_vertical,
|
||||
android: Icons.more_vert,
|
||||
);
|
||||
|
||||
static IconData get closeIcon =>
|
||||
platformIcon(ios: CupertinoIcons.xmark, android: Icons.close);
|
||||
|
||||
static IconData get checkIcon =>
|
||||
platformIcon(ios: CupertinoIcons.check_mark, android: Icons.check);
|
||||
|
||||
static IconData get globeIcon =>
|
||||
platformIcon(ios: CupertinoIcons.globe, android: Icons.public);
|
||||
|
||||
static IconData get folderIcon =>
|
||||
platformIcon(ios: CupertinoIcons.folder, android: Icons.folder);
|
||||
|
||||
static IconData get tagIcon =>
|
||||
platformIcon(ios: CupertinoIcons.tag, android: Icons.label);
|
||||
|
||||
static IconData get copyIcon =>
|
||||
platformIcon(ios: CupertinoIcons.doc_on_doc, android: Icons.copy);
|
||||
|
||||
static IconData get pinIcon =>
|
||||
platformIcon(ios: CupertinoIcons.pin_fill, android: Icons.push_pin);
|
||||
|
||||
static IconData get unpinIcon => platformIcon(
|
||||
ios: CupertinoIcons.pin_slash,
|
||||
android: Icons.push_pin_outlined,
|
||||
);
|
||||
|
||||
/// Shows a Conduit-styled snackbar with conversational messaging
|
||||
static void showMessage(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
bool isError = false,
|
||||
VoidCallback? onRetry,
|
||||
Duration? duration,
|
||||
}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: isError
|
||||
? context.conduitTheme.error
|
||||
: context.conduitTheme.buttonPrimary,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
action: onRetry != null
|
||||
? SnackBarAction(
|
||||
label: 'Try again',
|
||||
textColor: context.conduitTheme.textInverse,
|
||||
onPressed: onRetry,
|
||||
)
|
||||
: null,
|
||||
duration: duration ?? const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows a Conduit-styled confirmation dialog
|
||||
static Future<bool> showConfirmationDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmText = 'Confirm',
|
||||
String cancelText = 'Cancel',
|
||||
bool isDestructive = false,
|
||||
}) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(color: context.conduitTheme.textPrimary),
|
||||
),
|
||||
content: Text(
|
||||
message,
|
||||
style: TextStyle(color: context.conduitTheme.textSecondary),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(cancelText),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: isDestructive
|
||||
? TextButton.styleFrom(
|
||||
foregroundColor: context.conduitTheme.error,
|
||||
)
|
||||
: null,
|
||||
child: Text(confirmText),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
/// Formats dates in a conversational way following Conduit patterns
|
||||
static String formatDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(date);
|
||||
|
||||
if (difference.inDays == 0) {
|
||||
if (difference.inHours == 0) {
|
||||
if (difference.inMinutes == 0) {
|
||||
return 'Just now';
|
||||
}
|
||||
return '${difference.inMinutes}m ago';
|
||||
}
|
||||
return '${difference.inHours}h ago';
|
||||
} else if (difference.inDays == 1) {
|
||||
return 'Yesterday';
|
||||
} else if (difference.inDays < 7) {
|
||||
return '${difference.inDays} days ago';
|
||||
} else if (difference.inDays < 30) {
|
||||
final weeks = (difference.inDays / 7).floor();
|
||||
return weeks == 1 ? '1 week ago' : '$weeks weeks ago';
|
||||
} else if (difference.inDays < 365) {
|
||||
final months = (difference.inDays / 30).floor();
|
||||
return months == 1 ? '1 month ago' : '$months months ago';
|
||||
} else {
|
||||
return '${date.month}/${date.day}/${date.year}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a smooth haptic feedback on iOS
|
||||
static void hapticFeedback() {
|
||||
if (isIOS) {
|
||||
// iOS haptic feedback would be implemented here
|
||||
// For now, we'll leave this as a placeholder
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe area padding helper
|
||||
static EdgeInsets safeAreaPadding(BuildContext context) {
|
||||
return MediaQuery.of(context).padding;
|
||||
}
|
||||
|
||||
/// Screen size helpers
|
||||
static Size screenSize(BuildContext context) {
|
||||
return MediaQuery.of(context).size;
|
||||
}
|
||||
|
||||
static bool isSmallScreen(BuildContext context) {
|
||||
return screenSize(context).width < 375;
|
||||
}
|
||||
|
||||
static bool isLargeScreen(BuildContext context) {
|
||||
return screenSize(context).width > 414;
|
||||
}
|
||||
|
||||
/// Keyboard handling
|
||||
static bool isKeyboardOpen(BuildContext context) {
|
||||
return MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
}
|
||||
|
||||
/// Focus management
|
||||
static void unfocus(BuildContext context) {
|
||||
FocusScope.of(context).unfocus();
|
||||
}
|
||||
}
|
||||
143
lib/shared/widgets/cached_image.dart
Normal file
143
lib/shared/widgets/cached_image.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
958
lib/shared/widgets/conduit_components.dart
Normal file
958
lib/shared/widgets/conduit_components.dart
Normal 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!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
448
lib/shared/widgets/empty_states.dart
Normal file
448
lib/shared/widgets/empty_states.dart
Normal 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;
|
||||
}
|
||||
397
lib/shared/widgets/error_widgets.dart
Normal file
397
lib/shared/widgets/error_widgets.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
666
lib/shared/widgets/improved_loading_states.dart
Normal file
666
lib/shared/widgets/improved_loading_states.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
429
lib/shared/widgets/loading_states.dart
Normal file
429
lib/shared/widgets/loading_states.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
231
lib/shared/widgets/offline_indicator.dart
Normal file
231
lib/shared/widgets/offline_indicator.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
414
lib/shared/widgets/optimized_list.dart
Normal file
414
lib/shared/widgets/optimized_list.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
361
lib/shared/widgets/skeleton_loader.dart
Normal file
361
lib/shared/widgets/skeleton_loader.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
99
lib/shared/widgets/themed_dialogs.dart
Normal file
99
lib/shared/widgets/themed_dialogs.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user