diff --git a/lib/core/app_state.dart b/lib/core/app_state.dart new file mode 100644 index 0000000..87aa48e --- /dev/null +++ b/lib/core/app_state.dart @@ -0,0 +1,354 @@ +import 'package:flutter/foundation.dart'; +import 'package:uuid/uuid.dart'; +import 'models/enums.dart'; +import 'models/user.dart'; +import 'models/subscription.dart'; +import 'models/booking.dart'; +import 'models/wallet.dart'; +import 'models/chat.dart'; +import 'models/ads.dart'; +import 'models/notification.dart'; +import 'models/admin.dart'; + +class AppState extends ChangeNotifier { + final _uuid = const Uuid(); + + // Auth + UserAccount? _currentUser; + UserAccount? get currentUser => _currentUser; + + // Data stores (in-memory) + final List _users = []; + final List _customers = []; + final List _workers = []; + final List _stores = []; + + final List _bookings = []; + final List _wallet = []; + final List _chats = []; + final List _ads = []; + final List _plans = []; + final List _notifications = []; + final List _premiumApprovals = []; + + AppState() { + // Seed subscription plans + _plans.addAll([ + SubscriptionPlan( + id: _uuid.v4(), + name: 'Basic', + tier: SubscriptionTier.basic, + monthlyPrice: 9.99, + premiumFlag: false, + features: ['Standard listing', 'Basic tools'], + ), + SubscriptionPlan( + id: _uuid.v4(), + name: 'Pro', + tier: SubscriptionTier.pro, + monthlyPrice: 19.99, + premiumFlag: false, + features: ['Priority listing', 'Pro tools'], + ), + SubscriptionPlan( + id: _uuid.v4(), + name: 'Premium', + tier: SubscriptionTier.premium, + monthlyPrice: 49.99, + premiumFlag: true, + features: ['Top placement', 'Advanced tools'], + ), + ]); + + // Seed a few workers + final worker1 = UserAccount(id: _uuid.v4(), role: UserRole.worker, displayName: 'Ali Electric', walletBalance: 0); + final worker2 = UserAccount(id: _uuid.v4(), role: UserRole.worker, displayName: 'Mona Plumbing', walletBalance: 0); + _users.addAll([worker1, worker2]); + _workers.addAll([ + WorkerProfile(userId: worker1.id, skills: ['Electrician'], hourlyRate: 20, serviceAreas: ['City A']), + WorkerProfile(userId: worker2.id, skills: ['Plumber'], hourlyRate: 25, serviceAreas: ['City B']), + ]); + + // Seed a store + final store = UserAccount(id: _uuid.v4(), role: UserRole.store, displayName: 'FixIt Store', walletBalance: 0); + _users.add(store); + _stores.add(StoreProfile(userId: store.id, storeName: 'FixIt Store')); + + // Seed an admin + final admin = UserAccount(id: _uuid.v4(), role: UserRole.admin, displayName: 'Admin'); + _users.add(admin); + } + + // Public getters + List get workers => List.unmodifiable(_workers); + List get stores => List.unmodifiable(_stores); + List get bookings => List.unmodifiable(_bookings); + List get plans => List.unmodifiable(_plans); + List get notificationsForCurrent => + _currentUser == null ? [] : _notifications.where((n) => n.userId == _currentUser!.id).toList(); + List get premiumApprovals => List.unmodifiable(_premiumApprovals); + List get chatThreadsForCurrent => _currentUser == null + ? [] + : _chats.where((t) => t.customerId == _currentUser!.id || t.workerId == _currentUser!.id).toList(); + + // Auth actions (mock) + void loginAsRole(UserRole role) { + // Create or reuse a user per role for demo + final existing = _users.firstWhere((u) => u.role == role, orElse: () => UserAccount(id: _uuid.v4(), role: role, displayName: role.name)); + if (!_users.contains(existing)) _users.add(existing); + _currentUser = existing; + notifyListeners(); + } + + void logout() { + _currentUser = null; + notifyListeners(); + } + + // Profile helpers + WorkerProfile ensureWorkerProfile(String userId) { + final existing = _workers.where((w) => w.userId == userId).toList(); + if (existing.isNotEmpty) return existing.first; + final profile = WorkerProfile(userId: userId, skills: ['General'], hourlyRate: 20, serviceAreas: ['City']); + _workers.add(profile); + notifyListeners(); + return profile; + } + + StoreProfile ensureStoreProfile(String userId, {String storeName = 'My Store'}) { + final existing = _stores.where((s) => s.userId == userId).toList(); + if (existing.isNotEmpty) return existing.first; + final profile = StoreProfile(userId: userId, storeName: storeName); + _stores.add(profile); + notifyListeners(); + return profile; + } + + void submitKycApproved(String userId) { + final profile = _workers.firstWhere((w) => w.userId == userId, orElse: () => ensureWorkerProfile(userId)); + profile.kycStatus = KycStatus.approved; + notifyListeners(); + } + + // Wallet operations + void _addWalletTx({required String userId, required WalletTransactionType type, required double amount, required String description}) { + _wallet.add(WalletTransaction( + id: _uuid.v4(), + userId: userId, + createdAt: DateTime.now(), + type: type, + amount: amount, + description: description, + )); + } + + void topUpWallet(double amount) { + if (_currentUser == null) return; + _currentUser!.walletBalance += amount; + _addWalletTx(userId: _currentUser!.id, type: WalletTransactionType.topUp, amount: amount, description: 'Top-up'); + notifyListeners(); + } + + bool withdraw(double amount) { + if (_currentUser == null) return false; + if (_currentUser!.walletBalance < amount) return false; + _currentUser!.walletBalance -= amount; + _addWalletTx(userId: _currentUser!.id, type: WalletTransactionType.withdraw, amount: -amount, description: 'Withdraw'); + notifyListeners(); + return true; + } + + // Booking lifecycle + ServiceBooking createBooking({required String workerUserId, required DateTime scheduledAt, required double amount}) { + final customer = _currentUser; + if (customer == null || customer.role != UserRole.customer) { + throw StateError('Only customer can create booking'); + } + final booking = ServiceBooking( + id: _uuid.v4(), + customerId: customer.id, + workerId: workerUserId, + scheduledAt: scheduledAt, + amount: amount, + ); + _bookings.add(booking); + _notify(userId: workerUserId, title: 'New booking request', body: 'Customer requested a booking.'); + notifyListeners(); + return booking; + } + + void workerRespond({required String bookingId, required bool accept}) { + final booking = _bookings.firstWhere((b) => b.id == bookingId); + if (accept) { + booking.status = BookingStatus.awaitingPrepayment; + _notify(userId: booking.customerId, title: 'Booking accepted', body: 'Please make prepayment to confirm.'); + } else { + booking.status = BookingStatus.rejectedByWorker; + _notify(userId: booking.customerId, title: 'Booking rejected', body: 'Worker rejected your request.'); + } + notifyListeners(); + } + + bool customerPrepay(String bookingId) { + final booking = _bookings.firstWhere((b) => b.id == bookingId); + final customer = _users.firstWhere((u) => u.id == booking.customerId); + if (customer.walletBalance < booking.amount) return false; + customer.walletBalance -= booking.amount; + booking.escrowAmount = booking.amount; + booking.status = BookingStatus.confirmed; + _addWalletTx(userId: customer.id, type: WalletTransactionType.escrowDeposit, amount: -booking.amount, description: 'Escrow deposit'); + _notify(userId: booking.workerId, title: 'Booking confirmed', body: 'Prepayment received.'); + notifyListeners(); + return true; + } + + void workerMarkCompleted(String bookingId) { + final booking = _bookings.firstWhere((b) => b.id == bookingId); + booking.status = BookingStatus.awaitingCustomerApproval; + _notify(userId: booking.customerId, title: 'Service completed', body: 'Please approve or request refund.'); + notifyListeners(); + } + + void customerApprove({required String bookingId, int? rating, String? review}) { + final booking = _bookings.firstWhere((b) => b.id == bookingId); + booking.status = BookingStatus.released; + booking.rating = rating; + booking.review = review; + + final workerUser = _users.firstWhere((u) => u.id == booking.workerId); + workerUser.walletBalance += booking.escrowAmount; + _addWalletTx(userId: workerUser.id, type: WalletTransactionType.escrowRelease, amount: booking.escrowAmount, description: 'Escrow released'); + booking.escrowAmount = 0; + _notify(userId: booking.workerId, title: 'Payment released', body: 'Customer approved and funds released.'); + notifyListeners(); + } + + void customerRequestRefund(String bookingId) { + final booking = _bookings.firstWhere((b) => b.id == bookingId); + booking.status = BookingStatus.refundRequested; + _notify(userId: booking.workerId, title: 'Refund requested', body: 'Customer requested a refund.'); + notifyListeners(); + } + + void adminApproveRefund(String bookingId) { + final booking = _bookings.firstWhere((b) => b.id == bookingId); + final customer = _users.firstWhere((u) => u.id == booking.customerId); + customer.walletBalance += booking.escrowAmount; + _addWalletTx(userId: customer.id, type: WalletTransactionType.refundToCustomer, amount: booking.escrowAmount, description: 'Refund'); + booking.escrowAmount = 0; + booking.status = BookingStatus.refunded; + _notify(userId: booking.customerId, title: 'Refunded', body: 'Admin approved your refund.'); + _notify(userId: booking.workerId, title: 'Refund processed', body: 'Booking amount refunded to customer.'); + notifyListeners(); + } + + // Subscriptions + bool subscribeWorker(String workerUserId, SubscriptionTier tier) { + final profile = _workers.firstWhere((w) => w.userId == workerUserId); + final plan = _plans.firstWhere((p) => p.tier == tier); + final user = _users.firstWhere((u) => u.id == workerUserId); + if (user.walletBalance < plan.monthlyPrice) return false; + user.walletBalance -= plan.monthlyPrice; + if (plan.premiumFlag) { + _premiumApprovals.add(PremiumApprovalRequest( + id: _uuid.v4(), + userId: workerUserId, + role: UserRole.worker, + tier: plan.tier, + requestedAt: DateTime.now(), + )); + } else { + profile.activeSubscription = plan.tier; + } + _addWalletTx(userId: user.id, type: WalletTransactionType.subscriptionFee, amount: -plan.monthlyPrice, description: 'Subscription ${plan.name}'); + notifyListeners(); + return true; + } + + bool subscribeStore(String storeUserId, SubscriptionTier tier) { + final profile = _stores.firstWhere((s) => s.userId == storeUserId); + final plan = _plans.firstWhere((p) => p.tier == tier); + final user = _users.firstWhere((u) => u.id == storeUserId); + if (user.walletBalance < plan.monthlyPrice) return false; + user.walletBalance -= plan.monthlyPrice; + if (plan.premiumFlag) { + _premiumApprovals.add(PremiumApprovalRequest( + id: _uuid.v4(), + userId: storeUserId, + role: UserRole.store, + tier: plan.tier, + requestedAt: DateTime.now(), + )); + } else { + profile.activeSubscription = plan.tier; + } + _addWalletTx(userId: user.id, type: WalletTransactionType.subscriptionFee, amount: -plan.monthlyPrice, description: 'Subscription ${plan.name}'); + notifyListeners(); + return true; + } + + // Admin actions + void adminApprovePremium(String requestId, {required bool approve}) { + final request = _premiumApprovals.firstWhere((r) => r.id == requestId); + request.status = approve ? ApprovalStatus.approved : ApprovalStatus.rejected; + if (approve) { + if (request.role == UserRole.worker) { + final profile = _workers.firstWhere((w) => w.userId == request.userId); + profile.activeSubscription = request.tier; + } else if (request.role == UserRole.store) { + final profile = _stores.firstWhere((s) => s.userId == request.userId); + profile.activeSubscription = request.tier; + } + _notify(userId: request.userId, title: 'Premium approved', body: 'Your premium plan was approved.'); + } else { + _notify(userId: request.userId, title: 'Premium rejected', body: 'Your premium plan was rejected.'); + } + notifyListeners(); + } + + // Ads + bool purchaseAd({required String storeUserId, required AdPackageType type}) { + final user = _users.firstWhere((u) => u.id == storeUserId); + final double price = switch (type) { AdPackageType.bronze => 5, AdPackageType.silver => 10, AdPackageType.gold => 20 }; + if (user.walletBalance < price) return false; + user.walletBalance -= price; + _ads.add(AdPackage( + id: _uuid.v4(), + storeUserId: storeUserId, + type: type, + purchasedAt: DateTime.now(), + expiresAt: DateTime.now().add(const Duration(days: 30)), + )); + _addWalletTx(userId: user.id, type: WalletTransactionType.adPurchase, amount: -price, description: 'Ad ${type.name}'); + notifyListeners(); + return true; + } + + // Products (Store) + void addProduct({required String storeUserId, required String name, required double price, int stock = 0}) { + final store = _stores.firstWhere((s) => s.userId == storeUserId); + store.products.add(Product(id: _uuid.v4(), name: name, price: price, stock: stock)); + notifyListeners(); + } + + // Chat (simple) + ChatThread openThread(String customerId, String workerId) { + final existing = _chats.where((t) => t.customerId == customerId && t.workerId == workerId).toList(); + if (existing.isNotEmpty) return existing.first; + final thread = ChatThread(id: _uuid.v4(), customerId: customerId, workerId: workerId); + _chats.add(thread); + notifyListeners(); + return thread; + } + + void sendMessage({required String threadId, required String senderId, required String text}) { + final thread = _chats.firstWhere((t) => t.id == threadId); + thread.messages.add(ChatMessage(id: _uuid.v4(), senderId: senderId, sentAt: DateTime.now(), text: text)); + notifyListeners(); + } + + // Notifications + void _notify({required String userId, required String title, required String body}) { + _notifications.add(NotificationItem(id: _uuid.v4(), userId: userId, createdAt: DateTime.now(), title: title, body: body)); + } +} diff --git a/lib/core/models/admin.dart b/lib/core/models/admin.dart new file mode 100644 index 0000000..befcbe8 --- /dev/null +++ b/lib/core/models/admin.dart @@ -0,0 +1,21 @@ +import 'package:khadamati/core/models/enums.dart'; + +enum ApprovalStatus { pending, approved, rejected } + +class PremiumApprovalRequest { + final String id; + final String userId; + final UserRole role; // worker or store + final SubscriptionTier tier; // should be premium + final DateTime requestedAt; + ApprovalStatus status; + + PremiumApprovalRequest({ + required this.id, + required this.userId, + required this.role, + required this.tier, + required this.requestedAt, + this.status = ApprovalStatus.pending, + }); +} diff --git a/lib/core/models/ads.dart b/lib/core/models/ads.dart new file mode 100644 index 0000000..5429467 --- /dev/null +++ b/lib/core/models/ads.dart @@ -0,0 +1,19 @@ +import 'package:khadamati/core/models/enums.dart'; + +class AdPackage { + final String id; + final String storeUserId; + final AdPackageType type; + final DateTime purchasedAt; + final DateTime expiresAt; + + AdPackage({ + required this.id, + required this.storeUserId, + required this.type, + required this.purchasedAt, + required this.expiresAt, + }); + + bool get isActive => DateTime.now().isBefore(expiresAt); +} diff --git a/lib/core/models/booking.dart b/lib/core/models/booking.dart new file mode 100644 index 0000000..2b30cff --- /dev/null +++ b/lib/core/models/booking.dart @@ -0,0 +1,26 @@ +import 'package:khadamati/core/models/enums.dart'; + +class ServiceBooking { + final String id; + final String customerId; + final String workerId; + DateTime scheduledAt; + double amount; + + BookingStatus status; + double escrowAmount; // funds held + int? rating; // 1..5 + String? review; + + ServiceBooking({ + required this.id, + required this.customerId, + required this.workerId, + required this.scheduledAt, + required this.amount, + this.status = BookingStatus.requestPending, + this.escrowAmount = 0, + this.rating, + this.review, + }); +} diff --git a/lib/core/models/chat.dart b/lib/core/models/chat.dart new file mode 100644 index 0000000..783f752 --- /dev/null +++ b/lib/core/models/chat.dart @@ -0,0 +1,27 @@ +class ChatThread { + final String id; + final String customerId; + final String workerId; + final List messages; + + ChatThread({ + required this.id, + required this.customerId, + required this.workerId, + List? messages, + }) : messages = messages ?? []; +} + +class ChatMessage { + final String id; + final String senderId; + final DateTime sentAt; + final String text; + + ChatMessage({ + required this.id, + required this.senderId, + required this.sentAt, + required this.text, + }); +} diff --git a/lib/core/models/enums.dart b/lib/core/models/enums.dart new file mode 100644 index 0000000..b83c4dc --- /dev/null +++ b/lib/core/models/enums.dart @@ -0,0 +1,29 @@ +enum UserRole { customer, worker, store, admin } + +enum BookingStatus { + requestPending, // created by customer; waiting for worker confirmation + rejectedByWorker, + awaitingPrepayment, // accepted by worker; waiting for customer prepay to escrow + confirmed, // prepayment done; booking scheduled + completedByWorker, // worker marked as completed + awaitingCustomerApproval, // derived step when worker completes + released, // customer approved; funds released to worker + refundRequested, // customer requested refund + refunded // admin approved refund and returned funds to customer +} + +enum KycStatus { notSubmitted, pendingReview, approved, rejected } + +enum SubscriptionTier { basic, pro, premium } + +enum WalletTransactionType { + topUp, + escrowDeposit, + escrowRelease, + withdraw, + subscriptionFee, + adPurchase, + refundToCustomer +} + +enum AdPackageType { bronze, silver, gold } diff --git a/lib/core/models/notification.dart b/lib/core/models/notification.dart new file mode 100644 index 0000000..1926940 --- /dev/null +++ b/lib/core/models/notification.dart @@ -0,0 +1,17 @@ +class NotificationItem { + final String id; + final String userId; + final DateTime createdAt; + final String title; + final String body; + bool read; + + NotificationItem({ + required this.id, + required this.userId, + required this.createdAt, + required this.title, + required this.body, + this.read = false, + }); +} diff --git a/lib/core/models/subscription.dart b/lib/core/models/subscription.dart new file mode 100644 index 0000000..bac1e9d --- /dev/null +++ b/lib/core/models/subscription.dart @@ -0,0 +1,19 @@ +import 'package:khadamati/core/models/enums.dart'; + +class SubscriptionPlan { + final String id; + final String name; + final SubscriptionTier tier; + final double monthlyPrice; + final bool premiumFlag; // governs approval by admin + final List features; + + SubscriptionPlan({ + required this.id, + required this.name, + required this.tier, + required this.monthlyPrice, + required this.premiumFlag, + required this.features, + }); +} diff --git a/lib/core/models/user.dart b/lib/core/models/user.dart new file mode 100644 index 0000000..e882c2b --- /dev/null +++ b/lib/core/models/user.dart @@ -0,0 +1,66 @@ +import 'package:khadamati/core/models/enums.dart'; + +class UserAccount { + final String id; + final UserRole role; + String displayName; + String? email; + String? phone; + + double walletBalance; + + UserAccount({ + required this.id, + required this.role, + required this.displayName, + this.email, + this.phone, + this.walletBalance = 0, + }); +} + +class CustomerProfile { + final String userId; + CustomerProfile({required this.userId}); +} + +class WorkerProfile { + final String userId; + List skills; + double hourlyRate; + List serviceAreas; + KycStatus kycStatus; + SubscriptionTier? activeSubscription; + + WorkerProfile({ + required this.userId, + required this.skills, + required this.hourlyRate, + required this.serviceAreas, + this.kycStatus = KycStatus.notSubmitted, + this.activeSubscription, + }); +} + +class StoreProfile { + final String userId; + String storeName; + SubscriptionTier? activeSubscription; + List products; + + StoreProfile({ + required this.userId, + required this.storeName, + this.activeSubscription, + List? products, + }) : products = products ?? []; +} + +class Product { + final String id; + String name; + double price; + int stock; + + Product({required this.id, required this.name, required this.price, this.stock = 0}); +} diff --git a/lib/core/models/wallet.dart b/lib/core/models/wallet.dart new file mode 100644 index 0000000..8dd8502 --- /dev/null +++ b/lib/core/models/wallet.dart @@ -0,0 +1,19 @@ +import 'package:khadamati/core/models/enums.dart'; + +class WalletTransaction { + final String id; + final String userId; + final DateTime createdAt; + final WalletTransactionType type; + final double amount; // positive for credits to user wallet; negative for debits + final String description; + + WalletTransaction({ + required this.id, + required this.userId, + required this.createdAt, + required this.type, + required this.amount, + required this.description, + }); +} diff --git a/lib/core/routes.dart b/lib/core/routes.dart new file mode 100644 index 0000000..64f0ad6 --- /dev/null +++ b/lib/core/routes.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:khadamati/features/auth/pages/login_page.dart'; +import 'package:khadamati/features/auth/pages/register_page.dart'; +import 'package:khadamati/features/auth/pages/otp_page.dart'; +import 'package:khadamati/features/auth/pages/success_page.dart'; +import 'package:khadamati/features/shell/role_select_page.dart'; +import 'package:khadamati/features/shell/home_shell.dart'; + +class Routes { + static const String login = '/login'; + static const String register = '/register'; + static const String otp = '/otp'; + static const String home = '/home'; + static const String selectRole = '/select-role'; +} + +class OtpArguments { + final String channel; // 'phone' or 'email' + final String destinationValue; // phone number or email + final bool isRegistrationFlow; + + const OtpArguments({ + required this.channel, + required this.destinationValue, + required this.isRegistrationFlow, + }); +} + +class AppRouter { + static Route onGenerateRoute(RouteSettings settings) { + switch (settings.name) { + case Routes.selectRole: + return MaterialPageRoute(builder: (_) => const RoleSelectPage()); + case Routes.login: + return MaterialPageRoute(builder: (_) => const LoginPage()); + case Routes.register: + return MaterialPageRoute(builder: (_) => const RegisterPage()); + case Routes.otp: + final args = settings.arguments as OtpArguments?; + return MaterialPageRoute( + builder: (_) => OtpPage(arguments: args), + ); + case Routes.home: + return MaterialPageRoute(builder: (_) => const HomeShell()); + default: + return MaterialPageRoute(builder: (_) => const LoginPage()); + } + } +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..52c5e2a --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + AppTheme._(); + + static ThemeData get lightTheme { + const primaryColor = Colors.indigo; + const seedColor = Colors.indigo; + + return ThemeData( + useMaterial3: false, + colorScheme: ColorScheme.fromSeed(seedColor: seedColor), + primaryColor: primaryColor, + scaffoldBackgroundColor: Colors.white, + appBarTheme: const AppBarTheme( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + elevation: 0.5, + centerTitle: true, + ), + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder(), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: Color(0xFFBDBDBD)), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: Colors.indigo, width: 2), + ), + labelStyle: TextStyle(color: Colors.black87), + hintStyle: TextStyle(color: Colors.black54), + contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 12), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + textStyle: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: primaryColor, + textStyle: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + tabBarTheme: const TabBarTheme( + labelColor: Colors.indigo, + unselectedLabelColor: Colors.black54, + indicatorColor: Colors.indigo, + ), + ); + } +} diff --git a/lib/core/utils/formatters.dart b/lib/core/utils/formatters.dart new file mode 100644 index 0000000..06a1276 --- /dev/null +++ b/lib/core/utils/formatters.dart @@ -0,0 +1,11 @@ +import 'package:intl/intl.dart'; + +class Formatters { + static final _currency = NumberFormat.currency(symbol: 'USD ', decimalDigits: 2); + static final _date = DateFormat('yyyy-MM-dd'); + static final _time = DateFormat('HH:mm'); + + static String money(double amount) => _currency.format(amount); + static String date(DateTime dt) => _date.format(dt); + static String time(DateTime dt) => _time.format(dt); +} diff --git a/lib/features/auth/pages/login_page.dart b/lib/features/auth/pages/login_page.dart new file mode 100644 index 0000000..16224ae --- /dev/null +++ b/lib/features/auth/pages/login_page.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:khadamati/core/routes.dart'; +import 'package:khadamati/features/auth/widgets/auth_widgets.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State with SingleTickerProviderStateMixin { + late final TabController _tabController; + + final _formKeyEmail = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + + final _formKeyPhone = GlobalKey(); + final _phoneController = TextEditingController(); + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + _phoneController.dispose(); + super.dispose(); + } + + String? _validateEmail(String? value) { + if (value == null || value.trim().isEmpty) return 'Email is required'; + final emailReg = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$'); + if (!emailReg.hasMatch(value.trim())) return 'Enter a valid email'; + return null; + } + + String? _validatePassword(String? value) { + if (value == null || value.isEmpty) return 'Password is required'; + if (value.length < 6) return 'Password must be at least 6 characters'; + return null; + } + + String? _validatePhone(String? value) { + if (value == null || value.trim().isEmpty) return 'Phone is required'; + final numbersOnly = value.trim().replaceAll(RegExp(r'[^0-9+]'), ''); + if (numbersOnly.length < 8) return 'Enter a valid phone number'; + return null; + } + + void _onLoginEmail() { + if (_formKeyEmail.currentState?.validate() != true) return; + Navigator.of(context).pushNamed(Routes.home); + } + + void _onLoginPhone() { + if (_formKeyPhone.currentState?.validate() != true) return; + Navigator.of(context).pushNamed( + Routes.otp, + arguments: OtpArguments( + channel: 'phone', + destinationValue: _phoneController.text.trim(), + isRegistrationFlow: false, + ), + ); + } + + @override + Widget build(BuildContext context) { + return AuthScaffold( + title: 'Login', + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pushNamed(Routes.register), + child: const Text('Create account'), + ), + ], + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Email'), + Tab(text: 'Phone'), + ], + ), + const SizedBox(height: 16), + SizedBox( + height: 350, + child: TabBarView( + controller: _tabController, + children: [ + _buildEmailLogin(), + _buildPhoneLogin(), + ], + ), + ), + ], + ), + ); + } + + Widget _buildEmailLogin() { + return Form( + key: _formKeyEmail, + child: Column( + children: [ + LabeledField( + label: 'Email', + controller: _emailController, + keyboardType: TextInputType.emailAddress, + validator: _validateEmail, + prefix: const Icon(Icons.email_outlined), + ), + const SizedBox(height: 12), + LabeledField( + label: 'Password', + controller: _passwordController, + obscureText: true, + validator: _validatePassword, + prefix: const Icon(Icons.lock_outline), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _onLoginEmail, + child: const Text('Login'), + ), + ), + ], + ), + ); + } + + Widget _buildPhoneLogin() { + return Form( + key: _formKeyPhone, + child: Column( + children: [ + LabeledField( + label: 'Phone number', + controller: _phoneController, + keyboardType: TextInputType.phone, + validator: _validatePhone, + prefix: const Icon(Icons.phone_outlined), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _onLoginPhone, + child: const Text('Send OTP'), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/auth/pages/otp_page.dart b/lib/features/auth/pages/otp_page.dart new file mode 100644 index 0000000..432e636 --- /dev/null +++ b/lib/features/auth/pages/otp_page.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:khadamati/core/routes.dart'; +import 'package:khadamati/features/auth/widgets/auth_widgets.dart'; + +class OtpPage extends StatefulWidget { + final OtpArguments? arguments; + const OtpPage({super.key, this.arguments}); + + @override + State createState() => _OtpPageState(); +} + +class _OtpPageState extends State { + final _formKey = GlobalKey(); + final _otpController = TextEditingController(); + int _secondsRemaining = 60; + Timer? _timer; + + @override + void initState() { + super.initState(); + _startTimer(); + } + + @override + void dispose() { + _otpController.dispose(); + _timer?.cancel(); + super.dispose(); + } + + void _startTimer() { + _timer?.cancel(); + setState(() { + _secondsRemaining = 60; + }); + _timer = Timer.periodic(const Duration(seconds: 1), (t) { + if (_secondsRemaining <= 1) { + t.cancel(); + setState(() => _secondsRemaining = 0); + } else { + setState(() => _secondsRemaining -= 1); + } + }); + } + + String _destinationText() { + final args = widget.arguments; + if (args == null) return ''; + final channel = args.channel == 'email' ? 'email' : 'phone'; + return 'Enter the 6-digit code sent to your $channel: ${args.destinationValue}'; + } + + String? _validateOtp(String? value) { + if (value == null || value.trim().isEmpty) return 'OTP is required'; + final digits = value.trim(); + if (!RegExp(r"^\d{6}").hasMatch(digits)) return 'Enter a 6-digit code'; + return null; + } + + void _onVerify() { + if (_formKey.currentState?.validate() != true) return; + Navigator.of(context).pushNamedAndRemoveUntil(Routes.home, (route) => false); + } + + void _onResend() { + if (_secondsRemaining > 0) return; + _startTimer(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('OTP resent')), + ); + } + + @override + Widget build(BuildContext context) { + return AuthScaffold( + title: 'Verify OTP', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _destinationText(), + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Form( + key: _formKey, + child: LabeledField( + label: 'OTP code', + controller: _otpController, + keyboardType: TextInputType.number, + validator: _validateOtp, + prefix: const Icon(Icons.verified_outlined), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: _onVerify, + child: const Text('Verify'), + ), + ), + const SizedBox(width: 12), + TextButton( + onPressed: _secondsRemaining == 0 ? _onResend : null, + child: Text( + _secondsRemaining == 0 + ? 'Resend' + : 'Resend in 0:${_secondsRemaining.toString().padLeft(2, '0')}', + ), + ), + ], + ) + ], + ), + ); + } +} diff --git a/lib/features/auth/pages/register_page.dart b/lib/features/auth/pages/register_page.dart new file mode 100644 index 0000000..07e5f83 --- /dev/null +++ b/lib/features/auth/pages/register_page.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:khadamati/core/routes.dart'; +import 'package:khadamati/features/auth/widgets/auth_widgets.dart'; + +class RegisterPage extends StatefulWidget { + const RegisterPage({super.key}); + + @override + State createState() => _RegisterPageState(); +} + +class _RegisterPageState extends State with SingleTickerProviderStateMixin { + late final TabController _tabController; + + final _formKeyEmail = GlobalKey(); + final _nameControllerE = TextEditingController(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + + final _formKeyPhone = GlobalKey(); + final _nameControllerP = TextEditingController(); + final _phoneController = TextEditingController(); + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + _nameControllerE.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + _nameControllerP.dispose(); + _phoneController.dispose(); + super.dispose(); + } + + String? _validateName(String? value) { + if (value == null || value.trim().isEmpty) return 'Name is required'; + if (value.trim().length < 2) return 'Enter a valid name'; + return null; + } + + String? _validateEmail(String? value) { + if (value == null || value.trim().isEmpty) return 'Email is required'; + final emailReg = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$'); + if (!emailReg.hasMatch(value.trim())) return 'Enter a valid email'; + return null; + } + + String? _validatePassword(String? value) { + if (value == null || value.isEmpty) return 'Password is required'; + if (value.length < 6) return 'Password must be at least 6 characters'; + return null; + } + + String? _validatePhone(String? value) { + if (value == null || value.trim().isEmpty) return 'Phone is required'; + final numbersOnly = value.trim().replaceAll(RegExp(r'[^0-9+]'), ''); + if (numbersOnly.length < 8) return 'Enter a valid phone number'; + return null; + } + + void _onRegisterEmail() { + if (_formKeyEmail.currentState?.validate() != true) return; + Navigator.of(context).pushNamed( + Routes.otp, + arguments: OtpArguments( + channel: 'email', + destinationValue: _emailController.text.trim(), + isRegistrationFlow: true, + ), + ); + } + + void _onRegisterPhone() { + if (_formKeyPhone.currentState?.validate() != true) return; + Navigator.of(context).pushNamed( + Routes.otp, + arguments: OtpArguments( + channel: 'phone', + destinationValue: _phoneController.text.trim(), + isRegistrationFlow: true, + ), + ); + } + + @override + Widget build(BuildContext context) { + return AuthScaffold( + title: 'Create account', + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pushNamed(Routes.login), + child: const Text('Login'), + ), + ], + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Email'), + Tab(text: 'Phone'), + ], + ), + const SizedBox(height: 16), + SizedBox( + height: 420, + child: TabBarView( + controller: _tabController, + children: [ + _buildEmailRegister(), + _buildPhoneRegister(), + ], + ), + ), + ], + ), + ); + } + + Widget _buildEmailRegister() { + return Form( + key: _formKeyEmail, + child: Column( + children: [ + LabeledField( + label: 'Full name', + controller: _nameControllerE, + validator: _validateName, + prefix: const Icon(Icons.person_outline), + ), + const SizedBox(height: 12), + LabeledField( + label: 'Email', + controller: _emailController, + keyboardType: TextInputType.emailAddress, + validator: _validateEmail, + prefix: const Icon(Icons.email_outlined), + ), + const SizedBox(height: 12), + LabeledField( + label: 'Password', + controller: _passwordController, + obscureText: true, + validator: _validatePassword, + prefix: const Icon(Icons.lock_outline), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _onRegisterEmail, + child: const Text('Register'), + ), + ), + ], + ), + ); + } + + Widget _buildPhoneRegister() { + return Form( + key: _formKeyPhone, + child: Column( + children: [ + LabeledField( + label: 'Full name', + controller: _nameControllerP, + validator: _validateName, + prefix: const Icon(Icons.person_outline), + ), + const SizedBox(height: 12), + LabeledField( + label: 'Phone number', + controller: _phoneController, + keyboardType: TextInputType.phone, + validator: _validatePhone, + prefix: const Icon(Icons.phone_outlined), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _onRegisterPhone, + child: const Text('Send OTP'), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/auth/pages/success_page.dart b/lib/features/auth/pages/success_page.dart new file mode 100644 index 0000000..862a442 --- /dev/null +++ b/lib/features/auth/pages/success_page.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class SuccessPage extends StatelessWidget { + const SuccessPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Khadamati')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.check_circle_outline, size: 80, color: Colors.green), + const SizedBox(height: 16), + const Text( + 'Logged in! (Frontend only)', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 24), + SizedBox( + width: 220, + child: ElevatedButton( + onPressed: () => Navigator.of(context).popUntil((r) => r.isFirst), + child: const Text('Back to Login'), + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/features/auth/widgets/auth_widgets.dart b/lib/features/auth/widgets/auth_widgets.dart new file mode 100644 index 0000000..1860c48 --- /dev/null +++ b/lib/features/auth/widgets/auth_widgets.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +class AuthScaffold extends StatelessWidget { + final String title; + final Widget child; + final List? actions; + + const AuthScaffold({super.key, required this.title, required this.child, this.actions}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(title), + actions: actions, + ), + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: child, + ), + ), + ), + ), + ); + } +} + +class LabeledField extends StatelessWidget { + final String label; + final TextEditingController controller; + final TextInputType? keyboardType; + final bool obscureText; + final String? Function(String?)? validator; + final Widget? prefix; + + const LabeledField({ + super.key, + required this.label, + required this.controller, + this.keyboardType, + this.obscureText = false, + this.validator, + this.prefix, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: 8), + TextFormField( + controller: controller, + keyboardType: keyboardType, + obscureText: obscureText, + validator: validator, + decoration: InputDecoration(prefixIcon: prefix), + ), + ], + ); + } +} + +class FormDivider extends StatelessWidget { + const FormDivider({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Expanded(child: Divider(color: Colors.grey.shade400)), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Text('or'), + ), + Expanded(child: Divider(color: Colors.grey.shade400)), + ], + ), + ); + } +} diff --git a/lib/features/shell/home_shell.dart b/lib/features/shell/home_shell.dart new file mode 100644 index 0000000..5ee5532 --- /dev/null +++ b/lib/features/shell/home_shell.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:khadamati/core/app_state.dart'; +import 'package:khadamati/core/models/enums.dart'; +import 'package:khadamati/features/shell/role_select_page.dart'; +import 'package:khadamati/features/user/customer/customer_home.dart'; +import 'package:khadamati/features/user/customer/customer_nav.dart'; +import 'package:khadamati/features/user/worker/worker_home.dart'; +import 'package:khadamati/features/user/store/store_home.dart'; +import 'package:khadamati/features/user/admin/admin_home.dart'; + +class HomeShell extends StatelessWidget { + const HomeShell({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch(); + final user = state.currentUser; + + if (user == null) { + return const RoleSelectPage(); + } + + switch (user.role) { + case UserRole.customer: + return const CustomerNav(); + case UserRole.worker: + return const WorkerHome(); + case UserRole.store: + return const StoreHome(); + case UserRole.admin: + return const AdminHome(); + } + } +} diff --git a/lib/features/shell/notifications_badge.dart b/lib/features/shell/notifications_badge.dart new file mode 100644 index 0000000..efb2ea3 --- /dev/null +++ b/lib/features/shell/notifications_badge.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:khadamati/core/app_state.dart'; + +class NotificationsBadge extends StatelessWidget { + const NotificationsBadge({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch(); + final count = state.notificationsForCurrent.length; + + return Stack( + alignment: Alignment.center, + children: [ + const Icon(Icons.notifications_none), + if (count > 0) + Positioned( + right: 0, + top: 6, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration(color: Colors.red, borderRadius: BorderRadius.circular(10)), + child: Text( + count.toString(), + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/shell/role_select_page.dart b/lib/features/shell/role_select_page.dart new file mode 100644 index 0000000..7241ed8 --- /dev/null +++ b/lib/features/shell/role_select_page.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:khadamati/core/app_state.dart'; +import 'package:khadamati/core/models/enums.dart'; +import 'package:khadamati/core/routes.dart'; + +class RoleSelectPage extends StatelessWidget { + const RoleSelectPage({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch(); + + return Scaffold( + appBar: AppBar(title: const Text('Choose Role')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 12), + _roleButton(context, 'Customer', Icons.person_outline, UserRole.customer), + const SizedBox(height: 12), + _roleButton(context, 'Worker', Icons.handyman_outlined, UserRole.worker), + const SizedBox(height: 12), + _roleButton(context, 'Store', Icons.storefront_outlined, UserRole.store), + const SizedBox(height: 12), + _roleButton(context, 'Admin', Icons.admin_panel_settings_outlined, UserRole.admin), + const SizedBox(height: 24), + if (state.currentUser != null) + Text('Logged in as: ${state.currentUser!.displayName} (${state.currentUser!.role.name})'), + ], + ), + ), + ); + } + + Widget _roleButton(BuildContext context, String title, IconData icon, UserRole role) { + return SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + final state = context.read(); + state.loginAsRole(role); + Navigator.of(context).pushReplacementNamed(Routes.home); + }, + icon: Icon(icon), + label: Text(title), + ), + ); + } +} diff --git a/lib/features/user/admin/admin_home.dart b/lib/features/user/admin/admin_home.dart new file mode 100644 index 0000000..07c438c --- /dev/null +++ b/lib/features/user/admin/admin_home.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:khadamati/core/app_state.dart'; +import 'imports.dart'; + +class AdminHome extends StatelessWidget { + const AdminHome({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch(); + final approvals = state.premiumApprovals; + final refundRequests = state.bookings.where((b) => b.status == BookingStatus.refundRequested).toList(); + + return Scaffold( + appBar: AppBar( + title: const Text('Admin'), + actions: [ + IconButton(onPressed: () => state.logout(), icon: const Icon(Icons.logout)), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Premium approvals', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + if (approvals.isEmpty) const Text('No requests') else ...[ + for (final r in approvals) + ListTile( + leading: const Icon(Icons.workspace_premium_outlined), + title: Text('${r.role.name} request: ${r.tier.name}'), + subtitle: Text('Status: ${r.status.name}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton(onPressed: () => state.adminApprovePremium(r.id, approve: false), child: const Text('Reject')), + const SizedBox(width: 8), + ElevatedButton(onPressed: () => state.adminApprovePremium(r.id, approve: true), child: const Text('Approve')), + ], + ), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Refund requests', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + if (refundRequests.isEmpty) const Text('No refund requests') else ...[ + for (final b in refundRequests) + ListTile( + leading: const Icon(Icons.request_quote_outlined), + title: Text('Booking ${b.id.substring(0, 6)} - ${b.amount.toStringAsFixed(2)}'), + trailing: ElevatedButton( + onPressed: () => state.adminApproveRefund(b.id), + child: const Text('Approve refund'), + ), + ), + ], + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/user/admin/imports.dart b/lib/features/user/admin/imports.dart new file mode 100644 index 0000000..eac674a --- /dev/null +++ b/lib/features/user/admin/imports.dart @@ -0,0 +1 @@ +export 'package:khadamati/core/models/enums.dart'; \ No newline at end of file diff --git a/lib/features/user/customer/booking_details_page.dart b/lib/features/user/customer/booking_details_page.dart new file mode 100644 index 0000000..6257478 --- /dev/null +++ b/lib/features/user/customer/booking_details_page.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:khadamati/core/app_state.dart'; +import 'package:khadamati/core/models/booking.dart'; +import 'package:khadamati/core/models/enums.dart'; + +class BookingDetailsPage extends StatelessWidget { + final ServiceBooking booking; + const BookingDetailsPage({super.key, required this.booking}); + + @override + Widget build(BuildContext context) { + final state = context.watch(); + + return Scaffold( + appBar: AppBar(title: const Text('Booking Details')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Booking ID: ${booking.id}'), + const SizedBox(height: 8), + Text('Amount: ${booking.amount.toStringAsFixed(2)}'), + const SizedBox(height: 8), + Text('Status: ${booking.status.name}'), + const SizedBox(height: 16), + if (booking.status == BookingStatus.awaitingCustomerApproval) + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () => state.customerApprove(bookingId: booking.id, rating: 5, review: 'Well done!'), + child: const Text('Approve and release'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton( + onPressed: () => state.customerRequestRefund(booking.id), + child: const Text('Request refund'), + ), + ), + ], + ) + ], + ), + ), + ); + } +} diff --git a/lib/features/user/customer/browse_page.dart b/lib/features/user/customer/browse_page.dart new file mode 100644 index 0000000..cbd7ba8 --- /dev/null +++ b/lib/features/user/customer/browse_page.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:khadamati/core/app_state.dart'; +import 'package:khadamati/features/user/customer/chat_page.dart'; + +class BrowsePage extends StatelessWidget { + const BrowsePage({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch(); + final workers = state.workers; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text('Browse workers', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + for (final w in workers) + ListTile( + leading: const Icon(Icons.handyman_outlined), + title: Text(w.skills.join(', ')), + subtitle: Text('Rate: ${w.hourlyRate.toStringAsFixed(2)}'), + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => ChatPage(workerUserId: w.userId))), + ) + ], + ); + } +} diff --git a/lib/features/user/customer/chat_page.dart b/lib/features/user/customer/chat_page.dart new file mode 100644 index 0000000..073e502 --- /dev/null +++ b/lib/features/user/customer/chat_page.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:khadamati/core/app_state.dart'; + +class ChatPage extends StatefulWidget { + final String workerUserId; + const ChatPage({super.key, required this.workerUserId}); + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final state = context.watch(); + final me = state.currentUser!; + final thread = state.openThread(me.id, widget.workerUserId); + + return Scaffold( + appBar: AppBar(title: const Text('Chat')), + body: Column( + children: [ + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: thread.messages.length, + itemBuilder: (context, index) { + final msg = thread.messages[index]; + final isMe = msg.senderId == me.id; + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: isMe ? Colors.indigo.shade50 : Colors.grey.shade200, + borderRadius: BorderRadius.circular(8), + ), + child: Text(msg.text), + ), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: const InputDecoration(hintText: 'Type a message'), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.send), + onPressed: () { + final text = _controller.text.trim(); + if (text.isEmpty) return; + state.sendMessage(threadId: thread.id, senderId: me.id, text: text); + _controller.clear(); + }, + ) + ], + ), + ) + ], + ), + ); + } +} diff --git a/lib/features/user/customer/customer_home.dart b/lib/features/user/customer/customer_home.dart new file mode 100644 index 0000000..845c187 --- /dev/null +++ b/lib/features/user/customer/customer_home.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:khadamati/core/app_state.dart'; +import 'package:khadamati/core/models/booking.dart'; +import 'package:khadamati/features/user/customer/chat_page.dart'; +import 'package:khadamati/core/models/enums.dart'; +import 'package:khadamati/features/user/customer/notifications_drawer.dart'; + +class CustomerHome extends StatefulWidget { + const CustomerHome({super.key}); + + @override + State createState() => _CustomerHomeState(); +} + +class _CustomerHomeState extends State { + @override + Widget build(BuildContext context) { + final state = context.watch(); + + return Scaffold( + appBar: AppBar( + title: const Text('Customer'), + actions: [ + IconButton( + onPressed: () => Scaffold.of(context).openEndDrawer(), + icon: const Icon(Icons.notifications_none), + tooltip: 'Notifications', + ), + IconButton( + onPressed: () => state.logout(), + icon: const Icon(Icons.logout), + tooltip: 'Logout', + ), + ], + ), + endDrawer: const NotificationsDrawer(), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _walletCard(context), + const SizedBox(height: 16), + _browseWorkers(context), + const SizedBox(height: 16), + _myBookings(context), + ], + ), + ); + } + + Widget _walletCard(BuildContext context) { + final state = context.watch(); + final user = state.currentUser!; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.account_balance_wallet_outlined), + const SizedBox(width: 12), + Expanded(child: Text('Wallet: ${user.walletBalance.toStringAsFixed(2)}')), + TextButton( + onPressed: () => state.topUpWallet(50), + child: const Text('Top up 50'), + ), + ], + ), + ), + ); + } + + Widget _browseWorkers(BuildContext context) { + final state = context.watch(); + final workers = state.workers; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Browse workers', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + for (final w in workers) + ListTile( + leading: const Icon(Icons.handyman_outlined), + title: Text(w.skills.join(', ')), + subtitle: Text('Rate: ${w.hourlyRate.toStringAsFixed(2)}'), + trailing: ElevatedButton( + onPressed: () => _openCreateBooking(context, w.userId, w.hourlyRate), + child: const Text('Book'), + ), + onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => ChatPage(workerUserId: w.userId))), + ) + ], + ), + ), + ); + } + + void _openCreateBooking(BuildContext context, String workerUserId, double rate) async { + final dt = await showDatePicker( + context: context, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 30)), + initialDate: DateTime.now().add(const Duration(days: 1)), + ); + if (dt == null) return; + + final state = context.read(); + state.createBooking(workerUserId: workerUserId, scheduledAt: dt, amount: rate); + } + + Widget _myBookings(BuildContext context) { + final state = context.watch(); + final user = state.currentUser!; + final bookings = state.bookings.where((b) => b.customerId == user.id).toList(); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('My bookings', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + if (bookings.isEmpty) const Text('No bookings yet') else ...[ + for (final b in bookings) _bookingTile(context, b), + ] + ], + ), + ), + ); + } + + Widget _bookingTile(BuildContext context, ServiceBooking b) { + final state = context.read(); + + Widget? trailing; + switch (b.status) { + case BookingStatus.requestPending: + trailing = const Text('Waiting for worker'); + break; + case BookingStatus.rejectedByWorker: + trailing = const Text('Rejected'); + break; + case BookingStatus.awaitingPrepayment: + trailing = ElevatedButton( + onPressed: () => state.customerPrepay(b.id), + child: const Text('Prepay'), + ); + break; + case BookingStatus.confirmed: + trailing = const Text('Scheduled'); + break; + case BookingStatus.completedByWorker: + trailing = const Text('Pending your approval'); + break; + case BookingStatus.awaitingCustomerApproval: + trailing = Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () => state.customerRequestRefund(b.id), + child: const Text('Refund'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () => state.customerApprove(bookingId: b.id, rating: 5, review: 'Great service'), + child: const Text('Approve'), + ), + ], + ); + break; + case BookingStatus.released: + trailing = const Icon(Icons.check, color: Colors.green); + break; + case BookingStatus.refundRequested: + trailing = const Text('Refund requested'); + break; + case BookingStatus.refunded: + trailing = const Text('Refunded'); + break; + } + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.book_online_outlined), + title: Text('Booking ${b.id.substring(0, 6)} - ${b.amount.toStringAsFixed(2)}'), + subtitle: Text('Status: ${b.status.name}'), + trailing: trailing, + ); + } +} diff --git a/lib/features/user/customer/customer_nav.dart b/lib/features/user/customer/customer_nav.dart new file mode 100644 index 0000000..954be45 --- /dev/null +++ b/lib/features/user/customer/customer_nav.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:khadamati/core/app_state.dart'; +import 'browse_page.dart'; +import 'package:khadamati/features/shell/notifications_badge.dart'; + +class CustomerNav extends StatefulWidget { + const CustomerNav({super.key}); + + @override + State createState() => _CustomerNavState(); +} + +class _CustomerNavState extends State { + int _index = 0; + + @override + Widget build(BuildContext context) { + final pages = [ + const BrowsePage(), + _BookingsTab(), + _WalletTab(), + ]; + + return Scaffold( + appBar: AppBar( + title: const Text('Customer'), + actions: const [Padding(padding: EdgeInsets.only(right: 8), child: NotificationsBadge())], + ), + body: pages[_index], + bottomNavigationBar: BottomNavigationBar( + currentIndex: _index, + onTap: (i) => setState(() => _index = i), + items: const [ + BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Browse'), + BottomNavigationBarItem(icon: Icon(Icons.book_online_outlined), label: 'Bookings'), + BottomNavigationBarItem(icon: Icon(Icons.account_balance_wallet_outlined), label: 'Wallet'), + ], + ), + ); + } +} + +class _BookingsTab extends StatelessWidget { + @override + Widget build(BuildContext context) { + final state = context.watch(); + final me = state.currentUser!; + final bookings = state.bookings.where((b) => b.customerId == me.id).toList(); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text('My bookings', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + if (bookings.isEmpty) const Text('No bookings yet') else ...[ + for (final b in bookings) + ListTile( + leading: const Icon(Icons.book_online_outlined), + title: Text('Booking ${b.id.substring(0, 6)} - ${b.amount.toStringAsFixed(2)}'), + subtitle: Text('Status: ${b.status.name}'), + ), + ] + ], + ); + } +} + +class _WalletTab extends StatelessWidget { + @override + Widget build(BuildContext context) { + final state = context.watch(); + final me = state.currentUser!; + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Balance: ${me.walletBalance.toStringAsFixed(2)}'), + const SizedBox(height: 12), + ElevatedButton(onPressed: () => state.topUpWallet(50), child: const Text('Top up 50')), + ], + ), + ); + } +} diff --git a/lib/features/user/customer/notifications_drawer.dart b/lib/features/user/customer/notifications_drawer.dart new file mode 100644 index 0000000..b9f2a29 --- /dev/null +++ b/lib/features/user/customer/notifications_drawer.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:khadamati/core/app_state.dart'; + +class NotificationsDrawer extends StatelessWidget { + const NotificationsDrawer({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch(); + final items = state.notificationsForCurrent; + + return Drawer( + child: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text('Notifications', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + for (final n in items) + ListTile( + leading: const Icon(Icons.notifications_none), + title: Text(n.title), + subtitle: Text(n.body), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/user/customer/reviews_page.dart b/lib/features/user/customer/reviews_page.dart new file mode 100644 index 0000000..1be71f5 --- /dev/null +++ b/lib/features/user/customer/reviews_page.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:khadamati/core/app_state.dart'; + +class ReviewsPage extends StatelessWidget { + final String workerUserId; + const ReviewsPage({super.key, required this.workerUserId}); + + @override + Widget build(BuildContext context) { + final state = context.watch(); + final ratings = state.bookings + .where((b) => b.workerId == workerUserId && b.rating != null) + .map((b) => b.rating!) + .toList(); + final average = ratings.isEmpty ? 0 : ratings.reduce((a, b) => a + b) / ratings.length; + + return Scaffold( + appBar: AppBar(title: const Text('Reviews')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Average rating: ${average.toStringAsFixed(1)} / 5'), + const SizedBox(height: 12), + Expanded( + child: ListView( + children: [ + for (final b in state.bookings.where((b) => b.workerId == workerUserId && b.review != null)) + ListTile( + leading: const Icon(Icons.star_rate_outlined), + title: Text('Rating: ${b.rating ?? '-'}'), + subtitle: Text(b.review ?? ''), + ), + ], + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/features/user/customer/widgets.dart b/lib/features/user/customer/widgets.dart new file mode 100644 index 0000000..f57eb86 --- /dev/null +++ b/lib/features/user/customer/widgets.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:khadamati/core/models/booking.dart'; + +class BookingTile extends StatelessWidget { + final ServiceBooking booking; + final Widget? trailing; + const BookingTile({super.key, required this.booking, this.trailing}); + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.book_online_outlined), + title: Text('Booking ${booking.id.substring(0, 6)} - ${booking.amount.toStringAsFixed(2)}'), + subtitle: Text('Status: ${booking.status.name}'), + trailing: trailing, + ); + } +} diff --git a/lib/features/user/store/store_home.dart b/lib/features/user/store/store_home.dart new file mode 100644 index 0000000..c238dd5 --- /dev/null +++ b/lib/features/user/store/store_home.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:khadamati/core/app_state.dart'; +import 'package:khadamati/core/models/enums.dart'; +import 'package:khadamati/core/models/user.dart'; + +class StoreHome extends StatelessWidget { + const StoreHome({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch(); + final user = state.currentUser!; + final profile = state.stores.firstWhere((s) => s.userId == user.id, orElse: () { + // ensure store profile exists in demo + state.addProduct(storeUserId: user.id, name: 'Sample product', price: 10, stock: 5); + return state.stores.firstWhere((s) => s.userId == user.id); + }); + + return Scaffold( + appBar: AppBar( + title: const Text('Store'), + actions: [ + IconButton(onPressed: () => state.logout(), icon: const Icon(Icons.logout)), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _walletCard(context), + const SizedBox(height: 16), + _subscription(context, profile), + const SizedBox(height: 16), + _products(context, profile), + const SizedBox(height: 16), + _ads(context), + ], + ), + ); + } + + Widget _walletCard(BuildContext context) { + final state = context.watch(); + final user = state.currentUser!; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.account_balance_wallet_outlined), + const SizedBox(width: 12), + Expanded(child: Text('Wallet: ${user.walletBalance.toStringAsFixed(2)}')), + TextButton( + onPressed: () => state.topUpWallet(50), + child: const Text('Top up 50'), + ), + ], + ), + ), + ); + } + + Widget _subscription(BuildContext context, StoreProfile profile) { + final state = context.watch(); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Subscription', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + ElevatedButton(onPressed: () => state.subscribeStore(profile.userId, SubscriptionTier.basic), child: const Text('Basic')), + ElevatedButton(onPressed: () => state.subscribeStore(profile.userId, SubscriptionTier.pro), child: const Text('Pro')), + ElevatedButton(onPressed: () => state.subscribeStore(profile.userId, SubscriptionTier.premium), child: const Text('Premium')), + ], + ), + const SizedBox(height: 8), + Text('Active: ${profile.activeSubscription?.name ?? 'None'}'), + ], + ), + ), + ); + } + + Widget _products(BuildContext context, StoreProfile profile) { + final state = context.watch(); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Products', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + ElevatedButton( + onPressed: () => state.addProduct(storeUserId: profile.userId, name: 'Item ${profile.products.length + 1}', price: 9.99, stock: 10), + child: const Text('Add product'), + ), + ], + ), + const SizedBox(height: 8), + for (final p in profile.products) + ListTile( + leading: const Icon(Icons.inventory_2_outlined), + title: Text(p.name), + subtitle: Text('Price: ${p.price.toStringAsFixed(2)} | Stock: ${p.stock}'), + ), + ], + ), + ), + ); + } + + Widget _ads(BuildContext context) { + final state = context.watch(); + final user = state.currentUser!; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Ads Packages', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + ElevatedButton(onPressed: () => state.purchaseAd(storeUserId: user.id, type: AdPackageType.bronze), child: const Text('Bronze')), + ElevatedButton(onPressed: () => state.purchaseAd(storeUserId: user.id, type: AdPackageType.silver), child: const Text('Silver')), + ElevatedButton(onPressed: () => state.purchaseAd(storeUserId: user.id, type: AdPackageType.gold), child: const Text('Gold')), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/user/worker/worker_home.dart b/lib/features/user/worker/worker_home.dart new file mode 100644 index 0000000..9bcf00a --- /dev/null +++ b/lib/features/user/worker/worker_home.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:khadamati/core/app_state.dart'; +import 'package:khadamati/core/models/enums.dart'; +import 'package:khadamati/core/models/user.dart'; + +class WorkerHome extends StatelessWidget { + const WorkerHome({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch(); + final user = state.currentUser!; + final myBookings = state.bookings.where((b) => b.workerId == user.id).toList(); + + final profile = state.ensureWorkerProfile(user.id); + + return Scaffold( + appBar: AppBar( + title: const Text('Worker'), + actions: [ + IconButton(onPressed: () => state.logout(), icon: const Icon(Icons.logout)), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _walletCard(context), + const SizedBox(height: 16), + _kycAndSubscription(context, profile), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Incoming Requests', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + for (final b in myBookings.where((b) => b.status == BookingStatus.requestPending)) + ListTile( + leading: const Icon(Icons.mark_email_unread_outlined), + title: Text('Request ${b.id.substring(0, 6)} - ${b.amount.toStringAsFixed(2)}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton(onPressed: () => state.workerRespond(bookingId: b.id, accept: false), child: const Text('Reject')), + const SizedBox(width: 8), + ElevatedButton(onPressed: () => state.workerRespond(bookingId: b.id, accept: true), child: const Text('Accept')), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Active/Scheduled', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + for (final b in myBookings.where((b) => b.status == BookingStatus.confirmed)) + ListTile( + leading: const Icon(Icons.event_available_outlined), + title: Text('Booking ${b.id.substring(0, 6)}'), + trailing: ElevatedButton( + onPressed: () => state.workerMarkCompleted(b.id), + child: const Text('Mark done'), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _walletCard(BuildContext context) { + final state = context.watch(); + final user = state.currentUser!; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.account_balance_wallet_outlined), + const SizedBox(width: 12), + Expanded(child: Text('Wallet: ${user.walletBalance.toStringAsFixed(2)}')), + TextButton( + onPressed: () => state.withdraw(20), + child: const Text('Withdraw 20'), + ), + ], + ), + ), + ); + } + + Widget _kycAndSubscription(BuildContext context, WorkerProfile profile) { + final state = context.watch(); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('KYC & Subscription', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text('KYC: ${profile.kycStatus.name}'), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + ElevatedButton(onPressed: () => state.submitKycApproved(profile.userId), child: const Text('Submit KYC')), + ElevatedButton(onPressed: () => state.subscribeWorker(profile.userId, SubscriptionTier.basic), child: const Text('Basic')), + ElevatedButton(onPressed: () => state.subscribeWorker(profile.userId, SubscriptionTier.pro), child: const Text('Pro')), + ElevatedButton(onPressed: () => state.subscribeWorker(profile.userId, SubscriptionTier.premium), child: const Text('Premium')), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..0dfa867 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:khadamati/core/routes.dart'; +import 'package:khadamati/core/theme/app_theme.dart'; +import 'package:provider/provider.dart'; +import 'package:khadamati/core/app_state.dart'; + +void main() { + runApp(const KhadamatiApp()); +} + +class KhadamatiApp extends StatelessWidget { + const KhadamatiApp({super.key}); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => AppState(), + child: MaterialApp( + title: 'Khadamati', + theme: AppTheme.lightTheme, + debugShowCheckedModeBanner: false, + initialRoute: Routes.selectRole, + onGenerateRoute: AppRouter.onGenerateRoute, + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..6b69eda --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,24 @@ +name: khadamati +description: Khadamati - Marketplace & Services Booking (Frontend-only prototype) +publish_to: "none" + +version: 0.1.0+1 + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + provider: ^6.1.2 + intl: ^0.19.0 + uuid: ^4.5.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.1 + +flutter: + uses-material-design: true