diff --git a/.gitignore b/.gitignore index fe67f0d..44c7aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +mock-api/node_modules/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6241074 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "chat.tools.terminal.autoApprove": { + "dart": true + } +} diff --git a/README.md b/README.md index 9301f3b..ebf6f19 100644 --- a/README.md +++ b/README.md @@ -1 +1,80 @@ -# Frontend \ No newline at end of file +# SharpCut Flutter (Class Project) + +This project is simple: +- basic Flutter widgets +- Riverpod providers only where needed +- clean folder layout with clear responsibilities + +## Folder Structure + +```text +lib/ + core/ + config/ # Environment constants + network/ # Dio client + API service + router/ # Route names + GoRouter setup + storage/ # Secure storage helpers + shared_widgets/ # App-wide design tokens (colors) + theme/ # Global ThemeData + features/ + auth/ + data/models/ # Auth request/response models + providers/ # Auth controller/provider + booking/ + data/models/ # Appointment models + providers/ # Booking + appointments providers + services/ + data/models/ # Service model + providers/ # Service list provider + pages/ # App screens (beginner-friendly flat structure) + shared/ + data/ # Demo seed data fallback for unfinished backend + utils/ # Validators and simple helpers + widgets/ # Reusable UI pieces (section headers, bottom navs) +``` + +## Widget Suggestions Per Page + +- `login_page.dart` + - `SingleChildScrollView`, `Card`, `Form`, `TextFormField`, `ElevatedButton` +- `services_page.dart` + - `ListView.separated`, `Card`, `ChoiceChip`/badge, `NavigationBar` +- `booking_page.dart` + - `ChoiceChip`, `Wrap`, `Card`, `ElevatedButton`, `SnackBar` +- `my_appointments_page.dart` + - `ListView`, `Card`, small status badge container, `TextButton` +- `admin_dashboard_page.dart` + - `Wrap` (metric cards), `Card`, `ListTile`, `NavigationBar` +- `manage_services_page.dart` + - `ListView.separated`, `Card`, `TextButton`, `FloatingActionButton.extended` + +## Beginner Rules For This Project + +1. Keep each page readable: split complex UI into small private widgets inside the same file. +2. Prefer simple state (`StatefulWidget`) for local UI state and Riverpod providers for app data. +3. Use shared theme/colors first before adding custom per-page styling. +4. Keep TODO comments where backend integration is still pending. + +## Run With Mock Backend (json-server) + +From project root: + +```bash +cd mock-api +npm install +npm start +``` + +In a second terminal (project root): + +```bash +flutter pub get +flutter run +``` + +Quick login accounts: + +- `admin@sharpcut.dev` / `password123` +- `client@sharpcut.dev` / `password123` + +Any email containing `admin` is also accepted and created as admin automatically. diff --git a/assets/images/hair_model.png b/assets/images/hair_model.png new file mode 100644 index 0000000..06201ba Binary files /dev/null and b/assets/images/hair_model.png differ diff --git a/flutter-development-guide.md b/flutter-development-guide.md new file mode 100644 index 0000000..f5658b4 --- /dev/null +++ b/flutter-development-guide.md @@ -0,0 +1,364 @@ +# Flutter Development Guide (Class Project) + +Last updated: 2026-03-29 +Project context: Flutter frontend + Node.js backend +Audience: Beginners learning Flutter while building a real team project + +--- + +## 1) Recommended Project Scope + +Keep this project strong but realistic for class delivery. + +### MVP pages to build first + +1. `HomePage` +2. `ServicesPage` (list of services/items) +3. `ServiceDetailsPage` (single item details) +4. `ContactPage` (form submission) +5. `AboutPage` +6. `NotFoundPage` (for bad routes) + +### Optional pages (only if time allows) + +1. `SplashPage` (branding + initialization) +2. `LoginPage` and `RegisterPage` +3. `ProfilePage` +4. `SettingsPage` + +You already created: +- `lib/pages/home_page.dart` +- `lib/pages/services_page.dart` +- `lib/pages/contact_page.dart` +- `lib/pages/about_page.dart` + +Good next step is to complete those 4 first, then add `ServiceDetailsPage`. + +--- + +## 2) What Each Page Should Contain + +### `HomePage` + +- Purpose: first impression + quick navigation. +- Suggested sections: + - Hero/title area + - Short project/service intro + - Top services preview (3-5 cards) + - Primary actions (View Services, Contact Us) +- Backend needs: + - Optional featured services endpoint +- States to handle: + - Loading + - Empty (no featured services) + - Error + retry + +### `ServicesPage` + +- Purpose: browse all available services/items. +- Suggested sections: + - Search bar + - Category/filter chips (optional) + - Paginated or scroll list of service cards +- Backend needs: + - Get services list + - Optional query params: search, filter, page +- States to handle: + - Loading + - Empty results + - Network error + +### `ServiceDetailsPage` + +- Purpose: show full info for a selected service/item. +- Suggested sections: + - Main image + - Title and description + - Price/rate (if applicable) + - Contact/Book action +- Backend needs: + - Get service by id +- States to handle: + - Loading + - Item not found + - Error + back action + +### `ContactPage` + +- Purpose: let user send inquiry/feedback. +- Suggested fields: + - Name + - Email + - Phone (optional) + - Message +- Backend needs: + - Submit contact form endpoint +- Validation needed: + - Required fields + - Email format + - Minimum message length +- UX needed: + - Disabled submit while sending + - Success confirmation + - Friendly error message + +### `AboutPage` + +- Purpose: who you are + what the project does. +- Suggested sections: + - Team mission + - What problem you solve + - Team members (optional) + - Social/contact links (optional) +- Backend needs: + - Usually none (can be static) + +### `NotFoundPage` + +- Purpose: handle broken links/routes. +- Should include: + - Simple error message + - Button to return home + +--- + +## 3) Recommended Dependency Stack (Class Friendly) + +Use this minimal stack. It is beginner-friendly and enough for clean architecture. + +### Core dependencies + +1. `go_router` +- Why: clean navigation/routing and easy deep-link style route management. +- Install: `flutter pub add go_router` +- Use it for: central route table and named navigation. + +2. `flutter_riverpod` +- Why: predictable state management with less hidden behavior than `setState` everywhere. +- Install: `flutter pub add flutter_riverpod` +- Use it for: page state, API loading state, shared app state. + +3. `dio` +- Why: robust HTTP client with interceptors, timeout control, and cleaner error handling. +- Install: `flutter pub add dio` +- Use it for: all backend communication. + +4. `flutter_secure_storage` +- Why: secure local storage for auth tokens. +- Install: `flutter pub add flutter_secure_storage` +- Use it for: storing access token and clearing it on logout. + +5. `intl` +- Why: formatting dates, times, and numbers in a clean way. +- Install: `flutter pub add intl` +- Use it for: user-facing date/time and currency formatting. + +6. `url_launcher` +- Why: open email, phone dialer, or external links. +- Install: `flutter pub add url_launcher` +- Use it for: contact actions from About or Contact pages. + +7. `cached_network_image` (optional but useful) +- Why: smooth image loading with caching and placeholders. +- Install: `flutter pub add cached_network_image` +- Use it for: service images from backend URLs. + +### Model and serialization dependencies + +8. `json_annotation` +- Why: standard annotations for generated JSON model mapping. +- Install: `flutter pub add json_annotation` + +9. `json_serializable` (dev dependency) +- Why: auto-generates model parsing to reduce manual bugs. +- Install: `flutter pub add --dev json_serializable` + +10. `build_runner` (dev dependency) +- Why: runs code generation for models. +- Install: `flutter pub add --dev build_runner` + +### Testing dependencies + +11. `mocktail` (dev dependency) +- Why: easier mocking for unit tests. +- Install: `flutter pub add --dev mocktail` + +--- + +## 4) Dependency Installation Order + +Run these in this order: + +1. `flutter pub add go_router flutter_riverpod dio flutter_secure_storage intl url_launcher` +2. `flutter pub add cached_network_image` +3. `flutter pub add json_annotation` +4. `flutter pub add --dev build_runner json_serializable mocktail` +5. `flutter pub get` + +When model generation is needed: + +- `dart run build_runner build --delete-conflicting-outputs` + +When model files are changed frequently during development: + +- `dart run build_runner watch --delete-conflicting-outputs` + +--- + +## 5) How to Use Each Dependency (No-Code Workflow) + +### `go_router` workflow + +1. Create one central route file. +2. Define all page routes in one place. +3. Use named routes instead of hardcoded path strings across files. +4. Add unknown-route fallback to `NotFoundPage`. + +### `flutter_riverpod` workflow + +1. Create feature-level state providers. +2. Keep API calls outside widgets (service/repository layer). +3. Widgets read provider state and display loading/error/data UI. +4. Avoid mixing business logic directly inside page widgets. + +### `dio` workflow + +1. Create one configured HTTP client with base URL and timeout. +2. Add request/response interceptors for logging during development. +3. Centralize API error mapping into user-friendly messages. +4. Do not call backend directly from page widgets. + +### `flutter_secure_storage` workflow + +1. Save token after successful login. +2. Read token when app starts. +3. Attach token to API headers (through `dio` interceptor). +4. Delete token on logout or auth failure. + +### `json_serializable` workflow + +1. Create model classes based on backend JSON contracts. +2. Run generator after model changes. +3. Keep model naming aligned with backend keys. +4. Regenerate files before pushing code to avoid CI failures. + +### `mocktail` workflow + +1. Mock repository or API layer, not UI widgets. +2. Write tests for success, failure, and empty responses. +3. Use widget tests only for key flows and forms. + +--- + +## 6) Suggested Folder Structure + +Use this structure to keep project easy to understand: + +- `lib/main.dart` -> app entry +- `lib/app/` -> app-level setup +- `lib/app/router/` -> routing config +- `lib/core/config/` -> environment config and constants +- `lib/core/network/` -> API client and network error mapping +- `lib/core/storage/` -> secure/local storage service +- `lib/shared/widgets/` -> reusable UI components +- `lib/shared/theme/` -> colors, text styles, spacing +- `lib/features/home/` -> home feature files +- `lib/features/services/` -> services list + details +- `lib/features/contact/` -> contact form flow +- `lib/features/about/` -> about content +- `lib/features/auth/` -> login/register (if needed) + +Inside each feature folder: + +- `data/` -> models, remote data source, repository implementation +- `domain/` -> entities/use-cases (lightweight for class project) +- `presentation/` -> pages, providers, widgets + +--- + +## 7) Backend Contract Checklist (Before UI Implementation) + +For each endpoint, confirm: + +1. URL path and HTTP method +2. Required headers +3. Request body fields and types +4. Success response shape +5. Error response shape +6. Which fields are nullable +7. Example request and response payload + +Minimum backend endpoints to request from Node.js team: + +1. `GET /services` +2. `GET /services/{id}` +3. `POST /contact` +4. `POST /auth/login` (if auth is part of MVP) +5. `POST /auth/register` (if auth is part of MVP) + +--- + +## 8) Step-by-Step Build Plan (Execution Order) + +### Phase 1: Foundation + +1. Clean template app. +2. Configure theme and routing. +3. Add dependency stack. +4. Set folder structure. + +### Phase 2: Static UI + +1. Build Home, Services, Contact, About with static data. +2. Finalize navigation flow between pages. +3. Make UI responsive on small/medium phones. + +### Phase 3: Backend Integration + +1. Connect Services list and details to API. +2. Connect Contact form submission. +3. Add loading/empty/error handling on each connected page. + +### Phase 4: Quality Pass + +1. Add form validation and user feedback states. +2. Add tests for core services and key widgets. +3. Run `flutter analyze`, `dart format .`, `flutter test`. +4. Fix final bugs and freeze scope. + +--- + +## 9) Common Mistakes to Avoid + +1. Calling API directly inside widget build methods. +2. Mixing route names and literal paths across files. +3. Starting all pages at once before finishing one complete flow. +4. Ignoring loading and error states until the end. +5. Hardcoding backend URLs in many places. +6. Skipping basic tests for forms and API mapping. + +--- + +## 10) Definition of Done (Per Feature) + +A feature is considered done only when all are true: + +1. UI is complete and navigable. +2. Backend integration works for success and failure cases. +3. Loading, empty, and error states are visible and usable. +4. Basic validation exists for user inputs. +5. At least one test covers the feature's key behavior. +6. README/API notes updated if behavior changed. + +--- + +## 11) Quick Commands Reference + +- Install dependencies: `flutter pub get` +- Run app: `flutter run` +- Analyze: `flutter analyze` +- Format: `dart format .` +- Test: `flutter test` +- Build Android APK: `flutter build apk` +- Build web (if needed): `flutter build web` + diff --git a/lib/core/config/env.dart b/lib/core/config/env.dart new file mode 100644 index 0000000..05a5256 --- /dev/null +++ b/lib/core/config/env.dart @@ -0,0 +1,16 @@ +import 'package:flutter/foundation.dart'; + +class Env { + Env._(); + + static String get baseUrl { + if (kIsWeb) { + return 'http://localhost:3000'; + } + + return switch (defaultTargetPlatform) { + TargetPlatform.android => 'http://10.0.2.2:3000', + _ => 'http://localhost:3000', + }; + } +} diff --git a/lib/core/network/api_service.dart b/lib/core/network/api_service.dart new file mode 100644 index 0000000..3158ff8 --- /dev/null +++ b/lib/core/network/api_service.dart @@ -0,0 +1,158 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/core/network/dio_client.dart'; +import 'package:frontend/features/auth/data/models/auth_response_model.dart'; +import 'package:frontend/features/auth/data/models/login_request_model.dart'; +import 'package:frontend/features/booking/data/models/appointment_model.dart'; +import 'package:frontend/features/booking/data/models/create_appointment_request_model.dart'; +import 'package:frontend/features/services/data/models/service_model.dart'; + +final apiServiceProvider = Provider((ref) { + final dio = ref.watch(dioProvider); + return ApiService(dio); +}); + +class ApiService { + ApiService(this._dio); + + final Dio _dio; + + Future login(LoginRequestModel request) async { + try { + final response = await _dio.post('/auth/login', data: request.toJson()); + return AuthResponseModel.fromJson(_asMap(response.data)); + } catch (error) { + _throwHandledError(error); + } + } + + Future> fetchServices() async { + try { + final response = await _dio.get('/services'); + return _asList( + response.data, + ).map((item) => ServiceModel.fromJson(_asMap(item))).toList(); + } catch (error) { + _throwHandledError(error); + } + } + + Future createService(ServiceModel service) async { + try { + final payload = {...service.toJson()} + ..removeWhere((_, value) => value == null); + + final response = await _dio.post('/services', data: payload); + return ServiceModel.fromJson(_asMap(response.data)); + } catch (error) { + _throwHandledError(error); + } + } + + Future updateService(ServiceModel service) async { + try { + final payload = {...service.toJson()} + ..removeWhere((_, value) => value == null); + + final response = await _dio.put('/services/${service.id}', data: payload); + return ServiceModel.fromJson(_asMap(response.data)); + } catch (error) { + _throwHandledError(error); + } + } + + Future deleteService(String serviceId) async { + try { + await _dio.delete('/services/$serviceId'); + } catch (error) { + _throwHandledError(error); + } + } + + Future createAppointment( + CreateAppointmentRequestModel request, + ) async { + try { + final response = await _dio.post('/appointments', data: request.toJson()); + return AppointmentModel.fromJson(_asMap(response.data)); + } catch (error) { + _throwHandledError(error); + } + } + + Future> fetchMyAppointments() async { + try { + final response = await _dio.get('/appointments/me'); + return _asList( + response.data, + ).map((item) => AppointmentModel.fromJson(_asMap(item))).toList(); + } catch (error) { + _throwHandledError(error); + } + } + + Future> fetchAppointments({String? clientId}) async { + try { + final response = await _dio.get( + '/appointments', + queryParameters: clientId == null ? null : {'clientId': clientId}, + ); + + return _asList( + response.data, + ).map((item) => AppointmentModel.fromJson(_asMap(item))).toList(); + } catch (error) { + _throwHandledError(error); + } + } + + Map _asMap(dynamic data) { + if (data is Map) { + return data; + } + + if (data is Map) { + return data.map((key, value) => MapEntry(key.toString(), value)); + } + + throw Exception('Unexpected API response format.'); + } + + List _asList(dynamic data) { + if (data is List) { + return data; + } + + if (data is List) { + return data.cast(); + } + + throw Exception('Unexpected API response format.'); + } + + Never _throwHandledError(Object error) { + if (error is DioException) { + throw Exception(_extractDioErrorMessage(error)); + } + + throw Exception(error.toString()); + } + + String _extractDioErrorMessage(DioException error) { + final responseData = error.response?.data; + + if (responseData is Map && responseData['message'] != null) { + return responseData['message'].toString(); + } + + if (responseData is String && responseData.trim().isNotEmpty) { + return responseData; + } + + if (error.message != null && error.message!.trim().isNotEmpty) { + return error.message!; + } + + return 'Request failed. Please try again.'; + } +} diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart new file mode 100644 index 0000000..658b54b --- /dev/null +++ b/lib/core/network/dio_client.dart @@ -0,0 +1,41 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/core/config/env.dart'; +import 'package:frontend/core/storage/secure_storage_service.dart'; + +final secureStorageProvider = Provider((ref) { + return SecureStorageService(); +}); + +final dioProvider = Provider((ref) { + final dio = Dio( + BaseOptions( + baseUrl: Env.baseUrl, + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 15), + headers: const {'Content-Type': 'application/json'}, + ), + ); + + // NOTE: This interceptor is intentionally simple for MVP. + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) async { + final storage = ref.read(secureStorageProvider); + final token = await storage.readAccessToken(); + + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + + handler.next(options); + }, + onError: (error, handler) { + // MOCK: You can intercept interceptors and map backend errors to custom UI exceptions here. + handler.next(error); + }, + ), + ); + + return dio; +}); diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart new file mode 100644 index 0000000..c165890 --- /dev/null +++ b/lib/core/router/app_router.dart @@ -0,0 +1,87 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/core/router/route_names.dart'; +import 'package:frontend/features/auth/providers/auth_provider.dart'; +import 'package:frontend/pages/admin_dashboard_page.dart'; +import 'package:frontend/pages/booking_page.dart'; +import 'package:frontend/pages/home_page.dart'; +import 'package:frontend/pages/login_page.dart'; +import 'package:frontend/pages/manage_services_page.dart'; +import 'package:frontend/pages/my_appointments_page.dart'; +import 'package:frontend/pages/not_found_page.dart'; +import 'package:frontend/pages/services_page.dart'; +import 'package:go_router/go_router.dart'; + +final appRouterProvider = Provider((ref) { + final authState = ref.watch(authProvider); + final session = authState.valueOrNull; + + return GoRouter( + initialLocation: RouteNames.login, + routes: [ + GoRoute( + path: RouteNames.home, + name: 'home', + builder: (context, state) => const HomePage(), + ), + GoRoute( + path: RouteNames.login, + name: 'login', + builder: (context, state) => const LoginPage(), + ), + GoRoute( + path: RouteNames.services, + name: 'services', + builder: (context, state) => const ServicesPage(), + ), + GoRoute( + path: RouteNames.booking, + name: 'booking', + builder: (context, state) => const BookingPage(), + ), + GoRoute( + path: RouteNames.myAppointments, + name: 'my_appointments', + builder: (context, state) => const MyAppointmentsPage(), + ), + GoRoute( + path: RouteNames.adminDashboard, + name: 'admin_dashboard', + builder: (context, state) => const AdminDashboardPage(), + ), + GoRoute( + path: RouteNames.adminManageServices, + name: 'admin_manage_services', + builder: (context, state) => const ManageServicesPage(), + ), + ], + errorBuilder: (context, state) => const NotFoundPage(), + redirect: (context, state) { + final location = state.uri.toString(); + final isLoggedIn = session != null; + final role = session?.user.role; + + final isAuthPage = location == RouteNames.login; + final isClientPage = location.startsWith('/client/'); + final isAdminPage = location.startsWith('/admin/'); + + if (!isLoggedIn && !isAuthPage) { + return RouteNames.login; + } + + if (isLoggedIn && isAuthPage) { + if (role == UserRole.admin.name) return RouteNames.adminDashboard; + return RouteNames.services; + } + + if (isLoggedIn && role == UserRole.client.name && isAdminPage) { + return RouteNames.services; + } + + if (isLoggedIn && role == UserRole.admin.name && isClientPage) { + return RouteNames.adminDashboard; + } + + return null; + }, + ); +}); diff --git a/lib/core/router/route_names.dart b/lib/core/router/route_names.dart new file mode 100644 index 0000000..d106885 --- /dev/null +++ b/lib/core/router/route_names.dart @@ -0,0 +1,13 @@ +class RouteNames { + RouteNames._(); + + static const String home = '/'; + static const String login = '/login'; + + static const String services = '/client/services'; + static const String booking = '/client/booking'; + static const String myAppointments = '/client/my-appointments'; + + static const String adminDashboard = '/admin/dashboard'; + static const String adminManageServices = '/admin/manage-services'; +} diff --git a/lib/core/shared_widgets/app_colors.dart b/lib/core/shared_widgets/app_colors.dart new file mode 100644 index 0000000..f742545 --- /dev/null +++ b/lib/core/shared_widgets/app_colors.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class AppColors { + // Main dark color for CTA buttons and active navigation. + static const Color primary = Color(0xFF0C1B2A); + + // Soft tint of primary used for section backgrounds and badges. + static const Color primarySoft = Color(0xFFEAF0F7); + + // Bronze accent for premium highlights (prices, badges, key stats). + static const Color accentGold = Color(0xFFC4A97D); + + // Main page background. + static const Color background = Color(0xFFF4F5F7); + + // Card and input background. + static const Color surface = Color(0xFFFFFFFF); + + // Extra subtle background block for grouping content. + static const Color sectionTint = Color(0xFFF1F4F9); + + // Main text color. + static const Color textMain = Color(0xFF101828); + + // Secondary text color. + static const Color textMuted = Color(0xFF667085); + + // Text color on primary buttons. + static const Color textOnPrimary = Color(0xFFFFFFFF); + + // Borders and separators. + static const Color borderSubtle = Color(0xFFE4E7EC); + + // Error feedback color. + static const Color error = Color(0xFFD92D20); + + // Success feedback color. + static const Color success = Color(0xFF12B76A); + + // Warning or pending status color. + static const Color warning = Color(0xFFF79009); +} diff --git a/lib/core/storage/secure_storage_service.dart b/lib/core/storage/secure_storage_service.dart new file mode 100644 index 0000000..e84621c --- /dev/null +++ b/lib/core/storage/secure_storage_service.dart @@ -0,0 +1,35 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class SecureStorageService { + SecureStorageService({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + final FlutterSecureStorage _storage; + + static const String accessTokenKey = 'access_token'; + static const String refreshTokenKey = 'refresh_token'; + static const String userRoleKey = 'user_role'; + + Future saveAccessToken(String token) async { + // This correctly saves the token to local secure-storage after login. + await _storage.write(key: accessTokenKey, value: token); + } + + Future readAccessToken() async { + return _storage.read(key: accessTokenKey); + } + + Future saveRole(String role) async { + await _storage.write(key: userRoleKey, value: role); + } + + Future readRole() async { + return _storage.read(key: userRoleKey); + } + + Future clearSession() async { + await _storage.delete(key: accessTokenKey); + await _storage.delete(key: refreshTokenKey); + await _storage.delete(key: userRoleKey); + } +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..0860dd0 --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import '../shared_widgets/app_colors.dart'; + +final ThemeData sharpCutTheme = ThemeData( + useMaterial3: true, + scaffoldBackgroundColor: AppColors.background, + primaryColor: AppColors.primary, + colorScheme: const ColorScheme( + brightness: Brightness.light, + primary: AppColors.primary, + onPrimary: AppColors.textOnPrimary, + secondary: AppColors.accentGold, + onSecondary: AppColors.primary, + error: AppColors.error, + onError: AppColors.textOnPrimary, + surface: AppColors.surface, + onSurface: AppColors.textMain, + ), + + // Keep typography simple and readable for beginner teams. + textTheme: const TextTheme( + headlineLarge: TextStyle( + color: AppColors.textMain, + fontWeight: FontWeight.w700, + height: 1.2, + ), + headlineMedium: TextStyle( + color: AppColors.textMain, + fontWeight: FontWeight.w700, + ), + titleLarge: TextStyle( + color: AppColors.textMain, + fontWeight: FontWeight.w600, + ), + titleMedium: TextStyle( + color: AppColors.textMain, + fontWeight: FontWeight.w600, + ), + displayLarge: TextStyle( + color: AppColors.textMain, + fontWeight: FontWeight.w700, + ), + bodyLarge: TextStyle(color: AppColors.textMain, height: 1.4), + bodyMedium: TextStyle(color: AppColors.textMuted), + labelLarge: TextStyle(fontWeight: FontWeight.w600), + ), + + appBarTheme: const AppBarTheme( + backgroundColor: AppColors.background, + foregroundColor: AppColors.textMain, + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + ), + + dialogTheme: DialogThemeData( + backgroundColor: AppColors.surface, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + titleTextStyle: const TextStyle( + color: AppColors.textMain, + fontSize: 28, + fontWeight: FontWeight.w700, + ), + contentTextStyle: const TextStyle( + color: AppColors.textMain, + fontSize: 16, + height: 1.5, + ), + ), + + // Primary action buttons. + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.textOnPrimary, + minimumSize: const Size.fromHeight(50), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + ), + ), + + // Secondary actions such as "View History" or "Cancel". + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textMain, + minimumSize: const Size.fromHeight(48), + side: const BorderSide(color: AppColors.borderSubtle), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + ), + ), + + // Global card style used by all list/detail sections. + cardTheme: CardThemeData( + color: AppColors.surface, + elevation: 0, + shadowColor: Colors.transparent, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: const BorderSide(color: AppColors.borderSubtle), + ), + ), + + // Input fields for login and forms. + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: AppColors.surface, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + labelStyle: const TextStyle(color: AppColors.textMuted), + hintStyle: const TextStyle(color: AppColors.textMuted), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: AppColors.borderSubtle), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: AppColors.borderSubtle), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: AppColors.primary, width: 1.4), + ), + ), + + chipTheme: const ChipThemeData( + backgroundColor: AppColors.primarySoft, + labelStyle: TextStyle(color: AppColors.textMain), + side: BorderSide(color: AppColors.borderSubtle), + shape: StadiumBorder(), + selectedColor: AppColors.primary, + secondaryLabelStyle: TextStyle(color: AppColors.textOnPrimary), + ), + + navigationBarTheme: NavigationBarThemeData( + backgroundColor: AppColors.surface, + indicatorColor: AppColors.primarySoft, + labelTextStyle: WidgetStateProperty.resolveWith((states) { + final isSelected = states.contains(WidgetState.selected); + return TextStyle( + color: isSelected ? AppColors.primary : AppColors.textMuted, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + ); + }), + ), + + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: AppColors.primary, + contentTextStyle: const TextStyle(color: AppColors.textOnPrimary), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), +); diff --git a/lib/features/auth/data/models/auth_response_model.dart b/lib/features/auth/data/models/auth_response_model.dart new file mode 100644 index 0000000..67e3b70 --- /dev/null +++ b/lib/features/auth/data/models/auth_response_model.dart @@ -0,0 +1,22 @@ +import 'package:frontend/features/auth/data/models/user_model.dart'; + +class AuthResponseModel { + const AuthResponseModel({required this.accessToken, required this.user}); + + // JWT token used in Authorization header. + final String accessToken; + + // Logged-in user profile returned by backend. + final UserModel user; + + factory AuthResponseModel.fromJson(Map json) { + return AuthResponseModel( + accessToken: json['accessToken'] as String, + user: UserModel.fromJson(json['user'] as Map), + ); + } + + Map toJson() { + return {'accessToken': accessToken, 'user': user.toJson()}; + } +} diff --git a/lib/features/auth/data/models/login_request_model.dart b/lib/features/auth/data/models/login_request_model.dart new file mode 100644 index 0000000..e7d39a3 --- /dev/null +++ b/lib/features/auth/data/models/login_request_model.dart @@ -0,0 +1,10 @@ +class LoginRequestModel { + const LoginRequestModel({required this.email, required this.password}); + + final String email; + final String password; + + Map toJson() { + return {'email': email, 'password': password}; + } +} diff --git a/lib/features/auth/data/models/user_model.dart b/lib/features/auth/data/models/user_model.dart new file mode 100644 index 0000000..b82c436 --- /dev/null +++ b/lib/features/auth/data/models/user_model.dart @@ -0,0 +1,33 @@ +class UserModel { + const UserModel({ + required this.id, + required this.fullName, + required this.email, + required this.role, + }); + + // Unique identifier from backend (UUID or ObjectId string). + final String id; + + // Name displayed in app header/profile. + final String fullName; + + // User login email. + final String email; + + // Expected values for MVP: "client" or "admin". + final String role; + + factory UserModel.fromJson(Map json) { + return UserModel( + id: json['id'].toString(), + fullName: json['fullName'].toString(), + email: json['email'].toString(), + role: json['role'].toString(), + ); + } + + Map toJson() { + return {'id': id, 'fullName': fullName, 'email': email, 'role': role}; + } +} diff --git a/lib/features/auth/providers/auth_provider.dart b/lib/features/auth/providers/auth_provider.dart new file mode 100644 index 0000000..ea8f161 --- /dev/null +++ b/lib/features/auth/providers/auth_provider.dart @@ -0,0 +1,78 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/core/network/api_service.dart'; +import 'package:frontend/core/network/dio_client.dart'; +import 'package:frontend/core/storage/secure_storage_service.dart'; +import 'package:frontend/features/auth/data/models/login_request_model.dart'; +import 'package:frontend/features/auth/data/models/user_model.dart'; + +enum UserRole { client, admin } + +class AuthSession { + const AuthSession({required this.token, required this.user}); + + final String token; + final UserModel user; +} + +final authProvider = + StateNotifierProvider>((ref) { + final api = ref.watch(apiServiceProvider); + final storage = ref.watch(secureStorageProvider); + return AuthController(apiService: api, storage: storage); + }); + +class AuthController extends StateNotifier> { + AuthController({ + required ApiService apiService, + required SecureStorageService storage, + }) : _apiService = apiService, + _storage = storage, + super(const AsyncValue.data(null)); + + final ApiService _apiService; + final SecureStorageService _storage; + + Future login({required String email, required String password}) async { + state = const AsyncValue.loading(); + + state = await AsyncValue.guard(() async { + try { + final response = await _apiService.login( + LoginRequestModel(email: email, password: password), + ); + + await _storage.saveAccessToken(response.accessToken); + await _storage.saveRole(response.user.role); + + return AuthSession(token: response.accessToken, user: response.user); + } on UnimplementedError { + // Beginner-friendly fallback: any email containing "admin" logs in as admin. + final role = email.toLowerCase().contains('admin') + ? UserRole.admin.name + : UserRole.client.name; + + final demoSession = AuthSession( + token: 'demo-token-for-class-project', + user: UserModel( + id: role == UserRole.admin.name ? 'admin-demo' : 'client-demo', + fullName: role == UserRole.admin.name + ? 'SharpCut Admin' + : 'SharpCut Client', + email: email, + role: role, + ), + ); + + await _storage.saveAccessToken(demoSession.token); + await _storage.saveRole(demoSession.user.role); + + return demoSession; + } + }); + } + + Future logout() async { + await _storage.clearSession(); + state = const AsyncValue.data(null); + } +} diff --git a/lib/features/booking/data/models/appointment_model.dart b/lib/features/booking/data/models/appointment_model.dart new file mode 100644 index 0000000..4239db1 --- /dev/null +++ b/lib/features/booking/data/models/appointment_model.dart @@ -0,0 +1,56 @@ +class AppointmentModel { + const AppointmentModel({ + required this.id, + required this.serviceId, + required this.clientId, + required this.adminId, + required this.startTime, + required this.endTime, + required this.status, + }); + + // Unique appointment id. + final String id; + + // Linked service id. + final String serviceId; + + // User id for the client. + final String clientId; + + // User id for assigned admin/barber. + final String adminId; + + // Appointment start in ISO date string. + final DateTime startTime; + + // Appointment end in ISO date string. + final DateTime endTime; + + // Example values: pending, confirmed, cancelled, completed. + final String status; + + factory AppointmentModel.fromJson(Map json) { + return AppointmentModel( + id: json['id'].toString(), + serviceId: json['serviceId'].toString(), + clientId: json['clientId'].toString(), + adminId: json['adminId'].toString(), + startTime: DateTime.parse(json['startTime'].toString()), + endTime: DateTime.parse(json['endTime'].toString()), + status: json['status'].toString(), + ); + } + + Map toJson() { + return { + 'id': id, + 'serviceId': serviceId, + 'clientId': clientId, + 'adminId': adminId, + 'startTime': startTime.toIso8601String(), + 'endTime': endTime.toIso8601String(), + 'status': status, + }; + } +} diff --git a/lib/features/booking/data/models/create_appointment_request_model.dart b/lib/features/booking/data/models/create_appointment_request_model.dart new file mode 100644 index 0000000..31b95c0 --- /dev/null +++ b/lib/features/booking/data/models/create_appointment_request_model.dart @@ -0,0 +1,24 @@ +class CreateAppointmentRequestModel { + const CreateAppointmentRequestModel({ + required this.serviceId, + required this.adminId, + required this.startTime, + }); + + // Service the client selected. + final String serviceId; + + // Selected admin/barber id. + final String adminId; + + // Slot start time in ISO format. + final DateTime startTime; + + Map toJson() { + return { + 'serviceId': serviceId, + 'adminId': adminId, + 'startTime': startTime.toIso8601String(), + }; + } +} diff --git a/lib/features/booking/providers/appointments_provider.dart b/lib/features/booking/providers/appointments_provider.dart new file mode 100644 index 0000000..22c1052 --- /dev/null +++ b/lib/features/booking/providers/appointments_provider.dart @@ -0,0 +1,37 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/core/network/api_service.dart'; +import 'package:frontend/features/booking/data/models/appointment_model.dart'; +import 'package:frontend/features/booking/data/models/create_appointment_request_model.dart'; + +final appointmentsProvider = FutureProvider>(( + ref, +) async { + final api = ref.read(apiServiceProvider); + return api.fetchMyAppointments(); +}); + +final adminAppointmentsProvider = FutureProvider>(( + ref, +) async { + final api = ref.read(apiServiceProvider); + return api.fetchAppointments(); +}); + +class AppointmentController extends StateNotifier> { + AppointmentController(this._api) : super(const AsyncValue.data(null)); + + final ApiService _api; + + Future createAppointment(CreateAppointmentRequestModel request) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + await _api.createAppointment(request); + }); + } +} + +final appointmentControllerProvider = + StateNotifierProvider>((ref) { + final api = ref.watch(apiServiceProvider); + return AppointmentController(api); + }); diff --git a/lib/features/services/data/models/service_model.dart b/lib/features/services/data/models/service_model.dart new file mode 100644 index 0000000..bd1a637 --- /dev/null +++ b/lib/features/services/data/models/service_model.dart @@ -0,0 +1,50 @@ +class ServiceModel { + const ServiceModel({ + required this.id, + required this.name, + required this.description, + required this.price, + required this.durationMinutes, + this.imageUrl, + }); + + // Unique service identifier. + final String id; + + // Display name (e.g., Classic Haircut). + final String name; + + // Short description shown in list/detail card. + final String description; + + // Price from backend. + final double price; + + // Service duration used for slot booking. + final int durationMinutes; + + // Optional hero image URL. + final String? imageUrl; + + factory ServiceModel.fromJson(Map json) { + return ServiceModel( + id: json['id'].toString(), + name: (json['name'] ?? '').toString(), + description: (json['description'] ?? '').toString(), + price: (json['price'] as num).toDouble(), + durationMinutes: (json['durationMinutes'] as num).toInt(), + imageUrl: json['imageUrl'] as String?, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'price': price, + 'durationMinutes': durationMinutes, + 'imageUrl': imageUrl, + }; + } +} diff --git a/lib/features/services/providers/services_provider.dart b/lib/features/services/providers/services_provider.dart new file mode 100644 index 0000000..fc00d45 --- /dev/null +++ b/lib/features/services/providers/services_provider.dart @@ -0,0 +1,41 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/core/network/api_service.dart'; +import 'package:frontend/features/services/data/models/service_model.dart'; + +final servicesProvider = FutureProvider>((ref) async { + final api = ref.read(apiServiceProvider); + return api.fetchServices(); +}); + +class ServiceController extends StateNotifier> { + ServiceController(this._api) : super(const AsyncValue.data(null)); + + final ApiService _api; + + Future createService(ServiceModel service) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + await _api.createService(service); + }); + } + + Future updateService(ServiceModel service) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + await _api.updateService(service); + }); + } + + Future deleteService(String serviceId) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + await _api.deleteService(serviceId); + }); + } +} + +final serviceControllerProvider = + StateNotifierProvider>((ref) { + final api = ref.watch(apiServiceProvider); + return ServiceController(api); + }); diff --git a/lib/main.dart b/lib/main.dart index 7b7f5b6..61f8646 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,122 +1,25 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/core/router/app_router.dart'; +import 'package:frontend/core/theme/app_theme.dart'; void main() { - runApp(const MyApp()); + // Riverpod must wrap the app so providers can be read anywhere. + runApp(const ProviderScope(child: SharpCutApp())); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class SharpCutApp extends ConsumerWidget { + const SharpCutApp({super.key}); - // This widget is the root of your application. @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), // This trailing comma makes auto-formatting nicer for build methods. + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(appRouterProvider); + + return MaterialApp.router( + title: 'SharpCut', + theme: sharpCutTheme, + debugShowCheckedModeBanner: false, + routerConfig: router, ); } } diff --git a/lib/pages/about_page.dart b/lib/pages/about_page.dart new file mode 100644 index 0000000..385cadc --- /dev/null +++ b/lib/pages/about_page.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +class AboutPage extends StatelessWidget { + const AboutPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('About SharpCut')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Our Story', style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 8), + Text( + 'SharpCut was founded with the mission to modernize the grooming experience. ' + 'We connect premium grooming services with straightforward, hassle-free booking. ' + 'No more calling around—just find your perfect moment and book instantly.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 24), + Text( + 'Meet the Team', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + const ListTile( + leading: CircleAvatar(child: Icon(Icons.person)), + title: Text('Julian (Admin/Barber)'), + subtitle: Text('Master Barber with 10 years experience.'), + ), + const ListTile( + leading: CircleAvatar(child: Icon(Icons.person)), + title: Text('GDG Capstone Team'), + subtitle: Text( + 'Developers dedicated to modernizing appointment booking.', + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/admin_dashboard_page.dart b/lib/pages/admin_dashboard_page.dart new file mode 100644 index 0000000..bd0b363 --- /dev/null +++ b/lib/pages/admin_dashboard_page.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/core/router/route_names.dart'; +import 'package:frontend/core/shared_widgets/app_colors.dart'; +import 'package:frontend/features/booking/providers/appointments_provider.dart'; +import 'package:frontend/features/services/providers/services_provider.dart'; +import 'package:frontend/shared/widgets/admin_bottom_nav.dart'; +import 'package:frontend/shared/widgets/error_view.dart'; +import 'package:frontend/shared/widgets/loading_view.dart'; +import 'package:frontend/shared/widgets/section_header.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +class AdminDashboardPage extends ConsumerWidget { + const AdminDashboardPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appointmentsAsync = ref.watch(adminAppointmentsProvider); + final servicesAsync = ref.watch(servicesProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Admin Dashboard'), + actions: const [ + Padding( + padding: EdgeInsets.only(right: 16), + child: CircleAvatar( + radius: 14, + child: Icon(Icons.admin_panel_settings_outlined, size: 16), + ), + ), + ], + ), + bottomNavigationBar: const AdminBottomNav( + currentRoute: RouteNames.adminDashboard, + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + child: appointmentsAsync.when( + data: (appointments) => servicesAsync.when( + data: (services) { + final today = DateTime.now(); + final serviceNameById = { + for (final service in services) service.id: service.name, + }; + final servicePriceById = { + for (final service in services) service.id: service.price, + }; + + final confirmedToday = appointments.where((apt) { + final sameDay = + apt.startTime.year == today.year && + apt.startTime.month == today.month && + apt.startTime.day == today.day; + return sameDay && apt.status.toLowerCase() != 'cancelled'; + }).toList(); + + final completed = appointments + .where((apt) => apt.status.toLowerCase() == 'completed') + .toList(); + + final revenue = completed.fold(0, (sum, apt) { + return sum + (servicePriceById[apt.serviceId] ?? 0); + }); + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionHeader( + title: 'Executive Insights', + subtitle: + 'Quick view of bookings, revenue, and operations.', + ), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _MetricCard( + label: 'Revenue', + value: '\$${revenue.toStringAsFixed(0)}', + icon: Icons.payments_outlined, + ), + _MetricCard( + label: 'Completed', + value: '${completed.length}', + icon: Icons.check_circle_outline, + ), + _MetricCard( + label: 'Today', + value: '${confirmedToday.length}', + icon: Icons.event_available, + ), + ], + ), + const SizedBox(height: 14), + const SectionHeader( + title: 'Today\'s Schedule', + subtitle: + 'Tap manage services to edit prices, durations, and visibility.', + padding: EdgeInsets.only(bottom: 10), + ), + if (confirmedToday.isEmpty) + const _NoScheduleCard() + else + Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + children: confirmedToday.map((appointment) { + final time = DateFormat( + 'hh:mm a', + ).format(appointment.startTime); + final serviceName = + serviceNameById[appointment.serviceId] ?? + appointment.serviceId; + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: const CircleAvatar( + backgroundColor: AppColors.primarySoft, + child: Icon( + Icons.content_cut, + color: AppColors.primary, + ), + ), + title: Text(serviceName), + subtitle: Text('Starts at $time'), + trailing: Text( + appointment.status.toUpperCase(), + style: const TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w700, + fontSize: 11, + ), + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: 14), + Card( + color: AppColors.primary, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Curate Your Service Menu', + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(color: AppColors.textOnPrimary), + ), + const SizedBox(height: 8), + Text( + 'Add new services or adjust availability based on demand.', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: const Color(0xFFD9E6F3)), + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => context.go( + RouteNames.adminManageServices, + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.textOnPrimary, + foregroundColor: AppColors.primary, + ), + child: const Text('Manage Services'), + ), + ), + ], + ), + ), + ), + ], + ), + ); + }, + loading: () => const LoadingView(label: 'Loading services...'), + error: (error, _) => ErrorView( + message: 'Could not load services.\n$error', + onRetry: () => ref.invalidate(servicesProvider), + ), + ), + loading: () => + const LoadingView(label: 'Loading dashboard metrics...'), + error: (error, _) => ErrorView( + message: 'Could not load dashboard data.\n$error', + onRetry: () => ref.invalidate(adminAppointmentsProvider), + ), + ), + ), + ), + ); + } +} + +class _MetricCard extends StatelessWidget { + const _MetricCard({ + required this.label, + required this.value, + required this.icon, + }); + + final String label; + final String value; + final IconData icon; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 110, + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: AppColors.primary), + const SizedBox(height: 8), + Text( + value, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(color: AppColors.primary), + ), + const SizedBox(height: 2), + Text(label, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + ), + ); + } +} + +class _NoScheduleCard extends StatelessWidget { + const _NoScheduleCard(); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const CircleAvatar( + backgroundColor: AppColors.primarySoft, + child: Icon(Icons.event_busy, color: AppColors.primary), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'No confirmed appointments today. Promote top services to boost bookings.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/booking_page.dart b/lib/pages/booking_page.dart new file mode 100644 index 0000000..5263e54 --- /dev/null +++ b/lib/pages/booking_page.dart @@ -0,0 +1,327 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/core/router/route_names.dart'; +import 'package:frontend/core/shared_widgets/app_colors.dart'; +import 'package:frontend/features/booking/data/models/create_appointment_request_model.dart'; +import 'package:frontend/features/booking/providers/appointments_provider.dart'; +import 'package:frontend/features/services/data/models/service_model.dart'; +import 'package:frontend/features/services/providers/services_provider.dart'; +import 'package:frontend/shared/widgets/client_bottom_nav.dart'; +import 'package:frontend/shared/widgets/section_header.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +class BookingPage extends ConsumerStatefulWidget { + const BookingPage({super.key}); + + @override + ConsumerState createState() => _BookingPageState(); +} + +class _BookingPageState extends ConsumerState { + late DateTime _selectedDate; + DateTime? _selectedTime; + String? _selectedServiceId; + + @override + void initState() { + super.initState(); + final today = DateTime.now(); + _selectedDate = DateTime(today.year, today.month, today.day); + } + + List _slotsForDate(DateTime day) { + const hours = [9, 10, 11, 13, 14, 15]; + return hours + .map((hour) => DateTime(day.year, day.month, day.day, hour, 30)) + .toList(); + } + + Future _submitBooking() async { + if (_selectedTime == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Select a time slot first.')), + ); + return; + } + + final services = ref.read(servicesProvider).valueOrNull ?? const []; + final selectedService = _findSelectedService(services); + + if (selectedService == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Select a service first.'))); + return; + } + + final request = CreateAppointmentRequestModel( + serviceId: selectedService.id, + adminId: 'admin-julian', + startTime: _selectedTime!, + ); + + await ref + .read(appointmentControllerProvider.notifier) + .createAppointment(request); + + if (!mounted) return; + final createState = ref.read(appointmentControllerProvider); + if (createState.hasError) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(createState.error.toString()))); + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Appointment created successfully.')), + ); + + ref.invalidate(appointmentsProvider); + context.go(RouteNames.myAppointments); + } + + ServiceModel? _findSelectedService(List services) { + for (final service in services) { + if (service.id == _selectedServiceId) { + return service; + } + } + return null; + } + + bool _hasSelectedService(List services) { + for (final service in services) { + if (service.id == _selectedServiceId) { + return true; + } + } + return false; + } + + @override + Widget build(BuildContext context) { + final servicesAsync = ref.watch(servicesProvider); + final services = servicesAsync.valueOrNull ?? const []; + + if (services.isNotEmpty && !_hasSelectedService(services)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _selectedServiceId = services.first.id); + }); + } + + final selectedService = _findSelectedService(services); + final createState = ref.watch(appointmentControllerProvider); + final slots = _slotsForDate(_selectedDate); + final formattedDate = DateFormat('EEE, dd MMM').format(_selectedDate); + + return Scaffold( + appBar: AppBar(title: const Text('SharpCut')), + bottomNavigationBar: const ClientBottomNav( + currentRoute: RouteNames.booking, + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Step heading mirrors the clean "schedule" style in your references. + const SectionHeader( + title: 'Find Your Perfect Moment', + subtitle: + 'Pick a date and time for your next grooming appointment.', + ), + Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Select Service', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 10), + servicesAsync.when( + data: (loadedServices) { + if (loadedServices.isEmpty) { + return const Text( + 'No services available right now.', + ); + } + + return DropdownButtonFormField( + key: ValueKey(_selectedServiceId), + initialValue: _selectedServiceId, + decoration: const InputDecoration( + border: OutlineInputBorder(), + ), + items: loadedServices.map((service) { + return DropdownMenuItem( + value: service.id, + child: Text( + '${service.name} - \$${service.price.toStringAsFixed(0)}', + ), + ); + }).toList(), + onChanged: (value) { + setState(() => _selectedServiceId = value); + }, + ); + }, + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: LinearProgressIndicator(), + ), + error: (error, _) => Text( + 'Could not load services: $error', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const SizedBox(height: 10), + Text( + 'Select Date', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 10), + CalendarDatePicker( + initialDate: _selectedDate, + firstDate: DateTime( + DateTime.now().year, + DateTime.now().month, + DateTime.now().day, + ), + lastDate: DateTime.now().add(const Duration(days: 365)), + onDateChanged: (DateTime date) { + setState(() { + _selectedDate = date; + _selectedTime = null; + }); + }, + ), + ], + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Available Slots', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + formattedDate, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 10), + + // Slot chips are beginner-friendly and easy to replace with API data. + Wrap( + spacing: 8, + runSpacing: 8, + children: slots.map((slot) { + final isSelected = _selectedTime == slot; + return ChoiceChip( + label: Text(DateFormat('hh:mm a').format(slot)), + selected: isSelected, + onSelected: (_) => + setState(() => _selectedTime = slot), + ); + }).toList(), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Booking Summary', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + _SummaryRow( + label: 'Service', + value: selectedService?.name ?? 'Not selected', + ), + _SummaryRow( + label: 'Date', + value: DateFormat( + 'EEE, dd MMM yyyy', + ).format(_selectedDate), + ), + _SummaryRow( + label: 'Time', + value: _selectedTime == null + ? 'Not selected' + : DateFormat('hh:mm a').format(_selectedTime!), + ), + _SummaryRow( + label: 'Estimated Price', + value: selectedService == null + ? '-' + : '\$${selectedService.price.toStringAsFixed(0)}', + ), + ], + ), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: createState.isLoading ? null : _submitBooking, + child: Text( + createState.isLoading + ? 'Confirming...' + : 'Confirm Selection', + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SummaryRow extends StatelessWidget { + const _SummaryRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Text( + '$label:', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppColors.textMuted), + ), + const Spacer(), + Text(value, style: Theme.of(context).textTheme.bodyLarge), + ], + ), + ); + } +} diff --git a/lib/pages/contact_page.dart b/lib/pages/contact_page.dart new file mode 100644 index 0000000..7409b0c --- /dev/null +++ b/lib/pages/contact_page.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +class ContactPage extends StatelessWidget { + const ContactPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Contact')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ListTile( + leading: Icon(Icons.email_outlined), + title: Text('Email Us'), + subtitle: Text('support@sharpcut.demo.com'), + ), + const ListTile( + leading: Icon(Icons.phone_outlined), + title: Text('Call Us'), + subtitle: Text('+1 (555) 123-4567'), + ), + const SizedBox(height: 24), + Text( + 'Send us a message', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + const TextField( + decoration: InputDecoration( + labelText: 'Your Name', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + const TextField( + decoration: InputDecoration( + labelText: 'Message', + border: OutlineInputBorder(), + ), + maxLines: 4, + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Message sent successfully! (Mock)'), + ), + ); + }, + child: const Text('Send Message'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart new file mode 100644 index 0000000..80a9262 --- /dev/null +++ b/lib/pages/home_page.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:frontend/core/router/route_names.dart'; +import 'package:go_router/go_router.dart'; + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('SharpCut MVP')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Welcome', style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: 8), + const Text( + 'Use this screen as a temporary starter. ' + 'In production you can replace it with a splash/landing screen.', + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => context.go(RouteNames.login), + child: const Text('Go To Login'), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () => context.go(RouteNames.services), + child: const Text('Go To Services (Dev Shortcut)'), + ), + const SizedBox(height: 24), + const Text( + 'Once the backend login endpoints are fully hooked up, you can ' + 'remove these developer shortcuts.', + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart new file mode 100644 index 0000000..ee36fd0 --- /dev/null +++ b/lib/pages/login_page.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/core/router/route_names.dart'; +import 'package:frontend/core/shared_widgets/app_colors.dart'; +import 'package:frontend/features/auth/providers/auth_provider.dart'; +import 'package:frontend/shared/utils/validators.dart'; +import 'package:go_router/go_router.dart'; + +class LoginPage extends ConsumerStatefulWidget { + const LoginPage({super.key}); + + @override + ConsumerState createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _onLoginPressed() async { + if (!_formKey.currentState!.validate()) return; + + await ref + .read(authProvider.notifier) + .login( + email: _emailController.text.trim(), + password: _passwordController.text.trim(), + ); + + final authState = ref.read(authProvider); + if (!mounted) return; + + if (authState.hasError) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(authState.error.toString()))); + return; + } + + final session = authState.valueOrNull; + if (session == null) return; + + if (session.user.role == UserRole.admin.name) { + context.go(RouteNames.adminDashboard); + return; + } + context.go(RouteNames.services); + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authProvider); + + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Hero card gives a premium visual tone using basic widgets. + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: const LinearGradient( + colors: [AppColors.primary, Color(0xFF1A3350)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.content_cut_rounded, + color: AppColors.textOnPrimary, + ), + const SizedBox(width: 8), + Text( + 'SharpCut', + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(color: AppColors.textOnPrimary), + ), + ], + ), + const SizedBox(height: 18), + Text( + 'Refined style\nawaits you.', + style: Theme.of(context).textTheme.headlineMedium + ?.copyWith(color: AppColors.textOnPrimary), + ), + const SizedBox(height: 8), + Text( + 'Sign in to manage bookings, appointments, and your daily schedule.', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: const Color(0xFFDDE7F4)), + ), + ], + ), + ), + const SizedBox(height: 20), + + // Form card keeps all login fields grouped and easy to edit. + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sign In', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 6), + Text( + 'Use your account details below.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Email', + hintText: 'you@example.com', + prefixIcon: Icon(Icons.mail_outline), + ), + validator: Validators.email, + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordController, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Password', + hintText: 'Minimum 6 characters', + prefixIcon: Icon(Icons.lock_outline), + ), + validator: Validators.password, + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: authState.isLoading + ? null + : _onLoginPressed, + child: Text( + authState.isLoading + ? 'Signing In...' + : 'Continue', + ), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 12), + + // Demo rule keeps student teams unblocked while backend is pending. + Text( + 'Demo tip: emails containing "admin" open admin pages. Any other email opens client pages.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppColors.textMuted), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/manage_services_page.dart b/lib/pages/manage_services_page.dart new file mode 100644 index 0000000..601024b --- /dev/null +++ b/lib/pages/manage_services_page.dart @@ -0,0 +1,427 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/core/router/route_names.dart'; +import 'package:frontend/core/shared_widgets/app_colors.dart'; +import 'package:frontend/features/services/data/models/service_model.dart'; +import 'package:frontend/features/services/providers/services_provider.dart'; +import 'package:frontend/shared/widgets/admin_bottom_nav.dart'; +import 'package:frontend/shared/widgets/error_view.dart'; +import 'package:frontend/shared/widgets/loading_view.dart'; +import 'package:frontend/shared/widgets/section_header.dart'; + +class ManageServicesPage extends ConsumerWidget { + const ManageServicesPage({super.key}); + + static const String _serviceImagePath = 'assets/images/hair_model.png'; + + Future _deleteService( + BuildContext context, + WidgetRef ref, + ServiceModel service, + ) async { + final shouldDelete = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete Service'), + content: Text('Delete "${service.name}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('Delete'), + ), + ], + ), + ); + + if (shouldDelete != true) return; + + await ref + .read(serviceControllerProvider.notifier) + .deleteService(service.id); + + if (!context.mounted) return; + + final opState = ref.read(serviceControllerProvider); + if (opState.hasError) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(opState.error.toString()))); + return; + } + + ref.invalidate(servicesProvider); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Deleted ${service.name}'))); + } + + void _openForm(BuildContext context, ServiceModel? service) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) => _EditServiceForm(service: service), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final servicesAsync = ref.watch(servicesProvider); + final operationState = ref.watch(serviceControllerProvider); + final isBusy = operationState.isLoading; + + return Scaffold( + appBar: AppBar(title: const Text('Service Management')), + bottomNavigationBar: const AdminBottomNav( + currentRoute: RouteNames.adminManageServices, + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: isBusy ? null : () => _openForm(context, null), + icon: const Icon(Icons.add), + label: const Text('New Service'), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Column( + children: [ + if (isBusy) const LinearProgressIndicator(), + Expanded( + child: servicesAsync.when( + data: (services) { + if (services.isEmpty) { + return const Center( + child: Text( + 'No services available. Tap "New Service" to add one.', + ), + ); + } + + return Column( + children: [ + const SectionHeader( + title: 'Curate Your Service Menu', + subtitle: + 'Update pricing, duration, and visibility for each service.', + ), + Expanded( + child: ListView.separated( + itemCount: services.length, + separatorBuilder: (_, __) => + const SizedBox(height: 10), + itemBuilder: (context, index) { + final service = services[index]; + return _ServiceAdminCard( + service: service, + imagePath: _serviceImagePath, + isBusy: isBusy, + onEdit: () => _openForm(context, service), + onDelete: () => + _deleteService(context, ref, service), + ); + }, + ), + ), + ], + ); + }, + loading: () => + const LoadingView(label: 'Loading services...'), + error: (error, _) => ErrorView( + message: 'Could not load services.\n$error', + onRetry: () => ref.invalidate(servicesProvider), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ServiceAdminCard extends StatelessWidget { + const _ServiceAdminCard({ + required this.service, + required this.imagePath, + required this.onEdit, + required this.onDelete, + required this.isBusy, + }); + + final ServiceModel service; + final String imagePath; + final VoidCallback onEdit; + final VoidCallback onDelete; + final bool isBusy; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + height: 92, + width: double.infinity, + child: Image.asset( + imagePath, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + color: AppColors.sectionTint, + alignment: Alignment.center, + child: const Icon( + Icons.content_cut_rounded, + size: 32, + color: AppColors.primary, + ), + ), + ), + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: Text( + service.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: AppColors.primarySoft, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '\$${service.price.toStringAsFixed(0)}', + style: const TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + service.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 10), + Row( + children: [ + const Icon( + Icons.schedule, + size: 16, + color: AppColors.textMuted, + ), + const SizedBox(width: 6), + Text( + '${service.durationMinutes} minutes', + style: Theme.of(context).textTheme.bodySmall, + ), + const Spacer(), + TextButton( + onPressed: isBusy ? null : onEdit, + child: const Text('Edit'), + ), + TextButton( + onPressed: isBusy ? null : onDelete, + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('Delete'), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _EditServiceForm extends ConsumerStatefulWidget { + const _EditServiceForm({this.service}); + + final ServiceModel? service; + + @override + ConsumerState<_EditServiceForm> createState() => _EditServiceFormState(); +} + +class _EditServiceFormState extends ConsumerState<_EditServiceForm> { + final TextEditingController _nameCtrl = TextEditingController(); + final TextEditingController _descriptionCtrl = TextEditingController(); + final TextEditingController _priceCtrl = TextEditingController(); + final TextEditingController _durationCtrl = TextEditingController(); + + @override + void initState() { + super.initState(); + if (widget.service != null) { + _nameCtrl.text = widget.service!.name; + _descriptionCtrl.text = widget.service!.description; + _priceCtrl.text = widget.service!.price.toStringAsFixed(0); + _durationCtrl.text = widget.service!.durationMinutes.toString(); + } + } + + @override + void dispose() { + _nameCtrl.dispose(); + _descriptionCtrl.dispose(); + _priceCtrl.dispose(); + _durationCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + final messenger = ScaffoldMessenger.of(context); + + final name = _nameCtrl.text.trim(); + final description = _descriptionCtrl.text.trim(); + final price = double.tryParse(_priceCtrl.text.trim()); + final duration = int.tryParse(_durationCtrl.text.trim()); + + if (name.isEmpty || description.isEmpty) { + messenger.showSnackBar( + const SnackBar(content: Text('Name and description are required.')), + ); + return; + } + + if (price == null || price <= 0) { + messenger.showSnackBar( + const SnackBar(content: Text('Enter a valid price.')), + ); + return; + } + + if (duration == null || duration <= 0) { + messenger.showSnackBar( + const SnackBar(content: Text('Enter a valid duration.')), + ); + return; + } + + final service = ServiceModel( + id: widget.service?.id ?? 'svc-${DateTime.now().millisecondsSinceEpoch}', + name: name, + description: description, + price: price, + durationMinutes: duration, + ); + + final controller = ref.read(serviceControllerProvider.notifier); + + if (widget.service == null) { + await controller.createService(service); + } else { + await controller.updateService(service); + } + + if (!mounted) return; + + final opState = ref.read(serviceControllerProvider); + if (opState.hasError) { + messenger.showSnackBar(SnackBar(content: Text(opState.error.toString()))); + return; + } + + ref.invalidate(servicesProvider); + messenger.showSnackBar( + SnackBar( + content: Text( + widget.service == null ? 'Service created' : 'Service updated', + ), + ), + ); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final isBusy = ref.watch(serviceControllerProvider).isLoading; + + return Padding( + padding: EdgeInsets.only( + top: 16, + left: 16, + right: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.service == null ? 'New Service' : 'Edit Service', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + controller: _nameCtrl, + enabled: !isBusy, + decoration: const InputDecoration( + labelText: 'Name', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _descriptionCtrl, + enabled: !isBusy, + maxLines: 2, + decoration: const InputDecoration( + labelText: 'Description', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _priceCtrl, + enabled: !isBusy, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Price', + prefixText: '\$', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _durationCtrl, + enabled: !isBusy, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Duration (mins)', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: isBusy ? null : _save, + child: Text(isBusy ? 'Saving...' : 'Save Service'), + ), + ], + ), + ); + } +} diff --git a/lib/pages/my_appointments_page.dart b/lib/pages/my_appointments_page.dart new file mode 100644 index 0000000..20eaca6 --- /dev/null +++ b/lib/pages/my_appointments_page.dart @@ -0,0 +1,283 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/core/router/route_names.dart'; +import 'package:frontend/core/shared_widgets/app_colors.dart'; +import 'package:frontend/features/booking/data/models/appointment_model.dart'; +import 'package:frontend/features/booking/providers/appointments_provider.dart'; +import 'package:frontend/shared/widgets/client_bottom_nav.dart'; +import 'package:frontend/shared/widgets/error_view.dart'; +import 'package:frontend/shared/widgets/loading_view.dart'; +import 'package:frontend/shared/widgets/section_header.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +class MyAppointmentsPage extends ConsumerWidget { + const MyAppointmentsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appointmentsAsync = ref.watch(appointmentsProvider); + + return Scaffold( + appBar: AppBar(title: const Text('My Bookings')), + bottomNavigationBar: const ClientBottomNav( + currentRoute: RouteNames.myAppointments, + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: appointmentsAsync.when( + data: (appointments) { + if (appointments.isEmpty) { + return _EmptyAppointmentsState( + onExplorePressed: () => context.go(RouteNames.services), + ); + } + + final upcomingCount = appointments + .where((apt) => apt.startTime.isAfter(DateTime.now())) + .length; + + return Column( + children: [ + // Keep top metrics short so students can replace with backend values later. + SectionHeader( + title: 'My Appointments', + subtitle: '$upcomingCount upcoming sessions', + ), + Expanded( + child: ListView.separated( + itemCount: appointments.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + return _AppointmentCard( + appointment: appointments[index], + ); + }, + ), + ), + ], + ); + }, + loading: () => + const LoadingView(label: 'Loading your appointments...'), + error: (error, _) => ErrorView( + message: 'Could not load appointments.\n$error', + onRetry: () => ref.invalidate(appointmentsProvider), + ), + ), + ), + ), + ); + } +} + +class _AppointmentCard extends StatelessWidget { + const _AppointmentCard({required this.appointment}); + + final AppointmentModel appointment; + + static const _serviceNameById = { + 'svc-classic-cut': 'Classic Executive Cut', + 'svc-signature-beard': 'Signature Beard Sculpt', + 'svc-hot-towel': 'Hot Towel Ritual', + }; + + @override + Widget build(BuildContext context) { + final startDate = DateFormat('EEE, dd MMM').format(appointment.startTime); + final startTime = DateFormat('hh:mm a').format(appointment.startTime); + final statusColor = _statusColor(appointment.status); + final serviceName = + _serviceNameById[appointment.serviceId] ?? appointment.serviceId; + + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + serviceName, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + appointment.status.toUpperCase(), + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.w700, + fontSize: 11, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + '$startDate at $startTime', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 10), + Row( + children: [ + const Icon(Icons.store_mall_directory_outlined, size: 16), + const SizedBox(width: 6), + Text( + 'SharpCut Studio', + style: Theme.of(context).textTheme.bodySmall, + ), + const Spacer(), + TextButton( + onPressed: () { + final endTime = DateFormat( + 'hh:mm a', + ).format(appointment.endTime); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Appointment Details'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DetailRow(label: 'Service', value: serviceName), + _DetailRow(label: 'Date', value: startDate), + _DetailRow( + label: 'Time', + value: '$startTime - $endTime', + ), + _DetailRow( + label: 'Provider', + value: appointment.adminId, + ), + _DetailRow( + label: 'Status', + value: appointment.status.toUpperCase(), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Close'), + ), + // Optional: Add cancel logic here in the future + ], + ), + ); + }, + child: const Text('View Details'), + ), + ], + ), + ], + ), + ), + ); + } + + Color _statusColor(String status) { + switch (status.toLowerCase()) { + case 'confirmed': + case 'completed': + return AppColors.success; + case 'pending': + return AppColors.warning; + case 'cancelled': + return AppColors.error; + default: + return AppColors.textMuted; + } + } +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: RichText( + text: TextSpan( + style: Theme.of(context).textTheme.bodyMedium, + children: [ + TextSpan( + text: '$label: ', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700), + ), + TextSpan(text: value), + ], + ), + ), + ); + } +} + +class _EmptyAppointmentsState extends StatelessWidget { + const _EmptyAppointmentsState({required this.onExplorePressed}); + + final VoidCallback onExplorePressed; + + @override + Widget build(BuildContext context) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 380), + child: Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircleAvatar( + radius: 30, + backgroundColor: AppColors.primarySoft, + child: Icon( + Icons.event_available, + size: 30, + color: AppColors.primary, + ), + ), + const SizedBox(height: 12), + Text( + 'No Appointments Yet', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 6), + Text( + 'Book your first session to start tracking your schedule here.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 14), + ElevatedButton( + onPressed: onExplorePressed, + child: const Text('Explore Services'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/not_found_page.dart b/lib/pages/not_found_page.dart new file mode 100644 index 0000000..65d40f2 --- /dev/null +++ b/lib/pages/not_found_page.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:frontend/core/router/route_names.dart'; +import 'package:go_router/go_router.dart'; + +class NotFoundPage extends StatelessWidget { + const NotFoundPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Page Not Found')), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.search_off, size: 48), + const SizedBox(height: 12), + const Text('The page you requested does not exist.'), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => context.go(RouteNames.login), + child: const Text('Back To Login'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/services_page.dart b/lib/pages/services_page.dart new file mode 100644 index 0000000..8fb809b --- /dev/null +++ b/lib/pages/services_page.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/core/router/route_names.dart'; +import 'package:frontend/core/shared_widgets/app_colors.dart'; +import 'package:frontend/features/services/data/models/service_model.dart'; +import 'package:frontend/features/services/providers/services_provider.dart'; +import 'package:frontend/shared/widgets/client_bottom_nav.dart'; +import 'package:frontend/shared/widgets/error_view.dart'; +import 'package:frontend/shared/widgets/loading_view.dart'; +import 'package:frontend/shared/widgets/section_header.dart'; +import 'package:go_router/go_router.dart'; + +class ServicesPage extends ConsumerWidget { + const ServicesPage({super.key}); + + static const String _serviceImagePath = 'assets/images/hair_model.png'; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final servicesAsync = ref.watch(servicesProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('SharpCut'), + actions: const [ + Padding( + padding: EdgeInsets.only(right: 16), + child: CircleAvatar( + backgroundColor: AppColors.primary, + radius: 14, + child: Icon( + Icons.person_outline, + size: 16, + color: AppColors.textOnPrimary, + ), + ), + ), + ], + ), + bottomNavigationBar: const ClientBottomNav( + currentRoute: RouteNames.services, + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: servicesAsync.when( + data: (services) { + if (services.isEmpty) { + return _EmptyServicesState( + onExplorePressed: () => context.push(RouteNames.booking), + ); + } + + return Column( + children: [ + // Header gives visual hierarchy similar to the provided mockups. + const SectionHeader( + title: 'Service Catalog', + subtitle: 'Precision grooming for the modern gentleman.', + ), + Expanded( + child: ListView.separated( + itemCount: services.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final service = services[index]; + return _ServiceCard( + service: service, + imagePath: _serviceImagePath, + highlight: index == 0, + onBookPressed: () { + // Keep routing simple now; later pass selected service ID. + context.push(RouteNames.booking); + }, + ); + }, + ), + ), + ], + ); + }, + loading: () => const LoadingView(label: 'Loading services...'), + error: (error, _) => ErrorView( + message: 'Could not load services.\n$error', + onRetry: () => ref.invalidate(servicesProvider), + ), + ), + ), + ), + ); + } +} + +class _ServiceCard extends StatelessWidget { + const _ServiceCard({ + required this.service, + required this.imagePath, + required this.onBookPressed, + required this.highlight, + }); + + final ServiceModel service; + final String imagePath; + final VoidCallback onBookPressed; + final bool highlight; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (highlight) + Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: AppColors.accentGold, + borderRadius: BorderRadius.circular(999), + ), + child: const Text( + 'Most Popular', + style: TextStyle( + color: AppColors.textOnPrimary, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + ClipRRect( + borderRadius: BorderRadius.circular(14), + child: SizedBox( + height: 150, + width: double.infinity, + child: Stack( + fit: StackFit.expand, + children: [ + Image.asset( + imagePath, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + color: AppColors.sectionTint, + alignment: Alignment.center, + child: const Icon( + Icons.content_cut_rounded, + size: 46, + color: AppColors.primary, + ), + ), + ), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x260C1B2A), Color(0x590C1B2A)], + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Text( + service.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + Text( + '\$${service.price.toStringAsFixed(0)}', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(color: AppColors.primary), + ), + ], + ), + const SizedBox(height: 6), + Text( + service.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 10), + Row( + children: [ + const Icon( + Icons.schedule, + size: 16, + color: AppColors.textMuted, + ), + const SizedBox(width: 6), + Text( + '${service.durationMinutes} minutes', + style: Theme.of(context).textTheme.bodySmall, + ), + const Spacer(), + ElevatedButton( + onPressed: onBookPressed, + style: ElevatedButton.styleFrom( + minimumSize: const Size(118, 42), + padding: const EdgeInsets.symmetric(horizontal: 14), + ), + child: const Text('Book Now'), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _EmptyServicesState extends StatelessWidget { + const _EmptyServicesState({required this.onExplorePressed}); + + final VoidCallback onExplorePressed; + + @override + Widget build(BuildContext context) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 380), + child: Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.spa_outlined, + size: 36, + color: AppColors.primary, + ), + const SizedBox(height: 10), + Text( + 'No Services Yet', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 6), + const Text( + 'Add your first service from admin mode so clients can book appointments.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 14), + ElevatedButton( + onPressed: onExplorePressed, + child: const Text('Open Booking'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/shared/data/demo_seed_data.dart b/lib/shared/data/demo_seed_data.dart new file mode 100644 index 0000000..4e745d2 --- /dev/null +++ b/lib/shared/data/demo_seed_data.dart @@ -0,0 +1,83 @@ +import 'package:frontend/features/booking/data/models/appointment_model.dart'; +import 'package:frontend/features/services/data/models/service_model.dart'; + +class DemoSeedData { + DemoSeedData._(); + + // Demo service list used only while API endpoints are still TODO. + static const List services = [ + ServiceModel( + id: 'svc-classic-cut', + name: 'Classic Executive Cut', + description: 'A precision haircut with consultation and styling.', + price: 45, + durationMinutes: 45, + ), + ServiceModel( + id: 'svc-signature-beard', + name: 'Signature Beard Sculpt', + description: 'Shape, trim, and blend for a refined beard profile.', + price: 35, + durationMinutes: 30, + ), + ServiceModel( + id: 'svc-hot-towel', + name: 'Hot Towel Ritual', + description: 'Relaxing towel treatment with deep cleanse and finish.', + price: 25, + durationMinutes: 25, + ), + ]; + + // Creates appointment examples relative to "now" so dates stay realistic. + static List appointments({DateTime? now}) { + final base = now ?? DateTime.now(); + final today = DateTime(base.year, base.month, base.day); + + return [ + AppointmentModel( + id: 'apt-001', + serviceId: 'svc-classic-cut', + clientId: 'client-demo', + adminId: 'admin-julian', + startTime: today.add(const Duration(days: 1, hours: 10, minutes: 30)), + endTime: today.add(const Duration(days: 1, hours: 11, minutes: 15)), + status: 'confirmed', + ), + AppointmentModel( + id: 'apt-002', + serviceId: 'svc-signature-beard', + clientId: 'client-demo', + adminId: 'admin-julian', + startTime: today.add(const Duration(days: 2, hours: 13)), + endTime: today.add(const Duration(days: 2, hours: 13, minutes: 30)), + status: 'pending', + ), + AppointmentModel( + id: 'apt-003', + serviceId: 'svc-hot-towel', + clientId: 'client-demo', + adminId: 'admin-julian', + startTime: today.subtract(const Duration(days: 1, hours: 15)), + endTime: today.subtract( + const Duration(days: 1, hours: 14, minutes: 35), + ), + status: 'completed', + ), + ]; + } + + // Quick helper for dashboard cards. + static double estimatedRevenue() { + const servicePriceById = { + 'svc-classic-cut': 45.0, + 'svc-signature-beard': 35.0, + 'svc-hot-towel': 25.0, + }; + + final completed = appointments().where((apt) => apt.status == 'completed'); + return completed.fold(0, (total, apt) { + return total + (servicePriceById[apt.serviceId] ?? 0); + }); + } +} diff --git a/lib/shared/utils/validators.dart b/lib/shared/utils/validators.dart new file mode 100644 index 0000000..3956001 --- /dev/null +++ b/lib/shared/utils/validators.dart @@ -0,0 +1,31 @@ +class Validators { + Validators._(); + + static String? requiredField(String? value, {String fieldName = 'Field'}) { + if (value == null || value.trim().isEmpty) { + return '$fieldName is required'; + } + return null; + } + + static String? email(String? value) { + final emptyError = requiredField(value, fieldName: 'Email'); + if (emptyError != null) return emptyError; + + final emailPattern = RegExp(r'^[^@]+@[^@]+\.[^@]+$'); + if (!emailPattern.hasMatch(value!.trim())) { + return 'Enter a valid email'; + } + return null; + } + + static String? password(String? value) { + final emptyError = requiredField(value, fieldName: 'Password'); + if (emptyError != null) return emptyError; + + if (value!.length < 6) { + return 'Password must be at least 6 characters'; + } + return null; + } +} diff --git a/lib/shared/widgets/admin_bottom_nav.dart b/lib/shared/widgets/admin_bottom_nav.dart new file mode 100644 index 0000000..77526fb --- /dev/null +++ b/lib/shared/widgets/admin_bottom_nav.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:frontend/core/router/route_names.dart'; +import 'package:go_router/go_router.dart'; + +class AdminBottomNav extends StatelessWidget { + const AdminBottomNav({super.key, required this.currentRoute}); + + final String currentRoute; + + @override + Widget build(BuildContext context) { + return NavigationBar( + selectedIndex: _selectedIndex(), + onDestinationSelected: (index) { + final route = switch (index) { + 0 => RouteNames.adminDashboard, + _ => RouteNames.adminManageServices, + }; + + if (route != currentRoute) { + context.go(route); + } + }, + destinations: const [ + NavigationDestination(icon: Icon(Icons.analytics), label: 'Dashboard'), + NavigationDestination(icon: Icon(Icons.tune), label: 'Services'), + ], + ); + } + + int _selectedIndex() { + if (currentRoute.startsWith(RouteNames.adminManageServices)) return 1; + return 0; + } +} diff --git a/lib/shared/widgets/client_bottom_nav.dart b/lib/shared/widgets/client_bottom_nav.dart new file mode 100644 index 0000000..e54838e --- /dev/null +++ b/lib/shared/widgets/client_bottom_nav.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:frontend/core/router/route_names.dart'; +import 'package:go_router/go_router.dart'; + +class ClientBottomNav extends StatelessWidget { + const ClientBottomNav({super.key, required this.currentRoute}); + + final String currentRoute; + + @override + Widget build(BuildContext context) { + return NavigationBar( + selectedIndex: _selectedIndex(), + onDestinationSelected: (index) { + final route = switch (index) { + 0 => RouteNames.services, + 1 => RouteNames.booking, + _ => RouteNames.myAppointments, + }; + + if (route != currentRoute) { + context.go(route); + } + }, + destinations: const [ + NavigationDestination(icon: Icon(Icons.content_cut), label: 'Services'), + NavigationDestination(icon: Icon(Icons.calendar_month), label: 'Book'), + NavigationDestination( + icon: Icon(Icons.event_note), + label: 'My Bookings', + ), + ], + ); + } + + int _selectedIndex() { + if (currentRoute.startsWith(RouteNames.booking)) return 1; + if (currentRoute.startsWith(RouteNames.myAppointments)) return 2; + return 0; + } +} diff --git a/lib/shared/widgets/error_view.dart b/lib/shared/widgets/error_view.dart new file mode 100644 index 0000000..73b3a59 --- /dev/null +++ b/lib/shared/widgets/error_view.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +class ErrorView extends StatelessWidget { + const ErrorView({super.key, required this.message, this.onRetry}); + + final String message; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 12), + if (onRetry != null) + ElevatedButton(onPressed: onRetry, child: const Text('Retry')), + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/loading_view.dart b/lib/shared/widgets/loading_view.dart new file mode 100644 index 0000000..2d8c2d0 --- /dev/null +++ b/lib/shared/widgets/loading_view.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +class LoadingView extends StatelessWidget { + const LoadingView({super.key, this.label}); + + final String? label; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 12), + Text(label ?? 'Loading...'), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/section_header.dart b/lib/shared/widgets/section_header.dart new file mode 100644 index 0000000..b2ee41e --- /dev/null +++ b/lib/shared/widgets/section_header.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:frontend/core/shared_widgets/app_colors.dart'; + +class SectionHeader extends StatelessWidget { + const SectionHeader({ + super.key, + required this.title, + this.subtitle, + this.trailing, + this.padding = const EdgeInsets.only(bottom: 12), + }); + + final String title; + final String? subtitle; + final Widget? trailing; + final EdgeInsets padding; + + @override + Widget build(BuildContext context) { + final titleStyle = Theme.of(context).textTheme.headlineMedium; + final subtitleStyle = Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppColors.textMuted); + + return Padding( + padding: padding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: titleStyle), + if (subtitle != null) ...[ + const SizedBox(height: 4), + Text(subtitle!, style: subtitleStyle), + ], + ], + ), + ), + if (trailing != null) trailing!, + ], + ), + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..d0e7f79 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..b29e9ba 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..15a1671 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,10 @@ import FlutterMacOS import Foundation +import flutter_secure_storage_macos +import path_provider_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) } diff --git a/mock-api/db.json b/mock-api/db.json new file mode 100644 index 0000000..afbb51c --- /dev/null +++ b/mock-api/db.json @@ -0,0 +1,89 @@ +{ + "users": [ + { + "id": "admin-julian", + "fullName": "Julian Admin", + "email": "admin@sharpcut.dev", + "role": "admin", + "password": "password123" + }, + { + "id": "user-001", + "fullName": "Demo Client", + "email": "client@sharpcut.dev", + "role": "client", + "password": "password123" + }, + { + "id": "user-1775411382990", + "fullName": "SharpCut Client", + "email": "abelmekonen@gmail.com", + "role": "client", + "password": "abel1234" + } + ], + "services": [ + { + "id": "svc-classic-cut", + "name": "Classic Executive Cut", + "description": "A precision haircut with consultation and styling.", + "price": 45, + "durationMinutes": 45, + "imageUrl": null + }, + { + "id": "svc-signature-beard", + "name": "Signature Beard Sculpt", + "description": "Shape, trim, and blend for a refined beard profile.", + "price": 35, + "durationMinutes": 30, + "imageUrl": null + }, + { + "id": "svc-hot-towel", + "name": "Hot Towel Ritual", + "description": "Relaxing towel treatment with deep cleanse and finish.", + "price": 25, + "durationMinutes": 25, + "imageUrl": null + } + ], + "appointments": [ + { + "id": "apt-001", + "serviceId": "svc-classic-cut", + "clientId": "user-001", + "adminId": "admin-julian", + "startTime": "2026-04-07T10:30:00.000Z", + "endTime": "2026-04-07T11:15:00.000Z", + "status": "confirmed" + }, + { + "id": "apt-002", + "serviceId": "svc-signature-beard", + "clientId": "user-001", + "adminId": "admin-julian", + "startTime": "2026-04-08T13:00:00.000Z", + "endTime": "2026-04-08T13:30:00.000Z", + "status": "pending" + }, + { + "id": "apt-003", + "serviceId": "svc-hot-towel", + "clientId": "user-001", + "adminId": "admin-julian", + "startTime": "2026-04-04T11:00:00.000Z", + "endTime": "2026-04-04T11:25:00.000Z", + "status": "completed" + }, + { + "id": "apt-1775411425178", + "serviceId": "svc-hot-towel", + "adminId": "admin-julian", + "clientId": "user-1775411382990", + "startTime": "2026-04-14T06:30:00.000Z", + "endTime": "2026-04-14T06:55:00.000Z", + "status": "scheduled" + } + ] +} \ No newline at end of file diff --git a/mock-api/package-lock.json b/mock-api/package-lock.json new file mode 100644 index 0000000..c540467 --- /dev/null +++ b/mock-api/package-lock.json @@ -0,0 +1,1402 @@ +{ + "name": "sharpcut-mock-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sharpcut-mock-api", + "version": "1.0.0", + "dependencies": { + "json-server": "^0.17.4" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/connect-pause": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/connect-pause/-/connect-pause-0.1.1.tgz", + "integrity": "sha512-a1gSWQBQD73krFXdUEYJom2RTFrWUL3YvXDCRkyv//GVXc79cdW9MngtRuN9ih4FDKBtfJAJId+BbDuX+1rh2w==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/errorhandler": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/errorhandler/-/errorhandler-1.5.2.tgz", + "integrity": "sha512-kNAL7hESndBCrWwS72QyV3IVOTrVmj9D062FV5BQswNL5zEdeRmz/WJFyh6Aj/plvvSOrzddkxW57HgkZcR9Fw==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "escape-html": "~1.0.3" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-urlrewrite": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/express-urlrewrite/-/express-urlrewrite-1.4.0.tgz", + "integrity": "sha512-PI5h8JuzoweS26vFizwQl6UTF25CAHSggNv0J25Dn/IKZscJHWZzPrI5z2Y2jgOzIaw2qh8l6+/jUcig23Z2SA==", + "license": "MIT", + "dependencies": { + "debug": "*", + "path-to-regexp": "^1.0.3" + } + }, + "node_modules/express-urlrewrite/node_modules/path-to-regexp": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", + "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", + "license": "MIT", + "dependencies": { + "isarray": "0.0.1" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", + "license": "MIT" + }, + "node_modules/json-parse-helpfulerror": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/json-parse-helpfulerror/-/json-parse-helpfulerror-1.0.3.tgz", + "integrity": "sha512-XgP0FGR77+QhUxjXkwOMkC94k3WtqEBfcnjWqhRd82qTat4SWKRE+9kUnynz/shm3I4ea2+qISvTIeGTNU7kJg==", + "license": "MIT", + "dependencies": { + "jju": "^1.1.0" + } + }, + "node_modules/json-server": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/json-server/-/json-server-0.17.4.tgz", + "integrity": "sha512-bGBb0WtFuAKbgI7JV3A864irWnMZSvBYRJbohaOuatHwKSRFUfqtQlrYMrB6WbalXy/cJabyjlb7JkHli6dYjQ==", + "license": "MIT", + "dependencies": { + "body-parser": "^1.19.0", + "chalk": "^4.1.2", + "compression": "^1.7.4", + "connect-pause": "^0.1.1", + "cors": "^2.8.5", + "errorhandler": "^1.5.1", + "express": "^4.17.1", + "express-urlrewrite": "^1.4.0", + "json-parse-helpfulerror": "^1.0.3", + "lodash": "^4.17.21", + "lodash-id": "^0.14.1", + "lowdb": "^1.0.0", + "method-override": "^3.0.0", + "morgan": "^1.10.0", + "nanoid": "^3.1.23", + "please-upgrade-node": "^3.2.0", + "pluralize": "^8.0.0", + "server-destroy": "^1.0.1", + "yargs": "^17.0.1" + }, + "bin": { + "json-server": "lib/cli/bin.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-id": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/lodash-id/-/lodash-id-0.14.1.tgz", + "integrity": "sha512-ikQPBTiq/d5m6dfKQlFdIXFzvThPi2Be9/AHxktOnDSfSxE1j9ICbBT5Elk1ke7HSTgM38LHTpmJovo9/klnLg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/lowdb": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-1.0.0.tgz", + "integrity": "sha512-2+x8esE/Wb9SQ1F9IHaYWfsC9FIecLOPrK4g17FGEayjUWH172H6nwicRovGvSE2CPZouc2MCIqCI7h9d+GftQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.3", + "is-promise": "^2.1.0", + "lodash": "4", + "pify": "^3.0.0", + "steno": "^0.4.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/method-override": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/method-override/-/method-override-3.0.0.tgz", + "integrity": "sha512-IJ2NNN/mSl9w3kzWB92rcdHpz+HjkxhDJWNDBqSlas+zQdP8wBiJzITPg08M/k2uVvMow7Sk41atndNtt/PHSA==", + "license": "MIT", + "dependencies": { + "debug": "3.1.0", + "methods": "~1.1.2", + "parseurl": "~1.3.2", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/method-override/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/please-upgrade-node": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", + "integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==", + "license": "MIT", + "dependencies": { + "semver-compare": "^1.0.0" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/server-destroy": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz", + "integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/steno": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/steno/-/steno-0.4.4.tgz", + "integrity": "sha512-EEHMVYHNXFHfGtgjNITnka0aHhiAlo93F7z2/Pwd+g0teG9CnM3JIINM7hVVB5/rhw9voufD7Wukwgtw2uqh6w==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.3" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/mock-api/package.json b/mock-api/package.json new file mode 100644 index 0000000..ab1ddf4 --- /dev/null +++ b/mock-api/package.json @@ -0,0 +1,11 @@ +{ + "name": "sharpcut-mock-api", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "json-server": "^0.17.4" + } +} diff --git a/mock-api/server.js b/mock-api/server.js new file mode 100644 index 0000000..dc58d5e --- /dev/null +++ b/mock-api/server.js @@ -0,0 +1,134 @@ +const path = require('path'); +const jsonServer = require('json-server'); + +const server = jsonServer.create(); +const router = jsonServer.router(path.join(__dirname, 'db.json')); +const middlewares = jsonServer.defaults(); + +const TOKEN_PREFIX = 'mock-token-'; + +const normalizeEmail = (value) => String(value || '').trim().toLowerCase(); + +const getUserIdFromAuthHeader = (authorization) => { + if (!authorization || typeof authorization !== 'string') { + return null; + } + + const [scheme, token] = authorization.split(' '); + if (scheme !== 'Bearer' || !token || !token.startsWith(TOKEN_PREFIX)) { + return null; + } + + return token.slice(TOKEN_PREFIX.length); +}; + +server.use(middlewares); +server.use(jsonServer.bodyParser); + +server.post('/auth/login', (req, res) => { + const email = normalizeEmail(req.body?.email); + const password = String(req.body?.password || '').trim(); + + if (!email || !password) { + return res.status(400).json({ + message: 'Email and password are required.', + }); + } + + const db = router.db; + let user = db.get('users').find({ email }).value(); + + if (!user) { + const role = email.includes('admin') ? 'admin' : 'client'; + user = { + id: `user-${Date.now()}`, + fullName: role === 'admin' ? 'SharpCut Admin' : 'SharpCut Client', + email, + role, + password, + }; + + db.get('users').push(user).write(); + } + + return res.json({ + accessToken: `${TOKEN_PREFIX}${user.id}`, + user: { + id: user.id, + fullName: user.fullName, + email: user.email, + role: user.role, + }, + }); +}); + +server.get('/appointments/me', (req, res) => { + const userId = getUserIdFromAuthHeader(req.headers.authorization); + + if (!userId) { + return res.status(401).json({ + message: 'Missing or invalid bearer token.', + }); + } + + const appointments = router.db.get('appointments').filter({ clientId: userId }).value(); + return res.json(appointments); +}); + +server.post('/services', (req, _res, next) => { + if (!req.body.id) { + req.body.id = `svc-${Date.now()}`; + } + + next(); +}); + +server.post('/appointments', (req, res, next) => { + const { serviceId, adminId, startTime } = req.body || {}; + + if (!serviceId || !adminId || !startTime) { + return res.status(400).json({ + message: 'serviceId, adminId, and startTime are required.', + }); + } + + const db = router.db; + const service = db.get('services').find({ id: serviceId }).value(); + + if (!service) { + return res.status(400).json({ + message: 'Service not found.', + }); + } + + const clientIdFromToken = getUserIdFromAuthHeader(req.headers.authorization); + const clientId = clientIdFromToken || req.body.clientId || 'user-001'; + const parsedStart = new Date(startTime); + + if (Number.isNaN(parsedStart.getTime())) { + return res.status(400).json({ + message: 'startTime must be a valid ISO timestamp.', + }); + } + + const end = new Date(parsedStart.getTime() + Number(service.durationMinutes || 45) * 60000); + + req.body = { + id: `apt-${Date.now()}`, + serviceId, + adminId, + clientId, + startTime: parsedStart.toISOString(), + endTime: end.toISOString(), + status: req.body.status || 'scheduled', + }; + + next(); +}); + +server.use(router); + +server.listen(3000, () => { + // eslint-disable-next-line no-console + console.log('Mock API running on http://localhost:3000'); +}); diff --git a/mvp_flutter_starter_guide.md b/mvp_flutter_starter_guide.md new file mode 100644 index 0000000..1e231af --- /dev/null +++ b/mvp_flutter_starter_guide.md @@ -0,0 +1,726 @@ +# MVP Flutter Starter Guide (Mentor Mode) + +This guide is for your **2-day class project**: Flutter frontend + Node.js backend. +Goal: give you a clean structure and starter templates so **you implement it yourselves**. + +--- + +## Assumptions (So We Can Move Fast) + +I could not directly parse your PDF contract files in this environment, so I am using practical appointment-booking assumptions from your shared UI screens: + +- Roles: `client`, `admin` (admin/staff) +- Main entities: `User`, `Service`, `Appointment`, `TimeSlot` +- Core flows: + - Client: login -> browse services -> book -> view my appointments + - Admin: dashboard -> manage services -> view bookings + +When your backend contract differs, keep this structure and only rename fields/endpoints. + +--- + +## 1) Folder Structure (Simple + Real-World) + +Use this structure in `lib/`: + +```text +lib/ + main.dart + + core/ + config/ + env.dart + network/ + dio_client.dart + api_service.dart + api_exception.dart + router/ + app_router.dart + route_names.dart + storage/ + secure_storage_service.dart + theme/ + app_theme.dart + shared_widgets/ + app_colors.dart + + shared/ + widgets/ + app_scaffold.dart + loading_view.dart + error_view.dart + utils/ + validators.dart + date_time_formatter.dart + + features/ + auth/ + data/ + models/ + user_model.dart + login_request_model.dart + auth_response_model.dart + presentation/ + pages/ + login_page.dart + providers/ + auth_providers.dart + + services/ + data/ + models/ + service_model.dart + presentation/ + pages/ + service_list_page.dart + service_details_page.dart + providers/ + services_providers.dart + + bookings/ + data/ + models/ + appointment_model.dart + create_appointment_request_model.dart + timeslot_model.dart + presentation/ + pages/ + booking_page.dart + my_appointments_page.dart + providers/ + booking_providers.dart + + admin/ + presentation/ + pages/ + admin_dashboard_page.dart + manage_services_page.dart + providers/ + admin_providers.dart +``` + +### Responsibility of each layer + +- `presentation/`: UI widgets/screens only +- `providers/`: state + calling API services +- `data/models/`: API request/response objects +- `core/network/`: Dio setup and centralized API methods +- `shared/`: reusable widgets/helpers used by many features + +### Rule for your team + +- UI code must not call Dio directly. +- UI asks providers. +- Providers call `ApiService`. +- `ApiService` returns models. + +--- + +## 2) Dependencies Setup + +### Required dependencies for MVP + +1. `flutter_riverpod` +- Why: predictable state management; cleaner than passing state manually everywhere. + +2. `go_router` +- Why: centralized routing and easier role-based navigation. + +3. `dio` +- Why: strong HTTP client, interceptors, timeout, better error handling. + +4. `flutter_secure_storage` +- Why: store auth token safely on device. + +5. `intl` (recommended) +- Why: format booking dates/times clearly. + +### Install commands + +```bash +flutter pub add flutter_riverpod go_router dio flutter_secure_storage intl +flutter pub get +``` + +### Basic initialization templates + +`main.dart` (minimal app bootstrap): + +```dart +void main() { + // TODO: Wrap app with ProviderScope for Riverpod + runApp(const AppRoot()); +} + +class AppRoot extends ConsumerWidget { + const AppRoot({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // TODO: Read go_router instance from provider + // TODO: Use app theme from core/theme/app_theme.dart + return MaterialApp.router( + // routerConfig: ... + ); + } +} +``` + +`core/network/dio_client.dart`: + +```dart +final dioProvider = Provider((ref) { + final dio = Dio( + BaseOptions( + // TODO: Replace with backend base URL + baseUrl: 'http://YOUR_BACKEND_BASE_URL', + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 15), + ), + ); + + // TODO: Add interceptor for auth token from secure storage + // TODO: Add simple logging interceptor for debugging + return dio; +}); +``` + +--- + +## 3) Page Breakdown (Scaffold-Only Templates) + +Build these pages first. Keep layout simple and functional. + +### Client pages + +#### `LoginPage` +- Purpose: authenticate and know user role. + +```dart +class LoginPage extends ConsumerWidget { + const LoginPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar(title: const Text('Login')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // TODO: Email field + // TODO: Password field + // TODO: Sign in button + // TODO: Show validation / API error messages + ], + ), + ), + ); + } +} +``` + +#### `ServiceListPage` +- Purpose: show available services and allow booking entry. + +```dart +class ServiceListPage extends ConsumerWidget { + const ServiceListPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar(title: const Text('Services')), + body: Column( + children: [ + // TODO: Search/filter area (optional for MVP) + // TODO: Display list of services from API + // TODO: Tap item -> open service details or booking page + ], + ), + ); + } +} +``` + +#### `BookingPage` +- Purpose: select slot and create appointment. + +```dart +class BookingPage extends ConsumerWidget { + const BookingPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar(title: const Text('Book Appointment')), + body: Column( + children: [ + // TODO: Selected service summary + // TODO: Date picker / slot picker + // TODO: Confirm booking button + ], + ), + ); + } +} +``` + +#### `MyAppointmentsPage` +- Purpose: show upcoming/past bookings and status. + +```dart +class MyAppointmentsPage extends ConsumerWidget { + const MyAppointmentsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar(title: const Text('My Appointments')), + body: Column( + children: [ + // TODO: Tabs/filters (Upcoming, Past, Cancelled) + // TODO: Display appointments from API + // TODO: Empty state UI when no appointments + ], + ), + ); + } +} +``` + +### Admin pages + +#### `AdminDashboardPage` +- Purpose: quick stats and today's schedule. + +```dart +class AdminDashboardPage extends ConsumerWidget { + const AdminDashboardPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar(title: const Text('Dashboard')), + body: Column( + children: [ + // TODO: Stats cards (today bookings, revenue, etc.) + // TODO: Today's appointments list + ], + ), + ); + } +} +``` + +#### `ManageServicesPage` +- Purpose: admin adds/updates/deletes services. + +```dart +class ManageServicesPage extends ConsumerWidget { + const ManageServicesPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar(title: const Text('Manage Services')), + floatingActionButton: FloatingActionButton( + onPressed: () { + // TODO: Open add service form + }, + child: const Icon(Icons.add), + ), + body: Column( + children: [ + // TODO: Display admin's services + // TODO: Edit/Delete actions + ], + ), + ); + } +} +``` + +--- + +## 4) Models (Keep Simple, Match Backend Names) + +Below are starter DTOs. Replace field names to match contract exactly. + +### `user_model.dart` + +```dart +class UserModel { + // Unique user identifier from backend + final String id; + + // Display name shown in UI + final String fullName; + + // Login email + final String email; + + // Role used for route decisions: client | admin + final String role; + + UserModel({ + required this.id, + required this.fullName, + required this.email, + required this.role, + }); + + factory UserModel.fromJson(Map json) { + return UserModel( + id: json['id'] as String, + fullName: json['fullName'] as String, + email: json['email'] as String, + role: json['role'] as String, + ); + } + + Map toJson() { + return { + 'id': id, + 'fullName': fullName, + 'email': email, + 'role': role, + }; + } +} +``` + +### `service_model.dart` + +```dart +class ServiceModel { + // Service identifier + final String id; + + // Name shown in list and detail screen + final String name; + + // Short description of service + final String description; + + // Price in your backend currency format + final double price; + + // Duration in minutes for scheduling + final int durationMinutes; + + ServiceModel({ + required this.id, + required this.name, + required this.description, + required this.price, + required this.durationMinutes, + }); + + factory ServiceModel.fromJson(Map json) { + return ServiceModel( + id: json['id'] as String, + name: json['name'] as String, + description: json['description'] as String, + price: (json['price'] as num).toDouble(), + durationMinutes: json['durationMinutes'] as int, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'price': price, + 'durationMinutes': durationMinutes, + }; + } +} +``` + +### `appointment_model.dart` + +```dart +class AppointmentModel { + // Appointment identifier + final String id; + + // Linked service id + final String serviceId; + + // Client who created appointment + final String clientId; + + // Admin assigned to appointment + final String adminId; + + // Start time in ISO string format + final DateTime startTime; + + // End time in ISO string format + final DateTime endTime; + + // Status: pending | confirmed | cancelled | completed + final String status; + + AppointmentModel({ + required this.id, + required this.serviceId, + required this.clientId, + required this.adminId, + required this.startTime, + required this.endTime, + required this.status, + }); + + factory AppointmentModel.fromJson(Map json) { + return AppointmentModel( + id: json['id'] as String, + serviceId: json['serviceId'] as String, + clientId: json['clientId'] as String, + adminId: json['adminId'] as String, + startTime: DateTime.parse(json['startTime'] as String), + endTime: DateTime.parse(json['endTime'] as String), + status: json['status'] as String, + ); + } + + Map toJson() { + return { + 'id': id, + 'serviceId': serviceId, + 'clientId': clientId, + 'adminId': adminId, + 'startTime': startTime.toIso8601String(), + 'endTime': endTime.toIso8601String(), + 'status': status, + }; + } +} +``` + +### `create_appointment_request_model.dart` + +```dart +class CreateAppointmentRequestModel { + // Service selected by client + final String serviceId; + + // Admin selected for appointment + final String adminId; + + // Desired slot start time + final DateTime startTime; + + CreateAppointmentRequestModel({ + required this.serviceId, + required this.adminId, + required this.startTime, + }); + + Map toJson() { + return { + 'serviceId': serviceId, + 'adminId': adminId, + 'startTime': startTime.toIso8601String(), + }; + } +} +``` + +--- + +## 5) API Layer Structure (Dio) + +`core/network/api_service.dart`: + +```dart +class ApiService { + final Dio _dio; + + ApiService(this._dio); + + Future login({ + required String email, + required String password, + }) async { + // TODO: Call backend endpoint (example: POST /auth/login) + // TODO: Parse response into AuthResponseModel + throw UnimplementedError(); + } + + Future> fetchServices() async { + // TODO: Call backend endpoint (example: GET /services) + // TODO: Parse list response into List + throw UnimplementedError(); + } + + Future createAppointment( + CreateAppointmentRequestModel request, + ) async { + // TODO: Call backend endpoint (example: POST /appointments) + // TODO: Parse response into AppointmentModel + throw UnimplementedError(); + } + + Future> fetchMyAppointments() async { + // TODO: Call backend endpoint (example: GET /appointments/me) + // TODO: Parse response list + throw UnimplementedError(); + } +} +``` + +Provider wiring: + +```dart +final apiServiceProvider = Provider((ref) { + final dio = ref.read(dioProvider); + return ApiService(dio); +}); +``` + +--- + +## 6) State Management (Riverpod, MVP Style) + +Keep it simple: + +- `authProvider`: handles login/logout state +- `servicesProvider`: loads list of services +- `appointmentsProvider`: loads current user appointments + +`auth_providers.dart` (starter): + +```dart +final authProvider = StateNotifierProvider>( + (ref) => AuthController(ref.read(apiServiceProvider)), +); + +class AuthController extends StateNotifier> { + final ApiService _api; + + AuthController(this._api) : super(const AsyncValue.data(null)); + + Future login(String email, String password) async { + // TODO: set state loading + // TODO: call _api.login(...) + // TODO: save token in secure storage + // TODO: set state with current user + } + + Future logout() async { + // TODO: clear secure storage token + // TODO: reset state to null user + } +} +``` + +`services_providers.dart` (starter): + +```dart +final servicesProvider = FutureProvider>((ref) async { + final api = ref.read(apiServiceProvider); + // TODO: return api.fetchServices(); + throw UnimplementedError(); +}); +``` + +`booking_providers.dart` (starter): + +```dart +final appointmentsProvider = FutureProvider>((ref) async { + final api = ref.read(apiServiceProvider); + // TODO: return api.fetchMyAppointments(); + throw UnimplementedError(); +}); +``` + +--- + +## 7) Navigation (go_router + Role-Based Flow) + +### Route list + +- Public: + - `/login` +- Client: + - `/client/services` + - `/client/booking` + - `/client/appointments` +- Admin: + - `/admin/dashboard` + - `/admin/manage-services` + +### Router skeleton + +```dart +final appRouterProvider = Provider((ref) { + // TODO: read auth state from authProvider + // TODO: use redirect based on login state + role + return GoRouter( + initialLocation: '/login', + routes: [ + // TODO: Add GoRoute entries for each page + ], + redirect: (context, state) { + // TODO: If not logged in -> /login + // TODO: If logged in as client -> allow only /client/* + // TODO: If logged in as admin -> allow only /admin/* + return null; + }, + ); +}); +``` + +### Role navigation decision + +After successful login: + +- If role is `client`, navigate to `/client/services` +- If role is `admin`, navigate to `/admin/dashboard` + +--- + +## 8) Two-Day Execution Plan (Practical) + +### Day 1 + +1. Finalize folder structure +2. Install dependencies +3. Configure `main.dart` with `ProviderScope` + router +4. Create model files (with exact API field names) +5. Build page scaffolds only + +### Day 2 + +1. Implement `login`, `fetchServices`, `createAppointment` in `ApiService` +2. Connect providers to pages +3. Add loading/error UI states +4. Validate booking flow end-to-end +5. Polish theme + spacing using your `app_theme.dart` + +--- + +## 9) What Not To Do (For This MVP) + +1. Do not add complex architecture layers you cannot finish in 2 days. +2. Do not build every nice-to-have screen. +3. Do not duplicate API logic inside widgets. +4. Do not skip role-based routing checks. +5. Do not leave model field names mismatched with backend. + +--- + +## 10) Immediate Next Action for Your Team + +1. Open this guide and create missing folders/files first. +2. Put exact backend contract field names into the model templates. +3. Implement only these three API methods first: + - `login()` + - `fetchServices()` + - `createAppointment()` +4. Demo one complete path: + - login -> services -> booking -> my appointments + diff --git a/pubspec.lock b/pubspec.lock index eaab057..a97a25b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" fake_async: dependency: transitive description: @@ -57,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" flutter: dependency: "direct main" description: flutter @@ -70,11 +94,104 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: b465e99ce64ba75e61c8c0ce3d87b66d8ac07f0b35d0a7e0263fcfc10f99e836 + url: "https://pub.dev" + source: hosted + version: "13.2.5" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" leak_tracker: dependency: transitive description: @@ -107,6 +224,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -131,6 +256,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" path: dependency: transitive description: @@ -139,6 +272,78 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" + url: "https://pub.dev" + source: hosted + version: "2.2.23" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + url: "https://pub.dev" + source: hosted + version: "2.5.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" sky_engine: dependency: transitive description: flutter @@ -160,6 +365,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: @@ -192,6 +405,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.6" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -208,6 +429,30 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" sdks: dart: ">=3.9.2 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 530eb9a..7a6776f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,21 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + # State management + flutter_riverpod: ^2.5.1 + + # Declarative routing + go_router: ^13.2.2 + + # HTTP client for backend communication + dio: ^5.9.0 + + # Secure local storage for auth token/session + flutter_secure_storage: ^9.2.4 + + # Date/time formatting for booking UI + intl: ^0.20.2 + dev_dependencies: flutter_test: sdk: flutter @@ -57,10 +72,8 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/images/hair_model.png # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/test/widget_test.dart b/test/widget_test.dart index 812c978..3095ac0 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,26 +5,19 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + import 'package:frontend/main.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { + testWidgets('App smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); + await tester.pumpWidget(const ProviderScope(child: SharpCutApp())); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + // Our app has no default counter, but we can verify the title exists or simply + // that it pumped successfully without exceptions. + expect(find.byType(SharpCutApp), findsOneWidget); }); } diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..a1ca335 --- /dev/null +++ b/todo.md @@ -0,0 +1,95 @@ +# Flutter Frontend TODO (Class Project) + +> Scope: Flutter frontend only (backend by Node.js team) +> Last updated: 2026-03-29 +> Status: `[ ]` Not Started, `[~]` In Progress, `[x]` Done +> Goal: Ship a strong class project with clean code, core features, and basic testing. + +## 1) Scope and Planning + +- [ ] FE-001 Confirm MVP feature list (only what must demo in class). +- [ ] FE-002 Confirm target platform(s) for submission (`Android` only or `Android + Web`). +- [ ] FE-003 List all required screens and expected actions per screen. +- [ ] FE-004 Define user flow order (entry -> auth -> core features -> logout/exit). +- [ ] FE-005 Split work into milestone weeks with owners. + +## 2) Backend Integration Plan (with Node.js Team) + +- [ ] FE-101 Confirm base URL for local/dev backend. +- [ ] FE-102 Confirm auth flow (`login`, `register`, token usage). +- [ ] FE-103 Confirm endpoint list needed for frontend screens. +- [ ] FE-104 Confirm request/response field names for each endpoint. +- [ ] FE-105 Confirm standard error response format. +- [ ] FE-106 Create mock JSON responses for blocked endpoints. +- [ ] FE-107 Keep a shared API contract file in repo (`docs/api-contract.md` or OpenAPI link). + +## 3) Flutter Foundation Setup + +- [ ] FE-201 Remove counter template and create app shell. +- [ ] FE-202 Set app name and package identifiers. +- [ ] FE-203 Choose state management approach and stick to it. +- [ ] FE-204 Choose routing/navigation approach and stick to it. +- [ ] FE-205 Create clean folder structure (`core`, `features`, `shared`). +- [ ] FE-206 Add environment config for backend URL (`dev` at minimum). +- [ ] FE-207 Add reusable API service class (`GET`, `POST`, `PUT/PATCH`, `DELETE`). +- [ ] FE-208 Add common error handling for network/API failures. +- [ ] FE-209 Add loading/empty/error UI widgets reused across screens. + +## 4) UI and UX Baseline + +- [ ] FE-301 Define simple theme (colors, text styles, spacing). +- [ ] FE-302 Build reusable components (`PrimaryButton`, `AppTextField`, `AppCard`). +- [ ] FE-303 Ensure responsive layout for common phone sizes. +- [ ] FE-304 Add form validation messages (required fields, invalid input). +- [ ] FE-305 Add user feedback on actions (`SnackBar`/inline status). +- [ ] FE-306 Replace placeholder app icon/splash if required by rubric. + +## 5) Core Feature Implementation + +Use this checklist for each real feature in your project: + +- [ ] FE-401 Create models from backend response. +- [ ] FE-402 Implement API/repository functions. +- [ ] FE-403 Build screen UI and connect to state. +- [ ] FE-404 Handle loading, success, empty, and error states. +- [ ] FE-405 Add create/edit/delete actions where needed. +- [ ] FE-406 Add navigation to and from this feature. +- [ ] FE-407 Add one unit/widget test for key behavior. + +## 6) Minimum Security and Data Handling + +- [ ] FE-501 Do not hardcode secrets/tokens in source files. +- [ ] FE-502 Store auth token safely (secure storage if auth is used). +- [ ] FE-503 Clear token on logout. +- [ ] FE-504 Validate/sanitize user input before API submission. + +## 7) Testing and Code Quality (Class-Level) + +- [ ] FE-601 Keep `flutter analyze` clean (no errors). +- [ ] FE-602 Keep formatting clean (`dart format .`). +- [ ] FE-603 Write unit tests for core logic/services. +- [ ] FE-604 Write widget tests for critical screens/forms. +- [ ] FE-605 Test main user flow manually on emulator/device before submission. +- [ ] FE-606 Update `.github/workflows/flutter.yml` only for essential checks (`pub get`, `analyze`, `test`). + +## 8) Documentation and Submission Readiness + +- [ ] FE-701 Expand `README.md` with setup and run steps. +- [ ] FE-702 Add architecture summary (state management + folder structure). +- [ ] FE-703 Add API integration notes and known limitations. +- [ ] FE-704 Add team contribution section (who did what). +- [ ] FE-705 Add demo script/checklist for final presentation. +- [ ] FE-706 Freeze scope 2-3 days before deadline (bug fixes only after freeze). + +--- + +## Suggested Documentation Additions (For Class Project) + +| Suggestion | Reason | +| --- | --- | +| Add one-page MVP scope section | Prevents adding extra features late and missing core requirements. | +| Add screen inventory table (`Screen`, `Purpose`, `API Used`) | Makes frontend planning and grading evidence clearer. | +| Add API endpoint quick reference | Reduces frontend-backend confusion during integration. | +| Add local setup steps (`flutter pub get`, `flutter run`) | New team members and graders can run the app quickly. | +| Add known limitations section | Shows transparency and helps explain scope decisions in presentation. | +| Add test checklist used before demo | Improves confidence that key flows work during live demo. | diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..0c50753 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..4fc759c 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST