From 72881df210b8a3f3d2813da8b37da9e5b51a9071 Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Tue, 14 Jul 2026 11:12:09 +0330 Subject: [PATCH] Implement tender workflows and dashboard integration --- docs/home-page-api-benchmarks.md | 165 ++++++ docs/home-page-api-calls.md | 280 ++++++++++ lib/core/config/dependencies.dart | 8 + lib/core/network/network_manager.dart | 25 +- lib/data/repositories/home_repository.dart | 14 +- .../repositories/your_tenders_repository.dart | 44 +- lib/data/services/api/home_api.dart | 2 +- lib/data/services/api/liked_tenders_api.dart | 4 +- .../services/api/tender_submissions_api.dart | 10 + lib/data/services/home_service.dart | 4 +- lib/data/services/liked_tenders_service.dart | 19 +- .../tender_submission/tender_submission.dart | 235 ++++++++ .../services/tender_submissions_service.dart | 84 +++ lib/view_models/home_view_model.dart | 40 +- lib/view_models/tenders_view_model.dart | 75 ++- lib/view_models/your_tenders_view_model.dart | 127 ++++- lib/views/home/pages/d_home_page.dart | 52 +- lib/views/home/pages/m_home_page.dart | 50 +- lib/views/home/pages/t_home_page.dart | 28 +- lib/views/home/strings/home_strings.dart | 4 +- .../widgets/animated_feedback_button.dart | 29 +- .../tenders/widgets/main_tenders_slider.dart | 46 ++ .../widgets/tender_action_buttons_row.dart | 8 +- .../pages/your_tenders_desktop_page.dart | 8 +- .../pages/your_tenders_mobile_page.dart | 5 +- .../pages/your_tenders_tablet_page.dart | 8 +- .../widgets/submission_workflow.dart | 511 ++++++++++++++++++ test/model/tender_submission_test.dart | 90 +++ .../ai_recommendations_view_model_test.dart | 4 + 29 files changed, 1820 insertions(+), 159 deletions(-) create mode 100644 docs/home-page-api-benchmarks.md create mode 100644 docs/home-page-api-calls.md create mode 100644 lib/data/services/api/tender_submissions_api.dart create mode 100644 lib/data/services/model/tender_submission/tender_submission.dart create mode 100644 lib/data/services/tender_submissions_service.dart create mode 100644 lib/views/your_tenders/widgets/submission_workflow.dart create mode 100644 test/model/tender_submission_test.dart diff --git a/docs/home-page-api-benchmarks.md b/docs/home-page-api-benchmarks.md new file mode 100644 index 0000000..e692c7e --- /dev/null +++ b/docs/home-page-api-benchmarks.md @@ -0,0 +1,165 @@ +# Home Page API — Response Time Benchmarks + +Measured from the client network (curl) against production. + +| Field | Value | +|-------|-------| +| **Date** | 2026-07-07 | +| **Base URL** | `https://app.opplenz.com` | +| **Runs per endpoint** | 10 | +| **Account** | `test@email.com` | +| **Company ID** | `69dbef4e0917c63d28787737` | + +All requests returned **HTTP 200** on every run. + +--- + +## Average response time + +| # | Endpoint | Method | Avg (ms) | Min (ms) | Max (ms) | Blocks UI? | % of blocking load | +|---|----------|--------|----------|----------|----------|------------|-------------------| +| 1 | `/api/v1/tender-approvals/stats` | GET | **614** | 520 | 793 | Yes | 3.4% | +| 2 | `/api/v1/tender-approvals?limit=10&offset=0` | GET | **16,688** | 16,503 | 17,061 | Yes | **93.6%** | +| 3 | `/api/v1/feedback?feedback_type=like&limit=1&offset=0` | GET | **554** | 506 | 668 | Yes | 3.1% | +| 4 | `/api/v1/notifications?seen=false&limit=1&event_type=PUSH` | GET | **548** | 483 | 642 | No | — | +| 5 | `/api/v1/tenders/recommend?limit=10&offset=0` | GET | **5,716** | 4,010 | 8,361 | No* | — | + +\* Call #5 only fires when call #2 returns an empty tender list. It does not block the initial loading spinner, but still adds server load and may update the list shortly after render. + +--- + +## Totals + +| Metric | Time | +|--------|------| +| **Sequential blocking total** (calls 1 + 2 + 3) | **~17.9 s** | +| **Slowest single endpoint** | `GET /api/v1/tender-approvals` — **16.7 s avg** | +| **Fastest endpoint** | `GET /api/v1/notifications` — **0.5 s avg** | + +The home page waits on calls 1 → 2 → 3 in series before rendering. **`/api/v1/tender-approvals` accounts for ~94% of that wait.** + +--- + +## Raw timings (seconds) + +### 1. Tender approval stats +`GET /api/v1/tender-approvals/stats` + +| Run | Time (s) | +|-----|----------| +| 1 | 0.779 | +| 2 | 0.662 | +| 3 | 0.644 | +| 4 | 0.793 | +| 5 | 0.597 | +| 6 | 0.536 | +| 7 | 0.562 | +| 8 | 0.533 | +| 9 | 0.520 | +| 10 | 0.520 | +| **Avg** | **0.614** | + +### 2. Your tenders +`GET /api/v1/tender-approvals?limit=10&offset=0` + +| Run | Time (s) | +|-----|----------| +| 1 | 16.503 | +| 2 | 16.573 | +| 3 | 16.873 | +| 4 | 16.607 | +| 5 | 17.061 | +| 6 | 16.600 | +| 7 | 16.628 | +| 8 | 16.707 | +| 9 | 16.727 | +| 10 | 16.606 | +| **Avg** | **16.688** | + +### 3. Liked tenders count +`GET /api/v1/feedback?feedback_type=like&limit=1&offset=0` + +| Run | Time (s) | +|-----|----------| +| 1 | 0.629 | +| 2 | 0.506 | +| 3 | 0.515 | +| 4 | 0.594 | +| 5 | 0.521 | +| 6 | 0.509 | +| 7 | 0.529 | +| 8 | 0.529 | +| 9 | 0.542 | +| 10 | 0.668 | +| **Avg** | **0.554** | + +### 4. Unread notifications (background) +`GET /api/v1/notifications?seen=false&limit=1&event_type=PUSH` + +| Run | Time (s) | +|-----|----------| +| 1 | 0.566 | +| 2 | 0.540 | +| 3 | 0.486 | +| 4 | 0.607 | +| 5 | 0.541 | +| 6 | 0.498 | +| 7 | 0.614 | +| 8 | 0.642 | +| 9 | 0.506 | +| 10 | 0.483 | +| **Avg** | **0.548** | + +### 5. Recommended tenders (conditional) +`GET /api/v1/tenders/recommend?limit=10&offset=0` + +| Run | Time (s) | +|-----|----------| +| 1 | 4.364 | +| 2 | 5.880 | +| 3 | 5.402 | +| 4 | 4.010 | +| 5 | 6.512 | +| 6 | 5.827 | +| 7 | 5.584 | +| 8 | 5.653 | +| 9 | 8.361 | +| 10 | 5.575 | +| **Avg** | **5.716** | + +--- + +## Key finding + +| Priority | Endpoint | Issue | +|----------|----------|-------| +| **P0** | `GET /api/v1/tender-approvals?limit=10&offset=0` | ~17 s average — dominates home page load | +| P1 | `GET /api/v1/tenders/recommend?limit=10&offset=0` | ~5.7 s average — slow when used as fallback | +| P2 | `GET /api/v1/tender-approvals/stats` | ~0.6 s — acceptable but called on every home visit | +| — | Feedback + notifications | ~0.55 s each — not a bottleneck | + +--- + +## How to reproduce + +```bash +TOKEN='' +BASE='https://app.opplenz.com' + +for url in \ + "$BASE/api/v1/tender-approvals/stats" \ + "$BASE/api/v1/tender-approvals?limit=10&offset=0" \ + "$BASE/api/v1/feedback?feedback_type=like&limit=1&offset=0" \ + "$BASE/api/v1/notifications?seen=false&limit=1&event_type=PUSH" \ + "$BASE/api/v1/tenders/recommend?limit=10&offset=0" +do + curl -s -o /dev/null -w "%{time_total}s %{http_code} $url\n" \ + -H "Authorization: Bearer $TOKEN" "$url" +done +``` + +--- + +## Related + +See [home-page-api-calls.md](./home-page-api-calls.md) for full call order, parameters, and optimization suggestions. diff --git a/docs/home-page-api-calls.md b/docs/home-page-api-calls.md new file mode 100644 index 0000000..0594e07 --- /dev/null +++ b/docs/home-page-api-calls.md @@ -0,0 +1,280 @@ +# Home Page API Calls + +Document for the backend team. Describes every HTTP request the mobile/web client fires when the user opens `/home`, including call order, parameters, and what blocks the UI from rendering. + +**Context:** Home page load feels slow. The client currently waits on **3 sequential API calls** before showing content. A 4th call runs in the background; a 5th may fire when the user has no "your tenders". + +**Base URL:** `https://app.opplenz.com` +**Auth:** All requests include `Authorization: Bearer `. + +--- + +## Summary + +| # | Endpoint | Method | Blocking UI? | When | +|---|----------|--------|--------------|------| +| 1 | `/api/v1/tender-approvals/stats` | GET | **Yes** | Always on home load | +| 2 | `/api/v1/tender-approvals?limit=10&offset=0` | GET | **Yes** | Always on home load | +| 3 | `/api/v1/feedback?feedback_type=like&limit=1&offset=0` | GET | **Yes** | Always on home load | +| 4 | `/api/v1/notifications?seen=false&limit=1&event_type=PUSH` | GET | No (background) | App shell mount (parallel) | +| 5 | `/api/v1/tenders/recommend?limit=10&offset=0` | GET | No | Only when call #2 returns an empty tender list | + +**Minimum calls on first load:** 4 (calls 1–3 + 4) +**Maximum calls on first load:** 5 (adds call 5 when user has no your-tenders) + +**Perceived load time (client-side):** Sum of response times for calls 1 → 2 → 3 (sequential `await` chain). + +--- + +## Call sequence + +``` +User opens /home +│ +├─ [parallel, background] App shell +│ └─ GET /api/v1/notifications?seen=false&limit=1&event_type=PUSH +│ +└─ [blocking] HomeViewModel.init() + │ + ├─ 1. GET /api/v1/tender-approvals/stats ← await + ├─ 2. GET /api/v1/tender-approvals?limit=10&offset=0 ← await + │ └─ if tenders[] is empty (fire-and-forget, not awaited): + │ GET /api/v1/tenders/recommend?limit=10&offset=0 + └─ 3. GET /api/v1/feedback?feedback_type=like&limit=1&offset=0 ← await + └─ UI renders (_isLoading = false) +``` + +Source: `lib/view_models/home_view_model.dart` → `init()` + +--- + +## API details + +### 1. Tender approval stats + +Partnership / self-apply progress rings on the home dashboard. + +| Field | Value | +|-------|-------| +| **Method** | `GET` | +| **Path** | `/api/v1/tender-approvals/stats` | +| **Query params** | None | +| **Blocking** | Yes — first call in `init()` | +| **Response fields used** | `data.partnership_count`, `data.self_apply_count` | + +**Example:** +```http +GET /api/v1/tender-approvals/stats +Authorization: Bearer +Accept: application/json +``` + +--- + +### 2. Your tenders (horizontal list) + +Main tender carousel and "Your Tenders" section. + +| Field | Value | +|-------|-------| +| **Method** | `GET` | +| **Path** | `/api/v1/tender-approvals` | +| **Query params** | `limit=10`, `offset=0` | +| **Optional filters** | Not sent from home (`status`, `created_from`, `created_to` are empty) | +| **Blocking** | Yes — second call in `init()` | +| **Response fields used** | `data.tenders[].tender` (full tender objects), `data.metadata.pages` (pagination) | + +**Example:** +```http +GET /api/v1/tender-approvals?limit=10&offset=0 +Authorization: Bearer +Accept: application/json +``` + +**Note:** This is likely the heaviest call — it returns up to 10 full tender objects with nested data. + +--- + +### 3. Liked tenders count + +"Liked Tenders" stat card count. + +| Field | Value | +|-------|-------| +| **Method** | `GET` | +| **Path** | `/api/v1/feedback` | +| **Query params** | `feedback_type=like`, `limit=1`, `offset=0` | +| **Blocking** | Yes — third call in `init()` | +| **Response fields used** | `data.meta.total` only (client ignores the single returned item) | + +**Example:** +```http +GET /api/v1/feedback?feedback_type=like&limit=1&offset=0 +Authorization: Bearer +Accept: application/json +``` + +**Optimization opportunity:** Client only needs a count. A dedicated count endpoint (or `meta.total` without loading feedback records) would be faster than a full feedback query. + +--- + +### 4. Unread notification check (background) + +Red dot on the notification tab icon. + +| Field | Value | +|-------|-------| +| **Method** | `GET` | +| **Path** | `/api/v1/notifications` | +| **Query params** | `seen=false`, `limit=1`, `event_type=PUSH` | +| **Blocking** | No — fired from app shell `postFrameCallback` | +| **Response fields used** | Presence of any result (updates `NotificationStateService`) | + +**Example:** +```http +GET /api/v1/notifications?seen=false&limit=1&event_type=PUSH +Authorization: Bearer +Accept: application/json +``` + +Source: `lib/core/routes/app_shell/app_shell_screen.dart` + +--- + +### 5. Recommended tenders (conditional fallback) + +Shown when the user has no "your tenders". Replaces the horizontal list label with "Recommended Tenders". + +| Field | Value | +|-------|-------| +| **Method** | `GET` | +| **Path** | `/api/v1/tenders/recommend` | +| **Query params** | `limit=10`, `offset=0` | +| **Blocking** | No — triggered inside `getYourTenders()` without `await` | +| **Condition** | Fires only when call #2 returns `data.tenders` empty | +| **Response fields used** | `data.tenders[]` (full tender objects) | + +**Example:** +```http +GET /api/v1/tenders/recommend?limit=10&offset=0 +Authorization: Bearer +Accept: application/json +``` + +--- + +## Pagination (scroll, not initial load) + +After the home page renders, scrolling the horizontal tender list near the end triggers additional calls: + +| Mode | Endpoint | Params | +|------|----------|--------| +| Your tenders | `GET /api/v1/tender-approvals` | `limit=10`, `offset=` | +| Recommended (fallback mode) | `GET /api/v1/tenders/recommend` | `limit=10`, `offset=` | + +Page size is always **10**. + +--- + +## Re-fetch triggers + +The full `init()` sequence (calls 1–3, and possibly 5) runs again when: + +- User switches away from Home tab and returns to it +- User navigates to Liked Tenders and comes back (`homeViewModel.init()` on return) + +This means the same 3–5 API calls can repeat on every tab switch back to Home. + +--- + +## APIs defined but NOT called on home load + +| Endpoint | Notes | +|----------|-------| +| `GET /api/v1/feedback/stats/company` | Defined in client (`HomeApi.statsCompany`) but never called from home | + +Some home stat cards show hardcoded `0` values (approved tenders count, tender value) — no API backing today. + +--- + +## Performance notes for backend + +### Client-side bottleneck + +The client **does not parallelize** calls 1, 2, and 3. Total time-to-interactive ≈ `T(stats) + T(your-tenders) + T(liked-count)`. + +### Suggested backend investigations + +1. **`GET /api/v1/tender-approvals?limit=10&offset=0`** — Likely the slowest call. Check query plans, N+1 joins, and payload size (10 full tender objects). +2. **`GET /api/v1/tender-approvals/stats`** — Runs before every home load; consider caching per company/user. +3. **`GET /api/v1/feedback?feedback_type=like&limit=1&offset=0`** — Client only reads `meta.total`. A lightweight `GET /api/v1/feedback/count?feedback_type=like` would avoid loading feedback rows. +4. **`GET /api/v1/tenders/recommend`** — May involve recommendation/AI logic; profile separately from tender-approvals. + +### Suggested backend optimizations + +| Idea | Benefit | +|------|---------| +| Single aggregated `GET /api/v1/home` endpoint | One round-trip instead of 3 sequential | +| Return stats + liked count alongside tender-approvals list | Eliminate calls 1 and 3 | +| Slim tender DTO for list views (id, title, deadline, status only) | Smaller payloads on call #2 and #5 | +| Cache stats and liked count with short TTL | Faster repeat visits / tab switches | + +### Suggested client optimizations (for reference) + +These are frontend changes the mobile team could make independently: + +- Run calls 1, 2, and 3 in parallel (`Future.wait`) +- Do not re-run full `init()` on every tab switch — refresh only stale data +- Await the recommend fallback before clearing loading state (avoids UI flicker) + +--- + +## Source files (client) + +| File | Role | +|------|------| +| `lib/view_models/home_view_model.dart` | Orchestrates all home API calls | +| `lib/data/repositories/home_repository.dart` | Repository layer | +| `lib/data/services/home_service.dart` | Stats, recommend, notifications | +| `lib/data/services/your_tenders_service.dart` | Your tenders list | +| `lib/data/services/liked_tenders_service.dart` | Liked count | +| `lib/data/services/api/home_api.dart` | Endpoint path constants | +| `lib/data/services/api/your_tenders_api.dart` | Tender-approvals URL builder | +| `lib/data/services/api/liked_tenders_api.dart` | Feedback URL builder | +| `lib/core/routes/app_shell/app_shell_screen.dart` | Background notification check | +| `lib/views/home/pages/home_screen.dart` | Re-init on tab return | + +--- + +## Quick test commands + +Replace `$TOKEN` with a valid access token. + +```bash +# 1. Stats +curl -s -w "\n%{http_code} %{time_total}s\n" \ + 'https://app.opplenz.com/api/v1/tender-approvals/stats' \ + -H "Authorization: Bearer $TOKEN" | tail -1 + +# 2. Your tenders +curl -s -w "\n%{http_code} %{time_total}s\n" \ + 'https://app.opplenz.com/api/v1/tender-approvals?limit=10&offset=0' \ + -H "Authorization: Bearer $TOKEN" | tail -1 + +# 3. Liked count +curl -s -w "\n%{http_code} %{time_total}s\n" \ + 'https://app.opplenz.com/api/v1/feedback?feedback_type=like&limit=1&offset=0' \ + -H "Authorization: Bearer $TOKEN" | tail -1 + +# 4. Notifications (background) +curl -s -w "\n%{http_code} %{time_total}s\n" \ + 'https://app.opplenz.com/api/v1/notifications?seen=false&limit=1&event_type=PUSH' \ + -H "Authorization: Bearer $TOKEN" | tail -1 + +# 5. Recommended (only when your tenders empty) +curl -s -w "\n%{http_code} %{time_total}s\n" \ + 'https://app.opplenz.com/api/v1/tenders/recommend?limit=10&offset=0' \ + -H "Authorization: Bearer $TOKEN" | tail -1 +``` + +Run all five and compare `time_total` to identify the slowest endpoint. diff --git a/lib/core/config/dependencies.dart b/lib/core/config/dependencies.dart index 8858249..6d55595 100644 --- a/lib/core/config/dependencies.dart +++ b/lib/core/config/dependencies.dart @@ -18,6 +18,7 @@ import 'package:tm_app/data/services/company_ai_service.dart'; import 'package:tm_app/data/services/notification_state_service.dart'; import 'package:tm_app/data/services/tender_detail_service.dart'; import 'package:tm_app/data/services/your_tenders_service.dart'; +import 'package:tm_app/data/services/tender_submissions_service.dart'; import 'package:tm_app/view_models/final_completion_of_documents_view_model.dart'; import '../../data/repositories/auth_repository.dart'; @@ -81,6 +82,11 @@ List get apiClients { create: (context) => YourTendersService(networkManager: context.read()), lazy: true, ), + Provider( + create: + (context) => TenderSubmissionsService(networkManager: context.read()), + lazy: true, + ), Provider( create: (context) => LikedTendersService(networkManager: context.read()), lazy: true, @@ -119,6 +125,7 @@ List get repositories { (context) => YourTendersRepository( yourTendersService: context.read(), likedTendersService: context.read(), + tenderSubmissionsService: context.read(), ), lazy: true, ), @@ -128,6 +135,7 @@ List get repositories { homeService: context.read(), yourTendersService: context.read(), likedTendersService: context.read(), + tenderSubmissionsService: context.read(), ), lazy: true, ), diff --git a/lib/core/network/network_manager.dart b/lib/core/network/network_manager.dart index 09249df..3008b9a 100644 --- a/lib/core/network/network_manager.dart +++ b/lib/core/network/network_manager.dart @@ -179,7 +179,16 @@ class NetworkManager { /// Handle HTTP error responses Result _handleHttpError(Response response) { final statusCode = response.statusCode; - final message = _extractErrorMessage(response.data); + var message = _extractErrorMessage(response.data); + if (statusCode == 422 && response.data is Map) { + final envelope = response.data as Map; + final error = envelope['error']; + if (error is Map && + error['details'] is String && + (error['details'] as String).isNotEmpty) { + message = error['details'] as String; + } + } switch (statusCode) { case 400: @@ -238,16 +247,10 @@ class NetworkManager { ); case DioExceptionType.badResponse: - if (e.response?.statusCode == 401) { - // Handle token refresh logic here if needed - // For now, return authentication error - return Result.error( - AuthenticationException( - 'Authentication failed', - e.response?.statusCode, - e, - ), - ); + if (e.response != null) { + // Preserve the backend envelope (including validation details) for + // non-2xx responses such as 409 and 422. + return _handleHttpError(e.response!); } return Result.error( FetchDataException( diff --git a/lib/data/repositories/home_repository.dart b/lib/data/repositories/home_repository.dart index 50a9a7a..a2be08b 100644 --- a/lib/data/repositories/home_repository.dart +++ b/lib/data/repositories/home_repository.dart @@ -4,6 +4,9 @@ import 'package:tm_app/data/services/liked_tenders_service.dart'; import 'package:tm_app/data/services/model/recommended_tenders_response/recommended_tenders_response.dart'; import 'package:tm_app/data/services/model/tender_approvals_response/tender_approvals_response.dart'; import 'package:tm_app/data/services/model/tender_approvals_stats_response/tender_approvals_stats_response.dart'; +import 'package:tm_app/data/services/model/feedback_stats_response/feedback_stat_response.dart'; +import 'package:tm_app/data/services/model/tender_submission/tender_submission.dart'; +import 'package:tm_app/data/services/tender_submissions_service.dart'; import '../services/model/request_models/get_tenders_request_model/get_tenders_request_model.dart'; import '../services/model/request_models/tender_feedback_request_model/tender_feedback_request_model.dart'; @@ -14,12 +17,15 @@ class HomeRepository { required HomeService homeService, required YourTendersService yourTendersService, required LikedTendersService likedTendersService, + required TenderSubmissionsService tenderSubmissionsService, }) : _homeService = homeService, _yourTendersService = yourTendersService, - _likedTendersService = likedTendersService; + _likedTendersService = likedTendersService, + _tenderSubmissionsService = tenderSubmissionsService; final HomeService _homeService; final YourTendersService _yourTendersService; final LikedTendersService _likedTendersService; + final TenderSubmissionsService _tenderSubmissionsService; Future> getHomeRecommendedTenders({ required int limit, @@ -38,6 +44,12 @@ class HomeRepository { return _homeService.getTendersApprovalsStats(); } + Future> getCustomerFeedbackStats() => + _homeService.getFeedbackStats(); + + Future> getSubmissionStats() => + _tenderSubmissionsService.getStats(); + /// Returns how many tenders the current user has liked (not company-wide). Future> getUserLikedTendersCount() async { final result = await _likedTendersService.getLikedTenders( diff --git a/lib/data/repositories/your_tenders_repository.dart b/lib/data/repositories/your_tenders_repository.dart index cf95ed2..cb16eb8 100644 --- a/lib/data/repositories/your_tenders_repository.dart +++ b/lib/data/repositories/your_tenders_repository.dart @@ -4,17 +4,22 @@ import 'package:tm_app/data/services/model/liked_tenders_response/liked_tenders_ import 'package:tm_app/data/services/model/request_models/get_tenders_request_model/get_tenders_request_model.dart'; import 'package:tm_app/data/services/model/tender_approvals_response/tender_approvals_response.dart'; import 'package:tm_app/data/services/your_tenders_service.dart'; +import 'package:tm_app/data/services/tender_submissions_service.dart'; +import 'package:tm_app/data/services/model/tender_submission/tender_submission.dart'; import '../services/model/request_models/tender_feedback_request_model/tender_feedback_request_model.dart'; class YourTendersRepository { final YourTendersService _yourTendersService; final LikedTendersService _likedTendersService; + final TenderSubmissionsService _tenderSubmissionsService; YourTendersRepository({ required YourTendersService yourTendersService, required LikedTendersService likedTendersService, + required TenderSubmissionsService tenderSubmissionsService, }) : _yourTendersService = yourTendersService, - _likedTendersService = likedTendersService; + _likedTendersService = likedTendersService, + _tenderSubmissionsService = tenderSubmissionsService; Future> getTenders({ required GetTendersRequestModel requestModel, @@ -27,4 +32,41 @@ class YourTendersRepository { }) { return _likedTendersService.getLikedTenders(requestModel: requestModel); } + + Future> getSubmissions({ + required String stage, + int limit = 20, + int offset = 0, + }) => _tenderSubmissionsService.getSubmissions( + stages: [stage], + limit: limit, + offset: offset, + ); + + Future> getSubmissionById(String id) => + _tenderSubmissionsService.getById(id); + + Future> getSubmissionByTender( + String tenderId, + ) => _tenderSubmissionsService.getByTender(tenderId); + + Future> ensureSubmission( + String tenderId, { + String? companyId, + }) => _tenderSubmissionsService.ensure(tenderId, companyId: companyId); + + Future> updateSubmissionStatus({ + required String id, + required String status, + String? reason, + String? description, + }) => _tenderSubmissionsService.updateStatus( + id: id, + status: status, + reason: reason, + description: description, + ); + + Future> getSubmissionStats() => + _tenderSubmissionsService.getStats(); } diff --git a/lib/data/services/api/home_api.dart b/lib/data/services/api/home_api.dart index a19c799..9446ed6 100644 --- a/lib/data/services/api/home_api.dart +++ b/lib/data/services/api/home_api.dart @@ -14,7 +14,7 @@ class HomeApi { } static const String tenderApprovalStats = '/api/v1/tender-approvals/stats'; - static const String statsCompany = '/api/v1/feedback/stats/company'; + static const String statsCustomer = '/api/v1/feedback/stats/customer'; static const String checkUnreadNotifications = '/api/v1/notifications?seen=false&limit=1&event_type=PUSH'; } diff --git a/lib/data/services/api/liked_tenders_api.dart b/lib/data/services/api/liked_tenders_api.dart index 673fb72..927db5d 100644 --- a/lib/data/services/api/liked_tenders_api.dart +++ b/lib/data/services/api/liked_tenders_api.dart @@ -9,8 +9,8 @@ class LikedTendersApi { required String dateTo, }) { if (dateFrom.isNotEmpty && dateTo.isNotEmpty) { - return '$tendersFeedback?feedback_type=$feedbackType&limit=$limit&offset=$offset&date_from=$dateFrom&date_to=$dateTo'; + return '$tendersFeedback?feedback_type=$feedbackType&limit=$limit&offset=$offset&include_total=true&date_from=$dateFrom&date_to=$dateTo'; } - return '$tendersFeedback?feedback_type=$feedbackType&limit=$limit&offset=$offset'; + return '$tendersFeedback?feedback_type=$feedbackType&limit=$limit&offset=$offset&include_total=true'; } } diff --git a/lib/data/services/api/tender_submissions_api.dart b/lib/data/services/api/tender_submissions_api.dart new file mode 100644 index 0000000..31317ca --- /dev/null +++ b/lib/data/services/api/tender_submissions_api.dart @@ -0,0 +1,10 @@ +class TenderSubmissionsApi { + static const String base = '/api/v1/tender-submissions'; + + static const String list = base; + static const String stats = '$base/stats'; + static String byId(String id) => '$base/$id'; + static String byTender(String tenderId) => '$base/tender/$tenderId'; + static String ensure(String tenderId) => '$base/tender/$tenderId/ensure'; + static String status(String id) => '$base/$id/status'; +} diff --git a/lib/data/services/home_service.dart b/lib/data/services/home_service.dart index b7131e8..b934a31 100644 --- a/lib/data/services/home_service.dart +++ b/lib/data/services/home_service.dart @@ -35,7 +35,7 @@ class HomeService { Future> getFeedbackStats() async { final result = await _networkManager.makeRequest( - HomeApi.statsCompany, + HomeApi.statsCustomer, (json) => FeedbackStatResponse.fromJson(json), method: 'GET', ); @@ -44,7 +44,7 @@ class HomeService { Future>> checkUnreadNotifications() async { final result = await _networkManager.makeRequest( - HomeApi.checkUnreadNotifications, + HomeApi.checkUnreadNotifications, (json) => json, method: 'GET', ); diff --git a/lib/data/services/liked_tenders_service.dart b/lib/data/services/liked_tenders_service.dart index 75b304a..e6fb3f8 100644 --- a/lib/data/services/liked_tenders_service.dart +++ b/lib/data/services/liked_tenders_service.dart @@ -22,11 +22,20 @@ class LikedTendersService { dateTo: requestModel.dateTo, ); - final result = await _networkManager.makeRequest( - uri, - (json) => LikedTendersResponse.fromJson(json), - method: 'GET', - ); + final result = await _networkManager.makeRequest(uri, (json) { + // Feedback pagination is returned at the envelope root, while the + // legacy app model stores it beside data.feedback. + final normalized = Map.from(json); + final data = normalized['data']; + final meta = normalized['meta']; + if (data is Map && meta is Map) { + normalized['data'] = { + ...Map.from(data), + 'meta': Map.from(meta), + }; + } + return LikedTendersResponse.fromJson(normalized); + }, method: 'GET'); return result; } diff --git a/lib/data/services/model/tender_submission/tender_submission.dart b/lib/data/services/model/tender_submission/tender_submission.dart new file mode 100644 index 0000000..aa16396 --- /dev/null +++ b/lib/data/services/model/tender_submission/tender_submission.dart @@ -0,0 +1,235 @@ +import 'package:tm_app/core/utils/date_utils.dart'; +import 'package:tm_app/data/services/model/tender_data/tender_data.dart'; + +class TenderSubmissionResponse { + const TenderSubmissionResponse({ + required this.success, + required this.message, + required this.data, + }); + + final bool success; + final String? message; + final TenderSubmission? data; + + factory TenderSubmissionResponse.fromJson(Map json) { + final data = json['data']; + return TenderSubmissionResponse( + success: json['success'] == true, + message: json['message'] as String?, + data: + data is Map ? TenderSubmission.fromJson(data) : null, + ); + } +} + +class TenderSubmissionsResponse { + const TenderSubmissionsResponse({ + required this.success, + required this.message, + required this.data, + required this.meta, + }); + + final bool success; + final String? message; + final List data; + final TenderSubmissionsMeta? meta; + + factory TenderSubmissionsResponse.fromJson(Map json) { + final items = json['data']; + return TenderSubmissionsResponse( + success: json['success'] == true, + message: json['message'] as String?, + data: + items is List + ? items + .whereType() + .map( + (item) => TenderSubmission.fromJson( + Map.from(item), + ), + ) + .toList() + : const [], + meta: + json['meta'] is Map + ? TenderSubmissionsMeta.fromJson( + Map.from(json['meta'] as Map), + ) + : null, + ); + } +} + +class TenderSubmission { + const TenderSubmission({ + required this.id, + required this.tenderId, + required this.companyId, + required this.customerId, + required this.status, + required this.stage, + required this.submissionMode, + required this.statusHistory, + required this.createdAt, + required this.updatedAt, + required this.tender, + }); + + final String id; + final String tenderId; + final String? companyId; + final String? customerId; + final String status; + final String stage; + final String? submissionMode; + final List statusHistory; + final int? createdAt; + final int? updatedAt; + final TenderData? tender; + + bool get isTerminal => + status == 'rejected' || status == 'contract' || status == 'not_applied'; + + factory TenderSubmission.fromJson(Map json) { + final history = json['status_history']; + final tenderJson = json['tender']; + return TenderSubmission( + id: json['id']?.toString() ?? '', + tenderId: json['tender_id']?.toString() ?? '', + companyId: json['company_id']?.toString(), + customerId: json['customer_id']?.toString(), + status: json['status']?.toString() ?? 'opportunity', + stage: json['stage']?.toString() ?? 'opportunity', + submissionMode: json['submission_mode']?.toString(), + statusHistory: + history is List + ? history + .whereType() + .map( + (item) => TenderSubmissionHistory.fromJson( + Map.from(item), + ), + ) + .toList() + : const [], + createdAt: unixTimestampFromJson(json['created_at']), + updatedAt: unixTimestampFromJson(json['updated_at']), + tender: + tenderJson is Map + ? TenderData.fromJson(Map.from(tenderJson)) + : null, + ); + } +} + +class TenderSubmissionHistory { + const TenderSubmissionHistory({ + required this.status, + required this.reason, + required this.changedBy, + required this.changedAt, + required this.description, + }); + + final String status; + final String? reason; + final String? changedBy; + final int? changedAt; + final String? description; + + factory TenderSubmissionHistory.fromJson(Map json) { + return TenderSubmissionHistory( + status: json['status']?.toString() ?? '', + reason: json['reason']?.toString(), + changedBy: json['changed_by']?.toString(), + changedAt: unixTimestampFromJson(json['changed_at']), + description: json['description']?.toString(), + ); + } +} + +class TenderSubmissionsMeta { + const TenderSubmissionsMeta({ + required this.total, + required this.limit, + required this.offset, + required this.page, + required this.pages, + required this.hasMore, + }); + + final int total; + final int limit; + final int offset; + final int page; + final int pages; + final bool hasMore; + + factory TenderSubmissionsMeta.fromJson(Map json) { + int number(String key) => (json[key] as num?)?.toInt() ?? 0; + return TenderSubmissionsMeta( + total: number('total'), + limit: number('limit'), + offset: number('offset'), + page: number('page'), + pages: number('pages'), + hasMore: json['has_more'] == true, + ); + } +} + +class TenderSubmissionStatsResponse { + const TenderSubmissionStatsResponse({ + required this.success, + required this.data, + }); + + final bool success; + final TenderSubmissionStats? data; + + factory TenderSubmissionStatsResponse.fromJson(Map json) { + return TenderSubmissionStatsResponse( + success: json['success'] == true, + data: + json['data'] is Map + ? TenderSubmissionStats.fromJson( + Map.from(json['data'] as Map), + ) + : null, + ); + } +} + +class TenderSubmissionStats { + const TenderSubmissionStats({ + required this.companyId, + required this.total, + required this.byStage, + required this.byStatus, + required this.lastUpdated, + }); + + final String? companyId; + final int total; + final Map byStage; + final Map byStatus; + final int? lastUpdated; + + factory TenderSubmissionStats.fromJson(Map json) { + Map counts(value) => + value is Map + ? value.map( + (key, value) => MapEntry(key.toString(), (value as num).toInt()), + ) + : const {}; + return TenderSubmissionStats( + companyId: json['company_id']?.toString(), + total: (json['total'] as num?)?.toInt() ?? 0, + byStage: counts(json['by_stage']), + byStatus: counts(json['by_status']), + lastUpdated: unixTimestampFromJson(json['last_updated']), + ); + } +} diff --git a/lib/data/services/tender_submissions_service.dart b/lib/data/services/tender_submissions_service.dart new file mode 100644 index 0000000..f6f4fc7 --- /dev/null +++ b/lib/data/services/tender_submissions_service.dart @@ -0,0 +1,84 @@ +import 'package:tm_app/core/network/network_manager.dart'; +import 'package:tm_app/core/utils/result.dart'; +import 'package:tm_app/data/services/api/tender_submissions_api.dart'; +import 'package:tm_app/data/services/model/tender_submission/tender_submission.dart'; + +class TenderSubmissionsService { + TenderSubmissionsService({required NetworkManager networkManager}) + : _networkManager = networkManager; + + final NetworkManager _networkManager; + + Future> getSubmissions({ + List? statuses, + List? stages, + int? createdFrom, + int? createdTo, + int limit = 20, + int offset = 0, + String sortBy = 'updated_at', + String sortOrder = 'desc', + }) { + return _networkManager.makeRequest( + TenderSubmissionsApi.list, + TenderSubmissionsResponse.fromJson, + queryParameters: { + if (statuses?.isNotEmpty == true) 'status': statuses, + if (stages?.isNotEmpty == true) 'stage': stages, + if (createdFrom != null) 'created_from': createdFrom, + if (createdTo != null) 'created_to': createdTo, + 'limit': limit, + 'offset': offset, + 'sort_by': sortBy, + 'sort_order': sortOrder, + }, + ); + } + + Future> getById(String id) => + _networkManager.makeRequest( + TenderSubmissionsApi.byId(id), + TenderSubmissionResponse.fromJson, + ); + + Future> getByTender(String tenderId) => + _networkManager.makeRequest( + TenderSubmissionsApi.byTender(tenderId), + TenderSubmissionResponse.fromJson, + ); + + Future> ensure( + String tenderId, { + String? companyId, + }) => _networkManager.makeRequest( + TenderSubmissionsApi.ensure(tenderId), + TenderSubmissionResponse.fromJson, + method: 'POST', + queryParameters: { + if (companyId?.isNotEmpty == true) 'company_id': companyId, + }, + ); + + Future> updateStatus({ + required String id, + required String status, + String? reason, + String? description, + }) => _networkManager.makeRequest( + TenderSubmissionsApi.status(id), + TenderSubmissionResponse.fromJson, + method: 'PATCH', + data: { + 'status': status, + if (reason?.trim().isNotEmpty == true) 'reason': reason!.trim(), + if (description?.trim().isNotEmpty == true) + 'description': description!.trim(), + }, + ); + + Future> getStats() => + _networkManager.makeRequest( + TenderSubmissionsApi.stats, + TenderSubmissionStatsResponse.fromJson, + ); +} diff --git a/lib/view_models/home_view_model.dart b/lib/view_models/home_view_model.dart index 978d9a8..8d85c7c 100644 --- a/lib/view_models/home_view_model.dart +++ b/lib/view_models/home_view_model.dart @@ -4,6 +4,8 @@ import 'package:tm_app/data/services/model/recommended_tenders_response/recommen import 'package:tm_app/data/services/model/tender_approvals_response/tender_approvals_response.dart'; import 'package:tm_app/data/services/model/tender_approvals_stats_response/tender_approvals_stats_response.dart'; import 'package:tm_app/data/services/model/tender_data/tender_data.dart'; +import 'package:tm_app/data/services/model/feedback_stats_response/feedback_stat_response.dart'; +import 'package:tm_app/data/services/model/tender_submission/tender_submission.dart'; import '../data/repositories/home_repository.dart'; import '../data/services/model/home/home_response/home_response_model.dart'; @@ -44,6 +46,7 @@ class HomeViewModel with ChangeNotifier { RecommendedTendersResponse? _recommendedTendersResponse; TenderApprovalsStatsResponse? tenderApprovalsStateResponse; + TenderSubmissionStats? tenderSubmissionStats; int userLikedTendersCount = 0; final List _tenders = []; final int _pageSize = 10; @@ -71,11 +74,15 @@ class HomeViewModel with ChangeNotifier { _isRecommendedMode = false; data = null; tenderApprovalsStateResponse = null; + tenderSubmissionStats = null; notifyListeners(); - await getApprovedStates(); - await getYourTenders(reset: true); - await loadUserLikedTendersCount(); + await Future.wait([ + getApprovedStates(), + getYourTenders(reset: true), + loadUserLikedTendersCount(), + loadSubmissionStats(), + ]); if (_errorMessage != null) { _isLoading = false; @@ -199,19 +206,38 @@ class HomeViewModel with ChangeNotifier { } Future loadUserLikedTendersCount() async { - final result = await _homeRepository.getUserLikedTendersCount(); + final result = await _homeRepository.getCustomerFeedbackStats(); switch (result) { - case Ok(): - userLikedTendersCount = result.value; + case Ok(): + userLikedTendersCount = result.value.data?.totalLikes ?? 0; break; - case Error(): + case Error(): userLikedTendersCount = 0; break; } notifyListeners(); } + Future loadSubmissionStats() async { + final result = await _homeRepository.getSubmissionStats(); + switch (result) { + case Ok(): + tenderSubmissionStats = result.value.data; + break; + case Error(): + tenderSubmissionStats = null; + break; + } + notifyListeners(); + } + + int get approvedTendersCount => + tenderApprovalsStateResponse?.data?.submittedTenders ?? 0; + + int get submittedTendersCount => + tenderSubmissionStats?.byStage['submitted'] ?? 0; + double get partnershipCount { return tenderApprovalsStateResponse?.data?.partnershipCount?.toDouble() ?? 0.0; diff --git a/lib/view_models/tenders_view_model.dart b/lib/view_models/tenders_view_model.dart index 7dd0057..6c4f0bc 100644 --- a/lib/view_models/tenders_view_model.dart +++ b/lib/view_models/tenders_view_model.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:tm_app/core/utils/device_utils.dart'; import 'package:tm_app/core/utils/logger.dart'; +import 'package:tm_app/core/network/app_exceptions.dart'; import 'package:tm_app/data/repositories/tenders_repository.dart'; import 'package:tm_app/data/services/model/feedback_data/feedback_data.dart'; import 'package:tm_app/data/services/model/feedback_response/feedback_response.dart'; @@ -36,6 +37,12 @@ class TendersViewModel with ChangeNotifier { String? get searchQuery => _searchQuery; List feedbacks = []; + final Set _hydratedFeedbackTenderIds = {}; + final Set _feedbackHydrationsInFlight = {}; + final Set _feedbackRequestsInFlight = {}; + + bool isFeedbackLoading(String tenderId) => + _feedbackRequestsInFlight.contains(tenderId); static const int _pageSize = 10; @@ -207,8 +214,7 @@ class TendersViewModel with ChangeNotifier { _currentOffset = meta!.offset!; } final tenders = _tendersResponse?.data?.tenders ?? []; - _hasMoreData = - meta?.hasMore ?? (tenders.length >= _pageSize); + _hasMoreData = meta?.hasMore ?? (tenders.length >= _pageSize); if (tenders.isNotEmpty) { if (fetchAllFeedbacks) { for (final tender in tenders) { @@ -263,8 +269,13 @@ class TendersViewModel with ChangeNotifier { data: result.value.data?.copyWith(tenders: combined), ); final newMeta = result.value.meta; - _hasMoreData = - newMeta?.hasMore ?? (newTenders.length >= _pageSize); + _hasMoreData = newMeta?.hasMore ?? (newTenders.length >= _pageSize); + for (final tender in newTenders) { + final id = tender.id; + if (id != null && !_hydratedFeedbackTenderIds.contains(id)) { + getTenderFeedback(id); + } + } } else { _hasMoreData = false; } @@ -283,14 +294,18 @@ class TendersViewModel with ChangeNotifier { } Future getTenderFeedback(String tenderId) async { - _errorMessage = null; - notifyListeners(); + if (_hydratedFeedbackTenderIds.contains(tenderId) || + _feedbackHydrationsInFlight.contains(tenderId)) { + return; + } + _feedbackHydrationsInFlight.add(tenderId); final result = await _tendersRepository.getTenderFeedback( tenderId: tenderId, ); switch (result) { case Ok(): + _hydratedFeedbackTenderIds.add(tenderId); _feedbackData = result.value.data; if (_feedbackData != null) { final index = feedbacks.indexWhere( @@ -311,19 +326,33 @@ class TendersViewModel with ChangeNotifier { } break; case Error(): - _errorMessage = result.error.toString(); + if (result.error is NotFoundException) { + // A 404 is the expected representation of neutral feedback state. + feedbacks.removeWhere((feedback) => feedback.tenderId == tenderId); + _hydratedFeedbackTenderIds.add(tenderId); + } else { + _feedbackErrorMessage = result.error.toString(); + } break; } + _feedbackHydrationsInFlight.remove(tenderId); notifyListeners(); - _errorMessage = null; - notifyListeners(); + if (_feedbackErrorMessage != null) { + _feedbackErrorMessage = null; + notifyListeners(); + } } - Future toogleFeedback({ + Future toggleFeedback({ required String tenderId, required String feedbackType, }) async { + if (_feedbackRequestsInFlight.contains(tenderId)) { + return; + } + _feedbackErrorMessage = null; + _feedbackRequestsInFlight.add(tenderId); final existingFeedback = getFeedbackForTender(tenderId); final existingFeedbackIndex = feedbacks.indexWhere( @@ -335,13 +364,13 @@ class TendersViewModel with ChangeNotifier { if (existingFeedback == null) { optimisticFeedback = FeedbackData( companyId: null, - createdAt: DateTime.now().millisecondsSinceEpoch, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, customerId: null, feedbackType: feedbackType, id: null, tenderId: tenderId, tender: null, - updatedAt: DateTime.now().millisecondsSinceEpoch, + updatedAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, ); feedbacks.add(optimisticFeedback); } else { @@ -353,7 +382,7 @@ class TendersViewModel with ChangeNotifier { } else { optimisticFeedback = existingFeedback.copyWith( feedbackType: feedbackType, - updatedAt: DateTime.now().millisecondsSinceEpoch, + updatedAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, ); if (existingFeedbackIndex != -1) { feedbacks[existingFeedbackIndex] = optimisticFeedback; @@ -371,9 +400,13 @@ class TendersViewModel with ChangeNotifier { switch (result) { case Ok(): _feedbackData = result.value.data; - if (_feedbackData != null) { + final responseType = _feedbackData?.feedbackType; + if (responseType == 'unlike' || responseType == 'undislike') { + feedbacks.removeWhere((feedback) => feedback.tenderId == tenderId); + _feedbackData = null; + } else if (_feedbackData != null) { final index = feedbacks.indexWhere( - (f) => f.tender == _feedbackData!.tender, + (feedback) => feedback.tenderId == tenderId, ); if (index != -1) { feedbacks[index] = _feedbackData!; @@ -383,6 +416,7 @@ class TendersViewModel with ChangeNotifier { } else { feedbacks.removeWhere((f) => f.tenderId == tenderId); } + _hydratedFeedbackTenderIds.add(tenderId); break; case Error(): if (existingFeedback == null) { @@ -401,13 +435,24 @@ class TendersViewModel with ChangeNotifier { break; } + _feedbackRequestsInFlight.remove(tenderId); notifyListeners(); _feedbackErrorMessage = null; notifyListeners(); } + /// Kept temporarily for callers compiled against the old misspelled API. + @Deprecated('Use toggleFeedback') + Future toogleFeedback({ + required String tenderId, + required String feedbackType, + }) => toggleFeedback(tenderId: tenderId, feedbackType: feedbackType); + void clearFeedbacks() { feedbacks.clear(); + _hydratedFeedbackTenderIds.clear(); + _feedbackHydrationsInFlight.clear(); + _feedbackRequestsInFlight.clear(); _feedbackData = null; _feedbackErrorMessage = null; notifyListeners(); diff --git a/lib/view_models/your_tenders_view_model.dart b/lib/view_models/your_tenders_view_model.dart index 493b373..28ba90e 100644 --- a/lib/view_models/your_tenders_view_model.dart +++ b/lib/view_models/your_tenders_view_model.dart @@ -3,9 +3,11 @@ import 'package:intl/intl.dart'; import 'package:tm_app/core/theme/colors.dart'; import 'package:tm_app/core/utils/error_utils.dart'; import 'package:tm_app/core/utils/result.dart'; +import 'package:tm_app/core/network/app_exceptions.dart'; import 'package:tm_app/data/repositories/your_tenders_repository.dart'; import 'package:tm_app/data/services/model/liked_tenders_response/liked_tenders_response.dart'; import 'package:tm_app/data/services/model/tender_approvals_response/tender_approvals_response.dart'; +import 'package:tm_app/data/services/model/tender_submission/tender_submission.dart'; import 'package:tm_app/views/your_tenders/strings/your_tenders_strings.dart'; import '../core/constants/assets.dart'; @@ -22,6 +24,21 @@ class YourTendersViewModel with ChangeNotifier { String? errorMessage; TenderApprovalsResponse? data; LikedTendersResponse? likedTendersData; + TenderSubmissionsResponse? submissionsData; + TenderSubmissionStats? submissionStats; + String selectedSubmissionStage = 'opportunity'; + String? updatingSubmissionId; + String? submissionActionError; + + bool get isSubmissionWorkflow => + selectedStatus == TenderApprovalStatus.submitted.value; + + static const submissionStages = [ + 'opportunity', + 'in_progress', + 'submitted', + 'not_applied', + ]; // Pagination state for feedback lists bool isLoadingMore = false; @@ -247,6 +264,8 @@ class YourTendersViewModel with ChangeNotifier { // Clear main data data = null; likedTendersData = null; + submissionsData = null; + submissionStats = null; // Clear loading states isLoading = false; @@ -269,6 +288,7 @@ class YourTendersViewModel with ChangeNotifier { Future callWithFilter() async { likedTendersData = null; data = null; + submissionsData = null; isLoading = true; notifyListeners(); @@ -282,14 +302,8 @@ class YourTendersViewModel with ChangeNotifier { ); await getTenders(requestModel: requestModel, reset: true); } else if (selectedStatus == TenderApprovalStatus.submitted.value) { - final requestModel = GetTendersRequestModel( - status: TenderApprovalStatus.submitted.value, - createdFrom: startDateUnix, - createdTo: endDateUnix, - limit: _tendersLimit, - offset: _tendersOffset, - ); - await getTenders(requestModel: requestModel, reset: true); + selectedSubmissionStage = 'submitted'; + await loadSubmissions(); } else if (selectedStatus == TenderFeedback.liked.value) { await getTendersFeedback( feedbackType: TenderFeedback.liked.value, @@ -318,6 +332,11 @@ class YourTendersViewModel with ChangeNotifier { int currentPage = 1; int get totalPages { + if (isSubmissionWorkflow && submissionsData?.meta != null) { + return submissionsData!.meta!.pages == 0 + ? 1 + : submissionsData!.meta!.pages; + } if (likedTendersData?.data?.meta?.pages != null) { return likedTendersData!.data!.meta!.pages!; } @@ -329,6 +348,10 @@ class YourTendersViewModel with ChangeNotifier { Future jumpToPage(int page) async { currentPage = page; + if (isSubmissionWorkflow) { + await loadSubmissions(offset: (page - 1) * 20); + return; + } _offset = (page - 1) * _limit; _tendersOffset = (page - 1) * _tendersLimit; @@ -340,6 +363,94 @@ class YourTendersViewModel with ChangeNotifier { await callWithFilter(); } + Future selectSubmissionStage(String stage) async { + if (!submissionStages.contains(stage) || stage == selectedSubmissionStage) { + return; + } + selectedSubmissionStage = stage; + submissionActionError = null; + currentPage = 1; + notifyListeners(); + await loadSubmissions(); + } + + Future loadSubmissions({int offset = 0}) async { + isLoading = true; + errorMessage = null; + notifyListeners(); + + final result = await yourTendersRepository.getSubmissions( + stage: selectedSubmissionStage, + limit: 20, + offset: offset, + ); + switch (result) { + case Ok(): + submissionsData = result.value; + currentPage = result.value.meta?.page ?? 1; + case Error(): + errorMessage = ErrorUtils.getErrorMessage(result.error); + } + + final statsResult = await yourTendersRepository.getSubmissionStats(); + switch (statsResult) { + case Ok(): + submissionStats = statsResult.value.data; + case Error(): + // The list remains useful when stats are temporarily unavailable. + break; + } + + isLoading = false; + notifyListeners(); + } + + Future updateSubmissionStatus({ + required TenderSubmission submission, + required String status, + String? reason, + String? description, + }) async { + if (submission.id.isEmpty || submission.isTerminal) { + return false; + } + + updatingSubmissionId = submission.id; + submissionActionError = null; + notifyListeners(); + final result = await yourTendersRepository.updateSubmissionStatus( + id: submission.id, + status: status, + reason: reason, + description: description, + ); + + switch (result) { + case Ok(): + updatingSubmissionId = null; + await loadSubmissions(); + return true; + case Error(): + updatingSubmissionId = null; + if (result.error is AppException && + (result.error as AppException).statusCode == 409) { + await loadSubmissions(); + submissionActionError = + 'This submission changed elsewhere. The latest state has been loaded; please try again.'; + notifyListeners(); + return false; + } + submissionActionError = ErrorUtils.getErrorMessage(result.error); + notifyListeners(); + return false; + } + } + + void dismissSubmissionActionError() { + submissionActionError = null; + notifyListeners(); + } + Color getBackGroundColorByStatus(String status) { if (status == TenderFeedback.liked.value || status == TenderApprovalStatus.submitted.value) { diff --git a/lib/views/home/pages/d_home_page.dart b/lib/views/home/pages/d_home_page.dart index 2b7046b..baf5164 100644 --- a/lib/views/home/pages/d_home_page.dart +++ b/lib/views/home/pages/d_home_page.dart @@ -127,13 +127,7 @@ class DesktopHomePage extends StatelessWidget { backgroundColor: AppColors.primary20, iconPath: AssetsManager.arrows, title: HomeStrings.tenderSubmitted, - amount: - (homeViewModel - .tenderApprovalsStateResponse - ?.data - ?.submittedTenders ?? - 0) - .toString(), + amount: homeViewModel.submittedTendersCount.toString(), textColor: AppColors.mainBlue, enableTap: true, @@ -152,7 +146,7 @@ class DesktopHomePage extends StatelessWidget { backgroundColor: AppColors.primary10, iconPath: AssetsManager.approve, title: HomeStrings.approvedTenders, - amount: '0', + amount: homeViewModel.approvedTendersCount.toString(), textColor: AppColors.jellyBean, enableTap: true, width: 178, @@ -165,32 +159,22 @@ class DesktopHomePage extends StatelessWidget { ), ), const SizedBox(width: 10), - TenderCard( - backgroundColor: AppColors.orange10, - iconPath: AssetsManager.shield, - title: HomeStrings.tenderValue, - amount: '0', - textColor: AppColors.orange, - enableTap: false, - width: 178, - height: 148, - onTap: () {}, - ), - const SizedBox(width: 10), - TenderCard( - backgroundColor: AppColors.grey10, - iconPath: AssetsManager.thumbLike, - title: HomeStrings.likedTenders, - amount: homeViewModel.userLikedTendersCount.toString(), - textColor: AppColors.grey50, - enableTap: true, - width: 178, - height: 148, - onTap: () { - const LikedTendersRouteData().push(context).then((value) { - homeViewModel.init(); - }); - }, + Expanded( + child: TenderCard( + backgroundColor: AppColors.grey10, + iconPath: AssetsManager.thumbLike, + title: HomeStrings.likedTenders, + amount: homeViewModel.userLikedTendersCount.toString(), + textColor: AppColors.grey50, + enableTap: true, + width: 178, + height: 148, + onTap: () { + const LikedTendersRouteData().push(context).then((value) { + homeViewModel.init(); + }); + }, + ), ), ], ); diff --git a/lib/views/home/pages/m_home_page.dart b/lib/views/home/pages/m_home_page.dart index 22f59e3..c8db23d 100644 --- a/lib/views/home/pages/m_home_page.dart +++ b/lib/views/home/pages/m_home_page.dart @@ -5,7 +5,6 @@ import 'package:tm_app/core/constants/assets.dart'; import 'package:tm_app/core/routes/app_routes.dart'; import 'package:tm_app/core/theme/colors.dart'; import 'package:tm_app/core/utils/size_config.dart'; -import 'package:tm_app/data/services/model/tender_approvals_stats_response/tender_approvals_stats_response.dart'; import 'package:tm_app/view_models/home_view_model.dart'; import 'package:tm_app/views/shared/tender_app_bar.dart'; @@ -44,10 +43,7 @@ class MobileHomePage extends StatelessWidget { SizedBox(height: 32.0.h()), _progressBarsRow(homeViewModel), SizedBox(height: 32.0.h()), - _firstTenderCardsRow( - context, - homeViewModel.tenderApprovalsStateResponse!, - ), + _firstTenderCardsRow(context, homeViewModel), SizedBox(height: 8.0.h()), _secondTenderCardsRow(homeViewModel, context), SizedBox(height: 40.0.h()), @@ -93,7 +89,7 @@ class MobileHomePage extends StatelessWidget { Widget _firstTenderCardsRow( BuildContext context, - TenderApprovalsStatsResponse tenderApprovalsStateResponse, + HomeViewModel homeViewModel, ) { return Padding( padding: EdgeInsets.symmetric(horizontal: 24.0.w()), @@ -104,9 +100,7 @@ class MobileHomePage extends StatelessWidget { backgroundColor: AppColors.primary20, iconPath: AssetsManager.arrows, title: HomeStrings.tenderSubmitted, - amount: - (tenderApprovalsStateResponse.data?.submittedTenders ?? 0) - .toString(), + amount: homeViewModel.submittedTendersCount.toString(), textColor: AppColors.mainBlue, enableTap: true, onTap: () { @@ -119,7 +113,7 @@ class MobileHomePage extends StatelessWidget { backgroundColor: AppColors.primary10, iconPath: AssetsManager.approve, title: HomeStrings.approvedTenders, - amount: '0', + amount: homeViewModel.approvedTendersCount.toString(), textColor: AppColors.jellyBean, enableTap: true, @@ -143,27 +137,21 @@ class MobileHomePage extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - TenderCard( - backgroundColor: AppColors.orange10, - iconPath: AssetsManager.shield, - title: HomeStrings.tenderValue, - amount: '0', - textColor: AppColors.orange, - enableTap: false, - onTap: () {}, - ), - TenderCard( - backgroundColor: AppColors.grey10, - iconPath: AssetsManager.thumbLike, - title: HomeStrings.likedTenders, - amount: homeViewModel.userLikedTendersCount.toString(), - textColor: AppColors.grey50, - enableTap: true, - onTap: () { - const LikedTendersRouteData().push(context).then((value) { - homeViewModel.init(); - }); - }, + Expanded( + child: TenderCard( + backgroundColor: AppColors.grey10, + iconPath: AssetsManager.thumbLike, + title: HomeStrings.likedTenders, + amount: homeViewModel.userLikedTendersCount.toString(), + textColor: AppColors.grey50, + enableTap: true, + width: double.infinity, + onTap: () { + const LikedTendersRouteData().push(context).then((value) { + homeViewModel.init(); + }); + }, + ), ), ], ), diff --git a/lib/views/home/pages/t_home_page.dart b/lib/views/home/pages/t_home_page.dart index d809ad2..80d6696 100644 --- a/lib/views/home/pages/t_home_page.dart +++ b/lib/views/home/pages/t_home_page.dart @@ -4,7 +4,6 @@ import 'package:provider/provider.dart'; import 'package:tm_app/core/routes/app_routes.dart'; import 'package:tm_app/core/theme/colors.dart'; import 'package:tm_app/core/utils/size_config.dart'; -import 'package:tm_app/data/services/model/tender_approvals_stats_response/tender_approvals_stats_response.dart'; import 'package:tm_app/view_models/home_view_model.dart'; import 'package:tm_app/views/shared/tablet_navigation_widget.dart'; @@ -59,10 +58,7 @@ class TabletHomePage extends StatelessWidget { SizedBox(height: 40.0.h()), _progressBarsRow(homeViewModel), SizedBox(height: 32.0.h()), - _firstTenderCardsRow( - context, - homeViewModel.tenderApprovalsStateResponse!, - ), + _firstTenderCardsRow(context, homeViewModel), SizedBox(height: 8.0.h()), _secondTenderCardsRow(homeViewModel, context), SizedBox(height: 32.0.h()), @@ -113,7 +109,7 @@ class TabletHomePage extends StatelessWidget { Widget _firstTenderCardsRow( BuildContext context, - TenderApprovalsStatsResponse tenderApprovalsStateResponse, + HomeViewModel homeViewModel, ) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -123,9 +119,7 @@ class TabletHomePage extends StatelessWidget { backgroundColor: AppColors.primary20, iconPath: AssetsManager.arrows, title: HomeStrings.tenderSubmitted, - amount: - (tenderApprovalsStateResponse.data?.submittedTenders ?? 0) - .toString(), + amount: homeViewModel.submittedTendersCount.toString(), textColor: AppColors.mainBlue, enableTap: true, @@ -144,7 +138,7 @@ class TabletHomePage extends StatelessWidget { backgroundColor: AppColors.primary10, iconPath: AssetsManager.approve, title: HomeStrings.approvedTenders, - amount: '0', + amount: homeViewModel.approvedTendersCount.toString(), textColor: AppColors.jellyBean, enableTap: true, width: double.infinity, @@ -167,20 +161,6 @@ class TabletHomePage extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Expanded( - child: TenderCard( - backgroundColor: AppColors.orange10, - iconPath: AssetsManager.shield, - title: HomeStrings.tenderValue, - amount: '0', - textColor: AppColors.orange, - enableTap: false, - width: double.infinity, - height: 148, - onTap: () {}, - ), - ), - const SizedBox(width: 16.0), Expanded( child: TenderCard( backgroundColor: AppColors.grey10, diff --git a/lib/views/home/strings/home_strings.dart b/lib/views/home/strings/home_strings.dart index ee946e6..7d1d91f 100644 --- a/lib/views/home/strings/home_strings.dart +++ b/lib/views/home/strings/home_strings.dart @@ -5,11 +5,11 @@ class HomeStrings { static const String partnership = 'Partnership'; static const String selfApply = 'Self-apply'; static const String contracting = 'Contracting'; - static const String tenderSubmitted = 'Tenders submitted'; + static const String tenderSubmitted = 'Submitted tenders'; static const String approvedTenders = 'Approved tenders'; static const String tenderValue = 'Tender Value'; static const String thunderStatus = 'Thunder status'; - static const String likedTenders = 'Liked Tenders'; + static const String likedTenders = 'Liked tenders'; static const String yourTenders = 'Your tenders'; static const String recommendation = 'Recommendation'; static const String welcome = 'Welcome'; diff --git a/lib/views/tenders/widgets/animated_feedback_button.dart b/lib/views/tenders/widgets/animated_feedback_button.dart index af8a04f..0be7ec9 100644 --- a/lib/views/tenders/widgets/animated_feedback_button.dart +++ b/lib/views/tenders/widgets/animated_feedback_button.dart @@ -8,6 +8,7 @@ class AnimatedFeedbackButton extends StatefulWidget { final Color activeColor; final Color inactiveColor; final bool isActive; + final bool isLoading; final VoidCallback onTap; final String tooltip; @@ -18,6 +19,7 @@ class AnimatedFeedbackButton extends StatefulWidget { required this.isActive, required this.onTap, required this.tooltip, + this.isLoading = false, super.key, }); @@ -63,6 +65,9 @@ class _AnimatedFeedbackButtonState extends State } void _handleTap() { + if (widget.isLoading) { + return; + } _clickController.forward().then((_) { _clickController.reverse(); }); @@ -75,7 +80,7 @@ class _AnimatedFeedbackButtonState extends State message: widget.tooltip, child: InkWell( splashColor: Colors.transparent, - onTap: _handleTap, + onTap: widget.isLoading ? null : _handleTap, child: AnimatedBuilder( animation: _clickController, builder: (context, child) { @@ -94,15 +99,23 @@ class _AnimatedFeedbackButtonState extends State duration: const Duration(milliseconds: 100), curve: Curves.easeInOut, builder: (context, color, child) { - return SvgPicture.asset( + return SizedBox( width: 24.0.w(), height: 24.0.w(), - fit: BoxFit.cover, - widget.iconPath, - colorFilter: ColorFilter.mode( - color ?? widget.inactiveColor, - BlendMode.srcATop, - ), + child: + widget.isLoading + ? CircularProgressIndicator( + strokeWidth: 2, + color: color ?? widget.inactiveColor, + ) + : SvgPicture.asset( + fit: BoxFit.cover, + widget.iconPath, + colorFilter: ColorFilter.mode( + color ?? widget.inactiveColor, + BlendMode.srcATop, + ), + ), ); }, ), diff --git a/lib/views/tenders/widgets/main_tenders_slider.dart b/lib/views/tenders/widgets/main_tenders_slider.dart index e9e2136..75c49c0 100644 --- a/lib/views/tenders/widgets/main_tenders_slider.dart +++ b/lib/views/tenders/widgets/main_tenders_slider.dart @@ -41,6 +41,52 @@ class _MainTendersSliderState extends State { if (widget.isDesktop) { _scrollController.addListener(_onScroll); } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _hydrateRenderedFeedback(); + } + }); + } + + @override + void didUpdateWidget(covariant MainTendersSlider oldWidget) { + super.didUpdateWidget(oldWidget); + final oldIds = oldWidget.tenders.map((tender) => tender.id).toList(); + final newIds = widget.tenders.map((tender) => tender.id).toList(); + if (oldIds.length != newIds.length || + !Iterable.generate( + newIds.length, + ).every((index) => oldIds[index] == newIds[index])) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _hydrateRenderedFeedback(); + } + }); + } + } + + void _hydrateRenderedFeedback() { + if (widget.tenders.isEmpty) { + return; + } + + final viewModel = context.read(); + final tendersToHydrate = + widget.isDesktop + ? widget.tenders + : [ + widget.tenders[(currentPage - 1).clamp( + 0, + widget.tenders.length - 1, + )], + ]; + + for (final tender in tendersToHydrate) { + final id = tender.id; + if (id != null && id.isNotEmpty) { + viewModel.getTenderFeedback(id); + } + } } void _onScroll() { diff --git a/lib/views/tenders/widgets/tender_action_buttons_row.dart b/lib/views/tenders/widgets/tender_action_buttons_row.dart index 1a844af..d306922 100644 --- a/lib/views/tenders/widgets/tender_action_buttons_row.dart +++ b/lib/views/tenders/widgets/tender_action_buttons_row.dart @@ -48,6 +48,7 @@ class TenderActionButtonsRow extends StatelessWidget { final feedback = viewModel.getFeedbackForTender(tender.id!); final isDisliked = feedback?.feedbackType == TenderFeedback.disliked.value; + final isLoading = viewModel.isFeedbackLoading(tender.id!); return Column( children: [ @@ -56,9 +57,10 @@ class TenderActionButtonsRow extends StatelessWidget { activeColor: AppColors.errorColor, inactiveColor: AppColors.grey50, isActive: isDisliked, + isLoading: isLoading, tooltip: TendersStrings.dislike, onTap: () { - viewModel.toogleFeedback( + viewModel.toggleFeedback( tenderId: tender.id!, feedbackType: TenderFeedback.disliked.value, ); @@ -85,6 +87,7 @@ class TenderActionButtonsRow extends StatelessWidget { final feedback = viewModel.getFeedbackForTender(tender.id!); final isLiked = feedback?.feedbackType == TenderFeedback.liked.value; + final isLoading = viewModel.isFeedbackLoading(tender.id!); return Column( children: [ @@ -93,9 +96,10 @@ class TenderActionButtonsRow extends StatelessWidget { activeColor: AppColors.successColor, inactiveColor: AppColors.grey50, isActive: isLiked, + isLoading: isLoading, tooltip: TendersStrings.like, onTap: () { - viewModel.toogleFeedback( + viewModel.toggleFeedback( tenderId: tender.id!, feedbackType: TenderFeedback.liked.value, ); diff --git a/lib/views/your_tenders/pages/your_tenders_desktop_page.dart b/lib/views/your_tenders/pages/your_tenders_desktop_page.dart index bdf8d07..51e97c3 100644 --- a/lib/views/your_tenders/pages/your_tenders_desktop_page.dart +++ b/lib/views/your_tenders/pages/your_tenders_desktop_page.dart @@ -11,6 +11,7 @@ import 'package:tm_app/views/your_tenders/strings/your_tenders_strings.dart'; import 'package:tm_app/views/your_tenders/widgets/filter_button.dart'; import 'package:tm_app/views/your_tenders/widgets/liked_disliked_tenders_list.dart'; import 'package:tm_app/views/your_tenders/widgets/tenders_submitted.dart'; +import 'package:tm_app/views/your_tenders/widgets/submission_workflow.dart'; import '../../shared/desktop_navigation_widget.dart'; import '../../shared/page_selection_dialog.dart'; @@ -79,7 +80,9 @@ class _YourTendersDesktopPageState extends State Widget content; - if (viewModel.selectedStatus == + if (viewModel.isSubmissionWorkflow) { + content = const SubmissionWorkflow(); + } else if (viewModel.selectedStatus == TenderFeedback.liked.value && viewModel.likedTendersData != null) { content = LikedDislikedTendersList( @@ -114,7 +117,8 @@ class _YourTendersDesktopPageState extends State Expanded(child: content), SizedBox(height: 10.0.h()), - _listPagesControll(context, viewModel), + if (!viewModel.isSubmissionWorkflow) + _listPagesControll(context, viewModel), SizedBox(height: 30.0.h()), ], diff --git a/lib/views/your_tenders/pages/your_tenders_mobile_page.dart b/lib/views/your_tenders/pages/your_tenders_mobile_page.dart index f3fc98e..9ace9d7 100644 --- a/lib/views/your_tenders/pages/your_tenders_mobile_page.dart +++ b/lib/views/your_tenders/pages/your_tenders_mobile_page.dart @@ -7,6 +7,7 @@ import 'package:tm_app/views/your_tenders/strings/your_tenders_strings.dart'; import 'package:tm_app/views/your_tenders/widgets/filter_button.dart'; import 'package:tm_app/views/your_tenders/widgets/liked_disliked_tenders_list.dart'; import 'package:tm_app/views/your_tenders/widgets/tenders_submitted.dart'; +import 'package:tm_app/views/your_tenders/widgets/submission_workflow.dart'; import '../../../core/constants/tender_feedback.dart'; @@ -60,7 +61,9 @@ class _YourTendersMobilePageState extends State { return Column( children: [ - if (viewModel.selectedStatus == + if (viewModel.isSubmissionWorkflow) + const Expanded(child: SubmissionWorkflow()) + else if (viewModel.selectedStatus == TenderFeedback.liked.value && viewModel.likedTendersData != null) Expanded( diff --git a/lib/views/your_tenders/pages/your_tenders_tablet_page.dart b/lib/views/your_tenders/pages/your_tenders_tablet_page.dart index 91a870a..719a783 100644 --- a/lib/views/your_tenders/pages/your_tenders_tablet_page.dart +++ b/lib/views/your_tenders/pages/your_tenders_tablet_page.dart @@ -9,6 +9,7 @@ import 'package:tm_app/views/shared/tablet_desktop_appbar.dart'; import 'package:tm_app/views/your_tenders/widgets/filter_button.dart'; import 'package:tm_app/views/your_tenders/widgets/liked_disliked_tenders_list.dart'; import 'package:tm_app/views/your_tenders/widgets/tenders_submitted.dart'; +import 'package:tm_app/views/your_tenders/widgets/submission_workflow.dart'; import '../../../core/constants/tender_feedback.dart'; import '../../shared/tablet_navigation_widget.dart'; @@ -85,7 +86,9 @@ class _YourTendersTabletPageState extends State Widget content; - if (viewModel.selectedStatus == + if (viewModel.isSubmissionWorkflow) { + content = const SubmissionWorkflow(); + } else if (viewModel.selectedStatus == TenderFeedback.liked.value && viewModel.likedTendersData != null) { content = LikedDislikedTendersList( @@ -120,7 +123,8 @@ class _YourTendersTabletPageState extends State Expanded(child: content), SizedBox(height: 10.0.h()), - _listPagesControll(context, viewModel), + if (!viewModel.isSubmissionWorkflow) + _listPagesControll(context, viewModel), const SizedBox(height: 30.0), ], diff --git a/lib/views/your_tenders/widgets/submission_workflow.dart b/lib/views/your_tenders/widgets/submission_workflow.dart new file mode 100644 index 0000000..f93115f --- /dev/null +++ b/lib/views/your_tenders/widgets/submission_workflow.dart @@ -0,0 +1,511 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tm_app/core/theme/colors.dart'; +import 'package:tm_app/core/utils/date_utils.dart'; +import 'package:tm_app/data/services/model/tender_submission/tender_submission.dart'; +import 'package:tm_app/view_models/your_tenders_view_model.dart'; + +class SubmissionWorkflow extends StatelessWidget { + const SubmissionWorkflow({super.key}); + + static const _stageLabels = { + 'opportunity': 'Opportunity', + 'in_progress': 'In Progress', + 'submitted': 'Submitted', + 'not_applied': 'Not Applied', + }; + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, viewModel, _) { + final submissions = viewModel.submissionsData?.data ?? const []; + return Column( + children: [ + SizedBox( + height: 54, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 6, + ), + children: + YourTendersViewModel.submissionStages.map((stage) { + final selected = + viewModel.selectedSubmissionStage == stage; + final count = + viewModel.submissionStats?.byStage[stage] ?? 0; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + selected: selected, + onSelected: + (_) => viewModel.selectSubmissionStage(stage), + label: Text('${_stageLabels[stage]} ($count)'), + selectedColor: AppColors.secondary20, + side: BorderSide( + color: + selected + ? AppColors.secondary50 + : AppColors.grey30, + ), + ), + ); + }).toList(), + ), + ), + if (viewModel.submissionActionError != null) + Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(16, 4, 16, 4), + padding: const EdgeInsets.fromLTRB(12, 8, 4, 8), + decoration: BoxDecoration( + color: AppColors.red0, + border: Border.all(color: AppColors.red10), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: AppColors.error, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + viewModel.submissionActionError!, + style: TextStyle(color: AppColors.error, fontSize: 12), + ), + ), + IconButton( + visualDensity: VisualDensity.compact, + onPressed: viewModel.dismissSubmissionActionError, + icon: const Icon(Icons.close, size: 18), + ), + ], + ), + ), + if (viewModel.isLoading && submissions.isEmpty) + const Expanded( + child: Center( + child: CircularProgressIndicator( + color: AppColors.secondary50, + ), + ), + ) + else if (submissions.isEmpty) + Expanded( + child: Center( + child: Text( + 'No ${_stageLabels[viewModel.selectedSubmissionStage]?.toLowerCase()} submissions.', + style: TextStyle(color: AppColors.grey60), + ), + ), + ) + else + Expanded( + child: RefreshIndicator( + onRefresh: viewModel.loadSubmissions, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + itemCount: submissions.length, + itemBuilder: + (context, index) => + _SubmissionCard(submission: submissions[index]), + ), + ), + ), + if ((viewModel.submissionsData?.meta?.pages ?? 1) > 1) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + tooltip: 'Previous page', + onPressed: + viewModel.currentPage > 1 && !viewModel.isLoading + ? () => viewModel.jumpToPage( + viewModel.currentPage - 1, + ) + : null, + icon: const Icon(Icons.chevron_left), + ), + Text( + 'Page ${viewModel.currentPage} of ${viewModel.totalPages}', + style: TextStyle(color: AppColors.grey60), + ), + IconButton( + tooltip: 'Next page', + onPressed: + viewModel.currentPage < viewModel.totalPages && + !viewModel.isLoading + ? () => viewModel.jumpToPage( + viewModel.currentPage + 1, + ) + : null, + icon: const Icon(Icons.chevron_right), + ), + ], + ), + ), + ], + ); + }, + ); + } +} + +class _SubmissionCard extends StatelessWidget { + const _SubmissionCard({required this.submission}); + + final TenderSubmission submission; + + static const _statusLabels = { + 'opportunity': 'Opportunity', + 'validating': 'Validating', + 'partner_pending': 'Partner Pending', + 'applying': 'Applying', + 'sent_waiting': 'Sent / Waiting', + 'rejected': 'Lost', + 'contract': 'Contract Awarded', + 'not_applied': 'Not Applied', + }; + + @override + Widget build(BuildContext context) { + final viewModel = context.watch(); + final tender = submission.tender; + final busy = viewModel.updatingSubmissionId == submission.id; + final deadline = tender?.submissionDeadline ?? tender?.tenderDeadline; + + return Card( + color: AppColors.cardBackground, + elevation: 0, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: AppColors.borderColor), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + tender?.title?.trim().isNotEmpty == true + ? tender!.title! + : 'Tender ${submission.tenderId}', + style: TextStyle( + color: AppColors.grey80, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 12), + _StatusBadge( + label: _statusLabels[submission.status] ?? submission.status, + status: submission.status, + ), + ], + ), + if (tender?.description?.trim().isNotEmpty == true) ...[ + const SizedBox(height: 8), + Text( + tender!.description!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: AppColors.grey70, height: 1.4), + ), + ], + const SizedBox(height: 12), + Wrap( + spacing: 16, + runSpacing: 6, + children: [ + if (tender?.countryCode?.isNotEmpty == true) + _Meta( + icon: Icons.location_on_outlined, + text: tender!.countryCode!, + ), + if (deadline != null) + _Meta( + icon: Icons.event_outlined, + text: 'Deadline ${timeConvertor(deadline)}', + ), + if (tender?.buyerOrganization?.name?.isNotEmpty == true) + _Meta( + icon: Icons.business_outlined, + text: tender!.buyerOrganization!.name!, + ), + ], + ), + if (submission.statusHistory.isNotEmpty) ...[ + const SizedBox(height: 4), + ExpansionTile( + tilePadding: EdgeInsets.zero, + childrenPadding: EdgeInsets.zero, + dense: true, + title: Text( + 'Activity (${submission.statusHistory.length})', + style: TextStyle(color: AppColors.grey60, fontSize: 13), + ), + children: + submission.statusHistory.reversed + .map( + (item) => ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.circle, size: 8), + title: Text( + _statusLabels[item.status] ?? item.status, + style: TextStyle(color: AppColors.grey80), + ), + subtitle: Text( + [ + timeConvertor(item.changedAt, hasTime: true), + if (item.description?.isNotEmpty == true) + item.description!, + if (item.reason?.isNotEmpty == true) + item.reason!, + ].join(' · '), + ), + ), + ) + .toList(), + ), + ], + if (!submission.isTerminal) ...[ + const SizedBox(height: 12), + if (busy) + const LinearProgressIndicator(color: AppColors.secondary50) + else + _Actions(submission: submission), + ], + ], + ), + ), + ); + } +} + +class _Actions extends StatelessWidget { + const _Actions({required this.submission}); + + final TenderSubmission submission; + + @override + Widget build(BuildContext context) { + final primary = <({String label, String status, String description})>[]; + switch (submission.status) { + case 'opportunity': + primary.add(( + label: 'Start validation', + status: 'validating', + description: 'User accepted tender', + )); + case 'validating': + if (submission.submissionMode == 'partnership') { + primary.add(( + label: 'Wait for partner', + status: 'partner_pending', + description: 'Validation completed; waiting for partner documents', + )); + } else if (submission.submissionMode == 'self-apply') { + primary.add(( + label: 'Start applying', + status: 'applying', + description: 'Validation completed', + )); + } else { + primary.addAll([ + ( + label: 'Start applying', + status: 'applying', + description: 'Validation completed', + ), + ( + label: 'Wait for partner', + status: 'partner_pending', + description: + 'Validation completed; waiting for partner documents', + ), + ]); + } + case 'partner_pending': + primary.add(( + label: 'Partner docs ready', + status: 'applying', + description: 'Partner documents received', + )); + case 'applying': + primary.add(( + label: 'Submitted to buyer', + status: 'sent_waiting', + description: 'Application filed with procurement authority', + )); + case 'sent_waiting': + primary.addAll([ + ( + label: 'Mark won', + status: 'contract', + description: 'Contract awarded', + ), + ( + label: 'Mark lost', + status: 'rejected', + description: 'Buyer rejected bid', + ), + ]); + } + + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final action in primary) + FilledButton( + onPressed: + () => _confirmUpdate( + context, + status: action.status, + label: action.label, + description: action.description, + ), + style: FilledButton.styleFrom( + backgroundColor: AppColors.secondary50, + ), + child: Text(action.label), + ), + if (submission.status != 'sent_waiting') + OutlinedButton( + onPressed: () => _reject(context), + style: OutlinedButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('Do not apply'), + ), + ], + ); + } + + Future _confirmUpdate( + BuildContext context, { + required String status, + required String label, + required String description, + }) async { + final confirmed = await showDialog( + context: context, + builder: + (dialogContext) => AlertDialog( + title: Text(label), + content: const Text('Update this tender submission?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Confirm'), + ), + ], + ), + ); + if (confirmed == true && context.mounted) { + await context.read().updateSubmissionStatus( + submission: submission, + status: status, + description: description, + ); + } + } + + Future _reject(BuildContext context) async { + final controller = TextEditingController(); + final reason = await showDialog( + context: context, + builder: + (dialogContext) => AlertDialog( + title: const Text('Do not apply'), + content: TextField( + controller: controller, + maxLength: 500, + maxLines: 3, + decoration: const InputDecoration( + labelText: 'Reason (optional)', + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, controller.text), + child: const Text('Confirm'), + ), + ], + ), + ); + controller.dispose(); + if (reason != null && context.mounted) { + await context.read().updateSubmissionStatus( + submission: submission, + status: 'not_applied', + reason: reason, + ); + } + } +} + +class _StatusBadge extends StatelessWidget { + const _StatusBadge({required this.label, required this.status}); + final String label; + final String status; + + @override + Widget build(BuildContext context) { + final terminal = status == 'rejected' || status == 'not_applied'; + final won = status == 'contract'; + final color = + terminal + ? AppColors.error + : (won ? AppColors.green30 : AppColors.blue10); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: color.withValues(alpha: .12), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _Meta extends StatelessWidget { + const _Meta({required this.icon, required this.text}); + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: AppColors.grey60), + const SizedBox(width: 4), + Text(text, style: TextStyle(color: AppColors.grey60, fontSize: 12)), + ], + ); +} diff --git a/test/model/tender_submission_test.dart b/test/model/tender_submission_test.dart new file mode 100644 index 0000000..fdb64f1 --- /dev/null +++ b/test/model/tender_submission_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tm_app/data/services/model/tender_submission/tender_submission.dart'; + +void main() { + group('TenderSubmissionsResponse', () { + test('parses root pagination metadata and a reduced tender payload', () { + final response = TenderSubmissionsResponse.fromJson({ + 'success': true, + 'message': 'Tender submissions retrieved successfully', + 'data': [ + { + 'id': 'submission-1', + 'tender_id': 'tender-1', + 'company_id': 'company-1', + 'customer_id': 'customer-1', + 'status': 'validating', + 'stage': 'in_progress', + 'created_at': 1710000000, + 'updated_at': 1710003600, + 'status_history': [ + { + 'status': 'validating', + 'changed_at': 1710003600, + 'description': 'User accepted tender', + }, + ], + 'tender': { + 'id': 'tender-1', + 'title': 'IT Infrastructure Services', + 'description': 'A reduced tender payload', + 'submission_deadline': 1714000000, + 'country_code': 'DE', + 'status': 'active', + }, + }, + ], + 'meta': { + 'total': 42, + 'limit': 20, + 'offset': 0, + 'page': 1, + 'pages': 3, + 'has_more': true, + }, + }); + + expect(response.success, isTrue); + expect(response.data, hasLength(1)); + expect(response.data.single.status, 'validating'); + expect(response.data.single.stage, 'in_progress'); + expect(response.data.single.tender?.title, 'IT Infrastructure Services'); + expect(response.data.single.statusHistory.single.changedAt, 1710003600); + expect(response.meta?.total, 42); + expect(response.meta?.hasMore, isTrue); + }); + + test('keeps a submission when its tender is null', () { + final response = TenderSubmissionResponse.fromJson({ + 'success': true, + 'data': { + 'id': 'submission-2', + 'tender_id': 'tender-2', + 'status': 'not_applied', + 'stage': 'not_applied', + 'tender': null, + }, + }); + + expect(response.data?.tender, isNull); + expect(response.data?.isTerminal, isTrue); + }); + }); + + test('parses company stats maps', () { + final response = TenderSubmissionStatsResponse.fromJson({ + 'success': true, + 'data': { + 'company_id': 'company-1', + 'total': 15, + 'by_stage': {'opportunity': 3, 'in_progress': 5}, + 'by_status': {'validating': 2, 'contract': 2}, + 'last_updated': 1710003600, + }, + }); + + expect(response.data?.total, 15); + expect(response.data?.byStage['in_progress'], 5); + expect(response.data?.byStatus['contract'], 2); + }); +} diff --git a/test/view_models/ai_recommendations_view_model_test.dart b/test/view_models/ai_recommendations_view_model_test.dart index de95297..25db156 100644 --- a/test/view_models/ai_recommendations_view_model_test.dart +++ b/test/view_models/ai_recommendations_view_model_test.dart @@ -11,6 +11,7 @@ import 'package:tm_app/data/services/model/recommended_tenders_data/recommended_ import 'package:tm_app/data/services/model/recommended_tenders_response/recommended_tenders_response.dart'; import 'package:tm_app/data/services/model/tender_data/tender_data.dart'; import 'package:tm_app/data/services/your_tenders_service.dart'; +import 'package:tm_app/data/services/tender_submissions_service.dart'; import 'package:tm_app/view_models/ai_recommendations_view_model.dart'; void main() { @@ -150,6 +151,9 @@ class _FakeHomeRepository extends HomeRepository { likedTendersService: LikedTendersService( networkManager: NetworkManager(prefs), ), + tenderSubmissionsService: TenderSubmissionsService( + networkManager: NetworkManager(prefs), + ), ); final List> _results;