Files
base_flutter/lib/core/widgets/app_card.dart
2025-09-26 18:48:14 +07:00

538 lines
14 KiB
Dart

import 'package:flutter/material.dart';
import '../theme/app_spacing.dart';
/// Reusable card component with Material 3 styling and customization options
///
/// Provides a consistent card design with support for different variants,
/// elevation levels, and content layouts.
class AppCard extends StatelessWidget {
/// Creates a card with the specified content and styling options
const AppCard({
super.key,
required this.child,
this.variant = AppCardVariant.elevated,
this.padding,
this.margin,
this.backgroundColor,
this.shadowColor,
this.surfaceTintColor,
this.elevation,
this.shape,
this.clipBehavior = Clip.none,
this.onTap,
this.semanticLabel,
}) : title = null,
subtitle = null,
leading = null,
trailing = null,
actions = null,
mediaWidget = null;
/// Creates a card with a title, subtitle, and optional actions
const AppCard.titled({
super.key,
required this.title,
this.subtitle,
this.child,
this.leading,
this.trailing,
this.actions,
this.variant = AppCardVariant.elevated,
this.padding,
this.margin,
this.backgroundColor,
this.shadowColor,
this.surfaceTintColor,
this.elevation,
this.shape,
this.clipBehavior = Clip.none,
this.onTap,
this.semanticLabel,
}) : mediaWidget = null;
/// Creates a card optimized for displaying media content
const AppCard.media({
super.key,
required this.mediaWidget,
this.title,
this.subtitle,
this.child,
this.actions,
this.variant = AppCardVariant.elevated,
this.padding,
this.margin,
this.backgroundColor,
this.shadowColor,
this.surfaceTintColor,
this.elevation,
this.shape,
this.clipBehavior = Clip.antiAlias,
this.onTap,
this.semanticLabel,
}) : leading = null,
trailing = null;
/// The content to display inside the card
final Widget? child;
/// Title for titled cards
final String? title;
/// Subtitle for titled cards
final String? subtitle;
/// Leading widget for titled cards (typically an icon or avatar)
final Widget? leading;
/// Trailing widget for titled cards (typically an icon or button)
final Widget? trailing;
/// Action buttons displayed at the bottom of the card
final List<Widget>? actions;
/// Media widget for media cards (typically an image or video)
final Widget? mediaWidget;
/// The visual variant of the card
final AppCardVariant variant;
/// Internal padding of the card content
final EdgeInsets? padding;
/// External margin around the card
final EdgeInsets? margin;
/// Background color override
final Color? backgroundColor;
/// Shadow color override
final Color? shadowColor;
/// Surface tint color for Material 3
final Color? surfaceTintColor;
/// Elevation level override
final double? elevation;
/// Shape override
final ShapeBorder? shape;
/// Clipping behavior for card content
final Clip clipBehavior;
/// Called when the card is tapped
final VoidCallback? onTap;
/// Semantic label for accessibility
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cardTheme = theme.cardTheme;
// Determine card content based on constructor used
Widget content;
if (title != null) {
content = _buildTitledContent(context);
} else if (mediaWidget != null) {
content = _buildMediaContent(context);
} else if (child != null) {
content = child!;
} else {
content = const SizedBox.shrink();
}
// Apply padding if specified
if (padding != null) {
content = Padding(
padding: padding!,
child: content,
);
} else if (title != null || mediaWidget != null) {
// Apply default padding for titled/media cards
content = Padding(
padding: const EdgeInsets.all(AppSpacing.cardPadding),
child: content,
);
}
// Create the card widget
Widget card = Card(
elevation: _getElevation(theme),
color: backgroundColor ?? cardTheme.color,
shadowColor: shadowColor ?? cardTheme.shadowColor,
surfaceTintColor: surfaceTintColor ?? cardTheme.surfaceTintColor,
shape: shape ?? cardTheme.shape ??
RoundedRectangleBorder(borderRadius: AppSpacing.cardRadius),
clipBehavior: clipBehavior,
margin: margin ?? cardTheme.margin ?? const EdgeInsets.all(AppSpacing.cardMargin),
child: content,
);
// Make card tappable if onTap is provided
if (onTap != null) {
card = InkWell(
onTap: onTap,
borderRadius: shape is RoundedRectangleBorder
? (shape as RoundedRectangleBorder).borderRadius as BorderRadius?
: AppSpacing.cardRadius,
child: card,
);
}
// Add semantic label for accessibility
if (semanticLabel != null) {
card = Semantics(
label: semanticLabel,
child: card,
);
}
return card;
}
/// Build content for titled cards
Widget _buildTitledContent(BuildContext context) {
final theme = Theme.of(context);
final titleStyle = theme.textTheme.titleMedium;
final subtitleStyle = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
final List<Widget> children = [];
// Add header with title, subtitle, leading, and trailing
if (title != null || leading != null || trailing != null) {
children.add(
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (leading != null) ...[
leading!,
AppSpacing.horizontalSpaceMD,
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (title != null)
Text(
title!,
style: titleStyle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (subtitle != null) ...[
AppSpacing.verticalSpaceXS,
Text(
subtitle!,
style: subtitleStyle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
if (trailing != null) ...[
AppSpacing.horizontalSpaceXS,
trailing!,
],
],
),
);
}
// Add main content
if (child != null) {
if (children.isNotEmpty) {
children.add(AppSpacing.verticalSpaceLG);
}
children.add(child!);
}
// Add actions
if (actions != null && actions!.isNotEmpty) {
if (children.isNotEmpty) {
children.add(AppSpacing.verticalSpaceLG);
}
children.add(
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: actions!
.expand((action) => [action, AppSpacing.horizontalSpaceSM])
.take(actions!.length * 2 - 1)
.toList(),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: children,
);
}
/// Build content for media cards
Widget _buildMediaContent(BuildContext context) {
final theme = Theme.of(context);
final titleStyle = theme.textTheme.titleMedium;
final subtitleStyle = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
final List<Widget> children = [];
// Add media widget
if (mediaWidget != null) {
children.add(mediaWidget!);
}
// Add title and subtitle
if (title != null || subtitle != null) {
children.add(
Padding(
padding: const EdgeInsets.all(AppSpacing.cardPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null)
Text(
title!,
style: titleStyle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (subtitle != null) ...[
AppSpacing.verticalSpaceXS,
Text(
subtitle!,
style: subtitleStyle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
);
}
// Add main content
if (child != null) {
children.add(
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.cardPadding,
).copyWith(
bottom: AppSpacing.cardPadding,
),
child: child!,
),
);
}
// Add actions
if (actions != null && actions!.isNotEmpty) {
children.add(
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.cardPadding,
).copyWith(
bottom: AppSpacing.cardPadding,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: actions!
.expand((action) => [action, AppSpacing.horizontalSpaceSM])
.take(actions!.length * 2 - 1)
.toList(),
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: children,
);
}
/// Get elevation based on variant and theme
double _getElevation(ThemeData theme) {
if (elevation != null) {
return elevation!;
}
return switch (variant) {
AppCardVariant.elevated => theme.cardTheme.elevation ?? AppSpacing.elevationLow,
AppCardVariant.filled => AppSpacing.elevationNone,
AppCardVariant.outlined => AppSpacing.elevationNone,
};
}
}
/// Available card variants
enum AppCardVariant {
/// Elevated card with shadow (default)
elevated,
/// Filled card with background color and no shadow
filled,
/// Outlined card with border and no shadow
outlined,
}
/// Compact card for list items
class AppListCard extends StatelessWidget {
const AppListCard({
super.key,
required this.title,
this.subtitle,
this.leading,
this.trailing,
this.onTap,
this.padding,
this.margin,
this.semanticLabel,
});
final String title;
final String? subtitle;
final Widget? leading;
final Widget? trailing;
final VoidCallback? onTap;
final EdgeInsets? padding;
final EdgeInsets? margin;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
return AppCard(
variant: AppCardVariant.filled,
padding: padding ?? const EdgeInsets.all(AppSpacing.md),
margin: margin ?? const EdgeInsets.symmetric(
horizontal: AppSpacing.screenPadding,
vertical: AppSpacing.xs,
),
onTap: onTap,
semanticLabel: semanticLabel,
child: Row(
children: [
if (leading != null) ...[
leading!,
AppSpacing.horizontalSpaceMD,
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (subtitle != null) ...[
AppSpacing.verticalSpaceXS,
Text(
subtitle!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
if (trailing != null) ...[
AppSpacing.horizontalSpaceSM,
trailing!,
],
],
),
);
}
}
/// Action card for interactive content
class AppActionCard extends StatelessWidget {
const AppActionCard({
super.key,
required this.title,
required this.onTap,
this.subtitle,
this.icon,
this.backgroundColor,
this.foregroundColor,
this.padding,
this.margin,
this.semanticLabel,
});
final String title;
final String? subtitle;
final IconData? icon;
final VoidCallback onTap;
final Color? backgroundColor;
final Color? foregroundColor;
final EdgeInsets? padding;
final EdgeInsets? margin;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppCard(
variant: AppCardVariant.filled,
backgroundColor: backgroundColor ?? theme.colorScheme.surfaceContainerHighest,
padding: padding ?? const EdgeInsets.all(AppSpacing.cardPadding),
margin: margin,
onTap: onTap,
semanticLabel: semanticLabel,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
size: AppSpacing.iconXL,
color: foregroundColor ?? theme.colorScheme.primary,
),
AppSpacing.verticalSpaceMD,
],
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
color: foregroundColor,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (subtitle != null) ...[
AppSpacing.verticalSpaceXS,
Text(
subtitle!,
style: theme.textTheme.bodySmall?.copyWith(
color: foregroundColor ?? theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
);
}
}