From 60fefb426660794ffb9d49857e7b8bf1928788ab Mon Sep 17 00:00:00 2001 From: Abel Mekonnen Date: Tue, 31 Mar 2026 19:33:02 +0300 Subject: [PATCH 1/5] feat: implement initial Flutter frontend structure with core theme, colors, and pages --- flutter-development-guide.md | 364 ++++++++++++++++++++++++ lib/core/shared_widgets/app_colors.dart | 39 +++ lib/core/theme/app_theme.dart | 39 +++ lib/main.dart | 110 +------ lib/pages/about_page.dart | 0 lib/pages/contact_page.dart | 0 lib/pages/home_page.dart | 10 + lib/pages/not_found_page.dart | 0 lib/pages/services_page.dart | 0 pubspec.lock | 45 +++ pubspec.yaml | 6 + todo.md | 95 +++++++ 12 files changed, 603 insertions(+), 105 deletions(-) create mode 100644 flutter-development-guide.md create mode 100644 lib/core/shared_widgets/app_colors.dart create mode 100644 lib/core/theme/app_theme.dart create mode 100644 lib/pages/about_page.dart create mode 100644 lib/pages/contact_page.dart create mode 100644 lib/pages/home_page.dart create mode 100644 lib/pages/not_found_page.dart create mode 100644 lib/pages/services_page.dart create mode 100644 todo.md 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/shared_widgets/app_colors.dart b/lib/core/shared_widgets/app_colors.dart new file mode 100644 index 0000000..4730345 --- /dev/null +++ b/lib/core/shared_widgets/app_colors.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class AppColors { + // --- BRAND COLORS --- + // The dominant dark navy used for buttons, active states, and heavy text. + static const Color primary = Color(0xFF0F172A); + + // The premium accent color used for "Most Popular", star ratings, and prices. + // It's a muted, sophisticated bronze/gold. + static const Color accentGold = Color(0xFFB8977E); + + // --- BACKGROUND & SURFACE --- + // The main background of the app. It is NOT pure white, it's a very soft gray + // to make the pure white cards pop out. + static const Color background = Color(0xFFF4F6F8); + + // Used for Cards, Bottom Navigation Bar, and input fields. + static const Color surface = Color(0xFFFFFFFF); + + // --- TYPOGRAPHY --- + // Main headers and important text. (Almost black/dark navy) + static const Color textMain = Color(0xFF1E293B); + + // Secondary text, descriptions, times, and unselected icons. + static const Color textMuted = Color(0xFF64748B); + + // Text used on top of primary dark buttons (Pure White) + static const Color textOnPrimary = Color(0xFFFFFFFF); + + // --- UTILITY / STATUS --- + // Used for borders, dividers, and inactive states. + static const Color borderSubtle = Color(0xFFE2E8F0); + + // Used for "Cancel" buttons or error states. + static const Color error = Color(0xFFEF4444); + + // Used for success states (if needed, though the design leans on primary) + static const Color success = Color(0xFF10B981); +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..eb4a12d --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import '../shared_widgets/app_colors.dart'; + +final ThemeData sharpCutTheme = ThemeData( + scaffoldBackgroundColor: AppColors.background, + primaryColor: AppColors.primary, + + // Makes all default Text look like the mockup + textTheme: const TextTheme( + displayLarge: TextStyle( + color: AppColors.textMain, + fontWeight: FontWeight.w700, + ), + bodyLarge: TextStyle(color: AppColors.textMain), + bodyMedium: TextStyle(color: AppColors.textMuted), + ), + + // Makes all standard ElevatedButtons look like the "Book Now" buttons + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.textOnPrimary, // Text color on button + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), // Rounded corners from mockup + ), + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + + // Global styling for the white cards + cardTheme: CardThemeData( + color: AppColors.surface, + elevation: 2, + shadowColor: Colors.black.withOpacity(0.05), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + +); diff --git a/lib/main.dart b/lib/main.dart index 7b7f5b6..2a310e4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:frontend/pages/home_page.dart'; void main() { - runApp(const MyApp()); + runApp(const SharpCut()); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class SharpCut extends StatelessWidget { + const SharpCut({super.key}); // This widget is the root of your application. @override @@ -13,110 +14,9 @@ class MyApp extends StatelessWidget { 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. + home: HomePage(), ); } } diff --git a/lib/pages/about_page.dart b/lib/pages/about_page.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/pages/contact_page.dart b/lib/pages/contact_page.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart new file mode 100644 index 0000000..39848c4 --- /dev/null +++ b/lib/pages/home_page.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(contesxt) { + return Text("hi"); + } +} diff --git a/lib/pages/not_found_page.dart b/lib/pages/not_found_page.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/pages/services_page.dart b/lib/pages/services_page.dart new file mode 100644 index 0000000..e69de29 diff --git a/pubspec.lock b/pubspec.lock index eaab057..35a4271 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -70,11 +70,32 @@ 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_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" leak_tracker: dependency: transitive description: @@ -107,6 +128,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: @@ -139,6 +168,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + 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 +197,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: diff --git a/pubspec.yaml b/pubspec.yaml index 530eb9a..9c43a61 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,12 @@ 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 + dev_dependencies: flutter_test: sdk: flutter 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. | From 29b0e0c812033aea7631ccb24b3cbde5f8e3fe96 Mon Sep 17 00:00:00 2001 From: Abel Mekonnen Date: Sat, 4 Apr 2026 17:30:02 +0300 Subject: [PATCH 2/5] feat: Add demo seed data, validators, and UI components - Created demo_seed_data.dart for mock services and appointments. - Implemented validators.dart for form validation (required fields, email, password). - Added AdminBottomNav and ClientBottomNav widgets for navigation. - Introduced ErrorView and LoadingView widgets for better user feedback. - Added SectionHeader widget for consistent section titles. - Updated generated_plugin_registrant.cc and generated_plugins.cmake for secure storage support on Linux and Windows. - Updated pubspec.yaml to include dio, flutter_secure_storage, and intl dependencies. - Added mvp_flutter_starter_guide.md for project structure and implementation guidance. --- README.md | 57 +- lib/core/config/env.dart | 7 + lib/core/network/api_service.dart | 56 ++ lib/core/network/dio_client.dart | 41 + lib/core/router/app_router.dart | 87 +++ lib/core/router/route_names.dart | 13 + lib/core/shared_widgets/app_colors.dart | 65 +- lib/core/storage/secure_storage_service.dart | 35 + lib/core/theme/app_theme.dart | 124 ++- .../auth/data/models/auth_response_model.dart | 22 + .../auth/data/models/login_request_model.dart | 10 + lib/features/auth/data/models/user_model.dart | 33 + .../auth/providers/auth_provider.dart | 78 ++ .../data/models/appointment_model.dart | 56 ++ .../create_appointment_request_model.dart | 24 + .../providers/appointments_provider.dart | 42 + .../services/data/models/service_model.dart | 50 ++ .../services/providers/services_provider.dart | 15 + lib/main.dart | 27 +- lib/pages/about_page.dart | 18 + lib/pages/admin_dashboard_page.dart | 222 ++++++ lib/pages/booking_page.dart | 223 ++++++ lib/pages/contact_page.dart | 18 + lib/pages/home_page.dart | 36 +- lib/pages/login_page.dart | 194 +++++ lib/pages/manage_services_page.dart | 151 ++++ lib/pages/my_appointments_page.dart | 220 ++++++ lib/pages/not_found_page.dart | 29 + lib/pages/services_page.dart | 220 ++++++ lib/shared/data/demo_seed_data.dart | 81 ++ lib/shared/utils/validators.dart | 31 + lib/shared/widgets/admin_bottom_nav.dart | 35 + lib/shared/widgets/client_bottom_nav.dart | 41 + lib/shared/widgets/error_view.dart | 32 + lib/shared/widgets/loading_view.dart | 21 + lib/shared/widgets/section_header.dart | 47 ++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + mvp_flutter_starter_guide.md | 726 ++++++++++++++++++ pubspec.lock | 202 ++++- pubspec.yaml | 9 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 44 files changed, 3353 insertions(+), 58 deletions(-) create mode 100644 lib/core/config/env.dart create mode 100644 lib/core/network/api_service.dart create mode 100644 lib/core/network/dio_client.dart create mode 100644 lib/core/router/app_router.dart create mode 100644 lib/core/router/route_names.dart create mode 100644 lib/core/storage/secure_storage_service.dart create mode 100644 lib/features/auth/data/models/auth_response_model.dart create mode 100644 lib/features/auth/data/models/login_request_model.dart create mode 100644 lib/features/auth/data/models/user_model.dart create mode 100644 lib/features/auth/providers/auth_provider.dart create mode 100644 lib/features/booking/data/models/appointment_model.dart create mode 100644 lib/features/booking/data/models/create_appointment_request_model.dart create mode 100644 lib/features/booking/providers/appointments_provider.dart create mode 100644 lib/features/services/data/models/service_model.dart create mode 100644 lib/features/services/providers/services_provider.dart create mode 100644 lib/pages/admin_dashboard_page.dart create mode 100644 lib/pages/booking_page.dart create mode 100644 lib/pages/login_page.dart create mode 100644 lib/pages/manage_services_page.dart create mode 100644 lib/pages/my_appointments_page.dart create mode 100644 lib/shared/data/demo_seed_data.dart create mode 100644 lib/shared/utils/validators.dart create mode 100644 lib/shared/widgets/admin_bottom_nav.dart create mode 100644 lib/shared/widgets/client_bottom_nav.dart create mode 100644 lib/shared/widgets/error_view.dart create mode 100644 lib/shared/widgets/loading_view.dart create mode 100644 lib/shared/widgets/section_header.dart create mode 100644 mvp_flutter_starter_guide.md diff --git a/README.md b/README.md index 9301f3b..b7e118f 100644 --- a/README.md +++ b/README.md @@ -1 +1,56 @@ -# 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. diff --git a/lib/core/config/env.dart b/lib/core/config/env.dart new file mode 100644 index 0000000..c0843f8 --- /dev/null +++ b/lib/core/config/env.dart @@ -0,0 +1,7 @@ +class Env { + Env._(); + + // TODO(team): Replace with your real backend host from contract. + // Example: https://api.sharpcut.app/api/v1 + static const String baseUrl = 'http://10.0.2.2:3000'; +} diff --git a/lib/core/network/api_service.dart b/lib/core/network/api_service.dart new file mode 100644 index 0000000..c0dd42a --- /dev/null +++ b/lib/core/network/api_service.dart @@ -0,0 +1,56 @@ +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; + + Never _notImplemented(String methodName) { + // This keeps `_dio` actively used until real endpoint logic is added. + final baseUrl = _dio.options.baseUrl; + throw UnimplementedError( + '$methodName is not implemented yet. Configure endpoint under $baseUrl', + ); + } + + Future login(LoginRequestModel request) async { + // TODO(team): call your real login endpoint and parse response. + // final response = await _dio.post('/auth/login', data: request.toJson()); + // return AuthResponseModel.fromJson(response.data as Map); + _notImplemented('login'); + } + + Future> fetchServices() async { + // TODO(team): call your service list endpoint. + // final response = await _dio.get('/services'); + // final list = response.data['data'] as List; + // return list.map((e) => ServiceModel.fromJson(e)).toList(); + _notImplemented('fetchServices'); + } + + Future createAppointment( + CreateAppointmentRequestModel request, + ) async { + // TODO(team): call your create booking endpoint. + // final response = await _dio.post('/appointments', data: request.toJson()); + // return AppointmentModel.fromJson(response.data['data']); + _notImplemented('createAppointment'); + } + + Future> fetchMyAppointments() async { + // TODO(team): call your "my appointments" endpoint. + _notImplemented('fetchMyAppointments'); + } +} diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart new file mode 100644 index 0000000..5d16a83 --- /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) { + // TODO(team): map backend errors to UI-friendly messages. + 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 index 4730345..f742545 100644 --- a/lib/core/shared_widgets/app_colors.dart +++ b/lib/core/shared_widgets/app_colors.dart @@ -1,39 +1,42 @@ import 'package:flutter/material.dart'; class AppColors { - // --- BRAND COLORS --- - // The dominant dark navy used for buttons, active states, and heavy text. - static const Color primary = Color(0xFF0F172A); - - // The premium accent color used for "Most Popular", star ratings, and prices. - // It's a muted, sophisticated bronze/gold. - static const Color accentGold = Color(0xFFB8977E); - - // --- BACKGROUND & SURFACE --- - // The main background of the app. It is NOT pure white, it's a very soft gray - // to make the pure white cards pop out. - static const Color background = Color(0xFFF4F6F8); - - // Used for Cards, Bottom Navigation Bar, and input fields. + // 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); - // --- TYPOGRAPHY --- - // Main headers and important text. (Almost black/dark navy) - static const Color textMain = Color(0xFF1E293B); - - // Secondary text, descriptions, times, and unselected icons. - static const Color textMuted = Color(0xFF64748B); - - // Text used on top of primary dark buttons (Pure White) + // 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); - // --- UTILITY / STATUS --- - // Used for borders, dividers, and inactive states. - static const Color borderSubtle = Color(0xFFE2E8F0); - - // Used for "Cancel" buttons or error states. - static const Color error = Color(0xFFEF4444); - - // Used for success states (if needed, though the design leans on primary) - static const Color success = Color(0xFF10B981); + // 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..53c5d74 --- /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 { + // TODO(team): call this right after successful 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 index eb4a12d..899a106 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -2,38 +2,140 @@ 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, + ), - // Makes all default Text look like the mockup + // 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), + bodyLarge: TextStyle(color: AppColors.textMain, height: 1.4), bodyMedium: TextStyle(color: AppColors.textMuted), + labelLarge: TextStyle(fontWeight: FontWeight.w600), ), - // Makes all standard ElevatedButtons look like the "Book Now" buttons + appBarTheme: const AppBarTheme( + backgroundColor: AppColors.background, + foregroundColor: AppColors.textMain, + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + ), + + // Primary action buttons. elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: AppColors.primary, - foregroundColor: AppColors.textOnPrimary, // Text color on button + foregroundColor: AppColors.textOnPrimary, + minimumSize: const Size.fromHeight(50), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), // Rounded corners from mockup + borderRadius: BorderRadius.circular(14), ), elevation: 0, - padding: const EdgeInsets.symmetric(vertical: 16), + 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 styling for the white cards + // Global card style used by all list/detail sections. cardTheme: CardThemeData( color: AppColors.surface, - elevation: 2, - shadowColor: Colors.black.withOpacity(0.05), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + 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..a912357 --- /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'] 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}; + } +} 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..5353be9 --- /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'] 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, + }; + } +} 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..b0dbf70 --- /dev/null +++ b/lib/features/booking/providers/appointments_provider.dart @@ -0,0 +1,42 @@ +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'; +import 'package:frontend/shared/data/demo_seed_data.dart'; + +final appointmentsProvider = FutureProvider>(( + ref, +) async { + final api = ref.read(apiServiceProvider); + + try { + return await api.fetchMyAppointments(); + } on UnimplementedError { + // Show realistic sample cards while waiting for backend endpoint. + return DemoSeedData.appointments(); + } +}); + +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 { + try { + await _api.createAppointment(request); + } on UnimplementedError { + // Simulate API delay for smoother demo UX in class. + await Future.delayed(const Duration(milliseconds: 700)); + } + }); + } +} + +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..8275ab4 --- /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'] as String, + name: json['name'] as String, + description: json['description'] as String, + price: (json['price'] as num).toDouble(), + durationMinutes: json['durationMinutes'] as int, + 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..89b8036 --- /dev/null +++ b/lib/features/services/providers/services_provider.dart @@ -0,0 +1,15 @@ +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'; +import 'package:frontend/shared/data/demo_seed_data.dart'; + +final servicesProvider = FutureProvider>((ref) async { + final api = ref.read(apiServiceProvider); + + try { + return await api.fetchServices(); + } on UnimplementedError { + // Keep the page usable during class demos before backend is finished. + return DemoSeedData.services; + } +}); diff --git a/lib/main.dart b/lib/main.dart index 2a310e4..61f8646 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,22 +1,25 @@ import 'package:flutter/material.dart'; -import 'package:frontend/pages/home_page.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 SharpCut()); + // Riverpod must wrap the app so providers can be read anywhere. + runApp(const ProviderScope(child: SharpCutApp())); } -class SharpCut extends StatelessWidget { - const SharpCut({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( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - ), - home: HomePage(), + 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 index e69de29..d0be669 100644 --- a/lib/pages/about_page.dart +++ b/lib/pages/about_page.dart @@ -0,0 +1,18 @@ +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: const Padding( + padding: EdgeInsets.all(16), + child: Text( + '// TODO(team): Add your project story, mission, and team members here.', + ), + ), + ); + } +} diff --git a/lib/pages/admin_dashboard_page.dart b/lib/pages/admin_dashboard_page.dart new file mode 100644 index 0000000..57c14b6 --- /dev/null +++ b/lib/pages/admin_dashboard_page.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:frontend/core/router/route_names.dart'; +import 'package:frontend/core/shared_widgets/app_colors.dart'; +import 'package:frontend/shared/data/demo_seed_data.dart'; +import 'package:frontend/shared/widgets/admin_bottom_nav.dart'; +import 'package:frontend/shared/widgets/section_header.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +class AdminDashboardPage extends StatelessWidget { + const AdminDashboardPage({super.key}); + + @override + Widget build(BuildContext context) { + final appointments = DemoSeedData.appointments(); + final today = DateTime.now(); + 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 != 'cancelled'; + }).toList(); + + final completedCount = appointments + .where((apt) => apt.status == 'completed') + .length; + + 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: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top heading + subtitle for the analytics overview. + const SectionHeader( + title: 'Executive Insights', + subtitle: 'Quick view of bookings, revenue, and operations.', + ), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _MetricCard( + label: 'Revenue', + value: '\$${DemoSeedData.estimatedRevenue().toStringAsFixed(0)}', + icon: Icons.payments_outlined, + ), + _MetricCard( + label: 'Completed', + value: '$completedCount', + 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); + return ListTile( + contentPadding: EdgeInsets.zero, + leading: const CircleAvatar( + backgroundColor: AppColors.primarySoft, + child: Icon(Icons.content_cut, color: AppColors.primary), + ), + title: Text('Client booking #${appointment.id}'), + 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'), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +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..f68fc67 --- /dev/null +++ b/lib/pages/booking_page.dart @@ -0,0 +1,223 @@ +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/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; + + @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; + } + + // TODO(team): wire selected service/admin IDs from previous screens. + final request = CreateAppointmentRequestModel( + serviceId: 'svc-classic-cut', + 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); + } + + @override + Widget build(BuildContext context) { + 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 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), + const _SummaryRow(label: 'Service', value: 'Classic Executive Cut'), + _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!), + ), + const _SummaryRow(label: 'Estimated Price', value: '\$45'), + ], + ), + ), + ), + 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 index e69de29..71b02ac 100644 --- a/lib/pages/contact_page.dart +++ b/lib/pages/contact_page.dart @@ -0,0 +1,18 @@ +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: const Padding( + padding: EdgeInsets.all(16), + child: Text( + '// TODO(team): Add support phone, email, and optional contact form.', + ), + ), + ); + } +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 39848c4..bb947f9 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,10 +1,42 @@ 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(contesxt) { - return Text("hi"); + 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( + '// TODO(team): Remove dev shortcuts once login flow is connected.', + ), + ], + ), + ), + ); } } diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart new file mode 100644 index 0000000..fa35154 --- /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..7fc6e78 --- /dev/null +++ b/lib/pages/manage_services_page.dart @@ -0,0 +1,151 @@ +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}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final servicesAsync = ref.watch(servicesProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Service Management')), + bottomNavigationBar: const AdminBottomNav( + currentRoute: RouteNames.adminManageServices, + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () { + // TODO(team): open add service form (bottom sheet or new page). + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('TODO: open Add Service form.')), + ); + }, + icon: const Icon(Icons.add), + label: const Text('New Service'), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + 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) { + return _ServiceAdminCard(service: services[index]); + }, + ), + ), + ], + ); + }, + 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}); + + final ServiceModel service; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + service.name, + 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, 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(), + + // Simple actions for students to replace with real update/delete APIs. + TextButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('TODO: edit ${service.name}.')), + ); + }, + child: const Text('Edit'), + ), + TextButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('TODO: delete ${service.name}.')), + ); + }, + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('Delete'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/my_appointments_page.dart b/lib/pages/my_appointments_page.dart new file mode 100644 index 0000000..e21e339 --- /dev/null +++ b/lib/pages/my_appointments_page.dart @@ -0,0 +1,220 @@ +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: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'TODO: open appointment details and actions.', + ), + ), + ); + }, + 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 _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 index e69de29..65d40f2 100644 --- a/lib/pages/not_found_page.dart +++ 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 index e69de29..1ea617c 100644 --- a/lib/pages/services_page.dart +++ b/lib/pages/services_page.dart @@ -0,0 +1,220 @@ +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}); + + @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( + radius: 14, + child: Icon(Icons.person_outline, size: 16), + ), + ), + ], + ), + 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, + 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.onBookPressed, + required this.highlight, + }); + + final ServiceModel service; + 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, + ), + ), + ), + Container( + height: 130, + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.sectionTint, + borderRadius: BorderRadius.circular(14), + ), + child: const Icon( + Icons.content_cut_rounded, + size: 46, + color: AppColors.primary, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Text( + service.name, + 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, 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..5077ce8 --- /dev/null +++ b/lib/shared/data/demo_seed_data.dart @@ -0,0 +1,81 @@ +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..38b8fda --- /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/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 35a4271..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 @@ -78,6 +102,54 @@ packages: 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 @@ -96,6 +168,30 @@ packages: 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: @@ -160,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: @@ -168,6 +272,70 @@ 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: @@ -237,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: @@ -253,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 9c43a61..1d65ba8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,15 @@ dependencies: # 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 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 From e839a4cd3ed23c92652bc253ddcf5fe9e757ffc8 Mon Sep 17 00:00:00 2001 From: Abel Mekonnen Date: Sat, 4 Apr 2026 21:48:43 +0300 Subject: [PATCH 3/5] feat: Update API service with mock responses and enhance UI components across pages --- lib/core/config/env.dart | 3 - lib/core/network/api_service.dart | 78 ++++++++--- lib/core/network/dio_client.dart | 2 +- lib/core/storage/secure_storage_service.dart | 2 +- lib/core/theme/app_theme.dart | 4 +- lib/pages/about_page.dart | 36 ++++- lib/pages/admin_dashboard_page.dart | 27 ++-- lib/pages/booking_page.dart | 50 ++++--- lib/pages/contact_page.dart | 54 +++++++- lib/pages/home_page.dart | 3 +- lib/pages/login_page.dart | 12 +- lib/pages/manage_services_page.dart | 135 +++++++++++++++++-- lib/pages/my_appointments_page.dart | 31 ++++- lib/pages/services_page.dart | 28 +++- lib/shared/data/demo_seed_data.dart | 4 +- lib/shared/widgets/client_bottom_nav.dart | 6 +- 16 files changed, 385 insertions(+), 90 deletions(-) diff --git a/lib/core/config/env.dart b/lib/core/config/env.dart index c0843f8..9dabf27 100644 --- a/lib/core/config/env.dart +++ b/lib/core/config/env.dart @@ -1,7 +1,4 @@ class Env { Env._(); - - // TODO(team): Replace with your real backend host from contract. - // Example: https://api.sharpcut.app/api/v1 static const String baseUrl = 'http://10.0.2.2:3000'; } diff --git a/lib/core/network/api_service.dart b/lib/core/network/api_service.dart index c0dd42a..56257c2 100644 --- a/lib/core/network/api_service.dart +++ b/lib/core/network/api_service.dart @@ -6,6 +6,7 @@ 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'; +import 'package:frontend/features/auth/data/models/user_model.dart'; final apiServiceProvider = Provider((ref) { final dio = ref.watch(dioProvider); @@ -17,40 +18,85 @@ class ApiService { final Dio _dio; - Never _notImplemented(String methodName) { - // This keeps `_dio` actively used until real endpoint logic is added. - final baseUrl = _dio.options.baseUrl; - throw UnimplementedError( - '$methodName is not implemented yet. Configure endpoint under $baseUrl', - ); - } - Future login(LoginRequestModel request) async { - // TODO(team): call your real login endpoint and parse response. + // MOCK: Replace the delay and return with your real backend call. // final response = await _dio.post('/auth/login', data: request.toJson()); // return AuthResponseModel.fromJson(response.data as Map); - _notImplemented('login'); + + await Future.delayed(const Duration(seconds: 1)); + return AuthResponseModel( + accessToken: 'mock-jwt-token-12345', + user: UserModel( + id: 'user-001', + email: request.email, + name: 'Demo Client', + role: request.email.contains('admin') ? 'admin' : 'client', + ), + ); } Future> fetchServices() async { - // TODO(team): call your service list endpoint. + // MOCK: Replace with your real backend call. // final response = await _dio.get('/services'); // final list = response.data['data'] as List; // return list.map((e) => ServiceModel.fromJson(e)).toList(); - _notImplemented('fetchServices'); + + await Future.delayed(const Duration(seconds: 1)); + return const [ + ServiceModel( + id: 'svc-classic-cut', + name: 'Classic Executive Cut', + description: 'A tailored haircut.', + price: 45.0, + durationMinutes: 45, + ), + ServiceModel( + id: 'svc-beard-trim', + name: 'Beard Trim & Shape', + description: 'Expert beard care.', + price: 25.0, + durationMinutes: 30, + ), + ]; } Future createAppointment( CreateAppointmentRequestModel request, ) async { - // TODO(team): call your create booking endpoint. + // MOCK: Replace with your real backend call. // final response = await _dio.post('/appointments', data: request.toJson()); // return AppointmentModel.fromJson(response.data['data']); - _notImplemented('createAppointment'); + + await Future.delayed(const Duration(seconds: 1)); + return AppointmentModel( + id: 'apt-${DateTime.now().millisecondsSinceEpoch}', + clientId: 'user-001', + serviceId: request.serviceId, + adminId: request.adminId, + startTime: request.startTime, + endTime: request.startTime.add(const Duration(minutes: 45)), + status: 'scheduled', + ); } Future> fetchMyAppointments() async { - // TODO(team): call your "my appointments" endpoint. - _notImplemented('fetchMyAppointments'); + // MOCK: Replace with your real backend call. + // final response = await _dio.get('/appointments/me'); + // return (response.data['data'] as List).map((e) => AppointmentModel.fromJson(e)).toList(); + + await Future.delayed(const Duration(seconds: 1)); + return [ + AppointmentModel( + id: 'apt-001', + clientId: 'user-001', + serviceId: 'svc-classic-cut', + adminId: 'admin-julian', + startTime: DateTime.now().add(const Duration(days: 1, hours: 10)), + endTime: DateTime.now().add( + const Duration(days: 1, hours: 10, minutes: 45), + ), + status: 'scheduled', + ), + ]; } } diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart index 5d16a83..658b54b 100644 --- a/lib/core/network/dio_client.dart +++ b/lib/core/network/dio_client.dart @@ -31,7 +31,7 @@ final dioProvider = Provider((ref) { handler.next(options); }, onError: (error, handler) { - // TODO(team): map backend errors to UI-friendly messages. + // MOCK: You can intercept interceptors and map backend errors to custom UI exceptions here. handler.next(error); }, ), diff --git a/lib/core/storage/secure_storage_service.dart b/lib/core/storage/secure_storage_service.dart index 53c5d74..e84621c 100644 --- a/lib/core/storage/secure_storage_service.dart +++ b/lib/core/storage/secure_storage_service.dart @@ -11,7 +11,7 @@ class SecureStorageService { static const String userRoleKey = 'user_role'; Future saveAccessToken(String token) async { - // TODO(team): call this right after successful login. + // This correctly saves the token to local secure-storage after login. await _storage.write(key: accessTokenKey, value: token); } diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 899a106..b49a09a 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -59,9 +59,7 @@ final ThemeData sharpCutTheme = ThemeData( backgroundColor: AppColors.primary, foregroundColor: AppColors.textOnPrimary, minimumSize: const Size.fromHeight(50), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), elevation: 0, padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), ), diff --git a/lib/pages/about_page.dart b/lib/pages/about_page.dart index d0be669..385cadc 100644 --- a/lib/pages/about_page.dart +++ b/lib/pages/about_page.dart @@ -7,10 +7,38 @@ class AboutPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('About SharpCut')), - body: const Padding( - padding: EdgeInsets.all(16), - child: Text( - '// TODO(team): Add your project story, mission, and team members here.', + 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 index 57c14b6..0610225 100644 --- a/lib/pages/admin_dashboard_page.dart +++ b/lib/pages/admin_dashboard_page.dart @@ -15,7 +15,8 @@ class AdminDashboardPage extends StatelessWidget { final appointments = DemoSeedData.appointments(); final today = DateTime.now(); final confirmedToday = appointments.where((apt) { - final sameDay = apt.startTime.year == today.year && + final sameDay = + apt.startTime.year == today.year && apt.startTime.month == today.month && apt.startTime.day == today.day; return sameDay && apt.status != 'cancelled'; @@ -58,7 +59,8 @@ class AdminDashboardPage extends StatelessWidget { children: [ _MetricCard( label: 'Revenue', - value: '\$${DemoSeedData.estimatedRevenue().toStringAsFixed(0)}', + value: + '\$${DemoSeedData.estimatedRevenue().toStringAsFixed(0)}', icon: Icons.payments_outlined, ), _MetricCard( @@ -76,7 +78,8 @@ class AdminDashboardPage extends StatelessWidget { const SizedBox(height: 14), const SectionHeader( title: 'Today\'s Schedule', - subtitle: 'Tap manage services to edit prices, durations, and visibility.', + subtitle: + 'Tap manage services to edit prices, durations, and visibility.', padding: EdgeInsets.only(bottom: 10), ), if (confirmedToday.isEmpty) @@ -87,12 +90,17 @@ class AdminDashboardPage extends StatelessWidget { padding: const EdgeInsets.all(14), child: Column( children: confirmedToday.map((appointment) { - final time = DateFormat('hh:mm a').format(appointment.startTime); + final time = DateFormat( + 'hh:mm a', + ).format(appointment.startTime); return ListTile( contentPadding: EdgeInsets.zero, leading: const CircleAvatar( backgroundColor: AppColors.primarySoft, - child: Icon(Icons.content_cut, color: AppColors.primary), + child: Icon( + Icons.content_cut, + color: AppColors.primary, + ), ), title: Text('Client booking #${appointment.id}'), subtitle: Text('Starts at $time'), @@ -134,7 +142,8 @@ class AdminDashboardPage extends StatelessWidget { SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: () => context.go(RouteNames.adminManageServices), + onPressed: () => + context.go(RouteNames.adminManageServices), style: ElevatedButton.styleFrom( backgroundColor: AppColors.textOnPrimary, foregroundColor: AppColors.primary, @@ -179,9 +188,9 @@ class _MetricCard extends StatelessWidget { const SizedBox(height: 8), Text( value, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppColors.primary, - ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(color: AppColors.primary), ), const SizedBox(height: 2), Text(label, style: Theme.of(context).textTheme.bodySmall), diff --git a/lib/pages/booking_page.dart b/lib/pages/booking_page.dart index f68fc67..1c9de28 100644 --- a/lib/pages/booking_page.dart +++ b/lib/pages/booking_page.dart @@ -42,21 +42,24 @@ class _BookingPageState extends ConsumerState { return; } - // TODO(team): wire selected service/admin IDs from previous screens. + // MOCK: Default service/admin IDs. In the real app, these should be passed from the + // Service selection screen via navigation arguments or providers. final request = CreateAppointmentRequestModel( serviceId: 'svc-classic-cut', adminId: 'admin-julian', startTime: _selectedTime!, ); - await ref.read(appointmentControllerProvider.notifier).createAppointment(request); + 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())), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(createState.error.toString()))); return; } @@ -76,7 +79,9 @@ class _BookingPageState extends ConsumerState { return Scaffold( appBar: AppBar(title: const Text('SharpCut')), - bottomNavigationBar: const ClientBottomNav(currentRoute: RouteNames.booking), + bottomNavigationBar: const ClientBottomNav( + currentRoute: RouteNames.booking, + ), body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), @@ -102,7 +107,11 @@ class _BookingPageState extends ConsumerState { const SizedBox(height: 10), CalendarDatePicker( initialDate: _selectedDate, - firstDate: DateTime(DateTime.now().year, DateTime.now().month, DateTime.now().day), + firstDate: DateTime( + DateTime.now().year, + DateTime.now().month, + DateTime.now().day, + ), lastDate: DateTime.now().add(const Duration(days: 365)), onDateChanged: (DateTime date) { setState(() { @@ -142,7 +151,8 @@ class _BookingPageState extends ConsumerState { return ChoiceChip( label: Text(DateFormat('hh:mm a').format(slot)), selected: isSelected, - onSelected: (_) => setState(() => _selectedTime = slot), + onSelected: (_) => + setState(() => _selectedTime = slot), ); }).toList(), ), @@ -162,10 +172,15 @@ class _BookingPageState extends ConsumerState { style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), - const _SummaryRow(label: 'Service', value: 'Classic Executive Cut'), + const _SummaryRow( + label: 'Service', + value: 'Classic Executive Cut', + ), _SummaryRow( label: 'Date', - value: DateFormat('EEE, dd MMM yyyy').format(_selectedDate), + value: DateFormat( + 'EEE, dd MMM yyyy', + ).format(_selectedDate), ), _SummaryRow( label: 'Time', @@ -173,7 +188,10 @@ class _BookingPageState extends ConsumerState { ? 'Not selected' : DateFormat('hh:mm a').format(_selectedTime!), ), - const _SummaryRow(label: 'Estimated Price', value: '\$45'), + const _SummaryRow( + label: 'Estimated Price', + value: '\$45', + ), ], ), ), @@ -184,7 +202,9 @@ class _BookingPageState extends ConsumerState { child: ElevatedButton( onPressed: createState.isLoading ? null : _submitBooking, child: Text( - createState.isLoading ? 'Confirming...' : 'Confirm Selection', + createState.isLoading + ? 'Confirming...' + : 'Confirm Selection', ), ), ), @@ -210,9 +230,9 @@ class _SummaryRow extends StatelessWidget { children: [ Text( '$label:', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppColors.textMuted, - ), + 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 index 71b02ac..7409b0c 100644 --- a/lib/pages/contact_page.dart +++ b/lib/pages/contact_page.dart @@ -7,10 +7,56 @@ class ContactPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Contact')), - body: const Padding( - padding: EdgeInsets.all(16), - child: Text( - '// TODO(team): Add support phone, email, and optional contact form.', + 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 index bb947f9..80a9262 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -32,7 +32,8 @@ class HomePage extends StatelessWidget { ), const SizedBox(height: 24), const Text( - '// TODO(team): Remove dev shortcuts once login flow is connected.', + '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 index fa35154..ee36fd0 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -39,9 +39,9 @@ class _LoginPageState extends ConsumerState { if (!mounted) return; if (authState.hasError) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(authState.error.toString())), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(authState.error.toString()))); return; } @@ -179,9 +179,9 @@ class _LoginPageState extends ConsumerState { // 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, - ), + 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 index 7fc6e78..d1200d7 100644 --- a/lib/pages/manage_services_page.dart +++ b/lib/pages/manage_services_page.dart @@ -23,9 +23,10 @@ class ManageServicesPage extends ConsumerWidget { ), floatingActionButton: FloatingActionButton.extended( onPressed: () { - // TODO(team): open add service form (bottom sheet or new page). - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('TODO: open Add Service form.')), + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) => const _EditServiceForm(service: null), ); }, icon: const Icon(Icons.add), @@ -38,7 +39,9 @@ class ManageServicesPage extends ConsumerWidget { data: (services) { if (services.isEmpty) { return const Center( - child: Text('No services available. Tap "New Service" to add one.'), + child: Text( + 'No services available. Tap "New Service" to add one.', + ), ); } @@ -95,7 +98,10 @@ class _ServiceAdminCard extends StatelessWidget { ), ), Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), decoration: BoxDecoration( color: AppColors.primarySoft, borderRadius: BorderRadius.circular(999), @@ -111,11 +117,18 @@ class _ServiceAdminCard extends StatelessWidget { ], ), const SizedBox(height: 6), - Text(service.description, style: Theme.of(context).textTheme.bodyMedium), + Text( + service.description, + style: Theme.of(context).textTheme.bodyMedium, + ), const SizedBox(height: 10), Row( children: [ - const Icon(Icons.schedule, size: 16, color: AppColors.textMuted), + const Icon( + Icons.schedule, + size: 16, + color: AppColors.textMuted, + ), const SizedBox(width: 6), Text( '${service.durationMinutes} minutes', @@ -126,16 +139,22 @@ class _ServiceAdminCard extends StatelessWidget { // Simple actions for students to replace with real update/delete APIs. TextButton( onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('TODO: edit ${service.name}.')), + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) => _EditServiceForm(service: service), ); }, child: const Text('Edit'), ), TextButton( onPressed: () { + // MOCK: In the real app, call a delete endpoint on ApiService + // then invalidate `servicesProvider`. ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('TODO: delete ${service.name}.')), + SnackBar( + content: Text('Deleted \${service.name} (Mock)'), + ), ); }, style: TextButton.styleFrom(foregroundColor: AppColors.error), @@ -149,3 +168,99 @@ class _ServiceAdminCard extends StatelessWidget { ); } } + +class _EditServiceForm extends StatefulWidget { + final ServiceModel? service; + + const _EditServiceForm({this.service}); + + @override + State<_EditServiceForm> createState() => _EditServiceFormState(); +} + +class _EditServiceFormState extends State<_EditServiceForm> { + final TextEditingController _nameCtrl = TextEditingController(); + final TextEditingController _priceCtrl = TextEditingController(); + final TextEditingController _durationCtrl = TextEditingController(); + + @override + void initState() { + super.initState(); + if (widget.service != null) { + _nameCtrl.text = widget.service!.name; + _priceCtrl.text = widget.service!.price.toString(); + _durationCtrl.text = widget.service!.durationMinutes.toString(); + } + } + + @override + void dispose() { + _nameCtrl.dispose(); + _priceCtrl.dispose(); + _durationCtrl.dispose(); + super.dispose(); + } + + void _save() { + // MOCK: Replace with API call to create or update service. + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + widget.service == null ? 'Created (Mock)' : 'Updated (Mock)', + ), + ), + ); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + 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, + decoration: const InputDecoration( + labelText: 'Name', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _priceCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Price', + prefixText: '\$', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _durationCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Duration (mins)', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + ElevatedButton(onPressed: _save, child: const Text('Save Service')), + ], + ), + ); + } +} diff --git a/lib/pages/my_appointments_page.dart b/lib/pages/my_appointments_page.dart index e21e339..5081d21 100644 --- a/lib/pages/my_appointments_page.dart +++ b/lib/pages/my_appointments_page.dart @@ -50,14 +50,17 @@ class MyAppointmentsPage extends ConsumerWidget { itemCount: appointments.length, separatorBuilder: (_, __) => const SizedBox(height: 10), itemBuilder: (context, index) { - return _AppointmentCard(appointment: appointments[index]); + return _AppointmentCard( + appointment: appointments[index], + ); }, ), ), ], ); }, - loading: () => const LoadingView(label: 'Loading your appointments...'), + loading: () => + const LoadingView(label: 'Loading your appointments...'), error: (error, _) => ErrorView( message: 'Could not load appointments.\n$error', onRetry: () => ref.invalidate(appointmentsProvider), @@ -103,7 +106,10 @@ class _AppointmentCard extends StatelessWidget { ), ), Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), decoration: BoxDecoration( color: statusColor.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(999), @@ -136,11 +142,24 @@ class _AppointmentCard extends StatelessWidget { const Spacer(), TextButton( onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Appointment Details'), content: Text( - 'TODO: open appointment details and actions.', + 'Service: \${appointment.serviceId}\\n' + 'Date: \$startDate\\n' + 'Time: \$startTime\\n' + 'Provider: \${appointment.adminId}\\n' + 'Status: \${appointment.status}', ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Close'), + ), + // Optional: Add cancel logic here in the future + ], ), ); }, diff --git a/lib/pages/services_page.dart b/lib/pages/services_page.dart index 1ea617c..ee7b552 100644 --- a/lib/pages/services_page.dart +++ b/lib/pages/services_page.dart @@ -105,7 +105,10 @@ class _ServiceCard extends StatelessWidget { if (highlight) Container( margin: const EdgeInsets.only(bottom: 10), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), decoration: BoxDecoration( color: AppColors.accentGold, borderRadius: BorderRadius.circular(999), @@ -143,18 +146,25 @@ class _ServiceCard extends StatelessWidget { ), Text( '\$${service.price.toStringAsFixed(0)}', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppColors.primary, - ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(color: AppColors.primary), ), ], ), const SizedBox(height: 6), - Text(service.description, style: Theme.of(context).textTheme.bodyMedium), + Text( + service.description, + style: Theme.of(context).textTheme.bodyMedium, + ), const SizedBox(height: 10), Row( children: [ - const Icon(Icons.schedule, size: 16, color: AppColors.textMuted), + const Icon( + Icons.schedule, + size: 16, + color: AppColors.textMuted, + ), const SizedBox(width: 6), Text( '${service.durationMinutes} minutes', @@ -194,7 +204,11 @@ class _EmptyServicesState extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.spa_outlined, size: 36, color: AppColors.primary), + const Icon( + Icons.spa_outlined, + size: 36, + color: AppColors.primary, + ), const SizedBox(height: 10), Text( 'No Services Yet', diff --git a/lib/shared/data/demo_seed_data.dart b/lib/shared/data/demo_seed_data.dart index 5077ce8..4e745d2 100644 --- a/lib/shared/data/demo_seed_data.dart +++ b/lib/shared/data/demo_seed_data.dart @@ -59,7 +59,9 @@ class DemoSeedData { 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)), + endTime: today.subtract( + const Duration(days: 1, hours: 14, minutes: 35), + ), status: 'completed', ), ]; diff --git a/lib/shared/widgets/client_bottom_nav.dart b/lib/shared/widgets/client_bottom_nav.dart index 38b8fda..e54838e 100644 --- a/lib/shared/widgets/client_bottom_nav.dart +++ b/lib/shared/widgets/client_bottom_nav.dart @@ -24,11 +24,11 @@ class ClientBottomNav extends StatelessWidget { }, destinations: const [ NavigationDestination(icon: Icon(Icons.content_cut), label: 'Services'), + NavigationDestination(icon: Icon(Icons.calendar_month), label: 'Book'), NavigationDestination( - icon: Icon(Icons.calendar_month), - label: 'Book', + icon: Icon(Icons.event_note), + label: 'My Bookings', ), - NavigationDestination(icon: Icon(Icons.event_note), label: 'My Bookings'), ], ); } From c9c48c9d859264659119dc61d1b27e21d77c2fdd Mon Sep 17 00:00:00 2001 From: Abel Mekonnen Date: Sun, 5 Apr 2026 20:57:08 +0300 Subject: [PATCH 4/5] feat(mock-api): add mock API for authentication and appointment management - Created package.json for mock API with json-server dependency. - Implemented server.js to handle user authentication and appointment scheduling. - Added endpoints for user login and fetching user appointments. - Included middleware for request parsing and response formatting. test: update widget test to verify app structure - Refactored widget test to check for the main app widget. - Removed counter-related tests and focused on app initialization. --- .gitignore | 1 + .vscode/settings.json | 5 + README.md | 24 + lib/assets/images/hair_model.png | Bin 0 -> 151562 bytes lib/core/config/env.dart | 14 +- lib/core/network/api_service.dart | 194 ++- lib/features/auth/data/models/user_model.dart | 8 +- .../data/models/appointment_model.dart | 14 +- .../providers/appointments_provider.dart | 21 +- .../services/data/models/service_model.dart | 8 +- .../services/providers/services_provider.dart | 40 +- lib/pages/admin_dashboard_page.dart | 270 ++-- lib/pages/booking_page.dart | 98 +- lib/pages/manage_services_page.dart | 273 +++- mock-api/db.json | 89 ++ mock-api/package-lock.json | 1402 +++++++++++++++++ mock-api/package.json | 11 + mock-api/server.js | 134 ++ test/widget_test.dart | 21 +- 19 files changed, 2314 insertions(+), 313 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 lib/assets/images/hair_model.png create mode 100644 mock-api/db.json create mode 100644 mock-api/package-lock.json create mode 100644 mock-api/package.json create mode 100644 mock-api/server.js 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 b7e118f..ebf6f19 100644 --- a/README.md +++ b/README.md @@ -54,3 +54,27 @@ lib/ 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/lib/assets/images/hair_model.png b/lib/assets/images/hair_model.png new file mode 100644 index 0000000000000000000000000000000000000000..06201ba42d49a024ec686dc73b87d18cc52218da GIT binary patch literal 151562 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�?|$XPWo!_*Y;sT~hjP=q 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%0DBnfU05b1r!E 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ˤ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 literal 0 HcmV?d00001 diff --git a/lib/core/config/env.dart b/lib/core/config/env.dart index 9dabf27..05a5256 100644 --- a/lib/core/config/env.dart +++ b/lib/core/config/env.dart @@ -1,4 +1,16 @@ +import 'package:flutter/foundation.dart'; + class Env { Env._(); - static const String baseUrl = 'http://10.0.2.2:3000'; + + 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 index 56257c2..3158ff8 100644 --- a/lib/core/network/api_service.dart +++ b/lib/core/network/api_service.dart @@ -6,7 +6,6 @@ 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'; -import 'package:frontend/features/auth/data/models/user_model.dart'; final apiServiceProvider = Provider((ref) { final dio = ref.watch(dioProvider); @@ -19,84 +18,141 @@ class ApiService { final Dio _dio; Future login(LoginRequestModel request) async { - // MOCK: Replace the delay and return with your real backend call. - // final response = await _dio.post('/auth/login', data: request.toJson()); - // return AuthResponseModel.fromJson(response.data as Map); - - await Future.delayed(const Duration(seconds: 1)); - return AuthResponseModel( - accessToken: 'mock-jwt-token-12345', - user: UserModel( - id: 'user-001', - email: request.email, - name: 'Demo Client', - role: request.email.contains('admin') ? 'admin' : 'client', - ), - ); + try { + final response = await _dio.post('/auth/login', data: request.toJson()); + return AuthResponseModel.fromJson(_asMap(response.data)); + } catch (error) { + _throwHandledError(error); + } } Future> fetchServices() async { - // MOCK: Replace with your real backend call. - // final response = await _dio.get('/services'); - // final list = response.data['data'] as List; - // return list.map((e) => ServiceModel.fromJson(e)).toList(); - - await Future.delayed(const Duration(seconds: 1)); - return const [ - ServiceModel( - id: 'svc-classic-cut', - name: 'Classic Executive Cut', - description: 'A tailored haircut.', - price: 45.0, - durationMinutes: 45, - ), - ServiceModel( - id: 'svc-beard-trim', - name: 'Beard Trim & Shape', - description: 'Expert beard care.', - price: 25.0, - durationMinutes: 30, - ), - ]; + 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 { - // MOCK: Replace with your real backend call. - // final response = await _dio.post('/appointments', data: request.toJson()); - // return AppointmentModel.fromJson(response.data['data']); - - await Future.delayed(const Duration(seconds: 1)); - return AppointmentModel( - id: 'apt-${DateTime.now().millisecondsSinceEpoch}', - clientId: 'user-001', - serviceId: request.serviceId, - adminId: request.adminId, - startTime: request.startTime, - endTime: request.startTime.add(const Duration(minutes: 45)), - status: 'scheduled', - ); + try { + final response = await _dio.post('/appointments', data: request.toJson()); + return AppointmentModel.fromJson(_asMap(response.data)); + } catch (error) { + _throwHandledError(error); + } } Future> fetchMyAppointments() async { - // MOCK: Replace with your real backend call. - // final response = await _dio.get('/appointments/me'); - // return (response.data['data'] as List).map((e) => AppointmentModel.fromJson(e)).toList(); - - await Future.delayed(const Duration(seconds: 1)); - return [ - AppointmentModel( - id: 'apt-001', - clientId: 'user-001', - serviceId: 'svc-classic-cut', - adminId: 'admin-julian', - startTime: DateTime.now().add(const Duration(days: 1, hours: 10)), - endTime: DateTime.now().add( - const Duration(days: 1, hours: 10, minutes: 45), - ), - status: 'scheduled', - ), - ]; + 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/features/auth/data/models/user_model.dart b/lib/features/auth/data/models/user_model.dart index a912357..b82c436 100644 --- a/lib/features/auth/data/models/user_model.dart +++ b/lib/features/auth/data/models/user_model.dart @@ -20,10 +20,10 @@ class UserModel { 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, + id: json['id'].toString(), + fullName: json['fullName'].toString(), + email: json['email'].toString(), + role: json['role'].toString(), ); } diff --git a/lib/features/booking/data/models/appointment_model.dart b/lib/features/booking/data/models/appointment_model.dart index 5353be9..4239db1 100644 --- a/lib/features/booking/data/models/appointment_model.dart +++ b/lib/features/booking/data/models/appointment_model.dart @@ -32,13 +32,13 @@ class AppointmentModel { 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, + 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(), ); } diff --git a/lib/features/booking/providers/appointments_provider.dart b/lib/features/booking/providers/appointments_provider.dart index b0dbf70..22c1052 100644 --- a/lib/features/booking/providers/appointments_provider.dart +++ b/lib/features/booking/providers/appointments_provider.dart @@ -2,19 +2,19 @@ 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'; -import 'package:frontend/shared/data/demo_seed_data.dart'; final appointmentsProvider = FutureProvider>(( ref, ) async { final api = ref.read(apiServiceProvider); + return api.fetchMyAppointments(); +}); - try { - return await api.fetchMyAppointments(); - } on UnimplementedError { - // Show realistic sample cards while waiting for backend endpoint. - return DemoSeedData.appointments(); - } +final adminAppointmentsProvider = FutureProvider>(( + ref, +) async { + final api = ref.read(apiServiceProvider); + return api.fetchAppointments(); }); class AppointmentController extends StateNotifier> { @@ -25,12 +25,7 @@ class AppointmentController extends StateNotifier> { Future createAppointment(CreateAppointmentRequestModel request) async { state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { - try { - await _api.createAppointment(request); - } on UnimplementedError { - // Simulate API delay for smoother demo UX in class. - await Future.delayed(const Duration(milliseconds: 700)); - } + await _api.createAppointment(request); }); } } diff --git a/lib/features/services/data/models/service_model.dart b/lib/features/services/data/models/service_model.dart index 8275ab4..bd1a637 100644 --- a/lib/features/services/data/models/service_model.dart +++ b/lib/features/services/data/models/service_model.dart @@ -28,11 +28,11 @@ class ServiceModel { factory ServiceModel.fromJson(Map json) { return ServiceModel( - id: json['id'] as String, - name: json['name'] as String, - description: json['description'] as String, + id: json['id'].toString(), + name: (json['name'] ?? '').toString(), + description: (json['description'] ?? '').toString(), price: (json['price'] as num).toDouble(), - durationMinutes: json['durationMinutes'] as int, + durationMinutes: (json['durationMinutes'] as num).toInt(), imageUrl: json['imageUrl'] as String?, ); } diff --git a/lib/features/services/providers/services_provider.dart b/lib/features/services/providers/services_provider.dart index 89b8036..fc00d45 100644 --- a/lib/features/services/providers/services_provider.dart +++ b/lib/features/services/providers/services_provider.dart @@ -1,15 +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'; -import 'package:frontend/shared/data/demo_seed_data.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; - try { - return await api.fetchServices(); - } on UnimplementedError { - // Keep the page usable during class demos before backend is finished. - return DemoSeedData.services; + 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/pages/admin_dashboard_page.dart b/lib/pages/admin_dashboard_page.dart index 0610225..bd0b363 100644 --- a/lib/pages/admin_dashboard_page.dart +++ b/lib/pages/admin_dashboard_page.dart @@ -1,30 +1,23 @@ 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/shared/data/demo_seed_data.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 StatelessWidget { +class AdminDashboardPage extends ConsumerWidget { const AdminDashboardPage({super.key}); @override - Widget build(BuildContext context) { - final appointments = DemoSeedData.appointments(); - final today = DateTime.now(); - 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 != 'cancelled'; - }).toList(); - - final completedCount = appointments - .where((apt) => apt.status == 'completed') - .length; + Widget build(BuildContext context, WidgetRef ref) { + final appointmentsAsync = ref.watch(adminAppointmentsProvider); + final servicesAsync = ref.watch(servicesProvider); return Scaffold( appBar: AppBar( @@ -43,119 +36,164 @@ class AdminDashboardPage extends StatelessWidget { currentRoute: RouteNames.adminDashboard, ), body: SafeArea( - child: SingleChildScrollView( + child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Top heading + subtitle for the analytics overview. - const SectionHeader( - title: 'Executive Insights', - subtitle: 'Quick view of bookings, revenue, and operations.', - ), - Wrap( - spacing: 10, - runSpacing: 10, - children: [ - _MetricCard( - label: 'Revenue', - value: - '\$${DemoSeedData.estimatedRevenue().toStringAsFixed(0)}', - icon: Icons.payments_outlined, - ), - _MetricCard( - label: 'Completed', - value: '$completedCount', - 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); - return ListTile( - contentPadding: EdgeInsets.zero, - leading: const CircleAvatar( - backgroundColor: AppColors.primarySoft, - child: Icon( - Icons.content_cut, - color: AppColors.primary, - ), - ), - title: Text('Client booking #${appointment.id}'), - 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: 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: [ - Text( - 'Curate Your Service Menu', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppColors.textOnPrimary, - ), + const SectionHeader( + title: 'Executive Insights', + subtitle: + 'Quick view of bookings, revenue, and operations.', ), - 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), - ), + 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), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () => - context.go(RouteNames.adminManageServices), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.textOnPrimary, - foregroundColor: AppColors.primary, + 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'), + ), + ), + ], ), - 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), + ), ), ), ), diff --git a/lib/pages/booking_page.dart b/lib/pages/booking_page.dart index 1c9de28..5263e54 100644 --- a/lib/pages/booking_page.dart +++ b/lib/pages/booking_page.dart @@ -4,6 +4,8 @@ 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'; @@ -19,6 +21,7 @@ class BookingPage extends ConsumerStatefulWidget { class _BookingPageState extends ConsumerState { late DateTime _selectedDate; DateTime? _selectedTime; + String? _selectedServiceId; @override void initState() { @@ -42,10 +45,18 @@ class _BookingPageState extends ConsumerState { return; } - // MOCK: Default service/admin IDs. In the real app, these should be passed from the - // Service selection screen via navigation arguments or providers. + 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: 'svc-classic-cut', + serviceId: selectedService.id, adminId: 'admin-julian', startTime: _selectedTime!, ); @@ -71,8 +82,37 @@ class _BookingPageState extends ConsumerState { 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); @@ -100,6 +140,48 @@ class _BookingPageState extends ConsumerState { 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, @@ -172,9 +254,9 @@ class _BookingPageState extends ConsumerState { style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), - const _SummaryRow( + _SummaryRow( label: 'Service', - value: 'Classic Executive Cut', + value: selectedService?.name ?? 'Not selected', ), _SummaryRow( label: 'Date', @@ -188,9 +270,11 @@ class _BookingPageState extends ConsumerState { ? 'Not selected' : DateFormat('hh:mm a').format(_selectedTime!), ), - const _SummaryRow( + _SummaryRow( label: 'Estimated Price', - value: '\$45', + value: selectedService == null + ? '-' + : '\$${selectedService.price.toStringAsFixed(0)}', ), ], ), diff --git a/lib/pages/manage_services_page.dart b/lib/pages/manage_services_page.dart index d1200d7..c03dda4 100644 --- a/lib/pages/manage_services_page.dart +++ b/lib/pages/manage_services_page.dart @@ -12,9 +12,65 @@ import 'package:frontend/shared/widgets/section_header.dart'; class ManageServicesPage extends ConsumerWidget { const ManageServicesPage({super.key}); + 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')), @@ -22,53 +78,63 @@ class ManageServicesPage extends ConsumerWidget { currentRoute: RouteNames.adminManageServices, ), floatingActionButton: FloatingActionButton.extended( - onPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (ctx) => const _EditServiceForm(service: null), - ); - }, + 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: 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) { - return _ServiceAdminCard(service: services[index]); - }, - ), + 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, + 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), ), - ], - ); - }, - loading: () => const LoadingView(label: 'Loading services...'), - error: (error, _) => ErrorView( - message: 'Could not load services.\n$error', - onRetry: () => ref.invalidate(servicesProvider), - ), + ), + ), + ], ), ), ), @@ -77,9 +143,17 @@ class ManageServicesPage extends ConsumerWidget { } class _ServiceAdminCard extends StatelessWidget { - const _ServiceAdminCard({required this.service}); + const _ServiceAdminCard({ + required this.service, + required this.onEdit, + required this.onDelete, + required this.isBusy, + }); final ServiceModel service; + final VoidCallback onEdit; + final VoidCallback onDelete; + final bool isBusy; @override Widget build(BuildContext context) { @@ -135,28 +209,12 @@ class _ServiceAdminCard extends StatelessWidget { style: Theme.of(context).textTheme.bodySmall, ), const Spacer(), - - // Simple actions for students to replace with real update/delete APIs. TextButton( - onPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (ctx) => _EditServiceForm(service: service), - ); - }, + onPressed: isBusy ? null : onEdit, child: const Text('Edit'), ), TextButton( - onPressed: () { - // MOCK: In the real app, call a delete endpoint on ApiService - // then invalidate `servicesProvider`. - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Deleted \${service.name} (Mock)'), - ), - ); - }, + onPressed: isBusy ? null : onDelete, style: TextButton.styleFrom(foregroundColor: AppColors.error), child: const Text('Delete'), ), @@ -169,17 +227,18 @@ class _ServiceAdminCard extends StatelessWidget { } } -class _EditServiceForm extends StatefulWidget { - final ServiceModel? service; - +class _EditServiceForm extends ConsumerStatefulWidget { const _EditServiceForm({this.service}); + final ServiceModel? service; + @override - State<_EditServiceForm> createState() => _EditServiceFormState(); + ConsumerState<_EditServiceForm> createState() => _EditServiceFormState(); } -class _EditServiceFormState extends State<_EditServiceForm> { +class _EditServiceFormState extends ConsumerState<_EditServiceForm> { final TextEditingController _nameCtrl = TextEditingController(); + final TextEditingController _descriptionCtrl = TextEditingController(); final TextEditingController _priceCtrl = TextEditingController(); final TextEditingController _durationCtrl = TextEditingController(); @@ -188,7 +247,8 @@ class _EditServiceFormState extends State<_EditServiceForm> { super.initState(); if (widget.service != null) { _nameCtrl.text = widget.service!.name; - _priceCtrl.text = widget.service!.price.toString(); + _descriptionCtrl.text = widget.service!.description; + _priceCtrl.text = widget.service!.price.toStringAsFixed(0); _durationCtrl.text = widget.service!.durationMinutes.toString(); } } @@ -196,17 +256,70 @@ class _EditServiceFormState extends State<_EditServiceForm> { @override void dispose() { _nameCtrl.dispose(); + _descriptionCtrl.dispose(); _priceCtrl.dispose(); _durationCtrl.dispose(); super.dispose(); } - void _save() { - // MOCK: Replace with API call to create or update service. - ScaffoldMessenger.of(context).showSnackBar( + 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 ? 'Created (Mock)' : 'Updated (Mock)', + widget.service == null ? 'Service created' : 'Service updated', ), ), ); @@ -215,6 +328,8 @@ class _EditServiceFormState extends State<_EditServiceForm> { @override Widget build(BuildContext context) { + final isBusy = ref.watch(serviceControllerProvider).isLoading; + return Padding( padding: EdgeInsets.only( top: 16, @@ -233,15 +348,27 @@ class _EditServiceFormState extends State<_EditServiceForm> { 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, - keyboardType: TextInputType.number, + enabled: !isBusy, + keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration( labelText: 'Price', prefixText: '\$', @@ -251,6 +378,7 @@ class _EditServiceFormState extends State<_EditServiceForm> { const SizedBox(height: 12), TextField( controller: _durationCtrl, + enabled: !isBusy, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: 'Duration (mins)', @@ -258,7 +386,10 @@ class _EditServiceFormState extends State<_EditServiceForm> { ), ), const SizedBox(height: 16), - ElevatedButton(onPressed: _save, child: const Text('Save Service')), + ElevatedButton( + onPressed: isBusy ? null : _save, + child: Text(isBusy ? 'Saving...' : 'Save Service'), + ), ], ), ); 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/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); }); } From dbb61ccc2e46ba2ebbbebda79447d5f05e7495c5 Mon Sep 17 00:00:00 2001 From: Abel Mekonnen Date: Sun, 5 Apr 2026 21:03:51 +0300 Subject: [PATCH 5/5] feat: add hair model image asset and update service pages to display it --- {lib/assets => assets}/images/hair_model.png | Bin lib/core/theme/app_theme.dart | 16 +++++ lib/pages/manage_services_page.dart | 30 ++++++++++ lib/pages/my_appointments_page.dart | 56 ++++++++++++++++-- lib/pages/services_page.dart | 59 +++++++++++++++---- pubspec.yaml | 6 +- 6 files changed, 145 insertions(+), 22 deletions(-) rename {lib/assets => assets}/images/hair_model.png (100%) diff --git a/lib/assets/images/hair_model.png b/assets/images/hair_model.png similarity index 100% rename from lib/assets/images/hair_model.png rename to assets/images/hair_model.png diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index b49a09a..0860dd0 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -53,6 +53,22 @@ final ThemeData sharpCutTheme = ThemeData( 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( diff --git a/lib/pages/manage_services_page.dart b/lib/pages/manage_services_page.dart index c03dda4..601024b 100644 --- a/lib/pages/manage_services_page.dart +++ b/lib/pages/manage_services_page.dart @@ -12,6 +12,8 @@ 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, @@ -115,6 +117,7 @@ class ManageServicesPage extends ConsumerWidget { final service = services[index]; return _ServiceAdminCard( service: service, + imagePath: _serviceImagePath, isBusy: isBusy, onEdit: () => _openForm(context, service), onDelete: () => @@ -145,12 +148,14 @@ class ManageServicesPage extends ConsumerWidget { 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; @@ -163,11 +168,34 @@ class _ServiceAdminCard extends StatelessWidget { 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, ), ), @@ -193,6 +221,8 @@ class _ServiceAdminCard extends StatelessWidget { const SizedBox(height: 6), Text( service.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 10), diff --git a/lib/pages/my_appointments_page.dart b/lib/pages/my_appointments_page.dart index 5081d21..20eaca6 100644 --- a/lib/pages/my_appointments_page.dart +++ b/lib/pages/my_appointments_page.dart @@ -142,16 +142,32 @@ class _AppointmentCard extends StatelessWidget { 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: Text( - 'Service: \${appointment.serviceId}\\n' - 'Date: \$startDate\\n' - 'Time: \$startTime\\n' - 'Provider: \${appointment.adminId}\\n' - 'Status: \${appointment.status}', + 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( @@ -188,6 +204,34 @@ class _AppointmentCard extends StatelessWidget { } } +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}); diff --git a/lib/pages/services_page.dart b/lib/pages/services_page.dart index ee7b552..8fb809b 100644 --- a/lib/pages/services_page.dart +++ b/lib/pages/services_page.dart @@ -13,6 +13,8 @@ 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); @@ -24,8 +26,13 @@ class ServicesPage extends ConsumerWidget { Padding( padding: EdgeInsets.only(right: 16), child: CircleAvatar( + backgroundColor: AppColors.primary, radius: 14, - child: Icon(Icons.person_outline, size: 16), + child: Icon( + Icons.person_outline, + size: 16, + color: AppColors.textOnPrimary, + ), ), ), ], @@ -59,6 +66,7 @@ class ServicesPage extends ConsumerWidget { final service = services[index]; return _ServiceCard( service: service, + imagePath: _serviceImagePath, highlight: index == 0, onBookPressed: () { // Keep routing simple now; later pass selected service ID. @@ -86,11 +94,13 @@ class ServicesPage extends ConsumerWidget { 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; @@ -122,17 +132,38 @@ class _ServiceCard extends StatelessWidget { ), ), ), - Container( - height: 130, - width: double.infinity, - decoration: BoxDecoration( - color: AppColors.sectionTint, - borderRadius: BorderRadius.circular(14), - ), - child: const Icon( - Icons.content_cut_rounded, - size: 46, - color: AppColors.primary, + 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), @@ -141,6 +172,8 @@ class _ServiceCard extends StatelessWidget { Expanded( child: Text( service.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleLarge, ), ), @@ -155,6 +188,8 @@ class _ServiceCard extends StatelessWidget { const SizedBox(height: 6), Text( service.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 10), diff --git a/pubspec.yaml b/pubspec.yaml index 1d65ba8..7a6776f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,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