import 'package:flutter/foundation.dart'; import 'package:tm_app/core/network/app_exceptions.dart'; import 'package:tm_app/core/utils/result.dart'; import 'package:tm_app/data/repositories/company_ai_repository.dart'; import 'package:tm_app/data/repositories/home_repository.dart'; import 'package:tm_app/data/services/model/recommend_response/recommend_response.dart'; import 'package:tm_app/data/services/model/recommendation_item/recommendation_item.dart'; 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'; /// Drives the "Recommended" (AI) tab in the tenders section. /// /// Calls `POST /api/v1/recommend`. When the AI service is unavailable (503) it /// falls back to the legacy CPV/keyword list (`GET /api/v1/tenders/recommend`), /// and when the AI returns no results shortly after onboarding it surfaces a /// "still learning" hint instead of an empty error. class AiRecommendationsViewModel with ChangeNotifier { AiRecommendationsViewModel({ required CompanyAiRepository companyAiRepository, required HomeRepository homeRepository, }) : _companyAiRepository = companyAiRepository, _homeRepository = homeRepository; final CompanyAiRepository _companyAiRepository; final HomeRepository _homeRepository; /// How long after onboarding an empty result is treated as "still learning". static const Duration _stillLearningWindow = Duration(minutes: 30); static const int _fallbackPageSize = 20; bool _isLoading = false; String? _errorMessage; List _items = const []; List _fallbackTenders = const []; bool _usingFallback = false; bool _stillLearning = false; bool _hasLoaded = false; bool get isLoading => _isLoading; String? get errorMessage => _errorMessage; List get items => _items; List get fallbackTenders => _fallbackTenders; bool get usingFallback => _usingFallback; bool get stillLearning => _stillLearning; /// True once a load has completed (used to distinguish "no results" from /// "not loaded yet"). bool get hasLoaded => _hasLoaded; /// True when there is nothing to show and we're not loading or erroring. bool get isEmpty => !_isLoading && _errorMessage == null && _items.isEmpty && _fallbackTenders.isEmpty; /// Loads recommendations if they haven't been loaded yet this session. Future loadIfNeeded() async { if (_hasLoaded || _isLoading) { return; } await loadRecommendations(); } /// Loads AI recommendations, applying the 503 fallback and empty-state logic. Future loadRecommendations() async { _isLoading = true; _errorMessage = null; _usingFallback = false; _stillLearning = false; notifyListeners(); final result = await _companyAiRepository.getRecommendations(); var loadedSuccessfully = false; switch (result) { case Ok(): _items = result.value.data ?? const []; _fallbackTenders = const []; if (_items.isEmpty) { _stillLearning = _onboardingWasRecent(); } loadedSuccessfully = true; case Error(): loadedSuccessfully = await _handleError(result.error); } _isLoading = false; _hasLoaded = loadedSuccessfully; notifyListeners(); } /// Pull-to-refresh: always re-fetches. Future refresh() => loadRecommendations(); Future _handleError(Exception error) async { // 503 means the AI service isn't configured/available — fall back to the // legacy recommend list rather than showing an error. NetworkManager only // exposes this status when Dio received an HTTP response; connection and // timeout failures have no reliable status code and should remain retryable. if (error is AppException && error.statusCode == 503) { await _loadFallback(); return _errorMessage == null; } _items = const []; _fallbackTenders = const []; _errorMessage = error.toString(); return false; } Future _loadFallback() async { final result = await _homeRepository.getHomeRecommendedTenders( limit: _fallbackPageSize, offset: 0, ); switch (result) { case Ok(): _items = const []; _fallbackTenders = result.value.data?.tenders.whereType().toList() ?? const []; _usingFallback = true; case Error(): _errorMessage = result.error.toString(); } } bool _onboardingWasRecent() { final startedAt = _companyAiRepository.lastOnboardingStartedAt(); if (startedAt == null) { return false; } final elapsed = DateTime.now().millisecondsSinceEpoch - startedAt; return elapsed >= 0 && elapsed <= _stillLearningWindow.inMilliseconds; } }