import 'package:equatable/equatable.dart'; import 'package:uuid/uuid.dart'; import '../../core/constants/app_constants.dart'; import '../../core/utils/extensions.dart'; enum Gender { male('Male'), female('Female'), other('Other'), unspecified('Prefer not to say'); const Gender(this.label); final String label; } /// Loyalty tier, derived from lifetime spend. enum MembershipTier { bronze('Bronze', 0, 0.0), silver('Silver', 10000, 0.02), gold('Gold', 50000, 0.05), platinum('Platinum', 150000, 0.08); const MembershipTier(this.label, this.threshold, this.discountRate); final String label; /// Lifetime spend in rupees required to reach this tier. final double threshold; /// Automatic bill discount granted to members of this tier. final double discountRate; static MembershipTier forSpend(double lifetimeSpend) { return MembershipTier.values.lastWhere( (t) => lifetimeSpend >= t.threshold, orElse: () => MembershipTier.bronze, ); } MembershipTier? get next { final i = index; return i < MembershipTier.values.length - 1 ? MembershipTier.values[i + 1] : null; } } /// A registered shopper. A `null` customer on a sale means walk-in. class Customer extends Equatable { const Customer({ required this.id, required this.name, required this.mobile, this.email, this.gender = Gender.unspecified, this.dateOfBirth, this.loyaltyPoints = 0, this.lifetimeSpend = 0, this.visitCount = 0, this.createdAt, this.lastVisitAt, }); final String id; final String name; final String mobile; final String? email; final Gender gender; final DateTime? dateOfBirth; final int loyaltyPoints; final double lifetimeSpend; final int visitCount; final DateTime? createdAt; final DateTime? lastVisitAt; /// Fixed namespace for customer ids. Must never change: it is half the /// input to [idForMobile], so a new one renames every shopper in the fleet. static const _namespace = '9f2b7c14-3d6e-5a80-b1f7-2c4e8a05d913'; static const _uuid = Uuid(); /// The id for the shopper reachable on [mobile]. /// /// Derived from the number rather than minted at random, which is what lets /// a hundred terminals agree without talking to each other. A shopper who /// registers at counter 2 in Anna Nagar and shops at counter 5 in T Nagar /// gets the same id both times, so the back office collapses them on a /// primary key instead of guessing at a merge later. static String idForMobile(String mobile) => _uuid.v5(_namespace, normaliseMobile(mobile)); /// Every digit in [mobile], in order. Used for matching what a cashier types /// against what is stored, where a partial number should still find a row. static String digitsOf(String mobile) => mobile.replaceAll(RegExp(r'\D'), ''); /// Reduces a number to the ten-digit national one identity is keyed on. /// /// One cashier types `+91 98400 12345`, another `098400 12345`, a third /// `9840012345`. Keyed on raw digits those are three different shoppers, /// which is precisely the duplication [idForMobile] exists to prevent — the /// country code would fork a customer just as effectively as a random id. /// /// Only the two prefixes an Indian number actually carries are stripped, and /// only at the exact lengths that make them unambiguous. Anything else is /// left alone: mangling a number this rule was not written for is worse than /// storing it verbatim. static String normaliseMobile(String mobile) { final digits = digitsOf(mobile); // +91 98400 12345 if (digits.length == 12 && digits.startsWith('91')) { return digits.substring(2); } // 0 98400 12345 — the old STD trunk prefix, still muscle memory for many. if (digits.length == 11 && digits.startsWith('0')) { return digits.substring(1); } return digits; } MembershipTier get tier => MembershipTier.forSpend(lifetimeSpend); /// Cash value of the points currently held. double get redeemableValue => loyaltyPoints * AppConstants.loyaltyPointValue; /// Rupees of additional spend needed to reach the next tier. double? get spendToNextTier { final next = tier.next; if (next == null) return null; return (next.threshold - lifetimeSpend).clamp(0, double.infinity); } bool get isBirthdayToday { final dob = dateOfBirth; if (dob == null) return false; final now = DateTime.now(); return dob.month == now.month && dob.day == now.day; } /// The shopper as they stand after a completed sale. /// /// Pure, so the caller can compute the new row and persist it in the same /// transaction as the bill rather than as a separate write that might fail /// on its own. Customer applySale({ required double amount, required int pointsEarned, required int pointsRedeemed, DateTime? at, }) { return copyWith( loyaltyPoints: (loyaltyPoints - pointsRedeemed + pointsEarned).clamp(0, 1 << 31), lifetimeSpend: (lifetimeSpend + amount).asMoney, visitCount: visitCount + 1, lastVisitAt: at ?? DateTime.now(), ); } Customer copyWith({ String? name, String? email, Gender? gender, DateTime? dateOfBirth, int? loyaltyPoints, double? lifetimeSpend, int? visitCount, DateTime? lastVisitAt, }) { return Customer( id: id, name: name ?? this.name, mobile: mobile, email: email ?? this.email, gender: gender ?? this.gender, dateOfBirth: dateOfBirth ?? this.dateOfBirth, loyaltyPoints: loyaltyPoints ?? this.loyaltyPoints, lifetimeSpend: lifetimeSpend ?? this.lifetimeSpend, visitCount: visitCount ?? this.visitCount, createdAt: createdAt, lastVisitAt: lastVisitAt ?? this.lastVisitAt, ); } @override List get props => [id, name, mobile, loyaltyPoints, lifetimeSpend]; }