second commit
This commit is contained in:
84
lib/core/theme/app_colors.dart
Normal file
84
lib/core/theme/app_colors.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Central colour palette for Nearle POS.
|
||||
///
|
||||
/// Everything is derived from the brand primary `#662582` so that a single
|
||||
/// change here re-skins the entire application.
|
||||
class AppColors {
|
||||
const AppColors._();
|
||||
|
||||
// ---------------------------------------------------------------- Brand
|
||||
static const Color primary = Color(0xFF662582);
|
||||
static const Color primaryDark = Color(0xFF4B1A60);
|
||||
static const Color primaryLight = Color(0xFF8B4BA6);
|
||||
static const Color primarySurface = Color(0xFFF4EDF7);
|
||||
static const Color primaryBorder = Color(0xFFE3D3EC);
|
||||
|
||||
static const MaterialColor primarySwatch = MaterialColor(0xFF662582, {
|
||||
50: Color(0xFFF4EDF7),
|
||||
100: Color(0xFFE3D3EC),
|
||||
200: Color(0xFFCDB1DC),
|
||||
300: Color(0xFFB68FCC),
|
||||
400: Color(0xFFA475C0),
|
||||
500: Color(0xFF662582),
|
||||
600: Color(0xFF5C2175),
|
||||
700: Color(0xFF4B1A60),
|
||||
800: Color(0xFF3B144B),
|
||||
900: Color(0xFF2A0E36),
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- Neutrals
|
||||
static const Color background = Color(0xFFF7F7F9);
|
||||
static const Color surface = Color(0xFFFFFFFF);
|
||||
static const Color surfaceAlt = Color(0xFFFAFAFC);
|
||||
static const Color border = Color(0xFFE8E8EE);
|
||||
static const Color divider = Color(0xFFEFEFF4);
|
||||
|
||||
static const Color textPrimary = Color(0xFF17131C);
|
||||
static const Color textSecondary = Color(0xFF6B6577);
|
||||
static const Color textTertiary = Color(0xFF9A94A6);
|
||||
static const Color textOnPrimary = Color(0xFFFFFFFF);
|
||||
|
||||
// ------------------------------------------------------------- Semantic
|
||||
static const Color success = Color(0xFF16A34A);
|
||||
static const Color successSurface = Color(0xFFEAF7EF);
|
||||
static const Color warning = Color(0xFFF59E0B);
|
||||
static const Color warningSurface = Color(0xFFFEF6E7);
|
||||
static const Color danger = Color(0xFFDC2626);
|
||||
static const Color dangerSurface = Color(0xFFFDECEC);
|
||||
static const Color info = Color(0xFF2563EB);
|
||||
static const Color infoSurface = Color(0xFFEAF0FE);
|
||||
|
||||
// ------------------------------------------------------- Membership tiers
|
||||
static const Color tierBronze = Color(0xFFB08D57);
|
||||
static const Color tierSilver = Color(0xFF8E96A3);
|
||||
static const Color tierGold = Color(0xFFD4A017);
|
||||
static const Color tierPlatinum = Color(0xFF4C5A6B);
|
||||
|
||||
// ---------------------------------------------------------------- Effects
|
||||
static const Color glassTint = Color(0x14662582);
|
||||
|
||||
static const List<BoxShadow> shadowSm = [
|
||||
BoxShadow(color: Color(0x0D17131C), blurRadius: 6, offset: Offset(0, 2)),
|
||||
];
|
||||
|
||||
static const List<BoxShadow> shadowMd = [
|
||||
BoxShadow(color: Color(0x1417131C), blurRadius: 16, offset: Offset(0, 6)),
|
||||
];
|
||||
|
||||
static const List<BoxShadow> shadowLg = [
|
||||
BoxShadow(color: Color(0x1F17131C), blurRadius: 32, offset: Offset(0, 12)),
|
||||
];
|
||||
|
||||
static const LinearGradient primaryGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF7A2E9A), Color(0xFF4B1A60)],
|
||||
);
|
||||
|
||||
static const LinearGradient glassGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0x40FFFFFF), Color(0x0DFFFFFF)],
|
||||
);
|
||||
}
|
||||
83
lib/core/theme/app_dimens.dart
Normal file
83
lib/core/theme/app_dimens.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Spacing scale — 4pt grid.
|
||||
class AppSpacing {
|
||||
const AppSpacing._();
|
||||
|
||||
static const double xxs = 2;
|
||||
static const double xs = 4;
|
||||
static const double sm = 8;
|
||||
static const double md = 12;
|
||||
static const double lg = 16;
|
||||
static const double xl = 20;
|
||||
static const double xxl = 24;
|
||||
static const double xxxl = 32;
|
||||
static const double huge = 40;
|
||||
static const double giant = 56;
|
||||
|
||||
static const EdgeInsets pageDesktop = EdgeInsets.all(xxl);
|
||||
static const EdgeInsets pageTablet = EdgeInsets.all(lg);
|
||||
static const EdgeInsets card = EdgeInsets.all(lg);
|
||||
}
|
||||
|
||||
/// Corner radii — brand standard is 16.
|
||||
class AppRadius {
|
||||
const AppRadius._();
|
||||
|
||||
static const double xs = 6;
|
||||
static const double sm = 10;
|
||||
static const double md = 12;
|
||||
static const double lg = 16;
|
||||
static const double xl = 20;
|
||||
static const double xxl = 28;
|
||||
static const double pill = 999;
|
||||
|
||||
static const BorderRadius brXs = BorderRadius.all(Radius.circular(xs));
|
||||
static const BorderRadius brSm = BorderRadius.all(Radius.circular(sm));
|
||||
static const BorderRadius brMd = BorderRadius.all(Radius.circular(md));
|
||||
static const BorderRadius brLg = BorderRadius.all(Radius.circular(lg));
|
||||
static const BorderRadius brXl = BorderRadius.all(Radius.circular(xl));
|
||||
static const BorderRadius brPill = BorderRadius.all(Radius.circular(pill));
|
||||
}
|
||||
|
||||
/// Minimum sizes tuned for finger targets on tablets.
|
||||
class AppSizes {
|
||||
const AppSizes._();
|
||||
|
||||
static const double touchTarget = 48;
|
||||
static const double buttonHeight = 52;
|
||||
static const double buttonHeightLarge = 64;
|
||||
static const double inputHeight = 56;
|
||||
static const double headerHeight = 76;
|
||||
static const double customerBarHeight = 72;
|
||||
static const double navItemHeight = 46;
|
||||
static const double productCardAspect = 0.86;
|
||||
static const double keypadKeySize = 76;
|
||||
}
|
||||
|
||||
/// Motion durations & curves.
|
||||
class AppMotion {
|
||||
const AppMotion._();
|
||||
|
||||
static const Duration instant = Duration(milliseconds: 90);
|
||||
static const Duration fast = Duration(milliseconds: 160);
|
||||
static const Duration normal = Duration(milliseconds: 240);
|
||||
static const Duration slow = Duration(milliseconds: 400);
|
||||
|
||||
static const Curve emphasized = Curves.easeOutCubic;
|
||||
static const Curve standard = Curves.easeInOut;
|
||||
static const Curve bouncy = Curves.easeOutBack;
|
||||
}
|
||||
|
||||
/// Responsive breakpoints.
|
||||
class AppBreakpoints {
|
||||
const AppBreakpoints._();
|
||||
|
||||
static const double tabletPortrait = 920;
|
||||
static const double tabletLandscape = 1300;
|
||||
static const double desktop = 1650;
|
||||
|
||||
static bool isCompact(double w) => w < tabletPortrait;
|
||||
static bool isMedium(double w) => w >= tabletPortrait && w < desktop;
|
||||
static bool isExpanded(double w) => w >= desktop;
|
||||
}
|
||||
98
lib/core/theme/app_layout.dart
Normal file
98
lib/core/theme/app_layout.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// How the left navigation should render at the current width.
|
||||
enum SidebarMode {
|
||||
/// Off-canvas; reachable through the page-header menu button.
|
||||
drawer,
|
||||
|
||||
/// Icons only, 84px.
|
||||
rail,
|
||||
|
||||
/// Icons plus labels and section headers, 252px.
|
||||
expanded,
|
||||
}
|
||||
|
||||
/// How the bill should render at the current width.
|
||||
enum BillingMode {
|
||||
/// Bottom sheet, opened from the floating cart button.
|
||||
sheet,
|
||||
|
||||
/// Docked column on the right.
|
||||
docked,
|
||||
}
|
||||
|
||||
/// Resolves every layout decision for the POS dashboard from a single width.
|
||||
///
|
||||
/// Keeping this in one place means the sidebar, product grid and billing panel
|
||||
/// can never disagree about which breakpoint they are in.
|
||||
class PosLayout {
|
||||
const PosLayout({
|
||||
required this.sidebar,
|
||||
required this.billing,
|
||||
required this.billingWidth,
|
||||
required this.gridTileExtent,
|
||||
required this.contentPadding,
|
||||
});
|
||||
|
||||
final SidebarMode sidebar;
|
||||
final BillingMode billing;
|
||||
final double billingWidth;
|
||||
|
||||
/// Maximum width of a product card; the grid fits as many as will fit.
|
||||
final double gridTileExtent;
|
||||
|
||||
final double contentPadding;
|
||||
|
||||
static const double railWidth = 84;
|
||||
static const double expandedWidth = 252;
|
||||
|
||||
/// Below this the sidebar goes off-canvas.
|
||||
static const double drawerBelow = 920;
|
||||
|
||||
/// Below this the sidebar is icons-only.
|
||||
static const double railBelow = 1300;
|
||||
|
||||
/// Below this the bill becomes a bottom sheet.
|
||||
static const double sheetBelow = 1120;
|
||||
|
||||
/// Above this we have room for a wider bill and larger cards.
|
||||
static const double wideAbove = 1650;
|
||||
|
||||
factory PosLayout.of(BuildContext context) =>
|
||||
PosLayout.forWidth(MediaQuery.sizeOf(context).width);
|
||||
|
||||
factory PosLayout.forWidth(double width) {
|
||||
final sidebar = width < drawerBelow
|
||||
? SidebarMode.drawer
|
||||
: (width < railBelow ? SidebarMode.rail : SidebarMode.expanded);
|
||||
|
||||
final billing =
|
||||
width < sheetBelow ? BillingMode.sheet : BillingMode.docked;
|
||||
|
||||
final billingWidth = width >= wideAbove ? 440.0 : 380.0;
|
||||
|
||||
// Cards stay finger-sized on tablets and grow a little on large desktops.
|
||||
final tile = width < drawerBelow
|
||||
? 168.0
|
||||
: (width >= wideAbove ? 208.0 : 186.0);
|
||||
|
||||
final padding = width < drawerBelow ? 16.0 : 24.0;
|
||||
|
||||
return PosLayout(
|
||||
sidebar: sidebar,
|
||||
billing: billing,
|
||||
billingWidth: billingWidth,
|
||||
gridTileExtent: tile,
|
||||
contentPadding: padding,
|
||||
);
|
||||
}
|
||||
|
||||
double get sidebarWidth => switch (sidebar) {
|
||||
SidebarMode.drawer => 0,
|
||||
SidebarMode.rail => railWidth,
|
||||
SidebarMode.expanded => expandedWidth,
|
||||
};
|
||||
|
||||
bool get sidebarIsDrawer => sidebar == SidebarMode.drawer;
|
||||
bool get billingIsSheet => billing == BillingMode.sheet;
|
||||
}
|
||||
162
lib/core/theme/app_theme.dart
Normal file
162
lib/core/theme/app_theme.dart
Normal file
@@ -0,0 +1,162 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'app_colors.dart';
|
||||
import 'app_dimens.dart';
|
||||
import 'app_typography.dart';
|
||||
|
||||
/// Builds the single source of truth [ThemeData] for Nearle POS.
|
||||
class AppTheme {
|
||||
const AppTheme._();
|
||||
|
||||
static ThemeData get light {
|
||||
final colorScheme = ColorScheme.fromSeed(
|
||||
seedColor: AppColors.primary,
|
||||
primary: AppColors.primary,
|
||||
onPrimary: AppColors.textOnPrimary,
|
||||
secondary: AppColors.primaryLight,
|
||||
surface: AppColors.surface,
|
||||
onSurface: AppColors.textPrimary,
|
||||
error: AppColors.danger,
|
||||
brightness: Brightness.light,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: colorScheme,
|
||||
scaffoldBackgroundColor: AppColors.background,
|
||||
textTheme: AppTypography.textTheme,
|
||||
splashFactory: InkSparkle.splashFactory,
|
||||
visualDensity: VisualDensity.standard,
|
||||
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: AppColors.textOnPrimary,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
toolbarHeight: AppSizes.headerHeight,
|
||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||
titleTextStyle: AppTypography.textTheme.titleLarge
|
||||
?.copyWith(color: AppColors.textOnPrimary),
|
||||
),
|
||||
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.surface,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.brLg,
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
),
|
||||
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: AppColors.divider,
|
||||
thickness: 1,
|
||||
space: 1,
|
||||
),
|
||||
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: AppColors.textOnPrimary,
|
||||
disabledBackgroundColor: AppColors.border,
|
||||
disabledForegroundColor: AppColors.textTertiary,
|
||||
minimumSize: const Size(0, AppSizes.buttonHeight),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xxl),
|
||||
elevation: 0,
|
||||
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brMd),
|
||||
textStyle: AppTypography.textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.primary,
|
||||
minimumSize: const Size(0, AppSizes.buttonHeight),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xxl),
|
||||
side: const BorderSide(color: AppColors.primaryBorder, width: 1.5),
|
||||
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brMd),
|
||||
textStyle: AppTypography.textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.primary,
|
||||
minimumSize: const Size(0, AppSizes.touchTarget),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
||||
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brSm),
|
||||
textStyle: AppTypography.textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.lg,
|
||||
),
|
||||
hintStyle: AppTypography.textTheme.bodyMedium
|
||||
?.copyWith(color: AppColors.textTertiary),
|
||||
labelStyle: AppTypography.textTheme.bodyMedium
|
||||
?.copyWith(color: AppColors.textSecondary),
|
||||
border: _inputBorder(AppColors.border),
|
||||
enabledBorder: _inputBorder(AppColors.border),
|
||||
focusedBorder: _inputBorder(AppColors.primary, width: 1.8),
|
||||
errorBorder: _inputBorder(AppColors.danger),
|
||||
focusedErrorBorder: _inputBorder(AppColors.danger, width: 1.8),
|
||||
),
|
||||
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: AppColors.surface,
|
||||
selectedColor: AppColors.primary,
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
labelStyle: AppTypography.textTheme.labelMedium,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brPill),
|
||||
),
|
||||
|
||||
dialogTheme: DialogThemeData(
|
||||
backgroundColor: AppColors.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
),
|
||||
titleTextStyle: AppTypography.textTheme.headlineSmall,
|
||||
),
|
||||
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
backgroundColor: AppColors.textPrimary,
|
||||
contentTextStyle: AppTypography.textTheme.bodyMedium
|
||||
?.copyWith(color: Colors.white),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brMd),
|
||||
),
|
||||
|
||||
scrollbarTheme: ScrollbarThemeData(
|
||||
thickness: WidgetStateProperty.all(6),
|
||||
radius: const Radius.circular(AppRadius.pill),
|
||||
thumbColor: WidgetStateProperty.all(AppColors.primaryBorder),
|
||||
),
|
||||
|
||||
pageTransitionsTheme: const PageTransitionsTheme(builders: {
|
||||
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
|
||||
TargetPlatform.macOS: FadeUpwardsPageTransitionsBuilder(),
|
||||
TargetPlatform.linux: FadeUpwardsPageTransitionsBuilder(),
|
||||
TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static OutlineInputBorder _inputBorder(Color color, {double width = 1}) {
|
||||
return OutlineInputBorder(
|
||||
borderRadius: AppRadius.brMd,
|
||||
borderSide: BorderSide(color: color, width: width),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
lib/core/theme/app_typography.dart
Normal file
85
lib/core/theme/app_typography.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// Poppins-based type scale.
|
||||
///
|
||||
/// Poppins is geometric and runs slightly wide, so headings get negative
|
||||
/// tracking to keep them compact in the sidebar and page header. POS screens
|
||||
/// are read at arm's length, so body text starts at 15 rather than the
|
||||
/// Material default of 14.
|
||||
class AppTypography {
|
||||
const AppTypography._();
|
||||
|
||||
static TextTheme get textTheme {
|
||||
final base = GoogleFonts.poppinsTextTheme();
|
||||
|
||||
return base.copyWith(
|
||||
displayLarge: _s(base.displayLarge, 44, FontWeight.w700, -1.2),
|
||||
displayMedium: _s(base.displayMedium, 36, FontWeight.w700, -1.0),
|
||||
displaySmall: _s(base.displaySmall, 30, FontWeight.w700, -0.8),
|
||||
|
||||
headlineLarge: _s(base.headlineLarge, 28, FontWeight.w600, -0.7),
|
||||
headlineMedium: _s(base.headlineMedium, 24, FontWeight.w600, -0.6),
|
||||
headlineSmall: _s(base.headlineSmall, 20, FontWeight.w600, -0.4),
|
||||
|
||||
titleLarge: _s(base.titleLarge, 18, FontWeight.w600, -0.3),
|
||||
titleMedium: _s(base.titleMedium, 15.5, FontWeight.w600, -0.2),
|
||||
titleSmall: _s(base.titleSmall, 13.5, FontWeight.w600, -0.1),
|
||||
|
||||
bodyLarge: _s(base.bodyLarge, 15.5, FontWeight.w400, 0),
|
||||
bodyMedium: _s(base.bodyMedium, 14.5, FontWeight.w400, 0),
|
||||
bodySmall: _s(base.bodySmall, 12.5, FontWeight.w400, 0,
|
||||
color: AppColors.textSecondary),
|
||||
|
||||
labelLarge: _s(base.labelLarge, 14.5, FontWeight.w600, 0),
|
||||
labelMedium: _s(base.labelMedium, 12.5, FontWeight.w600, 0.1),
|
||||
labelSmall: _s(base.labelSmall, 10.5, FontWeight.w600, 0.6,
|
||||
color: AppColors.textTertiary),
|
||||
);
|
||||
}
|
||||
|
||||
static TextStyle _s(
|
||||
TextStyle? base,
|
||||
double size,
|
||||
FontWeight weight,
|
||||
double tracking, {
|
||||
Color color = AppColors.textPrimary,
|
||||
}) {
|
||||
return (base ?? const TextStyle()).copyWith(
|
||||
fontSize: size,
|
||||
fontWeight: weight,
|
||||
letterSpacing: tracking,
|
||||
color: color,
|
||||
height: 1.35,
|
||||
);
|
||||
}
|
||||
|
||||
/// Tabular figures — essential so totals don't jitter as quantities change.
|
||||
static TextStyle money(double size,
|
||||
{FontWeight weight = FontWeight.w700, Color? color}) =>
|
||||
GoogleFonts.poppins(
|
||||
fontSize: size,
|
||||
fontWeight: weight,
|
||||
color: color ?? AppColors.textPrimary,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.15,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
);
|
||||
|
||||
/// Fixed-pitch, used only for the receipt preview.
|
||||
static TextStyle mono(double size, {Color? color}) => GoogleFonts.robotoMono(
|
||||
fontSize: size,
|
||||
color: color ?? AppColors.textTertiary,
|
||||
letterSpacing: 0.2,
|
||||
);
|
||||
|
||||
/// Small uppercase caption for sidebar section dividers.
|
||||
static TextStyle sectionLabel() => GoogleFonts.poppins(
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 1.0,
|
||||
color: AppColors.textTertiary,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user