From ca8bd1efdb79c1cb5ddb1583f42bbdec3340cea6 Mon Sep 17 00:00:00 2001 From: hakanglgmobven Date: Fri, 13 Feb 2026 14:37:00 +0300 Subject: [PATCH 1/2] add new widget files for UI components including AppAvatar, AppBadge, AppBanner, and others; implement adaptive design patterns for various widgets. --- example/pubspec.yaml | 4 + lib/src/widgets/animation_helpers.dart | 45 +++++ lib/src/widgets/app_avatar.dart | 55 ++++++ lib/src/widgets/app_badge.dart | 84 +++++++++ lib/src/widgets/app_banner.dart | 94 ++++++++++ lib/src/widgets/app_bottom_sheet.dart | 122 +++++++++++++ lib/src/widgets/app_card.dart | 49 ++++++ lib/src/widgets/app_checkbox.dart | 73 ++++++++ lib/src/widgets/app_chip.dart | 38 +++++ lib/src/widgets/app_date_picker.dart | 152 +++++++++++++++++ lib/src/widgets/app_dialog.dart | 90 ++++++++++ lib/src/widgets/app_divider.dart | 70 ++++++++ lib/src/widgets/app_dropdown.dart | 190 +++++++++++++++++++++ lib/src/widgets/app_expansion_tile.dart | 45 +++++ lib/src/widgets/app_icon_button.dart | 92 ++++++++++ lib/src/widgets/app_image.dart | 92 ++++++++++ lib/src/widgets/app_list_tile.dart | 84 +++++++++ lib/src/widgets/app_loading_indicator.dart | 6 +- lib/src/widgets/app_progress_bar.dart | 65 +++++++ lib/src/widgets/app_radio_group.dart | 104 +++++++++++ lib/src/widgets/app_refresh_indicator.dart | 26 +++ lib/src/widgets/app_search_field.dart | 111 ++++++++++++ lib/src/widgets/app_section_header.dart | 57 +++++++ lib/src/widgets/app_segmented_control.dart | 81 +++++++++ lib/src/widgets/app_slider.dart | 78 +++++++++ lib/src/widgets/app_snack_bar.dart | 60 +++++++ lib/src/widgets/app_switch.dart | 73 ++++++++ lib/src/widgets/app_tag.dart | 77 +++++++++ lib/src/widgets/app_text_field.dart | 76 +++++++++ lib/src/widgets/app_tooltip.dart | 41 +++++ lib/src/widgets/primary_button.dart | 55 ++++-- lib/src/widgets/skeleton_loader.dart | 63 +++++++ lib/src/widgets/widgets.dart | 35 +++- pubspec.yaml | 9 +- 34 files changed, 2376 insertions(+), 20 deletions(-) create mode 100644 lib/src/widgets/animation_helpers.dart create mode 100644 lib/src/widgets/app_avatar.dart create mode 100644 lib/src/widgets/app_badge.dart create mode 100644 lib/src/widgets/app_banner.dart create mode 100644 lib/src/widgets/app_bottom_sheet.dart create mode 100644 lib/src/widgets/app_card.dart create mode 100644 lib/src/widgets/app_checkbox.dart create mode 100644 lib/src/widgets/app_chip.dart create mode 100644 lib/src/widgets/app_date_picker.dart create mode 100644 lib/src/widgets/app_dialog.dart create mode 100644 lib/src/widgets/app_divider.dart create mode 100644 lib/src/widgets/app_dropdown.dart create mode 100644 lib/src/widgets/app_expansion_tile.dart create mode 100644 lib/src/widgets/app_icon_button.dart create mode 100644 lib/src/widgets/app_image.dart create mode 100644 lib/src/widgets/app_list_tile.dart create mode 100644 lib/src/widgets/app_progress_bar.dart create mode 100644 lib/src/widgets/app_radio_group.dart create mode 100644 lib/src/widgets/app_refresh_indicator.dart create mode 100644 lib/src/widgets/app_search_field.dart create mode 100644 lib/src/widgets/app_section_header.dart create mode 100644 lib/src/widgets/app_segmented_control.dart create mode 100644 lib/src/widgets/app_slider.dart create mode 100644 lib/src/widgets/app_snack_bar.dart create mode 100644 lib/src/widgets/app_switch.dart create mode 100644 lib/src/widgets/app_tag.dart create mode 100644 lib/src/widgets/app_tooltip.dart create mode 100644 lib/src/widgets/skeleton_loader.dart diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 475285d..e06330f 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -4,6 +4,7 @@ publish_to: 'none' version: 0.1.0 environment: + #sdk: ^3.6.0 sdk: ^3.10.7 dependencies: @@ -11,3 +12,6 @@ dependencies: sdk: flutter mbvn_flutter_base: path: ../ + +flutter: + uses-material-design: true diff --git a/lib/src/widgets/animation_helpers.dart b/lib/src/widgets/animation_helpers.dart new file mode 100644 index 0000000..a181e38 --- /dev/null +++ b/lib/src/widgets/animation_helpers.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +/// Standard animation durations. +abstract final class AnimationDurations { + AnimationDurations._(); + + static const Duration fast = Duration(milliseconds: 150); + static const Duration normal = Duration(milliseconds: 300); + static const Duration slow = Duration(milliseconds: 500); +} + +/// Standard animation curves. +abstract final class AnimationCurves { + AnimationCurves._(); + + static const Curve standard = Curves.easeInOut; + static const Curve enter = Curves.easeOut; + static const Curve exit = Curves.easeIn; +} + +/// Animated visibility wrapper that fades in/out its child. +class AnimatedVisibility extends StatelessWidget { + const AnimatedVisibility({ + super.key, + required this.visible, + required this.child, + this.duration, + this.curve, + }); + + final bool visible; + final Widget child; + final Duration? duration; + final Curve? curve; + + @override + Widget build(BuildContext context) { + return AnimatedOpacity( + opacity: visible ? 1.0 : 0.0, + duration: duration ?? AnimationDurations.normal, + curve: curve ?? AnimationCurves.standard, + child: visible ? child : const SizedBox.shrink(), + ); + } +} diff --git a/lib/src/widgets/app_avatar.dart b/lib/src/widgets/app_avatar.dart new file mode 100644 index 0000000..1244102 --- /dev/null +++ b/lib/src/widgets/app_avatar.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +/// Avatar widget with image, initials, or icon variants. +class AppAvatar extends StatelessWidget { + const AppAvatar({ + super.key, + this.imageUrl, + this.initials, + this.icon, + this.radius = 20, + this.backgroundColor, + }); + + final String? imageUrl; + final String? initials; + final IconData? icon; + final double radius; + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final bg = backgroundColor ?? theme.colorScheme.primaryContainer; + final fg = theme.colorScheme.onPrimaryContainer; + + if (imageUrl != null) { + return CircleAvatar( + radius: radius, + backgroundImage: NetworkImage(imageUrl!), + backgroundColor: bg, + ); + } + + if (initials != null) { + return CircleAvatar( + radius: radius, + backgroundColor: bg, + child: Text( + initials!.toUpperCase(), + style: TextStyle( + color: fg, + fontSize: radius * 0.8, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + return CircleAvatar( + radius: radius, + backgroundColor: bg, + child: Icon(icon ?? Icons.person, color: fg, size: radius), + ); + } +} diff --git a/lib/src/widgets/app_badge.dart b/lib/src/widgets/app_badge.dart new file mode 100644 index 0000000..fc94d61 --- /dev/null +++ b/lib/src/widgets/app_badge.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; + +/// Badge overlay for notifications, counters, and status indicators. +/// +/// Wraps a [child] widget with a positioned badge. +/// Shows a dot badge when no [label] is provided. +/// Animates show/hide with [AnimatedOpacity]. +class AppBadge extends StatelessWidget { + const AppBadge({ + super.key, + required this.child, + this.label, + this.show = true, + this.backgroundColor, + this.alignment = Alignment.topRight, + }); + + final Widget child; + final String? label; + final bool show; + final Color? backgroundColor; + final Alignment alignment; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final bgColor = backgroundColor ?? theme.colorScheme.error; + final fgColor = theme.colorScheme.onError; + + final isDot = label == null; + + final badge = AnimatedOpacity( + opacity: show ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: Container( + padding: isDot + ? EdgeInsets.zero + : const EdgeInsets.symmetric( + horizontal: Spacing.xs + 2, + vertical: 1, + ), + constraints: BoxConstraints( + minWidth: isDot ? 8 : 16, + minHeight: isDot ? 8 : 16, + ), + decoration: BoxDecoration( + color: bgColor, + borderRadius: isDot ? AppRadius.allXs : AppRadius.allSm, + ), + child: isDot + ? null + : Text( + label!, + style: TextStyle( + color: fgColor, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + ), + ); + + // Calculate offset based on alignment + final isTop = alignment.y <= 0; + final isRight = alignment.x >= 0; + + return Stack( + clipBehavior: Clip.none, + children: [ + child, + Positioned( + top: isTop ? -4 : null, + bottom: isTop ? null : -4, + right: isRight ? -4 : null, + left: isRight ? null : -4, + child: badge, + ), + ], + ); + } +} diff --git a/lib/src/widgets/app_banner.dart b/lib/src/widgets/app_banner.dart new file mode 100644 index 0000000..1f68289 --- /dev/null +++ b/lib/src/widgets/app_banner.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; + +/// Semantic variant for [AppBanner]. +enum BannerVariant { success, error, warning, info } + +/// Persistent banner for informational or warning messages. +/// +/// Displays at the top of content with semantic color variants +/// and optional action and dismiss button. +class AppBanner extends StatelessWidget { + const AppBanner({ + super.key, + required this.message, + this.variant = BannerVariant.info, + this.action, + this.onDismiss, + }); + + final String message; + final BannerVariant variant; + final Widget? action; + final VoidCallback? onDismiss; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = _resolveColors(theme); + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm + Spacing.xs, + ), + decoration: BoxDecoration( + color: colors.$1, + borderRadius: AppRadius.allSm, + ), + child: Row( + children: [ + Icon(_resolveIcon(), size: 20, color: colors.$2), + const SizedBox(width: Spacing.sm), + Expanded( + child: Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.$2, + ), + ), + ), + if (action != null) ...[ + const SizedBox(width: Spacing.sm), + action!, + ], + if (onDismiss != null) ...[ + const SizedBox(width: Spacing.xs), + GestureDetector( + onTap: onDismiss, + child: Icon(Icons.close, size: 18, color: colors.$2), + ), + ], + ], + ), + ); + } + + IconData _resolveIcon() { + switch (variant) { + case BannerVariant.success: + return Icons.check_circle_outline; + case BannerVariant.error: + return Icons.error_outline; + case BannerVariant.warning: + return Icons.warning_amber_rounded; + case BannerVariant.info: + return Icons.info_outline; + } + } + + (Color background, Color foreground) _resolveColors(ThemeData theme) { + switch (variant) { + case BannerVariant.success: + return (const Color(0xFF16A34A).withValues(alpha: 0.12), const Color(0xFF16A34A)); + case BannerVariant.error: + return (theme.colorScheme.error.withValues(alpha: 0.12), theme.colorScheme.error); + case BannerVariant.warning: + return (const Color(0xFFF59E0B).withValues(alpha: 0.12), const Color(0xFFB45309)); + case BannerVariant.info: + return (theme.colorScheme.primary.withValues(alpha: 0.12), theme.colorScheme.primary); + } + } +} diff --git a/lib/src/widgets/app_bottom_sheet.dart b/lib/src/widgets/app_bottom_sheet.dart new file mode 100644 index 0000000..d845318 --- /dev/null +++ b/lib/src/widgets/app_bottom_sheet.dart @@ -0,0 +1,122 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; +import '../extensions/context_extensions.dart'; + +/// Standard bottom sheet with drag handle and optional title. +/// +/// Uses [showCupertinoModalPopup] on Apple platforms and +/// [showModalBottomSheet] on others. +class AppBottomSheet extends StatelessWidget { + const AppBottomSheet({ + super.key, + this.title, + required this.child, + }); + + final String? title; + final Widget child; + + /// Shows this bottom sheet using platform-adaptive presentation. + static Future show( + BuildContext context, { + String? title, + required Widget child, + bool isScrollControlled = true, + }) { + if (context.isApplePlatform) { + return showCupertinoModalPopup( + context: context, + builder: (_) => AppBottomSheet(title: title, child: child), + ); + } + + return showModalBottomSheet( + context: context, + isScrollControlled: isScrollControlled, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.md)), + ), + builder: (_) => AppBottomSheet(title: title, child: child), + ); + } + + @override + Widget build(BuildContext context) { + if (context.isApplePlatform) { + return _buildCupertino(context); + } + return _buildMaterial(context); + } + + Widget _buildMaterial(BuildContext context) { + final theme = Theme.of(context); + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(Spacing.md), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 32, + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + if (title != null) ...[ + const SizedBox(height: Spacing.md), + Text( + title!, + style: theme.textTheme.titleMedium, + ), + ], + const SizedBox(height: Spacing.md), + child, + ], + ), + ), + ); + } + + Widget _buildCupertino(BuildContext context) { + final theme = Theme.of(context); + return CupertinoPopupSurface( + child: Material( + type: MaterialType.transparency, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(Spacing.md), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, + height: 5, + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2.5), + ), + ), + if (title != null) ...[ + const SizedBox(height: Spacing.md), + Text( + title!, + style: theme.textTheme.headlineMedium, + ), + ], + const SizedBox(height: Spacing.md), + child, + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/widgets/app_card.dart b/lib/src/widgets/app_card.dart new file mode 100644 index 0000000..95784e6 --- /dev/null +++ b/lib/src/widgets/app_card.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; + +/// Standard card with consistent styling. +class AppCard extends StatelessWidget { + const AppCard({ + super.key, + required this.child, + this.onTap, + this.padding, + this.margin, + this.elevation, + }); + + final Widget child; + final VoidCallback? onTap; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + final double? elevation; + + @override + Widget build(BuildContext context) { + final card = Card( + elevation: elevation ?? 0, + margin: margin ?? EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: AppRadius.allSm, + side: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: Padding( + padding: padding ?? const EdgeInsets.all(Spacing.md), + child: child, + ), + ); + + if (onTap != null) { + return InkWell( + onTap: onTap, + borderRadius: AppRadius.allSm, + child: card, + ); + } + + return card; + } +} diff --git a/lib/src/widgets/app_checkbox.dart b/lib/src/widgets/app_checkbox.dart new file mode 100644 index 0000000..a5e66ce --- /dev/null +++ b/lib/src/widgets/app_checkbox.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; + +/// Adaptive checkbox with optional tappable label. +/// +/// Uses [Checkbox.adaptive] for platform-appropriate rendering. +/// Supports tristate for "select all" patterns. +class AppCheckbox extends StatelessWidget { + const AppCheckbox({ + super.key, + required this.value, + required this.onChanged, + this.label, + this.tristate = false, + this.enabled = true, + }); + + final bool? value; + final ValueChanged? onChanged; + final String? label; + final bool tristate; + final bool enabled; + + @override + Widget build(BuildContext context) { + final checkbox = Checkbox.adaptive( + value: value, + onChanged: enabled ? onChanged : null, + tristate: tristate, + ); + + if (label == null) return checkbox; + + final theme = Theme.of(context); + + return GestureDetector( + onTap: enabled + ? () { + if (tristate) { + onChanged?.call(value == null + ? true + : value == true + ? false + : null); + } else { + onChanged?.call(!(value ?? false)); + } + } + : null, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Spacing.xs), + child: Row( + children: [ + checkbox, + const SizedBox(width: Spacing.sm), + Expanded( + child: Text( + label!, + style: theme.textTheme.bodyLarge?.copyWith( + color: enabled + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withValues(alpha: 0.38), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/widgets/app_chip.dart b/lib/src/widgets/app_chip.dart new file mode 100644 index 0000000..891c358 --- /dev/null +++ b/lib/src/widgets/app_chip.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +/// A styled chip that can be selectable and/or deletable. +class AppChip extends StatelessWidget { + const AppChip({ + super.key, + required this.label, + this.isSelected = false, + this.onSelected, + this.onDeleted, + this.avatar, + }); + + final String label; + final bool isSelected; + final ValueChanged? onSelected; + final VoidCallback? onDeleted; + final Widget? avatar; + + @override + Widget build(BuildContext context) { + if (onSelected != null) { + return FilterChip( + label: Text(label), + selected: isSelected, + onSelected: onSelected, + avatar: avatar, + onDeleted: onDeleted, + ); + } + + return Chip( + label: Text(label), + avatar: avatar, + onDeleted: onDeleted, + ); + } +} diff --git a/lib/src/widgets/app_date_picker.dart b/lib/src/widgets/app_date_picker.dart new file mode 100644 index 0000000..2094efd --- /dev/null +++ b/lib/src/widgets/app_date_picker.dart @@ -0,0 +1,152 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; +import '../extensions/context_extensions.dart'; + +/// Adaptive date picker field. +/// +/// Tapping opens [CupertinoDatePicker] in a bottom sheet on iOS +/// and [showDatePicker] on Android. Integrates with [Form] for validation. +class AppDatePicker extends StatelessWidget { + const AppDatePicker({ + super.key, + required this.selectedDate, + required this.onDateSelected, + this.firstDate, + this.lastDate, + this.label, + this.hint, + this.errorText, + this.validator, + this.dateFormat, + this.enabled = true, + }); + + final DateTime? selectedDate; + final ValueChanged onDateSelected; + final DateTime? firstDate; + final DateTime? lastDate; + final String? label; + final String? hint; + final String? errorText; + final String? Function(DateTime?)? validator; + + /// Custom formatter for display. Defaults to dd/MM/yyyy. + final String Function(DateTime)? dateFormat; + final bool enabled; + + String _formatDate(DateTime date) { + if (dateFormat != null) return dateFormat!(date); + final d = date.day.toString().padLeft(2, '0'); + final m = date.month.toString().padLeft(2, '0'); + final y = date.year.toString(); + return '$d/$m/$y'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return FormField( + initialValue: selectedDate, + validator: validator, + builder: (field) { + final effectiveError = field.errorText ?? errorText; + + return GestureDetector( + onTap: enabled + ? () => _showPicker(context, field) + : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (label != null) + Padding( + padding: const EdgeInsets.only(bottom: Spacing.xs), + child: Text(label!, style: theme.textTheme.labelLarge), + ), + InputDecorator( + decoration: InputDecoration( + hintText: hint ?? 'Select date', + errorText: effectiveError, + suffixIcon: const Icon(Icons.calendar_today, size: 20), + enabled: enabled, + ), + child: selectedDate != null + ? Text( + _formatDate(selectedDate!), + style: theme.textTheme.bodyLarge, + ) + : null, + ), + ], + ), + ); + }, + ); + } + + Future _showPicker( + BuildContext context, FormFieldState field) async { + final now = DateTime.now(); + final first = firstDate ?? DateTime(1900); + final last = lastDate ?? DateTime(2100); + final initial = selectedDate ?? now; + + if (context.isApplePlatform) { + var picked = initial; + await showCupertinoModalPopup( + context: context, + builder: (_) => Container( + height: 260, + color: CupertinoColors.systemBackground.resolveFrom(context), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CupertinoButton( + child: const Text('Cancel'), + onPressed: () => Navigator.pop(context), + ), + CupertinoButton( + child: const Text('Done'), + onPressed: () { + field.didChange(picked); + onDateSelected(picked); + Navigator.pop(context); + }, + ), + ], + ), + Expanded( + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + initialDateTime: initial, + minimumDate: first, + maximumDate: last, + onDateTimeChanged: (date) => picked = date, + ), + ), + ], + ), + ), + ); + return; + } + + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: first, + lastDate: last, + ); + + if (picked != null) { + field.didChange(picked); + onDateSelected(picked); + } + } +} diff --git a/lib/src/widgets/app_dialog.dart b/lib/src/widgets/app_dialog.dart new file mode 100644 index 0000000..1a4d393 --- /dev/null +++ b/lib/src/widgets/app_dialog.dart @@ -0,0 +1,90 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; +import '../extensions/context_extensions.dart'; + +/// Standard app dialog with title, content, and actions. +/// +/// Uses [AlertDialog.adaptive] which renders [CupertinoAlertDialog] +/// on Apple platforms and [AlertDialog] on others. +class AppDialog extends StatelessWidget { + const AppDialog({ + super.key, + required this.title, + this.content, + this.confirmLabel = 'OK', + this.cancelLabel = 'Cancel', + this.onConfirm, + this.onCancel, + this.customContent, + }); + + final String title; + final String? content; + final String confirmLabel; + final String cancelLabel; + final VoidCallback? onConfirm; + final VoidCallback? onCancel; + final Widget? customContent; + + /// Shows this dialog using [showAdaptiveDialog]. + static Future show( + BuildContext context, { + required String title, + String? content, + String confirmLabel = 'OK', + String cancelLabel = 'Cancel', + Widget? customContent, + }) { + return showAdaptiveDialog( + context: context, + builder: (_) => AppDialog( + title: title, + content: content, + confirmLabel: confirmLabel, + cancelLabel: cancelLabel, + customContent: customContent, + onConfirm: () => Navigator.of(context).pop(true), + onCancel: () => Navigator.of(context).pop(false), + ), + ); + } + + @override + Widget build(BuildContext context) { + final isApple = context.isApplePlatform; + + return AlertDialog.adaptive( + title: Text(title), + content: customContent ?? (content != null ? Text(content!) : null), + actions: isApple + ? [ + CupertinoDialogAction( + onPressed: + onCancel ?? () => Navigator.of(context).pop(false), + child: Text(cancelLabel), + ), + CupertinoDialogAction( + isDefaultAction: true, + onPressed: + onConfirm ?? () => Navigator.of(context).pop(true), + child: Text(confirmLabel), + ), + ] + : [ + TextButton( + onPressed: + onCancel ?? () => Navigator.of(context).pop(false), + child: Text(cancelLabel), + ), + FilledButton( + onPressed: + onConfirm ?? () => Navigator.of(context).pop(true), + child: Text(confirmLabel), + ), + ], + actionsPadding: const EdgeInsets.all(Spacing.md), + ); + } +} diff --git a/lib/src/widgets/app_divider.dart b/lib/src/widgets/app_divider.dart new file mode 100644 index 0000000..e7dad01 --- /dev/null +++ b/lib/src/widgets/app_divider.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; + +/// Design-system-consistent divider with optional center label. +/// +/// Supports horizontal and vertical orientations. +/// When [label] is provided, renders a line–text–line divider. +class AppDivider extends StatelessWidget { + const AppDivider({ + super.key, + this.height, + this.vertical = false, + this.margin, + this.color, + this.thickness = 1, + this.label, + }); + + /// Total height (horizontal) or width (vertical) including margin. + final double? height; + final bool vertical; + final EdgeInsetsGeometry? margin; + final Color? color; + final double thickness; + final String? label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final dividerColor = color ?? theme.colorScheme.outlineVariant; + + if (vertical) { + return Container( + margin: margin, + width: thickness, + height: height ?? 24, + color: dividerColor, + ); + } + + if (label != null) { + return Padding( + padding: margin ?? const EdgeInsets.symmetric(vertical: Spacing.md), + child: Row( + children: [ + Expanded(child: Divider(color: dividerColor, thickness: thickness)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.md), + child: Text( + label!, + style: theme.textTheme.bodySmall, + ), + ), + Expanded(child: Divider(color: dividerColor, thickness: thickness)), + ], + ), + ); + } + + return Padding( + padding: margin ?? EdgeInsets.zero, + child: Divider( + height: height ?? 1, + color: dividerColor, + thickness: thickness, + ), + ); + } +} diff --git a/lib/src/widgets/app_dropdown.dart b/lib/src/widgets/app_dropdown.dart new file mode 100644 index 0000000..2522922 --- /dev/null +++ b/lib/src/widgets/app_dropdown.dart @@ -0,0 +1,190 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; +import '../extensions/context_extensions.dart'; + +/// A dropdown item with a value and display label. +class AppDropdownItem { + const AppDropdownItem({required this.value, required this.label}); + + final T value; + final String label; +} + +/// Adaptive dropdown selector. +/// +/// On Android, renders [DropdownButtonFormField]. +/// On iOS, opens a [CupertinoPicker] in a bottom sheet. +/// Supports form validation and integrates with [Form]. +class AppDropdown extends StatelessWidget { + const AppDropdown({ + super.key, + required this.value, + required this.items, + required this.onChanged, + this.label, + this.hint, + this.errorText, + this.validator, + this.enabled = true, + }); + + final T? value; + final List> items; + final ValueChanged? onChanged; + final String? label; + final String? hint; + final String? errorText; + final String? Function(T?)? validator; + final bool enabled; + + @override + Widget build(BuildContext context) { + if (context.isApplePlatform) { + return _buildCupertino(context); + } + return _buildMaterial(context); + } + + Widget _buildMaterial(BuildContext context) { + return DropdownButtonFormField( + value: value, + decoration: InputDecoration( + labelText: label, + hintText: hint, + errorText: errorText, + ), + items: items + .map((item) => DropdownMenuItem( + value: item.value, + child: Text(item.label), + )) + .toList(), + onChanged: enabled ? onChanged : null, + validator: validator, + ); + } + + Widget _buildCupertino(BuildContext context) { + final theme = Theme.of(context); + final selectedLabel = + items.where((i) => i.value == value).map((i) => i.label).firstOrNull; + + return FormField( + initialValue: value, + validator: validator, + builder: (field) { + final effectiveError = field.errorText ?? errorText; + + return GestureDetector( + onTap: enabled + ? () => _showCupertinoPicker(context, field) + : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (label != null) + Padding( + padding: const EdgeInsets.only(bottom: Spacing.xs), + child: Text(label!, style: theme.textTheme.labelLarge), + ), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.md, + ), + decoration: BoxDecoration( + border: Border.all( + color: effectiveError != null + ? theme.colorScheme.error + : theme.colorScheme.outline, + ), + borderRadius: AppRadius.allSm, + ), + child: Row( + children: [ + Expanded( + child: Text( + selectedLabel ?? hint ?? '', + style: selectedLabel != null + ? theme.textTheme.bodyLarge + : theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Icon( + CupertinoIcons.chevron_down, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + ), + if (effectiveError != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.xs), + child: Text( + effectiveError, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ); + } + + void _showCupertinoPicker(BuildContext context, FormFieldState field) { + final initialIndex = + items.indexWhere((i) => i.value == value).clamp(0, items.length - 1); + var selectedIndex = initialIndex; + + showCupertinoModalPopup( + context: context, + builder: (_) => Container( + height: 260, + color: CupertinoColors.systemBackground.resolveFrom(context), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CupertinoButton( + child: const Text('Cancel'), + onPressed: () => Navigator.pop(context), + ), + CupertinoButton( + child: const Text('Done'), + onPressed: () { + final selected = items[selectedIndex].value; + field.didChange(selected); + onChanged?.call(selected); + Navigator.pop(context); + }, + ), + ], + ), + Expanded( + child: CupertinoPicker( + itemExtent: 36, + scrollController: + FixedExtentScrollController(initialItem: initialIndex), + onSelectedItemChanged: (index) => selectedIndex = index, + children: items + .map((item) => Center(child: Text(item.label))) + .toList(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/widgets/app_expansion_tile.dart b/lib/src/widgets/app_expansion_tile.dart new file mode 100644 index 0000000..5a48590 --- /dev/null +++ b/lib/src/widgets/app_expansion_tile.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; + +/// Collapsible tile for FAQ sections, settings groups, and expandable content. +/// +/// Wraps [ExpansionTile] with design system tokens. +class AppExpansionTile extends StatelessWidget { + const AppExpansionTile({ + super.key, + required this.title, + required this.child, + this.subtitle, + this.leading, + this.initiallyExpanded = false, + }); + + final String title; + final Widget child; + final String? subtitle; + final Widget? leading; + final bool initiallyExpanded; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ExpansionTile( + title: Text(title, style: theme.textTheme.bodyLarge), + subtitle: subtitle != null + ? Text(subtitle!, style: theme.textTheme.bodySmall) + : null, + leading: leading, + initiallyExpanded: initiallyExpanded, + shape: const Border(), + collapsedShape: const Border(), + childrenPadding: const EdgeInsets.only( + left: Spacing.md, + right: Spacing.md, + bottom: Spacing.md, + ), + children: [child], + ); + } +} diff --git a/lib/src/widgets/app_icon_button.dart b/lib/src/widgets/app_icon_button.dart new file mode 100644 index 0000000..97d41a6 --- /dev/null +++ b/lib/src/widgets/app_icon_button.dart @@ -0,0 +1,92 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../extensions/context_extensions.dart'; + +/// Visual variant for [AppIconButton]. +enum IconButtonVariant { standard, filled, outlined, tonal } + +/// Adaptive icon button with Material 3 variants. +/// +/// Uses [CupertinoButton] on Apple platforms and Material [IconButton] +/// variants on others. Tooltip is encouraged for accessibility. +class AppIconButton extends StatelessWidget { + const AppIconButton({ + super.key, + required this.icon, + required this.onPressed, + this.tooltip, + this.variant = IconButtonVariant.standard, + this.size, + }); + + final IconData icon; + final VoidCallback? onPressed; + final String? tooltip; + final IconButtonVariant variant; + final double? size; + + @override + Widget build(BuildContext context) { + if (context.isApplePlatform) { + return _buildCupertino(context); + } + return _buildMaterial(); + } + + Widget _buildMaterial() { + final iconWidget = Icon(icon, size: size); + + Widget button; + switch (variant) { + case IconButtonVariant.filled: + button = IconButton.filled( + icon: iconWidget, + onPressed: onPressed, + tooltip: tooltip, + ); + case IconButtonVariant.outlined: + button = IconButton.outlined( + icon: iconWidget, + onPressed: onPressed, + tooltip: tooltip, + ); + case IconButtonVariant.tonal: + button = IconButton.filledTonal( + icon: iconWidget, + onPressed: onPressed, + tooltip: tooltip, + ); + case IconButtonVariant.standard: + button = IconButton( + icon: iconWidget, + onPressed: onPressed, + tooltip: tooltip, + ); + } + + return button; + } + + Widget _buildCupertino(BuildContext context) { + final theme = Theme.of(context); + + Widget button = CupertinoButton( + padding: EdgeInsets.zero, + onPressed: onPressed, + child: Icon( + icon, + size: size ?? 24, + color: onPressed != null + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withValues(alpha: 0.38), + ), + ); + + if (tooltip != null) { + button = Tooltip(message: tooltip!, child: button); + } + + return button; + } +} diff --git a/lib/src/widgets/app_image.dart b/lib/src/widgets/app_image.dart new file mode 100644 index 0000000..dc396e9 --- /dev/null +++ b/lib/src/widgets/app_image.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import 'skeleton_loader.dart'; + +/// Versatile image widget with network/asset support, loading skeleton, +/// and error fallback. +/// +/// Uses [SkeletonLoader] as loading placeholder and an icon as error fallback. +class AppImage extends StatelessWidget { + const AppImage({ + super.key, + this.url, + this.assetPath, + this.width, + this.height, + this.fit = BoxFit.cover, + this.placeholder, + this.errorWidget, + this.borderRadius, + }) : assert( + url != null || assetPath != null, + 'Either url or assetPath must be provided', + ); + + final String? url; + final String? assetPath; + final double? width; + final double? height; + final BoxFit fit; + final Widget? placeholder; + final Widget? errorWidget; + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + Widget image; + + if (url != null) { + image = Image.network( + url!, + width: width, + height: height, + fit: fit, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return placeholder ?? + SkeletonLoader( + width: width, + height: height ?? 100, + borderRadius: borderRadius?.topLeft.x ?? 0, + ); + }, + errorBuilder: (context, error, stackTrace) { + return _buildError(context); + }, + ); + } else { + image = Image.asset( + assetPath!, + width: width, + height: height, + fit: fit, + errorBuilder: (context, error, stackTrace) { + return _buildError(context); + }, + ); + } + + if (borderRadius != null) { + return ClipRRect( + borderRadius: borderRadius!, + child: image, + ); + } + + return image; + } + + Widget _buildError(BuildContext context) { + final theme = Theme.of(context); + return errorWidget ?? + Container( + width: width, + height: height, + color: theme.colorScheme.surfaceContainerHighest, + child: Icon( + Icons.broken_image_outlined, + color: theme.colorScheme.onSurfaceVariant, + ), + ); + } +} diff --git a/lib/src/widgets/app_list_tile.dart b/lib/src/widgets/app_list_tile.dart new file mode 100644 index 0000000..bbdc81c --- /dev/null +++ b/lib/src/widgets/app_list_tile.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; + +/// A design-system-consistent list tile. +/// +/// Provides consistent typography and spacing with optional divider. +class AppListTile extends StatelessWidget { + const AppListTile({ + super.key, + required this.title, + this.subtitle, + this.leading, + this.trailing, + this.onTap, + this.showDivider = false, + }); + + final String title; + final String? subtitle; + final Widget? leading; + final Widget? trailing; + final VoidCallback? onTap; + final bool showDivider; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + borderRadius: AppRadius.allSm, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: Spacing.sm + Spacing.xs, + horizontal: Spacing.md, + ), + child: Row( + children: [ + if (leading != null) ...[ + leading!, + const SizedBox(width: Spacing.md), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: theme.textTheme.bodyLarge, + ), + if (subtitle != null) ...[ + const SizedBox(height: Spacing.xs), + Text( + subtitle!, + style: theme.textTheme.bodySmall, + ), + ], + ], + ), + ), + if (trailing != null) ...[ + const SizedBox(width: Spacing.md), + trailing!, + ], + ], + ), + ), + ), + if (showDivider) + Divider( + height: 1, + indent: leading != null ? Spacing.md + 40 + Spacing.md : Spacing.md, + endIndent: Spacing.md, + color: theme.colorScheme.outlineVariant, + ), + ], + ); + } +} diff --git a/lib/src/widgets/app_loading_indicator.dart b/lib/src/widgets/app_loading_indicator.dart index cc29cb1..5df75d0 100644 --- a/lib/src/widgets/app_loading_indicator.dart +++ b/lib/src/widgets/app_loading_indicator.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; /// Centered loading indicator. Use in place of content while loading. +/// +/// Uses [CircularProgressIndicator.adaptive] which renders +/// [CupertinoActivityIndicator] on Apple platforms and +/// [CircularProgressIndicator] on others. class AppLoadingIndicator extends StatelessWidget { const AppLoadingIndicator({super.key, this.size = 32}); @@ -12,7 +16,7 @@ class AppLoadingIndicator extends StatelessWidget { child: SizedBox( width: size, height: size, - child: const CircularProgressIndicator(), + child: const CircularProgressIndicator.adaptive(), ), ); } diff --git a/lib/src/widgets/app_progress_bar.dart b/lib/src/widgets/app_progress_bar.dart new file mode 100644 index 0000000..ab5ecab --- /dev/null +++ b/lib/src/widgets/app_progress_bar.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; + +/// Linear progress bar with optional label and percentage display. +/// +/// Wraps [LinearProgressIndicator] with design system tokens. +class AppProgressBar extends StatelessWidget { + const AppProgressBar({ + super.key, + required this.value, + this.label, + this.showPercentage = false, + this.color, + this.height = 8, + }); + + /// Progress value between 0.0 and 1.0. + final double value; + final String? label; + final bool showPercentage; + final Color? color; + final double height; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final hasHeader = label != null || showPercentage; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasHeader) + Padding( + padding: const EdgeInsets.only(bottom: Spacing.xs), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (label != null) + Text(label!, style: theme.textTheme.labelLarge), + if (showPercentage) + Text( + '${(value * 100).round()}%', + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ClipRRect( + borderRadius: BorderRadius.circular(height / 2), + child: LinearProgressIndicator( + value: value.clamp(0.0, 1.0), + minHeight: height, + color: color, + backgroundColor: + theme.colorScheme.onSurface.withValues(alpha: 0.08), + ), + ), + ], + ); + } +} diff --git a/lib/src/widgets/app_radio_group.dart b/lib/src/widgets/app_radio_group.dart new file mode 100644 index 0000000..0a4841b --- /dev/null +++ b/lib/src/widgets/app_radio_group.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; + +/// A radio option with value, label and optional description. +class AppRadioOption { + const AppRadioOption({ + required this.value, + required this.label, + this.description, + }); + + final T value; + final String label; + final String? description; +} + +/// Adaptive radio button group. +/// +/// Uses [Radio.adaptive] for platform-appropriate rendering. +/// Supports vertical and horizontal layouts. +class AppRadioGroup extends StatelessWidget { + const AppRadioGroup({ + super.key, + required this.value, + required this.onChanged, + required this.options, + this.label, + this.direction = Axis.vertical, + this.enabled = true, + }); + + final T? value; + final ValueChanged? onChanged; + final List> options; + final String? label; + final Axis direction; + final bool enabled; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final radioWidgets = options.map((option) { + return GestureDetector( + onTap: enabled ? () => onChanged?.call(option.value) : null, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: direction == Axis.vertical + ? const EdgeInsets.symmetric(vertical: Spacing.xs) + : const EdgeInsets.only(right: Spacing.md), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Radio.adaptive( + value: option.value, + groupValue: value, + onChanged: enabled ? onChanged : null, + ), + const SizedBox(width: Spacing.xs), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + option.label, + style: theme.textTheme.bodyLarge?.copyWith( + color: enabled + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface + .withValues(alpha: 0.38), + ), + ), + if (option.description != null) + Text( + option.description!, + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + ], + ), + ), + ); + }).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (label != null) ...[ + Text(label!, style: theme.textTheme.labelLarge), + const SizedBox(height: Spacing.sm), + ], + if (direction == Axis.vertical) + ...radioWidgets + else + Wrap(children: radioWidgets), + ], + ); + } +} diff --git a/lib/src/widgets/app_refresh_indicator.dart b/lib/src/widgets/app_refresh_indicator.dart new file mode 100644 index 0000000..52eca0d --- /dev/null +++ b/lib/src/widgets/app_refresh_indicator.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +/// Adaptive pull-to-refresh wrapper. +/// +/// Uses [RefreshIndicator.adaptive] for platform-appropriate rendering. +class AppRefreshIndicator extends StatelessWidget { + const AppRefreshIndicator({ + super.key, + required this.onRefresh, + required this.child, + this.color, + }); + + final Future Function() onRefresh; + final Widget child; + final Color? color; + + @override + Widget build(BuildContext context) { + return RefreshIndicator.adaptive( + onRefresh: onRefresh, + color: color ?? Theme.of(context).colorScheme.primary, + child: child, + ); + } +} diff --git a/lib/src/widgets/app_search_field.dart b/lib/src/widgets/app_search_field.dart new file mode 100644 index 0000000..8f2f540 --- /dev/null +++ b/lib/src/widgets/app_search_field.dart @@ -0,0 +1,111 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; +import '../extensions/context_extensions.dart'; +import '../utils/debouncer.dart'; + +/// Adaptive search field with built-in debounce and clear button. +/// +/// Uses [CupertinoSearchTextField] on iOS and a styled [TextField] on Android. +/// Integrates with the [Debouncer] utility for delayed search callbacks. +class AppSearchField extends StatefulWidget { + const AppSearchField({ + super.key, + this.controller, + this.onChanged, + this.onClear, + this.hint, + this.autofocus = false, + this.debounce = const Duration(milliseconds: 300), + }); + + final TextEditingController? controller; + final ValueChanged? onChanged; + final VoidCallback? onClear; + final String? hint; + final bool autofocus; + final Duration debounce; + + @override + State createState() => _AppSearchFieldState(); +} + +class _AppSearchFieldState extends State { + late final TextEditingController _controller; + late final Debouncer _debouncer; + bool _ownsController = false; + + @override + void initState() { + super.initState(); + if (widget.controller != null) { + _controller = widget.controller!; + } else { + _controller = TextEditingController(); + _ownsController = true; + } + _debouncer = Debouncer(delay: widget.debounce); + _controller.addListener(_onTextChanged); + } + + void _onTextChanged() { + setState(() {}); + } + + @override + void dispose() { + _controller.removeListener(_onTextChanged); + if (_ownsController) _controller.dispose(); + _debouncer.dispose(); + super.dispose(); + } + + void _handleChanged(String value) { + if (widget.debounce == Duration.zero) { + widget.onChanged?.call(value); + } else { + _debouncer.run(() => widget.onChanged?.call(value)); + } + } + + void _handleClear() { + _controller.clear(); + _debouncer.cancel(); + widget.onChanged?.call(''); + widget.onClear?.call(); + } + + @override + Widget build(BuildContext context) { + if (context.isApplePlatform) { + return CupertinoSearchTextField( + controller: _controller, + placeholder: widget.hint ?? 'Search', + autofocus: widget.autofocus, + onChanged: _handleChanged, + onSuffixTap: _handleClear, + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.sm, + ), + ); + } + + return TextField( + controller: _controller, + autofocus: widget.autofocus, + onChanged: _handleChanged, + decoration: InputDecoration( + hintText: widget.hint ?? 'Search', + prefixIcon: const Icon(Icons.search), + suffixIcon: _controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: _handleClear, + ) + : null, + ), + ); + } +} diff --git a/lib/src/widgets/app_section_header.dart b/lib/src/widgets/app_section_header.dart new file mode 100644 index 0000000..2069c36 --- /dev/null +++ b/lib/src/widgets/app_section_header.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; + +/// Section header for list groups (e.g., "Personal Info", "Notifications"). +/// +/// Displays a title with optional trailing action (e.g., "See All") and divider. +class AppSectionHeader extends StatelessWidget { + const AppSectionHeader({ + super.key, + required this.title, + this.trailing, + this.padding, + this.showDivider = false, + }); + + final String title; + final Widget? trailing; + final EdgeInsetsGeometry? padding; + final bool showDivider; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showDivider) + Divider( + height: 1, + color: theme.colorScheme.outlineVariant, + ), + Padding( + padding: padding ?? + const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title.toUpperCase(), + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + if (trailing != null) trailing!, + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/widgets/app_segmented_control.dart b/lib/src/widgets/app_segmented_control.dart new file mode 100644 index 0000000..90f2ddd --- /dev/null +++ b/lib/src/widgets/app_segmented_control.dart @@ -0,0 +1,81 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; +import '../extensions/context_extensions.dart'; + +/// A segment option with value and label. +class AppSegment { + const AppSegment({required this.value, required this.label, this.icon}); + + final T value; + final String label; + final IconData? icon; +} + +/// Adaptive segmented control for tab-like selection (2-5 options). +/// +/// Uses [CupertinoSlidingSegmentedControl] on iOS and [SegmentedButton] on Android. +class AppSegmentedControl extends StatelessWidget { + const AppSegmentedControl({ + super.key, + required this.value, + required this.onChanged, + required this.segments, + this.enabled = true, + }); + + final T value; + final ValueChanged onChanged; + final List> segments; + final bool enabled; + + @override + Widget build(BuildContext context) { + if (context.isApplePlatform) { + return _buildCupertino(); + } + return _buildMaterial(); + } + + Widget _buildMaterial() { + return SegmentedButton( + segments: segments + .map((s) => ButtonSegment( + value: s.value, + label: Text(s.label), + icon: s.icon != null ? Icon(s.icon) : null, + )) + .toList(), + selected: {value}, + onSelectionChanged: enabled + ? (Set selected) => onChanged(selected.first) + : null, + ); + } + + Widget _buildCupertino() { + return CupertinoSlidingSegmentedControl( + groupValue: value, + onValueChanged: (val) { + if (enabled && val != null) onChanged(val); + }, + children: { + for (final s in segments) + s.value: Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), + child: s.icon != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(s.icon, size: 16), + const SizedBox(width: Spacing.xs), + Text(s.label), + ], + ) + : Text(s.label), + ), + }, + ); + } +} diff --git a/lib/src/widgets/app_slider.dart b/lib/src/widgets/app_slider.dart new file mode 100644 index 0000000..87f927d --- /dev/null +++ b/lib/src/widgets/app_slider.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; + +/// Adaptive slider with optional value display. +/// +/// Uses [Slider.adaptive] for platform-appropriate rendering. +/// Supports custom value formatting (e.g., currency, percentage). +class AppSlider extends StatelessWidget { + const AppSlider({ + super.key, + required this.value, + required this.onChanged, + this.min = 0.0, + this.max = 1.0, + this.divisions, + this.label, + this.showValue = false, + this.valueFormatter, + this.enabled = true, + }); + + final double value; + final ValueChanged? onChanged; + final double min; + final double max; + final int? divisions; + final String? label; + final bool showValue; + + /// Custom formatter for value display. Receives current value. + final String Function(double)? valueFormatter; + final bool enabled; + + String _formatValue(double val) { + if (valueFormatter != null) return valueFormatter!(val); + return val.toStringAsFixed(divisions != null ? 0 : 1); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (label != null || showValue) + Padding( + padding: const EdgeInsets.only(bottom: Spacing.xs), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (label != null) + Text(label!, style: theme.textTheme.labelLarge), + if (showValue) + Text( + _formatValue(value), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + Slider.adaptive( + value: value, + onChanged: enabled ? onChanged : null, + min: min, + max: max, + divisions: divisions, + label: showValue ? _formatValue(value) : null, + ), + ], + ); + } +} diff --git a/lib/src/widgets/app_snack_bar.dart b/lib/src/widgets/app_snack_bar.dart new file mode 100644 index 0000000..126225e --- /dev/null +++ b/lib/src/widgets/app_snack_bar.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; + +/// Snack bar variant type. +enum SnackBarVariant { success, error, warning, info } + +/// Styled snack bar with success/error/warning/info variants. +abstract final class AppSnackBar { + AppSnackBar._(); + + static void show( + BuildContext context, { + required String message, + SnackBarVariant variant = SnackBarVariant.info, + Duration duration = const Duration(seconds: 4), + SnackBarAction? action, + }) { + final theme = Theme.of(context); + final (Color bg, Color fg, IconData icon) = switch (variant) { + SnackBarVariant.success => ( + const Color(0xFF16A34A), + Colors.white, + Icons.check_circle_outline, + ), + SnackBarVariant.error => ( + theme.colorScheme.error, + theme.colorScheme.onError, + Icons.error_outline, + ), + SnackBarVariant.warning => ( + const Color(0xFFF59E0B), + Colors.black87, + Icons.warning_amber_outlined, + ), + SnackBarVariant.info => ( + theme.colorScheme.primary, + theme.colorScheme.onPrimary, + Icons.info_outline, + ), + }; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: bg, + duration: duration, + action: action, + content: Row( + children: [ + Icon(icon, color: fg, size: 20), + const SizedBox(width: Spacing.sm), + Expanded( + child: Text(message, style: TextStyle(color: fg)), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/widgets/app_switch.dart b/lib/src/widgets/app_switch.dart new file mode 100644 index 0000000..e6e7bfb --- /dev/null +++ b/lib/src/widgets/app_switch.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; + +/// Adaptive switch with optional label and subtitle. +/// +/// Uses [Switch.adaptive] to render platform-appropriate switch. +/// The entire row is tappable when a label is provided. +class AppSwitch extends StatelessWidget { + const AppSwitch({ + super.key, + required this.value, + required this.onChanged, + this.label, + this.subtitle, + this.enabled = true, + }); + + final bool value; + final ValueChanged? onChanged; + final String? label; + final String? subtitle; + final bool enabled; + + @override + Widget build(BuildContext context) { + final switchWidget = Switch.adaptive( + value: value, + onChanged: enabled ? onChanged : null, + ); + + if (label == null) return switchWidget; + + final theme = Theme.of(context); + + return GestureDetector( + onTap: enabled ? () => onChanged?.call(!value) : null, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Spacing.sm), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label!, + style: theme.textTheme.bodyLarge?.copyWith( + color: enabled + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withValues(alpha: 0.38), + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: Spacing.xs), + Text( + subtitle!, + style: theme.textTheme.bodySmall, + ), + ], + ], + ), + ), + const SizedBox(width: Spacing.md), + switchWidget, + ], + ), + ), + ); + } +} diff --git a/lib/src/widgets/app_tag.dart b/lib/src/widgets/app_tag.dart new file mode 100644 index 0000000..118f8da --- /dev/null +++ b/lib/src/widgets/app_tag.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; + +/// Semantic variant for [AppTag]. +enum TagVariant { success, error, warning, info, neutral } + +/// Non-interactive status label with semantic color variants. +/// +/// Unlike [AppChip], this is a read-only label for status indication +/// (e.g., "New", "Sale", "Premium", "Draft"). +class AppTag extends StatelessWidget { + const AppTag({ + super.key, + required this.label, + this.variant = TagVariant.neutral, + this.small = false, + this.icon, + }); + + final String label; + final TagVariant variant; + final bool small; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = _resolveColors(theme); + + return Container( + padding: EdgeInsets.symmetric( + horizontal: small ? Spacing.xs + 2 : Spacing.sm, + vertical: small ? 2 : Spacing.xs, + ), + decoration: BoxDecoration( + color: colors.$1, + borderRadius: small ? AppRadius.allXs : AppRadius.allSm, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: small ? 10 : 14, color: colors.$2), + SizedBox(width: small ? 2 : Spacing.xs), + ], + Text( + label, + style: TextStyle( + color: colors.$2, + fontSize: small ? 10 : 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + (Color background, Color foreground) _resolveColors(ThemeData theme) { + switch (variant) { + case TagVariant.success: + return (const Color(0xFF16A34A).withValues(alpha: 0.12), const Color(0xFF16A34A)); + case TagVariant.error: + return (theme.colorScheme.error.withValues(alpha: 0.12), theme.colorScheme.error); + case TagVariant.warning: + return (const Color(0xFFF59E0B).withValues(alpha: 0.12), const Color(0xFFB45309)); + case TagVariant.info: + return (theme.colorScheme.primary.withValues(alpha: 0.12), theme.colorScheme.primary); + case TagVariant.neutral: + return ( + theme.colorScheme.onSurface.withValues(alpha: 0.08), + theme.colorScheme.onSurfaceVariant, + ); + } + } +} diff --git a/lib/src/widgets/app_text_field.dart b/lib/src/widgets/app_text_field.dart index b3d529a..7d15e0c 100644 --- a/lib/src/widgets/app_text_field.dart +++ b/lib/src/widgets/app_text_field.dart @@ -1,8 +1,14 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import '../design_system/design_system.dart'; +import '../extensions/context_extensions.dart'; /// Styled text field with optional error text and validator hook. +/// +/// Renders [CupertinoTextField] on Apple platforms and +/// [TextFormField] on others. Both variants integrate with [Form] +/// for validation. class AppTextField extends StatelessWidget { const AppTextField({ super.key, @@ -35,6 +41,13 @@ class AppTextField extends StatelessWidget { @override Widget build(BuildContext context) { + if (context.isApplePlatform) { + return _buildCupertino(context); + } + return _buildMaterial(); + } + + Widget _buildMaterial() { return TextFormField( controller: controller, decoration: InputDecoration( @@ -52,4 +65,67 @@ class AppTextField extends StatelessWidget { maxLines: maxLines, ); } + + Widget _buildCupertino(BuildContext context) { + return FormField( + initialValue: controller?.text ?? '', + validator: validator, + builder: (FormFieldState field) { + final effectiveError = field.errorText ?? errorText; + + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (label != null) + Padding( + padding: const EdgeInsets.only(bottom: Spacing.xs), + child: Text( + label!, + style: theme.textTheme.labelLarge, + ), + ), + CupertinoTextField( + controller: controller, + placeholder: hint, + obscureText: obscureText, + keyboardType: keyboardType, + textInputAction: textInputAction, + onChanged: (value) { + field.didChange(value); + onChanged?.call(value); + }, + onSubmitted: onSubmitted, + enabled: enabled, + maxLines: maxLines, + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.md, + ), + decoration: BoxDecoration( + border: Border.all( + color: effectiveError != null + ? theme.colorScheme.error + : theme.colorScheme.outline, + ), + borderRadius: AppRadius.allSm, + ), + ), + if (effectiveError != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.xs), + child: Text( + effectiveError, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + ], + ); + }, + ); + } } diff --git a/lib/src/widgets/app_tooltip.dart b/lib/src/widgets/app_tooltip.dart new file mode 100644 index 0000000..42ca039 --- /dev/null +++ b/lib/src/widgets/app_tooltip.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; + +/// Design-system-consistent tooltip wrapper. +/// +/// Provides consistent styling for help text and info hints. +class AppTooltip extends StatelessWidget { + const AppTooltip({ + super.key, + required this.message, + required this.child, + this.preferBelow = true, + }); + + final String message; + final Widget child; + final bool preferBelow; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Tooltip( + message: message, + preferBelow: preferBelow, + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm + Spacing.xs, + vertical: Spacing.sm, + ), + decoration: BoxDecoration( + color: theme.colorScheme.inverseSurface, + borderRadius: AppRadius.allSm, + ), + textStyle: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onInverseSurface, + ), + child: child, + ); + } +} diff --git a/lib/src/widgets/primary_button.dart b/lib/src/widgets/primary_button.dart index 65e0c06..b6469f1 100644 --- a/lib/src/widgets/primary_button.dart +++ b/lib/src/widgets/primary_button.dart @@ -1,8 +1,13 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import '../design_system/design_system.dart'; +import '../extensions/context_extensions.dart'; /// Primary action button with optional loading state. +/// +/// Renders [CupertinoButton.filled] on Apple platforms and +/// [ElevatedButton] on others. class PrimaryButton extends StatelessWidget { const PrimaryButton({ super.key, @@ -19,26 +24,44 @@ class PrimaryButton extends StatelessWidget { @override Widget build(BuildContext context) { + final isApple = context.isApplePlatform; + + final child = isLoading + ? SizedBox( + height: 24, + width: 24, + child: isApple + ? const CupertinoActivityIndicator( + color: CupertinoColors.white, + ) + : const CircularProgressIndicator(strokeWidth: 2), + ) + : icon != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + icon!, + const SizedBox(width: Spacing.sm), + Text(label), + ], + ) + : Text(label); + + if (isApple) { + return SizedBox( + width: double.infinity, + child: CupertinoButton.filled( + onPressed: isLoading ? null : onPressed, + child: child, + ), + ); + } + return SizedBox( height: 48, child: ElevatedButton( onPressed: isLoading ? null : onPressed, - child: isLoading - ? const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : icon != null - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - icon!, - const SizedBox(width: Spacing.sm), - Text(label), - ], - ) - : Text(label), + child: child, ), ); } diff --git a/lib/src/widgets/skeleton_loader.dart b/lib/src/widgets/skeleton_loader.dart new file mode 100644 index 0000000..fb66d6e --- /dev/null +++ b/lib/src/widgets/skeleton_loader.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +import '../design_system/design_system.dart'; + +/// Shimmer-like skeleton loading placeholder. +class SkeletonLoader extends StatefulWidget { + const SkeletonLoader({ + super.key, + this.width, + this.height = 16, + this.borderRadius = AppRadius.xs, + }); + + final double? width; + final double height; + final double borderRadius; + + @override + State createState() => _SkeletonLoaderState(); +} + +class _SkeletonLoaderState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(reverse: true); + _animation = Tween(begin: 0.3, end: 0.7).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AnimatedBuilder( + animation: _animation, + builder: (context, _) { + return Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + color: + theme.colorScheme.onSurface.withValues(alpha: _animation.value), + borderRadius: BorderRadius.circular(widget.borderRadius), + ), + ); + }, + ); + } +} diff --git a/lib/src/widgets/widgets.dart b/lib/src/widgets/widgets.dart index 75a0606..74eadb2 100644 --- a/lib/src/widgets/widgets.dart +++ b/lib/src/widgets/widgets.dart @@ -1,6 +1,39 @@ -// Placeholder - will implement in step 6 export 'primary_button.dart'; export 'app_text_field.dart'; export 'app_loading_indicator.dart'; export 'empty_state.dart'; export 'error_state.dart'; +export 'app_dialog.dart'; +export 'app_bottom_sheet.dart'; +export 'app_snack_bar.dart'; +export 'app_chip.dart'; +export 'app_avatar.dart'; +export 'app_card.dart'; +export 'skeleton_loader.dart'; +export 'animation_helpers.dart'; + +// Tier 1 +export 'app_switch.dart'; +export 'app_checkbox.dart'; +export 'app_dropdown.dart'; +export 'app_search_field.dart'; +export 'app_list_tile.dart'; +export 'app_icon_button.dart'; + +// Tier 2 +export 'app_date_picker.dart'; +export 'app_radio_group.dart'; +export 'app_slider.dart'; +export 'app_badge.dart'; +export 'app_progress_bar.dart'; +export 'app_image.dart'; +export 'app_tag.dart'; +export 'app_divider.dart'; + +// Tier 3 +export 'app_expansion_tile.dart'; +export 'app_section_header.dart'; +export 'app_segmented_control.dart'; +export 'app_tooltip.dart'; +export 'app_refresh_indicator.dart'; +export 'app_banner.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 391fea2..0f22b8b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,15 +4,20 @@ publish_to: 'none' version: 0.1.0 environment: - sdk: ^3.10.7 + #sdk: ^3.6.0 + sdk: ^3.10.0 dependencies: flutter: sdk: flutter dio: ^5.7.0 intl: ^0.19.0 + matcher: ^0.12.0 dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^6.0.0 + flutter_lints: ^5.0.0 + +flutter: + uses-material-design: true From deb85f9fd4bdf8861a19c18d2110631015084a87 Mon Sep 17 00:00:00 2001 From: hakanglgmobven Date: Fri, 13 Feb 2026 14:43:51 +0300 Subject: [PATCH 2/2] Add AppRadius class for border radius constants and update design_system exports --- lib/src/design_system/app_radius.dart | 20 ++++++++++++++++++++ lib/src/design_system/design_system.dart | 1 + 2 files changed, 21 insertions(+) create mode 100644 lib/src/design_system/app_radius.dart diff --git a/lib/src/design_system/app_radius.dart b/lib/src/design_system/app_radius.dart new file mode 100644 index 0000000..3c7f66a --- /dev/null +++ b/lib/src/design_system/app_radius.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +/// Border radius scale constants for the design system. +abstract final class AppRadius { + AppRadius._(); + + // Raw double values + static const double xs = 4; + static const double sm = 8; + static const double md = 16; + static const double lg = 24; + static const double full = 9999; + + // Pre-built BorderRadius (all corners, const) + static const BorderRadius allXs = BorderRadius.all(Radius.circular(xs)); + static const BorderRadius allSm = BorderRadius.all(Radius.circular(sm)); + static const BorderRadius allMd = BorderRadius.all(Radius.circular(md)); + static const BorderRadius allLg = BorderRadius.all(Radius.circular(lg)); + static const BorderRadius allFull = BorderRadius.all(Radius.circular(full)); +} diff --git a/lib/src/design_system/design_system.dart b/lib/src/design_system/design_system.dart index 921c376..2cb7d61 100644 --- a/lib/src/design_system/design_system.dart +++ b/lib/src/design_system/design_system.dart @@ -1,5 +1,6 @@ // Placeholder - will implement in step 4 export 'spacing.dart'; +export 'app_radius.dart'; export 'app_colors.dart'; export 'app_typography.dart'; export 'app_theme.dart';