diff --git a/lib/core/config/dependencies.dart b/lib/core/config/dependencies.dart index 3ed519d..8858249 100644 --- a/lib/core/config/dependencies.dart +++ b/lib/core/config/dependencies.dart @@ -3,6 +3,7 @@ import 'package:provider/single_child_widget.dart'; import 'package:tm_app/core/network/network_manager.dart'; import 'package:tm_app/core/services/tab_navigation_service.dart'; import 'package:tm_app/core/theme/theme_provider.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/repositories/liked_tenders_repository.dart'; import 'package:tm_app/data/repositories/notification_repository.dart'; @@ -13,6 +14,7 @@ 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/notification_check_service.dart'; import 'package:tm_app/data/services/notification_service.dart'; +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'; @@ -99,6 +101,10 @@ List get apiClients { create: (context) => BoardService(networkManager: context.read()), lazy: true, ), + Provider( + create: (context) => CompanyAiService(networkManager: context.read()), + lazy: true, + ), ]; } @@ -155,6 +161,14 @@ List get repositories { create: (context) => BoardRepository(boardService: context.read()), lazy: true, ), + Provider( + create: + (context) => CompanyAiRepository( + companyAiService: context.read(), + prefs: context.read(), + ), + lazy: true, + ), ]; } diff --git a/lib/core/constants/pref_keys.dart b/lib/core/constants/pref_keys.dart index ffeb4f3..1bf0119 100644 --- a/lib/core/constants/pref_keys.dart +++ b/lib/core/constants/pref_keys.dart @@ -9,4 +9,13 @@ class PrefKeys { static const String bearer = 'bearer'; static const String refreshToken = 'refresh_token'; static const String customerData = 'customer_data'; + + /// Fingerprint (hash of the company's document ids + website) of the last + /// profile version we triggered AI onboarding for. Lets us skip redundant + /// `POST /api/v1/onboarding` calls until the company's data actually changes. + static const String onboardingFingerprint = 'ai_onboarding_fingerprint'; + + /// Epoch milliseconds when AI onboarding was last started. Used by the + /// Recommended tab to show a "still learning" hint for a short grace period. + static const String onboardingStartedAt = 'ai_onboarding_started_at'; } diff --git a/lib/core/providers/profile_provider.dart b/lib/core/providers/profile_provider.dart index 9170568..c7618c6 100644 --- a/lib/core/providers/profile_provider.dart +++ b/lib/core/providers/profile_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../data/repositories/auth_repository.dart'; +import '../../data/repositories/company_ai_repository.dart'; import '../../data/repositories/profile_repository.dart'; import '../../view_models/profile_view_model.dart'; @@ -13,6 +14,7 @@ Widget profileProvider({required Widget child}) { (context) => ProfileViewModel( profileRepository: context.read(), authRepository: context.read(), + companyAiRepository: context.read(), ), child: child, ); diff --git a/lib/core/providers/tenders_provider.dart b/lib/core/providers/tenders_provider.dart index 7b3dd3d..733a47c 100644 --- a/lib/core/providers/tenders_provider.dart +++ b/lib/core/providers/tenders_provider.dart @@ -1,17 +1,33 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../data/repositories/company_ai_repository.dart'; +import '../../data/repositories/home_repository.dart'; import '../../data/repositories/tenders_repository.dart'; +import '../../view_models/ai_recommendations_view_model.dart'; import '../../view_models/tenders_view_model.dart'; -/// Lazy TendersViewModel provider -/// Wraps screens that need TendersViewModel +/// Lazy providers for the tenders section. +/// +/// Provides [TendersViewModel] (the "All" tab) and [AiRecommendationsViewModel] +/// (the "Recommended" AI tab) to screens that need them. Widget tendersProvider({required Widget child}) { - return ChangeNotifierProvider( - create: - (context) => TendersViewModel( - tendersRepository: context.read(), - ), + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: + (context) => TendersViewModel( + tendersRepository: context.read(), + ), + ), + ChangeNotifierProvider( + create: + (context) => AiRecommendationsViewModel( + companyAiRepository: context.read(), + homeRepository: context.read(), + ), + ), + ], child: child, ); } diff --git a/lib/data/repositories/company_ai_repository.dart b/lib/data/repositories/company_ai_repository.dart new file mode 100644 index 0000000..4ef7678 --- /dev/null +++ b/lib/data/repositories/company_ai_repository.dart @@ -0,0 +1,88 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tm_app/core/constants/pref_keys.dart'; +import 'package:tm_app/core/utils/logger.dart'; +import 'package:tm_app/core/utils/result.dart'; +import 'package:tm_app/data/services/company_ai_service.dart'; +import 'package:tm_app/data/services/model/onboarding_response/onboarding_response.dart'; +import 'package:tm_app/data/services/model/recommend_response/recommend_response.dart'; + +/// Outcome of [CompanyAiRepository.maybeStartOnboarding]. +enum AiOnboardingOutcome { + /// Onboarding was started against the AI service. + started, + + /// Skipped: the company has no documents and no website yet. + skippedNoData, + + /// Skipped: this profile version was already onboarded. + skippedUnchanged, + + /// The onboarding request failed (network/server). Safe to retry later. + failed, +} + +/// Coordinates AI onboarding and recommendation calls. +/// +/// Onboarding is asynchronous on the AI side and only needs to run when the +/// company's documents or website change, so this repository fingerprints that +/// data in [SharedPreferences] and skips redundant calls — mirroring the +/// `onCompanyProfileReady` guidance in the mobile integration guide. +class CompanyAiRepository { + CompanyAiRepository({ + required CompanyAiService companyAiService, + required SharedPreferences prefs, + }) : _companyAiService = companyAiService, + _prefs = prefs; + + final CompanyAiService _companyAiService; + final SharedPreferences _prefs; + + /// Starts onboarding only when the company has data to index and its + /// fingerprint differs from the last successful run. + Future maybeStartOnboarding({ + required List documentFileIds, + required String? website, + }) async { + final hasWebsite = website != null && website.trim().isNotEmpty; + if (documentFileIds.isEmpty && !hasWebsite) { + return AiOnboardingOutcome.skippedNoData; + } + + final fingerprint = _fingerprint(documentFileIds, website); + if (fingerprint == _prefs.getString(PrefKeys.onboardingFingerprint)) { + return AiOnboardingOutcome.skippedUnchanged; + } + + final result = await _companyAiService.startOnboarding(); + switch (result) { + case Ok(): + await _prefs.setString(PrefKeys.onboardingFingerprint, fingerprint); + await _prefs.setInt( + PrefKeys.onboardingStartedAt, + DateTime.now().millisecondsSinceEpoch, + ); + AppLogger().info('✅ AI onboarding started for fingerprint $fingerprint'); + return AiOnboardingOutcome.started; + case Error(): + // Don't persist the fingerprint so the next profile load retries. + AppLogger().logWarning('⚠️ AI onboarding failed: ${result.error}'); + return AiOnboardingOutcome.failed; + } + } + + Future> getRecommendations() { + return _companyAiService.getRecommendations(); + } + + /// Epoch milliseconds of the last started onboarding, or null if none. + int? lastOnboardingStartedAt() => + _prefs.getInt(PrefKeys.onboardingStartedAt); + + /// Stable, order-independent fingerprint of the data the AI service indexes. + /// Stored verbatim (it is not sensitive) so it compares reliably across app + /// launches, unlike [String.hashCode]. + String _fingerprint(List documentFileIds, String? website) { + final ids = [...documentFileIds]..sort(); + return '${ids.join(',')}|${website?.trim() ?? ''}'; + } +} diff --git a/lib/data/services/api/company_ai_api.dart b/lib/data/services/api/company_ai_api.dart new file mode 100644 index 0000000..c02e25b --- /dev/null +++ b/lib/data/services/api/company_ai_api.dart @@ -0,0 +1,14 @@ +/// Backend endpoints that power AI-based tender recommendations. +/// +/// The app talks only to the backend; it gathers company data, forwards it to +/// the AI service and returns results in the standard API envelope. The +/// `company_id` is read from the JWT, so neither endpoint takes a request body. +class CompanyAiApi { + CompanyAiApi._(); + + /// Registers the company with the AI service. Asynchronous on the AI side. + static const String onboarding = '/api/v1/onboarding'; + + /// Fetches ranked AI tender recommendations for the company. + static const String recommend = '/api/v1/recommend'; +} diff --git a/lib/data/services/company_ai_service.dart b/lib/data/services/company_ai_service.dart new file mode 100644 index 0000000..a985f23 --- /dev/null +++ b/lib/data/services/company_ai_service.dart @@ -0,0 +1,34 @@ +import 'dart:convert'; + +import 'package:tm_app/core/utils/result.dart'; +import 'package:tm_app/data/services/api/company_ai_api.dart'; +import 'package:tm_app/data/services/model/onboarding_response/onboarding_response.dart'; +import 'package:tm_app/data/services/model/recommend_response/recommend_response.dart'; + +import '../../core/network/network_manager.dart'; + +/// Thin wrapper over the AI recommendation endpoints. The backend reads +/// `company_id` from the JWT, so both calls send an empty JSON body. +class CompanyAiService { + CompanyAiService({required NetworkManager networkManager}) + : _networkManager = networkManager; + final NetworkManager _networkManager; + + Future> startOnboarding() async { + return _networkManager.makeRequest( + CompanyAiApi.onboarding, + method: 'POST', + (json) => OnboardingResponse.fromJson(json), + data: jsonEncode(const {}), + ); + } + + Future> getRecommendations() async { + return _networkManager.makeRequest( + CompanyAiApi.recommend, + method: 'POST', + (json) => RecommendResponse.fromJson(json), + data: jsonEncode(const {}), + ); + } +} diff --git a/lib/data/services/model/company_profile_data/company_profile_data.dart b/lib/data/services/model/company_profile_data/company_profile_data.dart index dc1561a..4292965 100644 --- a/lib/data/services/model/company_profile_data/company_profile_data.dart +++ b/lib/data/services/model/company_profile_data/company_profile_data.dart @@ -14,6 +14,11 @@ abstract class CompanyProfileData with _$CompanyProfileData { required String? industry, @JsonKey(name: 'founded_year') required int? foundedYear, @JsonKey(name: 'created_at') required int? createdAt, + // Drives the AI onboarding fingerprint (see CompanyAiRepository): onboarding + // re-runs only when the company's documents or website change. Optional so + // existing payloads that omit them keep deserializing. + @JsonKey(name: 'document_file_ids') List? documentFileIds, + String? website, }) = _CompanyProfileData; factory CompanyProfileData.fromJson(Map json) => diff --git a/lib/data/services/model/company_profile_data/company_profile_data.freezed.dart b/lib/data/services/model/company_profile_data/company_profile_data.freezed.dart index 2519855..d79ad2d 100644 --- a/lib/data/services/model/company_profile_data/company_profile_data.freezed.dart +++ b/lib/data/services/model/company_profile_data/company_profile_data.freezed.dart @@ -15,7 +15,10 @@ T _$identity(T value) => value; /// @nodoc mixin _$CompanyProfileData { - String? get id; String? get name;@JsonKey(name: 'registration_number') String? get registrationNumber; String? get industry;@JsonKey(name: 'founded_year') int? get foundedYear;@JsonKey(name: 'created_at') int? get createdAt; + String? get id; String? get name;@JsonKey(name: 'registration_number') String? get registrationNumber; String? get industry;@JsonKey(name: 'founded_year') int? get foundedYear;@JsonKey(name: 'created_at') int? get createdAt;// Drives the AI onboarding fingerprint (see CompanyAiRepository): onboarding +// re-runs only when the company's documents or website change. Optional so +// existing payloads that omit them keep deserializing. +@JsonKey(name: 'document_file_ids') List? get documentFileIds; String? get website; /// Create a copy of CompanyProfileData /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -28,16 +31,16 @@ $CompanyProfileDataCopyWith get copyWith => _$CompanyProfile @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is CompanyProfileData&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.registrationNumber, registrationNumber) || other.registrationNumber == registrationNumber)&&(identical(other.industry, industry) || other.industry == industry)&&(identical(other.foundedYear, foundedYear) || other.foundedYear == foundedYear)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is CompanyProfileData&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.registrationNumber, registrationNumber) || other.registrationNumber == registrationNumber)&&(identical(other.industry, industry) || other.industry == industry)&&(identical(other.foundedYear, foundedYear) || other.foundedYear == foundedYear)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&const DeepCollectionEquality().equals(other.documentFileIds, documentFileIds)&&(identical(other.website, website) || other.website == website)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,name,registrationNumber,industry,foundedYear,createdAt); +int get hashCode => Object.hash(runtimeType,id,name,registrationNumber,industry,foundedYear,createdAt,const DeepCollectionEquality().hash(documentFileIds),website); @override String toString() { - return 'CompanyProfileData(id: $id, name: $name, registrationNumber: $registrationNumber, industry: $industry, foundedYear: $foundedYear, createdAt: $createdAt)'; + return 'CompanyProfileData(id: $id, name: $name, registrationNumber: $registrationNumber, industry: $industry, foundedYear: $foundedYear, createdAt: $createdAt, documentFileIds: $documentFileIds, website: $website)'; } @@ -48,7 +51,7 @@ abstract mixin class $CompanyProfileDataCopyWith<$Res> { factory $CompanyProfileDataCopyWith(CompanyProfileData value, $Res Function(CompanyProfileData) _then) = _$CompanyProfileDataCopyWithImpl; @useResult $Res call({ - String? id, String? name,@JsonKey(name: 'registration_number') String? registrationNumber, String? industry,@JsonKey(name: 'founded_year') int? foundedYear,@JsonKey(name: 'created_at') int? createdAt + String? id, String? name,@JsonKey(name: 'registration_number') String? registrationNumber, String? industry,@JsonKey(name: 'founded_year') int? foundedYear,@JsonKey(name: 'created_at') int? createdAt,@JsonKey(name: 'document_file_ids') List? documentFileIds, String? website }); @@ -65,7 +68,7 @@ class _$CompanyProfileDataCopyWithImpl<$Res> /// Create a copy of CompanyProfileData /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? name = freezed,Object? registrationNumber = freezed,Object? industry = freezed,Object? foundedYear = freezed,Object? createdAt = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? name = freezed,Object? registrationNumber = freezed,Object? industry = freezed,Object? foundedYear = freezed,Object? createdAt = freezed,Object? documentFileIds = freezed,Object? website = freezed,}) { return _then(_self.copyWith( id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String?,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable @@ -73,7 +76,9 @@ as String?,registrationNumber: freezed == registrationNumber ? _self.registratio as String?,industry: freezed == industry ? _self.industry : industry // ignore: cast_nullable_to_non_nullable as String?,foundedYear: freezed == foundedYear ? _self.foundedYear : foundedYear // ignore: cast_nullable_to_non_nullable as int?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable -as int?, +as int?,documentFileIds: freezed == documentFileIds ? _self.documentFileIds : documentFileIds // ignore: cast_nullable_to_non_nullable +as List?,website: freezed == website ? _self.website : website // ignore: cast_nullable_to_non_nullable +as String?, )); } @@ -158,10 +163,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String? id, String? name, @JsonKey(name: 'registration_number') String? registrationNumber, String? industry, @JsonKey(name: 'founded_year') int? foundedYear, @JsonKey(name: 'created_at') int? createdAt)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String? id, String? name, @JsonKey(name: 'registration_number') String? registrationNumber, String? industry, @JsonKey(name: 'founded_year') int? foundedYear, @JsonKey(name: 'created_at') int? createdAt, @JsonKey(name: 'document_file_ids') List? documentFileIds, String? website)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _CompanyProfileData() when $default != null: -return $default(_that.id,_that.name,_that.registrationNumber,_that.industry,_that.foundedYear,_that.createdAt);case _: +return $default(_that.id,_that.name,_that.registrationNumber,_that.industry,_that.foundedYear,_that.createdAt,_that.documentFileIds,_that.website);case _: return orElse(); } @@ -179,10 +184,10 @@ return $default(_that.id,_that.name,_that.registrationNumber,_that.industry,_tha /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String? id, String? name, @JsonKey(name: 'registration_number') String? registrationNumber, String? industry, @JsonKey(name: 'founded_year') int? foundedYear, @JsonKey(name: 'created_at') int? createdAt) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String? id, String? name, @JsonKey(name: 'registration_number') String? registrationNumber, String? industry, @JsonKey(name: 'founded_year') int? foundedYear, @JsonKey(name: 'created_at') int? createdAt, @JsonKey(name: 'document_file_ids') List? documentFileIds, String? website) $default,) {final _that = this; switch (_that) { case _CompanyProfileData(): -return $default(_that.id,_that.name,_that.registrationNumber,_that.industry,_that.foundedYear,_that.createdAt);case _: +return $default(_that.id,_that.name,_that.registrationNumber,_that.industry,_that.foundedYear,_that.createdAt,_that.documentFileIds,_that.website);case _: throw StateError('Unexpected subclass'); } @@ -199,10 +204,10 @@ return $default(_that.id,_that.name,_that.registrationNumber,_that.industry,_tha /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String? id, String? name, @JsonKey(name: 'registration_number') String? registrationNumber, String? industry, @JsonKey(name: 'founded_year') int? foundedYear, @JsonKey(name: 'created_at') int? createdAt)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String? id, String? name, @JsonKey(name: 'registration_number') String? registrationNumber, String? industry, @JsonKey(name: 'founded_year') int? foundedYear, @JsonKey(name: 'created_at') int? createdAt, @JsonKey(name: 'document_file_ids') List? documentFileIds, String? website)? $default,) {final _that = this; switch (_that) { case _CompanyProfileData() when $default != null: -return $default(_that.id,_that.name,_that.registrationNumber,_that.industry,_that.foundedYear,_that.createdAt);case _: +return $default(_that.id,_that.name,_that.registrationNumber,_that.industry,_that.foundedYear,_that.createdAt,_that.documentFileIds,_that.website);case _: return null; } @@ -214,7 +219,7 @@ return $default(_that.id,_that.name,_that.registrationNumber,_that.industry,_tha @JsonSerializable() class _CompanyProfileData implements CompanyProfileData { - const _CompanyProfileData({required this.id, required this.name, @JsonKey(name: 'registration_number') required this.registrationNumber, required this.industry, @JsonKey(name: 'founded_year') required this.foundedYear, @JsonKey(name: 'created_at') required this.createdAt}); + const _CompanyProfileData({required this.id, required this.name, @JsonKey(name: 'registration_number') required this.registrationNumber, required this.industry, @JsonKey(name: 'founded_year') required this.foundedYear, @JsonKey(name: 'created_at') required this.createdAt, @JsonKey(name: 'document_file_ids') final List? documentFileIds, this.website}): _documentFileIds = documentFileIds; factory _CompanyProfileData.fromJson(Map json) => _$CompanyProfileDataFromJson(json); @override final String? id; @@ -223,6 +228,22 @@ class _CompanyProfileData implements CompanyProfileData { @override final String? industry; @override@JsonKey(name: 'founded_year') final int? foundedYear; @override@JsonKey(name: 'created_at') final int? createdAt; +// Drives the AI onboarding fingerprint (see CompanyAiRepository): onboarding +// re-runs only when the company's documents or website change. Optional so +// existing payloads that omit them keep deserializing. + final List? _documentFileIds; +// Drives the AI onboarding fingerprint (see CompanyAiRepository): onboarding +// re-runs only when the company's documents or website change. Optional so +// existing payloads that omit them keep deserializing. +@override@JsonKey(name: 'document_file_ids') List? get documentFileIds { + final value = _documentFileIds; + if (value == null) return null; + if (_documentFileIds is EqualUnmodifiableListView) return _documentFileIds; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + +@override final String? website; /// Create a copy of CompanyProfileData /// with the given fields replaced by the non-null parameter values. @@ -237,16 +258,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _CompanyProfileData&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.registrationNumber, registrationNumber) || other.registrationNumber == registrationNumber)&&(identical(other.industry, industry) || other.industry == industry)&&(identical(other.foundedYear, foundedYear) || other.foundedYear == foundedYear)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CompanyProfileData&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.registrationNumber, registrationNumber) || other.registrationNumber == registrationNumber)&&(identical(other.industry, industry) || other.industry == industry)&&(identical(other.foundedYear, foundedYear) || other.foundedYear == foundedYear)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&const DeepCollectionEquality().equals(other._documentFileIds, _documentFileIds)&&(identical(other.website, website) || other.website == website)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,name,registrationNumber,industry,foundedYear,createdAt); +int get hashCode => Object.hash(runtimeType,id,name,registrationNumber,industry,foundedYear,createdAt,const DeepCollectionEquality().hash(_documentFileIds),website); @override String toString() { - return 'CompanyProfileData(id: $id, name: $name, registrationNumber: $registrationNumber, industry: $industry, foundedYear: $foundedYear, createdAt: $createdAt)'; + return 'CompanyProfileData(id: $id, name: $name, registrationNumber: $registrationNumber, industry: $industry, foundedYear: $foundedYear, createdAt: $createdAt, documentFileIds: $documentFileIds, website: $website)'; } @@ -257,7 +278,7 @@ abstract mixin class _$CompanyProfileDataCopyWith<$Res> implements $CompanyProfi factory _$CompanyProfileDataCopyWith(_CompanyProfileData value, $Res Function(_CompanyProfileData) _then) = __$CompanyProfileDataCopyWithImpl; @override @useResult $Res call({ - String? id, String? name,@JsonKey(name: 'registration_number') String? registrationNumber, String? industry,@JsonKey(name: 'founded_year') int? foundedYear,@JsonKey(name: 'created_at') int? createdAt + String? id, String? name,@JsonKey(name: 'registration_number') String? registrationNumber, String? industry,@JsonKey(name: 'founded_year') int? foundedYear,@JsonKey(name: 'created_at') int? createdAt,@JsonKey(name: 'document_file_ids') List? documentFileIds, String? website }); @@ -274,7 +295,7 @@ class __$CompanyProfileDataCopyWithImpl<$Res> /// Create a copy of CompanyProfileData /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = freezed,Object? name = freezed,Object? registrationNumber = freezed,Object? industry = freezed,Object? foundedYear = freezed,Object? createdAt = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = freezed,Object? name = freezed,Object? registrationNumber = freezed,Object? industry = freezed,Object? foundedYear = freezed,Object? createdAt = freezed,Object? documentFileIds = freezed,Object? website = freezed,}) { return _then(_CompanyProfileData( id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String?,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable @@ -282,7 +303,9 @@ as String?,registrationNumber: freezed == registrationNumber ? _self.registratio as String?,industry: freezed == industry ? _self.industry : industry // ignore: cast_nullable_to_non_nullable as String?,foundedYear: freezed == foundedYear ? _self.foundedYear : foundedYear // ignore: cast_nullable_to_non_nullable as int?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable -as int?, +as int?,documentFileIds: freezed == documentFileIds ? _self._documentFileIds : documentFileIds // ignore: cast_nullable_to_non_nullable +as List?,website: freezed == website ? _self.website : website // ignore: cast_nullable_to_non_nullable +as String?, )); } diff --git a/lib/data/services/model/company_profile_data/company_profile_data.g.dart b/lib/data/services/model/company_profile_data/company_profile_data.g.dart index 4098288..89ad9bd 100644 --- a/lib/data/services/model/company_profile_data/company_profile_data.g.dart +++ b/lib/data/services/model/company_profile_data/company_profile_data.g.dart @@ -14,6 +14,11 @@ _CompanyProfileData _$CompanyProfileDataFromJson(Map json) => industry: json['industry'] as String?, foundedYear: (json['founded_year'] as num?)?.toInt(), createdAt: (json['created_at'] as num?)?.toInt(), + documentFileIds: + (json['document_file_ids'] as List?) + ?.map((e) => e as String) + .toList(), + website: json['website'] as String?, ); Map _$CompanyProfileDataToJson(_CompanyProfileData instance) => @@ -24,4 +29,6 @@ Map _$CompanyProfileDataToJson(_CompanyProfileData instance) => 'industry': instance.industry, 'founded_year': instance.foundedYear, 'created_at': instance.createdAt, + 'document_file_ids': instance.documentFileIds, + 'website': instance.website, }; diff --git a/lib/data/services/model/onboarding_data/onboarding_data.dart b/lib/data/services/model/onboarding_data/onboarding_data.dart new file mode 100644 index 0000000..18ba6bb --- /dev/null +++ b/lib/data/services/model/onboarding_data/onboarding_data.dart @@ -0,0 +1,19 @@ +// ignore_for_file: invalid_annotation_target + +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'onboarding_data.freezed.dart'; +part 'onboarding_data.g.dart'; + +/// Payload returned by `POST /api/v1/onboarding`. +/// +/// Onboarding is asynchronous on the AI side, so the only field is a coarse +/// [status] (e.g. `started`). Recommendations may stay empty until the AI +/// service finishes indexing. +@freezed +abstract class OnboardingData with _$OnboardingData { + const factory OnboardingData({required String? status}) = _OnboardingData; + + factory OnboardingData.fromJson(Map json) => + _$OnboardingDataFromJson(json); +} diff --git a/lib/data/services/model/onboarding_data/onboarding_data.freezed.dart b/lib/data/services/model/onboarding_data/onboarding_data.freezed.dart new file mode 100644 index 0000000..138dd84 --- /dev/null +++ b/lib/data/services/model/onboarding_data/onboarding_data.freezed.dart @@ -0,0 +1,277 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'onboarding_data.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$OnboardingData { + + String? get status; +/// Create a copy of OnboardingData +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$OnboardingDataCopyWith get copyWith => _$OnboardingDataCopyWithImpl(this as OnboardingData, _$identity); + + /// Serializes this OnboardingData to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is OnboardingData&&(identical(other.status, status) || other.status == status)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,status); + +@override +String toString() { + return 'OnboardingData(status: $status)'; +} + + +} + +/// @nodoc +abstract mixin class $OnboardingDataCopyWith<$Res> { + factory $OnboardingDataCopyWith(OnboardingData value, $Res Function(OnboardingData) _then) = _$OnboardingDataCopyWithImpl; +@useResult +$Res call({ + String? status +}); + + + + +} +/// @nodoc +class _$OnboardingDataCopyWithImpl<$Res> + implements $OnboardingDataCopyWith<$Res> { + _$OnboardingDataCopyWithImpl(this._self, this._then); + + final OnboardingData _self; + final $Res Function(OnboardingData) _then; + +/// Create a copy of OnboardingData +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? status = freezed,}) { + return _then(_self.copyWith( +status: freezed == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [OnboardingData]. +extension OnboardingDataPatterns on OnboardingData { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _OnboardingData value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _OnboardingData() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _OnboardingData value) $default,){ +final _that = this; +switch (_that) { +case _OnboardingData(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _OnboardingData value)? $default,){ +final _that = this; +switch (_that) { +case _OnboardingData() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String? status)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _OnboardingData() when $default != null: +return $default(_that.status);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String? status) $default,) {final _that = this; +switch (_that) { +case _OnboardingData(): +return $default(_that.status);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String? status)? $default,) {final _that = this; +switch (_that) { +case _OnboardingData() when $default != null: +return $default(_that.status);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _OnboardingData implements OnboardingData { + const _OnboardingData({required this.status}); + factory _OnboardingData.fromJson(Map json) => _$OnboardingDataFromJson(json); + +@override final String? status; + +/// Create a copy of OnboardingData +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$OnboardingDataCopyWith<_OnboardingData> get copyWith => __$OnboardingDataCopyWithImpl<_OnboardingData>(this, _$identity); + +@override +Map toJson() { + return _$OnboardingDataToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _OnboardingData&&(identical(other.status, status) || other.status == status)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,status); + +@override +String toString() { + return 'OnboardingData(status: $status)'; +} + + +} + +/// @nodoc +abstract mixin class _$OnboardingDataCopyWith<$Res> implements $OnboardingDataCopyWith<$Res> { + factory _$OnboardingDataCopyWith(_OnboardingData value, $Res Function(_OnboardingData) _then) = __$OnboardingDataCopyWithImpl; +@override @useResult +$Res call({ + String? status +}); + + + + +} +/// @nodoc +class __$OnboardingDataCopyWithImpl<$Res> + implements _$OnboardingDataCopyWith<$Res> { + __$OnboardingDataCopyWithImpl(this._self, this._then); + + final _OnboardingData _self; + final $Res Function(_OnboardingData) _then; + +/// Create a copy of OnboardingData +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? status = freezed,}) { + return _then(_OnboardingData( +status: freezed == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/lib/data/services/model/onboarding_data/onboarding_data.g.dart b/lib/data/services/model/onboarding_data/onboarding_data.g.dart new file mode 100644 index 0000000..e65e3f1 --- /dev/null +++ b/lib/data/services/model/onboarding_data/onboarding_data.g.dart @@ -0,0 +1,13 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'onboarding_data.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_OnboardingData _$OnboardingDataFromJson(Map json) => + _OnboardingData(status: json['status'] as String?); + +Map _$OnboardingDataToJson(_OnboardingData instance) => + {'status': instance.status}; diff --git a/lib/data/services/model/onboarding_response/onboarding_response.dart b/lib/data/services/model/onboarding_response/onboarding_response.dart new file mode 100644 index 0000000..ede468b --- /dev/null +++ b/lib/data/services/model/onboarding_response/onboarding_response.dart @@ -0,0 +1,24 @@ +// ignore_for_file: invalid_annotation_target + +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../error/error_model.dart'; +import '../onboarding_data/onboarding_data.dart'; + +part 'onboarding_response.freezed.dart'; +part 'onboarding_response.g.dart'; + +/// Standard API envelope for `POST /api/v1/onboarding`. +@freezed +abstract class OnboardingResponse with _$OnboardingResponse { + @JsonSerializable(explicitToJson: true) + const factory OnboardingResponse({ + @JsonKey(name: 'data') required OnboardingData? data, + @JsonKey(name: 'error') required ErrorModel? error, + required String? message, + required bool? success, + }) = _OnboardingResponse; + + factory OnboardingResponse.fromJson(Map json) => + _$OnboardingResponseFromJson(json); +} diff --git a/lib/data/services/model/onboarding_response/onboarding_response.freezed.dart b/lib/data/services/model/onboarding_response/onboarding_response.freezed.dart new file mode 100644 index 0000000..cb55a53 --- /dev/null +++ b/lib/data/services/model/onboarding_response/onboarding_response.freezed.dart @@ -0,0 +1,334 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'onboarding_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$OnboardingResponse { + +@JsonKey(name: 'data') OnboardingData? get data;@JsonKey(name: 'error') ErrorModel? get error; String? get message; bool? get success; +/// Create a copy of OnboardingResponse +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$OnboardingResponseCopyWith get copyWith => _$OnboardingResponseCopyWithImpl(this as OnboardingResponse, _$identity); + + /// Serializes this OnboardingResponse to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is OnboardingResponse&&(identical(other.data, data) || other.data == data)&&(identical(other.error, error) || other.error == error)&&(identical(other.message, message) || other.message == message)&&(identical(other.success, success) || other.success == success)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,data,error,message,success); + +@override +String toString() { + return 'OnboardingResponse(data: $data, error: $error, message: $message, success: $success)'; +} + + +} + +/// @nodoc +abstract mixin class $OnboardingResponseCopyWith<$Res> { + factory $OnboardingResponseCopyWith(OnboardingResponse value, $Res Function(OnboardingResponse) _then) = _$OnboardingResponseCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'data') OnboardingData? data,@JsonKey(name: 'error') ErrorModel? error, String? message, bool? success +}); + + +$OnboardingDataCopyWith<$Res>? get data;$ErrorModelCopyWith<$Res>? get error; + +} +/// @nodoc +class _$OnboardingResponseCopyWithImpl<$Res> + implements $OnboardingResponseCopyWith<$Res> { + _$OnboardingResponseCopyWithImpl(this._self, this._then); + + final OnboardingResponse _self; + final $Res Function(OnboardingResponse) _then; + +/// Create a copy of OnboardingResponse +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? data = freezed,Object? error = freezed,Object? message = freezed,Object? success = freezed,}) { + return _then(_self.copyWith( +data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable +as OnboardingData?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as ErrorModel?,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String?,success: freezed == success ? _self.success : success // ignore: cast_nullable_to_non_nullable +as bool?, + )); +} +/// Create a copy of OnboardingResponse +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$OnboardingDataCopyWith<$Res>? get data { + if (_self.data == null) { + return null; + } + + return $OnboardingDataCopyWith<$Res>(_self.data!, (value) { + return _then(_self.copyWith(data: value)); + }); +}/// Create a copy of OnboardingResponse +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ErrorModelCopyWith<$Res>? get error { + if (_self.error == null) { + return null; + } + + return $ErrorModelCopyWith<$Res>(_self.error!, (value) { + return _then(_self.copyWith(error: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [OnboardingResponse]. +extension OnboardingResponsePatterns on OnboardingResponse { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _OnboardingResponse value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _OnboardingResponse() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _OnboardingResponse value) $default,){ +final _that = this; +switch (_that) { +case _OnboardingResponse(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _OnboardingResponse value)? $default,){ +final _that = this; +switch (_that) { +case _OnboardingResponse() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'data') OnboardingData? data, @JsonKey(name: 'error') ErrorModel? error, String? message, bool? success)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _OnboardingResponse() when $default != null: +return $default(_that.data,_that.error,_that.message,_that.success);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'data') OnboardingData? data, @JsonKey(name: 'error') ErrorModel? error, String? message, bool? success) $default,) {final _that = this; +switch (_that) { +case _OnboardingResponse(): +return $default(_that.data,_that.error,_that.message,_that.success);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'data') OnboardingData? data, @JsonKey(name: 'error') ErrorModel? error, String? message, bool? success)? $default,) {final _that = this; +switch (_that) { +case _OnboardingResponse() when $default != null: +return $default(_that.data,_that.error,_that.message,_that.success);case _: + return null; + +} +} + +} + +/// @nodoc + +@JsonSerializable(explicitToJson: true) +class _OnboardingResponse implements OnboardingResponse { + const _OnboardingResponse({@JsonKey(name: 'data') required this.data, @JsonKey(name: 'error') required this.error, required this.message, required this.success}); + factory _OnboardingResponse.fromJson(Map json) => _$OnboardingResponseFromJson(json); + +@override@JsonKey(name: 'data') final OnboardingData? data; +@override@JsonKey(name: 'error') final ErrorModel? error; +@override final String? message; +@override final bool? success; + +/// Create a copy of OnboardingResponse +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$OnboardingResponseCopyWith<_OnboardingResponse> get copyWith => __$OnboardingResponseCopyWithImpl<_OnboardingResponse>(this, _$identity); + +@override +Map toJson() { + return _$OnboardingResponseToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _OnboardingResponse&&(identical(other.data, data) || other.data == data)&&(identical(other.error, error) || other.error == error)&&(identical(other.message, message) || other.message == message)&&(identical(other.success, success) || other.success == success)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,data,error,message,success); + +@override +String toString() { + return 'OnboardingResponse(data: $data, error: $error, message: $message, success: $success)'; +} + + +} + +/// @nodoc +abstract mixin class _$OnboardingResponseCopyWith<$Res> implements $OnboardingResponseCopyWith<$Res> { + factory _$OnboardingResponseCopyWith(_OnboardingResponse value, $Res Function(_OnboardingResponse) _then) = __$OnboardingResponseCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'data') OnboardingData? data,@JsonKey(name: 'error') ErrorModel? error, String? message, bool? success +}); + + +@override $OnboardingDataCopyWith<$Res>? get data;@override $ErrorModelCopyWith<$Res>? get error; + +} +/// @nodoc +class __$OnboardingResponseCopyWithImpl<$Res> + implements _$OnboardingResponseCopyWith<$Res> { + __$OnboardingResponseCopyWithImpl(this._self, this._then); + + final _OnboardingResponse _self; + final $Res Function(_OnboardingResponse) _then; + +/// Create a copy of OnboardingResponse +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? data = freezed,Object? error = freezed,Object? message = freezed,Object? success = freezed,}) { + return _then(_OnboardingResponse( +data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable +as OnboardingData?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as ErrorModel?,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String?,success: freezed == success ? _self.success : success // ignore: cast_nullable_to_non_nullable +as bool?, + )); +} + +/// Create a copy of OnboardingResponse +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$OnboardingDataCopyWith<$Res>? get data { + if (_self.data == null) { + return null; + } + + return $OnboardingDataCopyWith<$Res>(_self.data!, (value) { + return _then(_self.copyWith(data: value)); + }); +}/// Create a copy of OnboardingResponse +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ErrorModelCopyWith<$Res>? get error { + if (_self.error == null) { + return null; + } + + return $ErrorModelCopyWith<$Res>(_self.error!, (value) { + return _then(_self.copyWith(error: value)); + }); +} +} + +// dart format on diff --git a/lib/data/services/model/onboarding_response/onboarding_response.g.dart b/lib/data/services/model/onboarding_response/onboarding_response.g.dart new file mode 100644 index 0000000..ee0d55b --- /dev/null +++ b/lib/data/services/model/onboarding_response/onboarding_response.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'onboarding_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_OnboardingResponse _$OnboardingResponseFromJson(Map json) => + _OnboardingResponse( + data: + json['data'] == null + ? null + : OnboardingData.fromJson(json['data'] as Map), + error: + json['error'] == null + ? null + : ErrorModel.fromJson(json['error'] as Map), + message: json['message'] as String?, + success: json['success'] as bool?, + ); + +Map _$OnboardingResponseToJson(_OnboardingResponse instance) => + { + 'data': instance.data?.toJson(), + 'error': instance.error?.toJson(), + 'message': instance.message, + 'success': instance.success, + }; diff --git a/lib/data/services/model/recommend_response/recommend_response.dart b/lib/data/services/model/recommend_response/recommend_response.dart new file mode 100644 index 0000000..8845c3c --- /dev/null +++ b/lib/data/services/model/recommend_response/recommend_response.dart @@ -0,0 +1,27 @@ +// ignore_for_file: invalid_annotation_target + +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../error/error_model.dart'; +import '../recommendation_item/recommendation_item.dart'; + +part 'recommend_response.freezed.dart'; +part 'recommend_response.g.dart'; + +/// Standard API envelope for `POST /api/v1/recommend`. +/// +/// [data] holds the ranked recommendations and may be empty (or null) until +/// the AI service finishes indexing the company. +@freezed +abstract class RecommendResponse with _$RecommendResponse { + @JsonSerializable(explicitToJson: true) + const factory RecommendResponse({ + @JsonKey(name: 'data') required List? data, + @JsonKey(name: 'error') required ErrorModel? error, + required String? message, + required bool? success, + }) = _RecommendResponse; + + factory RecommendResponse.fromJson(Map json) => + _$RecommendResponseFromJson(json); +} diff --git a/lib/data/services/model/recommend_response/recommend_response.freezed.dart b/lib/data/services/model/recommend_response/recommend_response.freezed.dart new file mode 100644 index 0000000..a559b79 --- /dev/null +++ b/lib/data/services/model/recommend_response/recommend_response.freezed.dart @@ -0,0 +1,318 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'recommend_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$RecommendResponse { + +@JsonKey(name: 'data') List? get data;@JsonKey(name: 'error') ErrorModel? get error; String? get message; bool? get success; +/// Create a copy of RecommendResponse +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RecommendResponseCopyWith get copyWith => _$RecommendResponseCopyWithImpl(this as RecommendResponse, _$identity); + + /// Serializes this RecommendResponse to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RecommendResponse&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.error, error) || other.error == error)&&(identical(other.message, message) || other.message == message)&&(identical(other.success, success) || other.success == success)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),error,message,success); + +@override +String toString() { + return 'RecommendResponse(data: $data, error: $error, message: $message, success: $success)'; +} + + +} + +/// @nodoc +abstract mixin class $RecommendResponseCopyWith<$Res> { + factory $RecommendResponseCopyWith(RecommendResponse value, $Res Function(RecommendResponse) _then) = _$RecommendResponseCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'data') List? data,@JsonKey(name: 'error') ErrorModel? error, String? message, bool? success +}); + + +$ErrorModelCopyWith<$Res>? get error; + +} +/// @nodoc +class _$RecommendResponseCopyWithImpl<$Res> + implements $RecommendResponseCopyWith<$Res> { + _$RecommendResponseCopyWithImpl(this._self, this._then); + + final RecommendResponse _self; + final $Res Function(RecommendResponse) _then; + +/// Create a copy of RecommendResponse +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? data = freezed,Object? error = freezed,Object? message = freezed,Object? success = freezed,}) { + return _then(_self.copyWith( +data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable +as List?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as ErrorModel?,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String?,success: freezed == success ? _self.success : success // ignore: cast_nullable_to_non_nullable +as bool?, + )); +} +/// Create a copy of RecommendResponse +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ErrorModelCopyWith<$Res>? get error { + if (_self.error == null) { + return null; + } + + return $ErrorModelCopyWith<$Res>(_self.error!, (value) { + return _then(_self.copyWith(error: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [RecommendResponse]. +extension RecommendResponsePatterns on RecommendResponse { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _RecommendResponse value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _RecommendResponse() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _RecommendResponse value) $default,){ +final _that = this; +switch (_that) { +case _RecommendResponse(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _RecommendResponse value)? $default,){ +final _that = this; +switch (_that) { +case _RecommendResponse() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'data') List? data, @JsonKey(name: 'error') ErrorModel? error, String? message, bool? success)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _RecommendResponse() when $default != null: +return $default(_that.data,_that.error,_that.message,_that.success);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'data') List? data, @JsonKey(name: 'error') ErrorModel? error, String? message, bool? success) $default,) {final _that = this; +switch (_that) { +case _RecommendResponse(): +return $default(_that.data,_that.error,_that.message,_that.success);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'data') List? data, @JsonKey(name: 'error') ErrorModel? error, String? message, bool? success)? $default,) {final _that = this; +switch (_that) { +case _RecommendResponse() when $default != null: +return $default(_that.data,_that.error,_that.message,_that.success);case _: + return null; + +} +} + +} + +/// @nodoc + +@JsonSerializable(explicitToJson: true) +class _RecommendResponse implements RecommendResponse { + const _RecommendResponse({@JsonKey(name: 'data') required final List? data, @JsonKey(name: 'error') required this.error, required this.message, required this.success}): _data = data; + factory _RecommendResponse.fromJson(Map json) => _$RecommendResponseFromJson(json); + + final List? _data; +@override@JsonKey(name: 'data') List? get data { + final value = _data; + if (value == null) return null; + if (_data is EqualUnmodifiableListView) return _data; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + +@override@JsonKey(name: 'error') final ErrorModel? error; +@override final String? message; +@override final bool? success; + +/// Create a copy of RecommendResponse +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$RecommendResponseCopyWith<_RecommendResponse> get copyWith => __$RecommendResponseCopyWithImpl<_RecommendResponse>(this, _$identity); + +@override +Map toJson() { + return _$RecommendResponseToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _RecommendResponse&&const DeepCollectionEquality().equals(other._data, _data)&&(identical(other.error, error) || other.error == error)&&(identical(other.message, message) || other.message == message)&&(identical(other.success, success) || other.success == success)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_data),error,message,success); + +@override +String toString() { + return 'RecommendResponse(data: $data, error: $error, message: $message, success: $success)'; +} + + +} + +/// @nodoc +abstract mixin class _$RecommendResponseCopyWith<$Res> implements $RecommendResponseCopyWith<$Res> { + factory _$RecommendResponseCopyWith(_RecommendResponse value, $Res Function(_RecommendResponse) _then) = __$RecommendResponseCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'data') List? data,@JsonKey(name: 'error') ErrorModel? error, String? message, bool? success +}); + + +@override $ErrorModelCopyWith<$Res>? get error; + +} +/// @nodoc +class __$RecommendResponseCopyWithImpl<$Res> + implements _$RecommendResponseCopyWith<$Res> { + __$RecommendResponseCopyWithImpl(this._self, this._then); + + final _RecommendResponse _self; + final $Res Function(_RecommendResponse) _then; + +/// Create a copy of RecommendResponse +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? data = freezed,Object? error = freezed,Object? message = freezed,Object? success = freezed,}) { + return _then(_RecommendResponse( +data: freezed == data ? _self._data : data // ignore: cast_nullable_to_non_nullable +as List?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as ErrorModel?,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String?,success: freezed == success ? _self.success : success // ignore: cast_nullable_to_non_nullable +as bool?, + )); +} + +/// Create a copy of RecommendResponse +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ErrorModelCopyWith<$Res>? get error { + if (_self.error == null) { + return null; + } + + return $ErrorModelCopyWith<$Res>(_self.error!, (value) { + return _then(_self.copyWith(error: value)); + }); +} +} + +// dart format on diff --git a/lib/data/services/model/recommend_response/recommend_response.g.dart b/lib/data/services/model/recommend_response/recommend_response.g.dart new file mode 100644 index 0000000..cc5d1d3 --- /dev/null +++ b/lib/data/services/model/recommend_response/recommend_response.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recommend_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_RecommendResponse _$RecommendResponseFromJson(Map json) => + _RecommendResponse( + data: + (json['data'] as List?) + ?.map( + (e) => RecommendationItem.fromJson(e as Map), + ) + .toList(), + error: + json['error'] == null + ? null + : ErrorModel.fromJson(json['error'] as Map), + message: json['message'] as String?, + success: json['success'] as bool?, + ); + +Map _$RecommendResponseToJson(_RecommendResponse instance) => + { + 'data': instance.data?.map((e) => e.toJson()).toList(), + 'error': instance.error?.toJson(), + 'message': instance.message, + 'success': instance.success, + }; diff --git a/lib/data/services/model/recommendation_item/recommendation_item.dart b/lib/data/services/model/recommendation_item/recommendation_item.dart new file mode 100644 index 0000000..ea3c241 --- /dev/null +++ b/lib/data/services/model/recommendation_item/recommendation_item.dart @@ -0,0 +1,24 @@ +// ignore_for_file: invalid_annotation_target + +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'recommendation_item.freezed.dart'; +part 'recommendation_item.g.dart'; + +/// A single AI tender recommendation returned by `POST /api/v1/recommend`. +/// +/// [tenderId] is the backend tender id used with +/// `GET /api/v1/tenders/details/:id`. [rank] is a display order string where +/// `1` is the best match. All fields are nullable to stay resilient to partial +/// API payloads. +@freezed +abstract class RecommendationItem with _$RecommendationItem { + const factory RecommendationItem({ + required String? rank, + @JsonKey(name: 'tender_id') required String? tenderId, + required String? analysis, + }) = _RecommendationItem; + + factory RecommendationItem.fromJson(Map json) => + _$RecommendationItemFromJson(json); +} diff --git a/lib/data/services/model/recommendation_item/recommendation_item.freezed.dart b/lib/data/services/model/recommendation_item/recommendation_item.freezed.dart new file mode 100644 index 0000000..0078273 --- /dev/null +++ b/lib/data/services/model/recommendation_item/recommendation_item.freezed.dart @@ -0,0 +1,283 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'recommendation_item.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$RecommendationItem { + + String? get rank;@JsonKey(name: 'tender_id') String? get tenderId; String? get analysis; +/// Create a copy of RecommendationItem +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RecommendationItemCopyWith get copyWith => _$RecommendationItemCopyWithImpl(this as RecommendationItem, _$identity); + + /// Serializes this RecommendationItem to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RecommendationItem&&(identical(other.rank, rank) || other.rank == rank)&&(identical(other.tenderId, tenderId) || other.tenderId == tenderId)&&(identical(other.analysis, analysis) || other.analysis == analysis)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,rank,tenderId,analysis); + +@override +String toString() { + return 'RecommendationItem(rank: $rank, tenderId: $tenderId, analysis: $analysis)'; +} + + +} + +/// @nodoc +abstract mixin class $RecommendationItemCopyWith<$Res> { + factory $RecommendationItemCopyWith(RecommendationItem value, $Res Function(RecommendationItem) _then) = _$RecommendationItemCopyWithImpl; +@useResult +$Res call({ + String? rank,@JsonKey(name: 'tender_id') String? tenderId, String? analysis +}); + + + + +} +/// @nodoc +class _$RecommendationItemCopyWithImpl<$Res> + implements $RecommendationItemCopyWith<$Res> { + _$RecommendationItemCopyWithImpl(this._self, this._then); + + final RecommendationItem _self; + final $Res Function(RecommendationItem) _then; + +/// Create a copy of RecommendationItem +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? rank = freezed,Object? tenderId = freezed,Object? analysis = freezed,}) { + return _then(_self.copyWith( +rank: freezed == rank ? _self.rank : rank // ignore: cast_nullable_to_non_nullable +as String?,tenderId: freezed == tenderId ? _self.tenderId : tenderId // ignore: cast_nullable_to_non_nullable +as String?,analysis: freezed == analysis ? _self.analysis : analysis // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [RecommendationItem]. +extension RecommendationItemPatterns on RecommendationItem { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _RecommendationItem value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _RecommendationItem() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _RecommendationItem value) $default,){ +final _that = this; +switch (_that) { +case _RecommendationItem(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _RecommendationItem value)? $default,){ +final _that = this; +switch (_that) { +case _RecommendationItem() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String? rank, @JsonKey(name: 'tender_id') String? tenderId, String? analysis)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _RecommendationItem() when $default != null: +return $default(_that.rank,_that.tenderId,_that.analysis);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String? rank, @JsonKey(name: 'tender_id') String? tenderId, String? analysis) $default,) {final _that = this; +switch (_that) { +case _RecommendationItem(): +return $default(_that.rank,_that.tenderId,_that.analysis);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String? rank, @JsonKey(name: 'tender_id') String? tenderId, String? analysis)? $default,) {final _that = this; +switch (_that) { +case _RecommendationItem() when $default != null: +return $default(_that.rank,_that.tenderId,_that.analysis);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _RecommendationItem implements RecommendationItem { + const _RecommendationItem({required this.rank, @JsonKey(name: 'tender_id') required this.tenderId, required this.analysis}); + factory _RecommendationItem.fromJson(Map json) => _$RecommendationItemFromJson(json); + +@override final String? rank; +@override@JsonKey(name: 'tender_id') final String? tenderId; +@override final String? analysis; + +/// Create a copy of RecommendationItem +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$RecommendationItemCopyWith<_RecommendationItem> get copyWith => __$RecommendationItemCopyWithImpl<_RecommendationItem>(this, _$identity); + +@override +Map toJson() { + return _$RecommendationItemToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _RecommendationItem&&(identical(other.rank, rank) || other.rank == rank)&&(identical(other.tenderId, tenderId) || other.tenderId == tenderId)&&(identical(other.analysis, analysis) || other.analysis == analysis)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,rank,tenderId,analysis); + +@override +String toString() { + return 'RecommendationItem(rank: $rank, tenderId: $tenderId, analysis: $analysis)'; +} + + +} + +/// @nodoc +abstract mixin class _$RecommendationItemCopyWith<$Res> implements $RecommendationItemCopyWith<$Res> { + factory _$RecommendationItemCopyWith(_RecommendationItem value, $Res Function(_RecommendationItem) _then) = __$RecommendationItemCopyWithImpl; +@override @useResult +$Res call({ + String? rank,@JsonKey(name: 'tender_id') String? tenderId, String? analysis +}); + + + + +} +/// @nodoc +class __$RecommendationItemCopyWithImpl<$Res> + implements _$RecommendationItemCopyWith<$Res> { + __$RecommendationItemCopyWithImpl(this._self, this._then); + + final _RecommendationItem _self; + final $Res Function(_RecommendationItem) _then; + +/// Create a copy of RecommendationItem +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? rank = freezed,Object? tenderId = freezed,Object? analysis = freezed,}) { + return _then(_RecommendationItem( +rank: freezed == rank ? _self.rank : rank // ignore: cast_nullable_to_non_nullable +as String?,tenderId: freezed == tenderId ? _self.tenderId : tenderId // ignore: cast_nullable_to_non_nullable +as String?,analysis: freezed == analysis ? _self.analysis : analysis // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/lib/data/services/model/recommendation_item/recommendation_item.g.dart b/lib/data/services/model/recommendation_item/recommendation_item.g.dart new file mode 100644 index 0000000..9b61aaa --- /dev/null +++ b/lib/data/services/model/recommendation_item/recommendation_item.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recommendation_item.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_RecommendationItem _$RecommendationItemFromJson(Map json) => + _RecommendationItem( + rank: json['rank'] as String?, + tenderId: json['tender_id'] as String?, + analysis: json['analysis'] as String?, + ); + +Map _$RecommendationItemToJson(_RecommendationItem instance) => + { + 'rank': instance.rank, + 'tender_id': instance.tenderId, + 'analysis': instance.analysis, + }; diff --git a/lib/view_models/ai_recommendations_view_model.dart b/lib/view_models/ai_recommendations_view_model.dart new file mode 100644 index 0000000..16c31a0 --- /dev/null +++ b/lib/view_models/ai_recommendations_view_model.dart @@ -0,0 +1,132 @@ +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(); + + switch (result) { + case Ok(): + _items = result.value.data ?? const []; + _fallbackTenders = const []; + if (_items.isEmpty) { + _stillLearning = _onboardingWasRecent(); + } + case Error(): + await _handleError(result.error); + } + + _isLoading = false; + _hasLoaded = true; + 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. + if (error is AppException && error.statusCode == 503) { + await _loadFallback(); + return; + } + _items = const []; + _fallbackTenders = const []; + _errorMessage = error.toString(); + } + + 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; + } +} diff --git a/lib/view_models/profile_view_model.dart b/lib/view_models/profile_view_model.dart index fa2667a..4e72555 100644 --- a/lib/view_models/profile_view_model.dart +++ b/lib/view_models/profile_view_model.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:tm_app/data/repositories/auth_repository.dart'; +import 'package:tm_app/data/repositories/company_ai_repository.dart'; import 'package:tm_app/data/services/model/company_profile_data/company_profile_data.dart'; import 'package:tm_app/data/services/model/company_profile_response/company_profile_response.dart'; import 'package:tm_app/data/services/model/profile_data/profile_data.dart'; @@ -12,12 +15,15 @@ import '../data/services/model/profile_response/profile_response.dart'; class ProfileViewModel with ChangeNotifier { final ProfileRepository _profileRepository; final AuthRepository _authRepository; + final CompanyAiRepository _companyAiRepository; ProfileViewModel({ required ProfileRepository profileRepository, required AuthRepository authRepository, + required CompanyAiRepository companyAiRepository, }) : _profileRepository = profileRepository, - _authRepository = authRepository; + _authRepository = authRepository, + _companyAiRepository = companyAiRepository; bool _isLoading = false; String? _errorMessage; @@ -62,6 +68,7 @@ class ProfileViewModel with ChangeNotifier { switch (result) { case Ok(): _companyProfileData = result.value.data; + _maybeStartAiOnboarding(_companyProfileData); break; case Error(): _errorMessage = result.error.toString(); @@ -73,6 +80,22 @@ class ProfileViewModel with ChangeNotifier { notifyListeners(); } + /// Best-effort AI onboarding once the company profile is ready. Fire-and- + /// forget: onboarding is asynchronous on the AI side and the fingerprint + /// guard in [CompanyAiRepository] keeps it from re-running unnecessarily, so + /// the profile screen never blocks or surfaces errors for it. + void _maybeStartAiOnboarding(CompanyProfileData? company) { + if (company == null) { + return; + } + unawaited( + _companyAiRepository.maybeStartOnboarding( + documentFileIds: company.documentFileIds ?? const [], + website: company.website, + ), + ); + } + Future logout() async { _errorMessage = null; notifyListeners(); diff --git a/lib/views/profile/pages/profile_screen.dart b/lib/views/profile/pages/profile_screen.dart index 2db6856..a8c272f 100644 --- a/lib/views/profile/pages/profile_screen.dart +++ b/lib/views/profile/pages/profile_screen.dart @@ -28,8 +28,10 @@ class _ProfileScreenState extends State { _tabService.addListener(_onTabChanged); WidgetsBinding.instance.addPostFrameCallback((_) { - // _viewModel.getCompanyProfile(); _viewModel.getProfile(); + // Loads the company profile and, when its documents/website are ready, + // kicks off AI onboarding (guarded so it only runs on profile changes). + _viewModel.getCompanyProfile(); }); } diff --git a/lib/views/tenders/pages/d_tenders_page.dart b/lib/views/tenders/pages/d_tenders_page.dart index 98e88da..a8b8628 100644 --- a/lib/views/tenders/pages/d_tenders_page.dart +++ b/lib/views/tenders/pages/d_tenders_page.dart @@ -11,8 +11,10 @@ import 'package:tm_app/views/shared/base_button.dart'; import 'package:tm_app/views/shared/desktop_navigation_widget.dart'; import 'package:tm_app/views/tenders/strings/tenders_strings.dart'; import 'package:tm_app/views/tenders/widgets/main_tenders_slider.dart'; +import 'package:tm_app/views/tenders/widgets/recommended_tenders_view.dart'; import 'package:tm_app/views/tenders/widgets/tenders_filter_dialog.dart'; import 'package:tm_app/views/tenders/widgets/tenders_sort_dialog.dart'; +import 'package:tm_app/views/tenders/widgets/tenders_tab_bar.dart'; import '../../../core/constants/common_strings.dart'; import '../../../core/utils/app_toast.dart'; @@ -28,6 +30,9 @@ class DesktopTendersPage extends StatefulWidget { class _DesktopTendersPageState extends State { late final TendersViewModel viewModel; + /// 0 = All, 1 = Recommended. + int _selectedTab = 0; + @override void initState() { super.initState(); @@ -50,47 +55,34 @@ class _DesktopTendersPageState extends State { @override Widget build(BuildContext context) { final userRole = context.watch().userRole; + final bool isAllTab = _selectedTab == 0; return Scaffold( backgroundColor: AppColors.backgroundColor, body: SafeArea( child: Column( children: [ const DesktopNavigationWidget(currentIndex: 1), - SizedBox(height: 48.0.h()), - if (userRole == Role.analyst) const _SearchBox(), - if (userRole == Role.analyst) const _ActionButtons(), + SizedBox(height: 24.0.h()), + SizedBox( + width: 740, + child: TendersTabBar( + selectedIndex: _selectedTab, + onChanged: (index) => setState(() => _selectedTab = index), + ), + ), + SizedBox(height: 24.0.h()), + if (isAllTab && userRole == Role.analyst) const _SearchBox(), + if (isAllTab && userRole == Role.analyst) const _ActionButtons(), Expanded( - child: Consumer( - builder: (context, vm, child) { - if (vm.isLoading) { - return const Center( - child: CircularProgressIndicator( - color: AppColors.jellyBean, - ), - ); - } - if (vm.errorMessage != null) { - return Center(child: Text(vm.errorMessage!)); - } - final tenders = vm.tendersResponse?.data?.tenders ?? []; - if (tenders.isEmpty) { - return const Center(child: Text(CommonStrings.noData)); - } - - return Center( - child: SizedBox( - width: 740, - child: Padding( - padding: EdgeInsets.symmetric(vertical: 4.0.h()), - child: MainTendersSlider( - tenders: tenders, - isDesktop: true, + child: + isAllTab + ? const _AllTendersTab() + : const Center( + child: SizedBox( + width: 740, + child: RecommendedTendersView(), ), ), - ), - ); - }, - ), ), ], ), @@ -99,6 +91,40 @@ class _DesktopTendersPageState extends State { } } +class _AllTendersTab extends StatelessWidget { + const _AllTendersTab(); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, vm, child) { + if (vm.isLoading) { + return const Center( + child: CircularProgressIndicator(color: AppColors.jellyBean), + ); + } + if (vm.errorMessage != null) { + return Center(child: Text(vm.errorMessage!)); + } + final tenders = vm.tendersResponse?.data?.tenders ?? []; + if (tenders.isEmpty) { + return const Center(child: Text(CommonStrings.noData)); + } + + return Center( + child: SizedBox( + width: 740, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 4.0.h()), + child: MainTendersSlider(tenders: tenders, isDesktop: true), + ), + ), + ); + }, + ); + } +} + class _SearchBox extends StatefulWidget { const _SearchBox(); diff --git a/lib/views/tenders/pages/m_tenders_page.dart b/lib/views/tenders/pages/m_tenders_page.dart index 5325e16..d80776e 100644 --- a/lib/views/tenders/pages/m_tenders_page.dart +++ b/lib/views/tenders/pages/m_tenders_page.dart @@ -9,6 +9,8 @@ import '../../../core/utils/app_toast.dart'; import '../../shared/tender_app_bar.dart'; import '../strings/tenders_strings.dart'; import '../widgets/main_tenders_slider.dart'; +import '../widgets/recommended_tenders_view.dart'; +import '../widgets/tenders_tab_bar.dart'; class MobileTendersPage extends StatefulWidget { const MobileTendersPage({super.key}); @@ -19,6 +21,10 @@ class MobileTendersPage extends StatefulWidget { class _MobileTendersPageState extends State { late final TendersViewModel viewModel; + + /// 0 = All, 1 = Recommended. + int _selectedTab = 0; + @override void initState() { super.initState(); @@ -44,34 +50,62 @@ class _MobileTendersPageState extends State { backgroundColor: AppColors.backgroundColor, appBar: tenderMobileAppBar(title: TendersStrings.tendersTitle), body: SafeArea( - child: Consumer( - builder: (context, viewModel, child) { - if (viewModel.isLoading) { - return const Center( - child: CircularProgressIndicator(color: AppColors.jellyBean), - ); - } - if (viewModel.errorMessage != null) { - return Center(child: Text(viewModel.errorMessage!)); - } - - if ((viewModel.tendersResponse?.data?.tenders == null || - viewModel.tendersResponse!.data!.tenders!.isEmpty) && - viewModel.isLoading == false) { - return const Center(child: Text(CommonStrings.noData)); - } - - return SingleChildScrollView( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 18.0.h()), - child: MainTendersSlider( - tenders: viewModel.tendersResponse?.data?.tenders ?? [], - ), + child: Column( + children: [ + Padding( + padding: EdgeInsets.symmetric( + horizontal: 24.0.w(), + vertical: 12.0.h(), ), - ); - }, + child: TendersTabBar( + selectedIndex: _selectedTab, + onChanged: (index) => setState(() => _selectedTab = index), + ), + ), + Expanded( + child: + _selectedTab == 0 + ? const _AllTendersTab() + : const RecommendedTendersView(), + ), + ], ), ), ); } } + +class _AllTendersTab extends StatelessWidget { + const _AllTendersTab(); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, viewModel, child) { + if (viewModel.isLoading) { + return const Center( + child: CircularProgressIndicator(color: AppColors.jellyBean), + ); + } + if (viewModel.errorMessage != null) { + return Center(child: Text(viewModel.errorMessage!)); + } + + if ((viewModel.tendersResponse?.data?.tenders == null || + viewModel.tendersResponse!.data!.tenders!.isEmpty) && + viewModel.isLoading == false) { + return const Center(child: Text(CommonStrings.noData)); + } + + return SingleChildScrollView( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 18.0.h()), + child: MainTendersSlider( + tenders: viewModel.tendersResponse?.data?.tenders ?? [], + ), + ), + ); + }, + ); + } +} diff --git a/lib/views/tenders/pages/t_tenders_page.dart b/lib/views/tenders/pages/t_tenders_page.dart index e3b38de..d036fbc 100644 --- a/lib/views/tenders/pages/t_tenders_page.dart +++ b/lib/views/tenders/pages/t_tenders_page.dart @@ -9,6 +9,8 @@ import 'package:tm_app/views/tenders/widgets/main_tenders_slider.dart'; import '../../../core/constants/common_strings.dart'; import '../../shared/tender_app_bar.dart'; import '../strings/tenders_strings.dart'; +import '../widgets/recommended_tenders_view.dart'; +import '../widgets/tenders_tab_bar.dart'; class TabletTendersPage extends StatefulWidget { const TabletTendersPage({super.key}); @@ -20,6 +22,9 @@ class TabletTendersPage extends StatefulWidget { class _TabletTendersPageState extends State { late final TendersViewModel viewModel; + /// 0 = All, 1 = Recommended. + int _selectedTab = 0; + @override void initState() { super.initState(); @@ -38,34 +43,54 @@ class _TabletTendersPageState extends State { context: context, ), drawer: const TabletNavigationWidget(currentIndex: 1), - body: Consumer( - builder: (context, viewModel, child) { - if (viewModel.isLoading) { - return const Center( - child: CircularProgressIndicator(color: AppColors.jellyBean), - ); - } - if (viewModel.errorMessage != null) { - return Center(child: Text(viewModel.errorMessage!)); - } - if ((viewModel.tendersResponse?.data?.tenders == null || - viewModel.tendersResponse!.data!.tenders!.isEmpty) && - viewModel.isLoading == false) { - return const Center(child: Text(CommonStrings.noData)); - } - return SingleChildScrollView( - child: Padding( - padding: EdgeInsetsDirectional.only( - end: 24.0.w(), - top: 128.0.h(), - ), - child: MainTendersSlider( - tenders: viewModel.tendersResponse?.data?.tenders ?? [], - ), + body: Padding( + padding: EdgeInsetsDirectional.only(end: 24.0.w(), top: 24.0.h()), + child: Column( + children: [ + TendersTabBar( + selectedIndex: _selectedTab, + onChanged: (index) => setState(() => _selectedTab = index), ), - ); - }, + SizedBox(height: 24.0.h()), + Expanded( + child: + _selectedTab == 0 + ? const _AllTendersTab() + : const RecommendedTendersView(), + ), + ], + ), ), ); } } + +class _AllTendersTab extends StatelessWidget { + const _AllTendersTab(); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, viewModel, child) { + if (viewModel.isLoading) { + return const Center( + child: CircularProgressIndicator(color: AppColors.jellyBean), + ); + } + if (viewModel.errorMessage != null) { + return Center(child: Text(viewModel.errorMessage!)); + } + if ((viewModel.tendersResponse?.data?.tenders == null || + viewModel.tendersResponse!.data!.tenders!.isEmpty) && + viewModel.isLoading == false) { + return const Center(child: Text(CommonStrings.noData)); + } + return SingleChildScrollView( + child: MainTendersSlider( + tenders: viewModel.tendersResponse?.data?.tenders ?? [], + ), + ); + }, + ); + } +} diff --git a/lib/views/tenders/strings/tenders_strings.dart b/lib/views/tenders/strings/tenders_strings.dart index 80d85e4..1839143 100644 --- a/lib/views/tenders/strings/tenders_strings.dart +++ b/lib/views/tenders/strings/tenders_strings.dart @@ -27,4 +27,22 @@ class TendersStrings { static const String minimumTime = 'Minimum time'; static const String tenderBudgetLabel = 'Budget :'; static const String board = 'Board'; + + // Recommended (AI) tab + static const String allTab = 'All'; + static const String recommendedTab = 'Recommended'; + static const String recommendedMatchLabel = 'Match'; + static const String recommendedRankPrefix = '#'; + static const String recommendedStillLearningTitle = + 'We’re still learning about your company'; + static const String recommendedStillLearningBody = + 'Recommendations will appear here once we finish analyzing your company. Check back soon.'; + static const String recommendedEmptyTitle = 'No recommendations yet'; + static const String recommendedEmptyBody = + 'Add your company documents or website to get personalized tender recommendations.'; + static const String recommendedError = + 'Could not load recommendations. Please try again.'; + static const String recommendedFallbackNotice = + 'Showing keyword-based suggestions while AI recommendations are unavailable.'; + static const String retry = 'Retry'; } diff --git a/lib/views/tenders/widgets/recommendation_card.dart b/lib/views/tenders/widgets/recommendation_card.dart new file mode 100644 index 0000000..9694716 --- /dev/null +++ b/lib/views/tenders/widgets/recommendation_card.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.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/recommendation_item/recommendation_item.dart'; + +import '../strings/tenders_strings.dart'; + +/// A single AI recommendation: rank badge + the AI's analysis, tappable to open +/// the tender detail via [RecommendationItem.tenderId]. +class RecommendationCard extends StatelessWidget { + const RecommendationCard({required this.item, super.key}); + + final RecommendationItem item; + + void _openDetail(BuildContext context) { + final tenderId = item.tenderId; + if (tenderId == null || tenderId.isEmpty) { + return; + } + TenderDetailRouteData(tenderId: tenderId).push(context); + } + + @override + Widget build(BuildContext context) { + final analysis = item.analysis?.trim() ?? ''; + return InkWell( + borderRadius: BorderRadius.circular(12.0.w()), + onTap: () => _openDetail(context), + child: Container( + width: double.infinity, + padding: EdgeInsets.all(16.0.h()), + decoration: BoxDecoration( + color: AppColors.grey0, + borderRadius: BorderRadius.circular(12.0.w()), + border: Border.all(color: AppColors.grey30), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _rankBadge(), + SizedBox(width: 12.0.w()), + Text( + TendersStrings.recommendedMatchLabel, + style: TextStyle( + fontSize: 14.0.sp(), + fontWeight: FontWeight.w600, + color: AppColors.grey70, + ), + ), + ], + ), + if (analysis.isNotEmpty) ...[ + SizedBox(height: 12.0.h()), + Text( + analysis, + style: TextStyle( + fontSize: 14.0.sp(), + fontWeight: FontWeight.w400, + color: AppColors.grey80, + height: 1.5, + ), + ), + ], + SizedBox(height: 16.0.h()), + Align( + alignment: AlignmentDirectional.centerEnd, + child: Text( + TendersStrings.tenderSeeMore, + style: TextStyle( + fontSize: 14.0.sp(), + fontWeight: FontWeight.w600, + color: AppColors.mainBlue, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _rankBadge() { + final rank = item.rank?.trim(); + final label = + (rank == null || rank.isEmpty) + ? TendersStrings.recommendedRankPrefix + : '${TendersStrings.recommendedRankPrefix}$rank'; + return Container( + padding: EdgeInsets.symmetric(horizontal: 12.0.w(), vertical: 4.0.h()), + decoration: BoxDecoration( + color: AppColors.primary10, + borderRadius: BorderRadius.circular(8.0.w()), + ), + child: Text( + label, + style: TextStyle( + fontSize: 14.0.sp(), + fontWeight: FontWeight.w700, + color: AppColors.mainBlue, + ), + ), + ); + } +} diff --git a/lib/views/tenders/widgets/recommended_tenders_view.dart b/lib/views/tenders/widgets/recommended_tenders_view.dart new file mode 100644 index 0000000..b891aa7 --- /dev/null +++ b/lib/views/tenders/widgets/recommended_tenders_view.dart @@ -0,0 +1,283 @@ +import 'package:flutter/material.dart'; +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_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]. +class RecommendedTendersView extends StatefulWidget { + const RecommendedTendersView({super.key}); + + @override + State createState() => _RecommendedTendersViewState(); +} + +class _RecommendedTendersViewState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + context.read().loadIfNeeded(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, vm, child) { + if (vm.isLoading) { + return const _LoadingSkeleton(); + } + if (vm.errorMessage != null) { + return _MessageState( + title: TendersStrings.recommendedError, + onRetry: vm.refresh, + ); + } + if (vm.stillLearning) { + return _MessageState( + title: TendersStrings.recommendedStillLearningTitle, + body: TendersStrings.recommendedStillLearningBody, + onRetry: vm.refresh, + ); + } + if (vm.usingFallback && vm.fallbackTenders.isNotEmpty) { + return _FallbackList(tenders: vm.fallbackTenders, onRefresh: vm.refresh); + } + if (vm.items.isNotEmpty) { + return RefreshIndicator( + color: AppColors.jellyBean, + onRefresh: vm.refresh, + child: ListView.separated( + padding: EdgeInsets.all(16.0.h()), + itemCount: vm.items.length, + separatorBuilder: (_, __) => SizedBox(height: 12.0.h()), + itemBuilder: + (context, index) => + RecommendationCard(item: vm.items[index]), + ), + ); + } + return _MessageState( + title: TendersStrings.recommendedEmptyTitle, + body: TendersStrings.recommendedEmptyBody, + onRetry: vm.refresh, + ); + }, + ); + } +} + +/// 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}); + + final TenderData tender; + + void _openDetail(BuildContext context) { + final id = tender.id; + if (id == null || id.isEmpty) { + return; + } + TenderDetailRouteData(tenderId: id).push(context); + } + + @override + Widget build(BuildContext context) { + final title = tender.title?.trim() ?? ''; + final buyer = tender.buyerOrganization?.name?.trim() ?? ''; + return InkWell( + borderRadius: BorderRadius.circular(12.0.w()), + onTap: () => _openDetail(context), + child: Container( + width: double.infinity, + padding: EdgeInsets.all(16.0.h()), + decoration: BoxDecoration( + color: AppColors.grey0, + borderRadius: BorderRadius.circular(12.0.w()), + border: Border.all(color: AppColors.grey30), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title.isNotEmpty) + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.0.sp(), + fontWeight: FontWeight.w600, + color: AppColors.grey80, + ), + ), + if (buyer.isNotEmpty) ...[ + SizedBox(height: 8.0.h()), + Text( + buyer, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.0.sp(), + fontWeight: FontWeight.w400, + color: AppColors.grey60, + ), + ), + ], + ], + ), + ), + ); + } +} + +/// Centered title/body message with an optional retry button. Wrapped in a +/// scroll view so pull-to-refresh works even when empty. +class _MessageState extends StatelessWidget { + const _MessageState({required this.title, this.body, this.onRetry}); + + final String title; + final String? body; + final Future Function()? onRetry; + + @override + Widget build(BuildContext context) { + final content = Padding( + padding: EdgeInsets.symmetric(horizontal: 32.0.w(), vertical: 48.0.h()), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16.0.sp(), + fontWeight: FontWeight.w600, + color: AppColors.grey70, + ), + ), + if (body != null) ...[ + SizedBox(height: 12.0.h()), + Text( + body!, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14.0.sp(), + fontWeight: FontWeight.w400, + color: AppColors.grey60, + height: 1.5, + ), + ), + ], + if (onRetry != null) ...[ + SizedBox(height: 24.0.h()), + TextButton( + onPressed: onRetry, + child: Text( + TendersStrings.retry, + style: TextStyle( + fontSize: 14.0.sp(), + fontWeight: FontWeight.w600, + color: AppColors.mainBlue, + ), + ), + ), + ], + ], + ), + ); + + if (onRetry == null) { + return Center(child: content); + } + return RefreshIndicator( + color: AppColors.jellyBean, + onRefresh: onRetry!, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: SizeConfig.screenHeight * 0.6), + child: Center(child: content), + ), + ), + ); + } +} + +/// Simple placeholder list shown while recommendations load. +class _LoadingSkeleton extends StatelessWidget { + const _LoadingSkeleton(); + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: EdgeInsets.all(16.0.h()), + itemCount: 4, + separatorBuilder: (_, __) => SizedBox(height: 12.0.h()), + itemBuilder: + (context, index) => Container( + height: 120.0.h(), + decoration: BoxDecoration( + color: AppColors.grey0, + borderRadius: BorderRadius.circular(12.0.w()), + border: Border.all(color: AppColors.grey30), + ), + ), + ); + } +} diff --git a/lib/views/tenders/widgets/tenders_tab_bar.dart b/lib/views/tenders/widgets/tenders_tab_bar.dart new file mode 100644 index 0000000..c9e874b --- /dev/null +++ b/lib/views/tenders/widgets/tenders_tab_bar.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:tm_app/core/theme/colors.dart'; +import 'package:tm_app/core/utils/size_config.dart'; + +import '../strings/tenders_strings.dart'; + +/// Segmented control switching between the "All" tenders list and the +/// "Recommended" AI tab. +class TendersTabBar extends StatelessWidget { + const TendersTabBar({ + required this.selectedIndex, + required this.onChanged, + super.key, + }); + + /// 0 = All, 1 = Recommended. + final int selectedIndex; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + height: 44.0.h(), + padding: EdgeInsets.all(4.0.h()), + decoration: BoxDecoration( + color: AppColors.grey0, + borderRadius: BorderRadius.circular(12.0.w()), + border: Border.all(color: AppColors.grey30), + ), + child: Row( + children: [ + _segment(TendersStrings.allTab, 0), + _segment(TendersStrings.recommendedTab, 1), + ], + ), + ); + } + + Widget _segment(String label, int index) { + final bool isActive = selectedIndex == index; + return Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onChanged(index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + alignment: Alignment.center, + decoration: BoxDecoration( + color: isActive ? AppColors.mainBlue : Colors.transparent, + borderRadius: BorderRadius.circular(8.0.w()), + ), + child: Text( + label, + style: TextStyle( + fontSize: 14.0.sp(), + fontWeight: FontWeight.w600, + color: isActive ? AppColors.grey0 : AppColors.grey60, + ), + ), + ), + ), + ); + } +} diff --git a/test/model/onboarding_response_test.dart b/test/model/onboarding_response_test.dart new file mode 100644 index 0000000..b3960cd --- /dev/null +++ b/test/model/onboarding_response_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tm_app/data/services/model/onboarding_response/onboarding_response.dart'; + +void main() { + group('OnboardingResponse Model Test', () { + final mockJson = { + 'success': true, + 'message': 'AI onboarding started successfully', + 'data': {'status': 'started'}, + 'error': {'message': null, 'code': null, 'details': null}, + }; + + test('fromJson creates valid OnboardingResponse instance', () { + final response = OnboardingResponse.fromJson(mockJson); + + expect(response.success, true); + expect(response.message, 'AI onboarding started successfully'); + expect(response.data?.status, 'started'); + }); + + test('handles null data gracefully', () { + final response = OnboardingResponse.fromJson({ + 'success': false, + 'message': 'AI service not configured', + 'data': null, + 'error': {'message': 'unavailable', 'code': '503', 'details': null}, + }); + + expect(response.success, false); + expect(response.data, isNull); + expect(response.error?.message, 'unavailable'); + }); + + test('toJson round-trips status', () { + final response = OnboardingResponse.fromJson(mockJson); + final json = response.toJson(); + final data = Map.from(json['data'] as Map); + + expect(json['success'], true); + expect(data['status'], 'started'); + }); + + test('equality and copyWith work as expected', () { + final response = OnboardingResponse.fromJson(mockJson); + final updated = response.copyWith(message: 'Updated'); + + expect(updated.message, 'Updated'); + expect(updated != response, true); + }); + }); +} diff --git a/test/model/recommend_response_test.dart b/test/model/recommend_response_test.dart new file mode 100644 index 0000000..7cc0be4 --- /dev/null +++ b/test/model/recommend_response_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tm_app/data/services/model/recommend_response/recommend_response.dart'; + +void main() { + group('RecommendResponse Model Test', () { + final mockJson = { + 'success': true, + 'message': 'AI recommendations retrieved successfully', + 'data': [ + { + 'rank': '1', + 'tender_id': '674a1b2c3d4e5f6789012345', + 'analysis': 'Strong match based on company industry and CPV codes.', + }, + { + 'rank': '2', + 'tender_id': '674a1b2c3d4e5f6789012346', + 'analysis': 'Relevant scope and geographic fit.', + }, + ], + 'error': {'message': null, 'code': null, 'details': null}, + }; + + test('fromJson parses the ranked recommendation list', () { + final response = RecommendResponse.fromJson(mockJson); + + expect(response.success, true); + expect(response.data, isNotNull); + expect(response.data!.length, 2); + expect(response.data!.first.rank, '1'); + expect(response.data!.first.tenderId, '674a1b2c3d4e5f6789012345'); + expect( + response.data!.first.analysis, + 'Strong match based on company industry and CPV codes.', + ); + }); + + test('handles empty data (AI still indexing)', () { + final response = RecommendResponse.fromJson({ + 'success': true, + 'message': 'AI recommendations retrieved successfully', + 'data': [], + 'error': null, + }); + + expect(response.success, true); + expect(response.data, isEmpty); + }); + + test('toJson round-trips the nested items', () { + final response = RecommendResponse.fromJson(mockJson); + final json = response.toJson(); + final data = (json['data'] as List).cast>(); + + expect(json['success'], true); + expect(data.length, 2); + expect(data[1]['tender_id'], '674a1b2c3d4e5f6789012346'); + expect(data[1]['rank'], '2'); + }); + + test('equality and copyWith work as expected', () { + final response = RecommendResponse.fromJson(mockJson); + final updated = response.copyWith(message: 'Updated'); + + expect(updated.message, 'Updated'); + expect(updated != response, true); + }); + }); +}