diff --git a/.gitignore b/.gitignore index a1bdb5c..1d03477 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,7 @@ app.*.map.json # Firebase Configuration (sensitive files - should not be in version control) **/android/app/google-services.json **/ios/Runner/GoogleService-Info.plist -**/lib/firebase_options.dart \ No newline at end of file +**/lib/firebase_options.dart + +# IDE +.cursor \ No newline at end of file diff --git a/lib/view_models/ai_recommendations_view_model.dart b/lib/view_models/ai_recommendations_view_model.dart index 8eac157..358878c 100644 --- a/lib/view_models/ai_recommendations_view_model.dart +++ b/lib/view_models/ai_recommendations_view_model.dart @@ -1,19 +1,15 @@ 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. +/// Drives the "Recommended" 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. +/// Calls `GET /api/v1/tenders/recommend`. When the result is empty shortly +/// after onboarding it surfaces a "still learning" hint instead of an empty +/// error. class AiRecommendationsViewModel with ChangeNotifier { AiRecommendationsViewModel({ required CompanyAiRepository companyAiRepository, @@ -26,21 +22,17 @@ class AiRecommendationsViewModel with ChangeNotifier { /// How long after onboarding an empty result is treated as "still learning". static const Duration _stillLearningWindow = Duration(minutes: 30); - static const int _fallbackPageSize = 20; + static const int _pageSize = 20; bool _isLoading = false; String? _errorMessage; - List _items = const []; - List _fallbackTenders = const []; - bool _usingFallback = false; + List _recommendedTenders = const []; 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; + List get recommendedTenders => _recommendedTenders; bool get stillLearning => _stillLearning; /// True once a load has completed (used to distinguish "no results" from @@ -51,8 +43,7 @@ class AiRecommendationsViewModel with ChangeNotifier { bool get isEmpty => !_isLoading && _errorMessage == null && - _items.isEmpty && - _fallbackTenders.isEmpty; + _recommendedTenders.isEmpty; /// Loads recommendations if they haven't been loaded yet this session. Future loadIfNeeded() async { @@ -62,28 +53,32 @@ class AiRecommendationsViewModel with ChangeNotifier { await loadRecommendations(); } - /// Loads AI recommendations, applying the 503 fallback and empty-state logic. + /// Loads recommended tenders for the Recommended tab. Future loadRecommendations() async { _isLoading = true; _errorMessage = null; - _usingFallback = false; _stillLearning = false; notifyListeners(); - final result = await _companyAiRepository.getRecommendations(); + final result = await _homeRepository.getHomeRecommendedTenders( + limit: _pageSize, + offset: 0, + ); var loadedSuccessfully = false; switch (result) { - case Ok(): - _items = result.value.data ?? const []; - _fallbackTenders = const []; - if (_items.isEmpty) { + case Ok(): + _recommendedTenders = + result.value.data?.tenders.whereType().toList() ?? + const []; + if (_recommendedTenders.isEmpty) { _stillLearning = _onboardingWasRecent(); } loadedSuccessfully = true; - case Error(): - loadedSuccessfully = await _handleError(result.error); + case Error(): + _recommendedTenders = const []; + _errorMessage = result.error.toString(); } _isLoading = false; @@ -94,39 +89,6 @@ class AiRecommendationsViewModel with ChangeNotifier { /// 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) { diff --git a/lib/view_models/notification_view_model.dart b/lib/view_models/notification_view_model.dart index 2aac757..5684daa 100644 --- a/lib/view_models/notification_view_model.dart +++ b/lib/view_models/notification_view_model.dart @@ -87,7 +87,7 @@ class NotificationViewModel with ChangeNotifier { } notifyListeners(); - final int offset = page * _pageSize; + final int offset = (page - 1) * _pageSize; final result = await _repository.getNotifications( limit: _pageSize, @@ -138,7 +138,7 @@ class NotificationViewModel with ChangeNotifier { } notifyListeners(); - final int offset = page * _pageSize; + final int offset = (page - 1) * _pageSize; final result = await _repository.getUnreadNotifications( limit: _pageSize, offset: offset, @@ -181,7 +181,7 @@ class NotificationViewModel with ChangeNotifier { } notifyListeners(); - final int offset = page * _pageSize; + final int offset = (page - 1) * _pageSize; final result = await _repository.getImportantNotifications( limit: _pageSize, diff --git a/lib/views/board/pages/board_screen.dart b/lib/views/board/pages/board_screen.dart index 07df1d4..a35b2f6 100644 --- a/lib/views/board/pages/board_screen.dart +++ b/lib/views/board/pages/board_screen.dart @@ -301,7 +301,7 @@ class _TaskCard extends StatelessWidget { ), ), if (due != null) const SizedBox(height: 12), - Text( + SelectableText( card.title ?? '', style: TextStyle( fontSize: 14, @@ -309,7 +309,6 @@ class _TaskCard extends StatelessWidget { color: AppColors.grey70, ), maxLines: 3, - overflow: TextOverflow.ellipsis, ), const SizedBox(height: 12), if (card.priority != null) _PriorityChip(priority: card.priority!), diff --git a/lib/views/detail/widgets/tender_detail_header.dart b/lib/views/detail/widgets/tender_detail_header.dart index 66e4af9..a207e41 100644 --- a/lib/views/detail/widgets/tender_detail_header.dart +++ b/lib/views/detail/widgets/tender_detail_header.dart @@ -141,7 +141,7 @@ class TenderDetailHeader extends StatelessWidget { ); } - Widget _titleText() => Text( + Widget _titleText() => SelectableText( detail.title ?? '', style: TextStyle( color: AppColors.grey80, diff --git a/lib/views/home/tenders_list_item.dart b/lib/views/home/tenders_list_item.dart index 6e1fc50..90cb2c5 100644 --- a/lib/views/home/tenders_list_item.dart +++ b/lib/views/home/tenders_list_item.dart @@ -75,10 +75,10 @@ class TendersListItem extends StatelessWidget { ), ), SizedBox(height: 12.0.h()), - Text( + SelectableText( tender.title ?? '', + maxLines: 2, style: TextStyle( - overflow: TextOverflow.ellipsis, fontSize: 16.0.sp(), fontWeight: FontWeight.w600, color: AppColors.grey80, diff --git a/lib/views/liked_tenders/widgets/liked_tenders_list_item.dart b/lib/views/liked_tenders/widgets/liked_tenders_list_item.dart index 04334ab..d495479 100644 --- a/lib/views/liked_tenders/widgets/liked_tenders_list_item.dart +++ b/lib/views/liked_tenders/widgets/liked_tenders_list_item.dart @@ -128,10 +128,10 @@ class LikedListItem extends StatelessWidget { ), ), SizedBox(height: 12.0.h()), - Text( + SelectableText( tender.title!, + maxLines: 2, style: TextStyle( - overflow: TextOverflow.ellipsis, fontSize: 16.0.sp(), fontWeight: FontWeight.w600, color: AppColors.grey80, diff --git a/lib/views/tenders/widgets/recommended_tenders_view.dart b/lib/views/tenders/widgets/recommended_tenders_view.dart index b891aa7..f583320 100644 --- a/lib/views/tenders/widgets/recommended_tenders_view.dart +++ b/lib/views/tenders/widgets/recommended_tenders_view.dart @@ -7,10 +7,9 @@ import 'package:tm_app/data/services/model/tender_data/tender_data.dart'; import 'package:tm_app/view_models/ai_recommendations_view_model.dart'; import '../strings/tenders_strings.dart'; -import 'recommendation_card.dart'; -/// The "Recommended" (AI) tab body. Handles loading, empty/"still learning", -/// error+retry and the 503 fallback states for [AiRecommendationsViewModel]. +/// The "Recommended" tab body. Handles loading, empty/"still learning", +/// error+retry and the recommended tender list for [AiRecommendationsViewModel]. class RecommendedTendersView extends StatefulWidget { const RecommendedTendersView({super.key}); @@ -49,20 +48,16 @@ class _RecommendedTendersViewState extends State { onRetry: vm.refresh, ); } - if (vm.usingFallback && vm.fallbackTenders.isNotEmpty) { - return _FallbackList(tenders: vm.fallbackTenders, onRefresh: vm.refresh); - } - if (vm.items.isNotEmpty) { + if (vm.recommendedTenders.isNotEmpty) { return RefreshIndicator( color: AppColors.jellyBean, onRefresh: vm.refresh, child: ListView.separated( padding: EdgeInsets.all(16.0.h()), - itemCount: vm.items.length, + itemCount: vm.recommendedTenders.length, separatorBuilder: (_, __) => SizedBox(height: 12.0.h()), - itemBuilder: - (context, index) => - RecommendationCard(item: vm.items[index]), + itemBuilder: (context, index) => + _RecommendedTenderCard(tender: vm.recommendedTenders[index]), ), ); } @@ -76,56 +71,8 @@ class _RecommendedTendersViewState extends State { } } -/// Renders the legacy keyword-based fallback list with a notice banner. -class _FallbackList extends StatelessWidget { - const _FallbackList({required this.tenders, required this.onRefresh}); - - final List tenders; - final Future Function() onRefresh; - - @override - Widget build(BuildContext context) { - return RefreshIndicator( - color: AppColors.jellyBean, - onRefresh: onRefresh, - child: ListView.separated( - padding: EdgeInsets.all(16.0.h()), - itemCount: tenders.length + 1, - separatorBuilder: (_, __) => SizedBox(height: 12.0.h()), - itemBuilder: (context, index) { - if (index == 0) { - return _fallbackNotice(); - } - return _FallbackTenderCard(tender: tenders[index - 1]); - }, - ), - ); - } - - Widget _fallbackNotice() { - return Container( - width: double.infinity, - padding: EdgeInsets.all(12.0.h()), - decoration: BoxDecoration( - color: AppColors.orange10, - borderRadius: BorderRadius.circular(8.0.w()), - ), - child: Text( - TendersStrings.recommendedFallbackNotice, - style: TextStyle( - fontSize: 13.0.sp(), - fontWeight: FontWeight.w500, - color: AppColors.yellow4, - ), - ), - ); - } -} - -/// Lightweight card for a fallback tender. Decoupled from [TendersViewModel] -/// (no feedback/pagination) so it can render outside the All-tab flow. -class _FallbackTenderCard extends StatelessWidget { - const _FallbackTenderCard({required this.tender}); +class _RecommendedTenderCard extends StatelessWidget { + const _RecommendedTenderCard({required this.tender}); final TenderData tender; @@ -156,10 +103,9 @@ class _FallbackTenderCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (title.isNotEmpty) - Text( + SelectableText( title, maxLines: 2, - overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.0.sp(), fontWeight: FontWeight.w600, diff --git a/lib/views/tenders/widgets/tender_card.dart b/lib/views/tenders/widgets/tender_card.dart index c336844..8b1240a 100644 --- a/lib/views/tenders/widgets/tender_card.dart +++ b/lib/views/tenders/widgets/tender_card.dart @@ -198,7 +198,7 @@ class TenderCard extends StatelessWidget { } Widget _tenderTitle() { - return Text( + return SelectableText( tender.title ?? '', maxLines: isDesktop ? 2 : 4, style: TextStyle( diff --git a/lib/views/your_tenders/widgets/tender_card.dart b/lib/views/your_tenders/widgets/tender_card.dart index 222d2a0..4c2ff05 100644 --- a/lib/views/your_tenders/widgets/tender_card.dart +++ b/lib/views/your_tenders/widgets/tender_card.dart @@ -99,10 +99,9 @@ class TenderCard extends StatelessWidget { SizedBox(height: 12.0.h()), // Title - Text( + SelectableText( tender.title!, maxLines: 1, - overflow: TextOverflow.ellipsis, style: TextStyle( color: AppColors.grey80, fontSize: 16.0.sp(), diff --git a/test/view_models/ai_recommendations_view_model_test.dart b/test/view_models/ai_recommendations_view_model_test.dart index be17c9f..8e3b382 100644 --- a/test/view_models/ai_recommendations_view_model_test.dart +++ b/test/view_models/ai_recommendations_view_model_test.dart @@ -1,6 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:tm_app/core/network/app_exceptions.dart'; import 'package:tm_app/core/network/network_manager.dart'; import 'package:tm_app/core/utils/result.dart'; import 'package:tm_app/data/repositories/company_ai_repository.dart'; @@ -8,79 +7,74 @@ import 'package:tm_app/data/repositories/home_repository.dart'; import 'package:tm_app/data/services/company_ai_service.dart'; import 'package:tm_app/data/services/home_service.dart'; import 'package:tm_app/data/services/liked_tenders_service.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_data/recommended_tenders_data.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'; import 'package:tm_app/data/services/your_tenders_service.dart'; import 'package:tm_app/view_models/ai_recommendations_view_model.dart'; void main() { group('AiRecommendationsViewModel', () { - test('falls back to legacy recommendations on HTTP 503', () async { + test('loads recommended tenders from GET /api/v1/tenders/recommend', () async { final prefs = await _prefs(); - final companyRepository = _FakeCompanyAiRepository( - prefs, - results: [Result.error(ServerException('unavailable', 503))], - ); final homeRepository = _FakeHomeRepository( prefs, - result: const Result.ok( + result: Result.ok( RecommendedTendersResponse( succes: true, message: 'ok', error: null, - data: RecommendedTendersData(tenders: [], metadata: null), + data: RecommendedTendersData( + tenders: [_sampleTender(id: 'tender-1', title: 'Bridge repair')], + metadata: null, + ), ), ), ); final viewModel = AiRecommendationsViewModel( - companyAiRepository: companyRepository, + companyAiRepository: _FakeCompanyAiRepository(prefs), homeRepository: homeRepository, ); await viewModel.loadRecommendations(); expect(viewModel.hasLoaded, isTrue); - expect(viewModel.usingFallback, isTrue); expect(viewModel.errorMessage, isNull); expect(homeRepository.recommendedTendersCalls, 1); + expect(viewModel.recommendedTenders.single.id, 'tender-1'); }); test('keeps failed transient loads retryable for loadIfNeeded', () async { final prefs = await _prefs(); - final companyRepository = _FakeCompanyAiRepository( + final homeRepository = _FakeHomeRepository( prefs, results: [ - Result.error(FetchDataException('network failed')), - const Result.ok( - RecommendResponse( - data: [ - RecommendationItem( - rank: '1', - tenderId: 'tender-1', - analysis: 'Strong match', - ), - ], - error: null, + Result.error(Exception('network failed')), + Result.ok( + RecommendedTendersResponse( + succes: true, message: 'ok', - success: true, + error: null, + data: RecommendedTendersData( + tenders: [_sampleTender(id: 'tender-1', title: 'Bridge repair')], + metadata: null, + ), ), ), ], ); final viewModel = AiRecommendationsViewModel( - companyAiRepository: companyRepository, - homeRepository: _FakeHomeRepository(prefs), + companyAiRepository: _FakeCompanyAiRepository(prefs), + homeRepository: homeRepository, ); await viewModel.loadRecommendations(); await viewModel.loadIfNeeded(); - expect(companyRepository.recommendationCalls, 2); + expect(homeRepository.recommendedTendersCalls, 2); expect(viewModel.hasLoaded, isTrue); expect(viewModel.errorMessage, isNull); - expect(viewModel.items.single.tenderId, 'tender-1'); + expect(viewModel.recommendedTenders.single.id, 'tender-1'); }); test( @@ -91,16 +85,6 @@ void main() { companyAiRepository: _FakeCompanyAiRepository( prefs, lastOnboardingStartedAt: DateTime.now().millisecondsSinceEpoch, - results: [ - const Result.ok( - RecommendResponse( - data: [], - error: null, - message: 'ok', - success: true, - ), - ), - ], ), homeRepository: _FakeHomeRepository(prefs), ); @@ -123,22 +107,11 @@ Future _prefs() async { class _FakeCompanyAiRepository extends CompanyAiRepository { _FakeCompanyAiRepository( SharedPreferences prefs, { - required List> results, int? lastOnboardingStartedAt, - }) : _results = results, - _lastOnboardingStartedAt = lastOnboardingStartedAt, + }) : _lastOnboardingStartedAt = lastOnboardingStartedAt, super(companyAiService: _UnusedCompanyAiService(prefs), prefs: prefs); - final List> _results; final int? _lastOnboardingStartedAt; - int recommendationCalls = 0; - - @override - Future> getRecommendations() async { - final index = recommendationCalls; - recommendationCalls += 1; - return _results[index]; - } @override int? lastOnboardingStartedAt() => _lastOnboardingStartedAt; @@ -147,15 +120,22 @@ class _FakeCompanyAiRepository extends CompanyAiRepository { class _FakeHomeRepository extends HomeRepository { _FakeHomeRepository( SharedPreferences prefs, { - this.result = const Result.ok( - RecommendedTendersResponse( - succes: true, - message: 'ok', - error: null, - data: RecommendedTendersData(tenders: [], metadata: null), - ), - ), - }) : super( + List>? results, + Result? result, + }) : _results = + results ?? + [ + result ?? + const Result.ok( + RecommendedTendersResponse( + succes: true, + message: 'ok', + error: null, + data: RecommendedTendersData(tenders: [], metadata: null), + ), + ), + ], + super( homeService: HomeService(networkManager: NetworkManager(prefs)), yourTendersService: YourTendersService( networkManager: NetworkManager(prefs), @@ -165,7 +145,7 @@ class _FakeHomeRepository extends HomeRepository { ), ); - final Result result; + final List> _results; int recommendedTendersCalls = 0; @override @@ -173,8 +153,9 @@ class _FakeHomeRepository extends HomeRepository { required int limit, required int offset, }) async { + final index = recommendedTendersCalls; recommendedTendersCalls += 1; - return result; + return _results[index]; } } @@ -182,3 +163,36 @@ class _UnusedCompanyAiService extends CompanyAiService { _UnusedCompanyAiService(SharedPreferences prefs) : super(networkManager: NetworkManager(prefs)); } + +TenderData _sampleTender({required String id, required String title}) { + return TenderData( + success: null, + message: null, + id: id, + tenderId: id, + noticePublicationId: null, + noticeTypeCode: null, + formType: null, + noticeLanguageCode: null, + issueDate: null, + issueTime: null, + publicationDate: null, + title: title, + description: null, + procurementTypeCode: null, + procedureCode: null, + estimatedValue: null, + currency: null, + duration: null, + tenderDeadline: null, + submissionDeadline: null, + applicationDeadline: null, + submissionUrl: null, + countryCode: null, + durationUnit: null, + buyerOrganization: null, + lots: null, + officialLanguages: null, + status: null, + ); +}