second commit
This commit is contained in:
72
lib/core/widgets/empty_state.dart
Normal file
72
lib/core/widgets/empty_state.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
|
||||
class EmptyState extends StatelessWidget {
|
||||
const EmptyState({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.message,
|
||||
this.emoji = '🛒',
|
||||
this.action,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? message;
|
||||
final String emoji;
|
||||
final Widget? action;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: compact ? 64 : 96,
|
||||
height: compact ? 64 : 96,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(emoji,
|
||||
style: TextStyle(fontSize: compact ? 28 : 40)),
|
||||
),
|
||||
SizedBox(height: compact ? AppSpacing.lg : AppSpacing.xxl),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 15 : 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
message!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
action!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
lib/core/widgets/glass_card.dart
Normal file
85
lib/core/widgets/glass_card.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
|
||||
/// Glassmorphism-inspired surface used for elevated panels and product cards.
|
||||
class GlassCard extends StatelessWidget {
|
||||
const GlassCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = AppSpacing.card,
|
||||
this.radius = AppRadius.lg,
|
||||
this.blur = 0,
|
||||
this.tinted = false,
|
||||
this.borderColor,
|
||||
this.shadows,
|
||||
this.onTap,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final double radius;
|
||||
|
||||
/// Backdrop blur strength. Zero renders an opaque card, which is cheaper and
|
||||
/// is the right default for the dense product grid.
|
||||
final double blur;
|
||||
|
||||
final bool tinted;
|
||||
final Color? borderColor;
|
||||
final List<BoxShadow>? shadows;
|
||||
final VoidCallback? onTap;
|
||||
final double? width;
|
||||
final double? height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final borderRadius = BorderRadius.circular(radius);
|
||||
|
||||
Widget surface = AnimatedContainer(
|
||||
duration: AppMotion.fast,
|
||||
width: width,
|
||||
height: height,
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: tinted
|
||||
? AppColors.primarySurface.withValues(alpha: blur > 0 ? 0.75 : 1)
|
||||
: AppColors.surface.withValues(alpha: blur > 0 ? 0.78 : 1),
|
||||
borderRadius: borderRadius,
|
||||
border: Border.all(
|
||||
color: borderColor ??
|
||||
(tinted ? AppColors.primaryBorder : AppColors.border),
|
||||
),
|
||||
boxShadow: shadows ?? AppColors.shadowSm,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
if (blur > 0) {
|
||||
surface = ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||
child: surface,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (onTap == null) return surface;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: borderRadius,
|
||||
splashColor: AppColors.primary.withValues(alpha: 0.08),
|
||||
highlightColor: AppColors.primary.withValues(alpha: 0.04),
|
||||
child: surface,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
146
lib/core/widgets/numeric_keypad.dart
Normal file
146
lib/core/widgets/numeric_keypad.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
|
||||
/// Large on-screen keypad for mobile numbers and cash amounts.
|
||||
class NumericKeypad extends StatelessWidget {
|
||||
const NumericKeypad({
|
||||
super.key,
|
||||
required this.onKey,
|
||||
required this.onBackspace,
|
||||
this.onClear,
|
||||
this.onSubmit,
|
||||
this.submitLabel,
|
||||
this.allowDecimal = false,
|
||||
this.maxWidth = 360,
|
||||
});
|
||||
|
||||
final ValueChanged<String> onKey;
|
||||
final VoidCallback onBackspace;
|
||||
final VoidCallback? onClear;
|
||||
final VoidCallback? onSubmit;
|
||||
final String? submitLabel;
|
||||
final bool allowDecimal;
|
||||
final double maxWidth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final row in const [
|
||||
['1', '2', '3'],
|
||||
['4', '5', '6'],
|
||||
['7', '8', '9'],
|
||||
])
|
||||
_row(row.map((d) => _digit(d)).toList()),
|
||||
_row([
|
||||
allowDecimal
|
||||
? _digit('.')
|
||||
: _action(
|
||||
icon: Icons.clear_all_rounded,
|
||||
onTap: onClear,
|
||||
tone: AppColors.textSecondary,
|
||||
),
|
||||
_digit('0'),
|
||||
_action(
|
||||
icon: Icons.backspace_outlined,
|
||||
onTap: onBackspace,
|
||||
tone: AppColors.danger,
|
||||
),
|
||||
]),
|
||||
if (onSubmit != null) ...[
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: AppSizes.buttonHeight,
|
||||
child: FilledButton.icon(
|
||||
onPressed: onSubmit,
|
||||
icon: const Icon(Icons.check_rounded),
|
||||
label: Text(submitLabel ?? 'Done'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(List<Widget> children) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < children.length; i++) ...[
|
||||
Expanded(child: children[i]),
|
||||
if (i != children.length - 1)
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _digit(String value) => _Key(
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onKey(value);
|
||||
},
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _action({
|
||||
required IconData icon,
|
||||
required VoidCallback? onTap,
|
||||
required Color tone,
|
||||
}) =>
|
||||
_Key(
|
||||
onTap: onTap == null
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onTap();
|
||||
},
|
||||
child: Icon(icon, size: 24, color: tone),
|
||||
);
|
||||
}
|
||||
|
||||
class _Key extends StatelessWidget {
|
||||
const _Key({required this.child, this.onTap});
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: AppSizes.keypadKeySize,
|
||||
child: Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: AppRadius.brMd,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: AppRadius.brMd,
|
||||
splashColor: AppColors.primary.withValues(alpha: 0.1),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: AppRadius.brMd,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
126
lib/core/widgets/primary_button.dart
Normal file
126
lib/core/widgets/primary_button.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
|
||||
enum ButtonTone { primary, neutral, success, danger, ghost }
|
||||
|
||||
/// Large, touch-first action button with built-in busy state.
|
||||
class PrimaryButton extends StatelessWidget {
|
||||
const PrimaryButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.icon,
|
||||
this.tone = ButtonTone.primary,
|
||||
this.expanded = true,
|
||||
this.large = false,
|
||||
this.busy = false,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final IconData? icon;
|
||||
final ButtonTone tone;
|
||||
final bool expanded;
|
||||
final bool large;
|
||||
final bool busy;
|
||||
|
||||
/// Optional right-aligned widget, typically the bill total.
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = onPressed != null && !busy;
|
||||
final (bg, fg, border) = _palette;
|
||||
final height =
|
||||
large ? AppSizes.buttonHeightLarge : AppSizes.buttonHeight;
|
||||
|
||||
final content = busy
|
||||
? SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.4, color: fg),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: expanded ? MainAxisSize.max : MainAxisSize.min,
|
||||
mainAxisAlignment: trailing != null
|
||||
? MainAxisAlignment.spaceBetween
|
||||
: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: large ? 24 : 20, color: fg),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: fg,
|
||||
fontSize: large ? 19 : 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
width: expanded ? double.infinity : null,
|
||||
height: height,
|
||||
child: Material(
|
||||
color: enabled ? bg : AppColors.border,
|
||||
borderRadius: AppRadius.brMd,
|
||||
child: InkWell(
|
||||
onTap: enabled ? onPressed : null,
|
||||
borderRadius: AppRadius.brMd,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: large ? AppSpacing.xxl : AppSpacing.xl,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: AppRadius.brMd,
|
||||
border: border == null
|
||||
? null
|
||||
: Border.all(color: border, width: 1.5),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: DefaultTextStyle.merge(
|
||||
style: TextStyle(color: enabled ? fg : AppColors.textTertiary),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
(Color, Color, Color?) get _palette => switch (tone) {
|
||||
ButtonTone.primary => (
|
||||
AppColors.primary,
|
||||
AppColors.textOnPrimary,
|
||||
null
|
||||
),
|
||||
ButtonTone.success => (AppColors.success, Colors.white, null),
|
||||
ButtonTone.danger => (AppColors.danger, Colors.white, null),
|
||||
ButtonTone.neutral => (
|
||||
AppColors.surface,
|
||||
AppColors.textPrimary,
|
||||
AppColors.border
|
||||
),
|
||||
ButtonTone.ghost => (
|
||||
AppColors.primarySurface,
|
||||
AppColors.primary,
|
||||
AppColors.primaryBorder
|
||||
),
|
||||
};
|
||||
}
|
||||
95
lib/core/widgets/status_pill.dart
Normal file
95
lib/core/widgets/status_pill.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
import '../../domain/entities/customer.dart';
|
||||
|
||||
/// Compact labelled badge — stock states, tiers, live indicator.
|
||||
class StatusPill extends StatelessWidget {
|
||||
const StatusPill({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.color = AppColors.primary,
|
||||
this.background,
|
||||
this.icon,
|
||||
this.dense = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color color;
|
||||
final Color? background;
|
||||
final IconData? icon;
|
||||
final bool dense;
|
||||
|
||||
factory StatusPill.tier(MembershipTier tier, {bool dense = false}) {
|
||||
final color = switch (tier) {
|
||||
MembershipTier.bronze => AppColors.tierBronze,
|
||||
MembershipTier.silver => AppColors.tierSilver,
|
||||
MembershipTier.gold => AppColors.tierGold,
|
||||
MembershipTier.platinum => AppColors.tierPlatinum,
|
||||
};
|
||||
return StatusPill(
|
||||
label: tier.label.toUpperCase(),
|
||||
color: color,
|
||||
background: color.withValues(alpha: 0.12),
|
||||
dense: dense,
|
||||
);
|
||||
}
|
||||
|
||||
factory StatusPill.stock(double stock, {required int lowThreshold}) {
|
||||
if (stock <= 0) {
|
||||
return const StatusPill(
|
||||
label: 'Out of stock',
|
||||
color: AppColors.danger,
|
||||
background: AppColors.dangerSurface,
|
||||
dense: true,
|
||||
);
|
||||
}
|
||||
if (stock <= lowThreshold) {
|
||||
return StatusPill(
|
||||
label: '${stock.toStringAsFixed(0)} left',
|
||||
color: AppColors.warning,
|
||||
background: AppColors.warningSurface,
|
||||
dense: true,
|
||||
);
|
||||
}
|
||||
return StatusPill(
|
||||
label: '${stock.toStringAsFixed(0)} in stock',
|
||||
color: AppColors.textTertiary,
|
||||
background: Colors.transparent,
|
||||
dense: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: dense ? AppSpacing.sm : AppSpacing.md,
|
||||
vertical: dense ? 3 : AppSpacing.xs + 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: background ?? color.withValues(alpha: 0.12),
|
||||
borderRadius: AppRadius.brPill,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: dense ? 11 : 13, color: color),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: dense ? 10.5 : 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user