feat: add AI recommendations flow for tenders #1
@@ -72,6 +72,8 @@ class AiRecommendationsViewModel with ChangeNotifier {
|
|||||||
|
|
||||||
final result = await _companyAiRepository.getRecommendations();
|
final result = await _companyAiRepository.getRecommendations();
|
||||||
|
|
||||||
|
var loadedSuccessfully = false;
|
||||||
|
|
||||||
switch (result) {
|
switch (result) {
|
||||||
case Ok<RecommendResponse>():
|
case Ok<RecommendResponse>():
|
||||||
_items = result.value.data ?? const [];
|
_items = result.value.data ?? const [];
|
||||||
@@ -79,28 +81,32 @@ class AiRecommendationsViewModel with ChangeNotifier {
|
|||||||
if (_items.isEmpty) {
|
if (_items.isEmpty) {
|
||||||
_stillLearning = _onboardingWasRecent();
|
_stillLearning = _onboardingWasRecent();
|
||||||
}
|
}
|
||||||
|
loadedSuccessfully = true;
|
||||||
case Error<RecommendResponse>():
|
case Error<RecommendResponse>():
|
||||||
await _handleError(result.error);
|
loadedSuccessfully = await _handleError(result.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_hasLoaded = true;
|
_hasLoaded = loadedSuccessfully;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pull-to-refresh: always re-fetches.
|
/// Pull-to-refresh: always re-fetches.
|
||||||
Future<void> refresh() => loadRecommendations();
|
Future<void> refresh() => loadRecommendations();
|
||||||
|
|
||||||
Future<void> _handleError(Exception error) async {
|
Future<bool> _handleError(Exception error) async {
|
||||||
// 503 means the AI service isn't configured/available — fall back to the
|
// 503 means the AI service isn't configured/available — fall back to the
|
||||||
// legacy recommend list rather than showing an error.
|
// legacy recommend list rather than showing an error. NetworkManager only
|
||||||
|
// exposes this status when Dio received an HTTP response; connection and
|
||||||
|
// timeout failures have no reliable status code and should remain retryable.
|
||||||
if (error is AppException && error.statusCode == 503) {
|
if (error is AppException && error.statusCode == 503) {
|
||||||
await _loadFallback();
|
await _loadFallback();
|
||||||
return;
|
return _errorMessage == null;
|
||||||
}
|
}
|
||||||
_items = const [];
|
_items = const [];
|
||||||
_fallbackTenders = const [];
|
_fallbackTenders = const [];
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadFallback() async {
|
Future<void> _loadFallback() async {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:tm_app/core/constants/pref_keys.dart';
|
||||||
|
import 'package:tm_app/core/network/network_manager.dart';
|
||||||
|
import 'package:tm_app/core/utils/logger.dart';
|
||||||
|
import 'package:tm_app/core/utils/result.dart';
|
||||||
|
import 'package:tm_app/data/repositories/company_ai_repository.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';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(() {
|
||||||
|
appLogger.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CompanyAiRepository', () {
|
||||||
|
test('skips onboarding when company has no indexable data', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final service = _FakeCompanyAiService(prefs);
|
||||||
|
final repository = CompanyAiRepository(
|
||||||
|
companyAiService: service,
|
||||||
|
prefs: prefs,
|
||||||
|
);
|
||||||
|
|
||||||
|
final outcome = await repository.maybeStartOnboarding(
|
||||||
|
documentFileIds: const [],
|
||||||
|
website: ' ',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(outcome, AiOnboardingOutcome.skippedNoData);
|
||||||
|
expect(service.startOnboardingCalls, 0);
|
||||||
|
expect(prefs.getString(PrefKeys.onboardingFingerprint), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stores fingerprint and skips unchanged profile data', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final service = _FakeCompanyAiService(prefs);
|
||||||
|
final repository = CompanyAiRepository(
|
||||||
|
companyAiService: service,
|
||||||
|
prefs: prefs,
|
||||||
|
);
|
||||||
|
|
||||||
|
final firstOutcome = await repository.maybeStartOnboarding(
|
||||||
|
documentFileIds: const ['b', 'a'],
|
||||||
|
website: ' https://example.com ',
|
||||||
|
);
|
||||||
|
final secondOutcome = await repository.maybeStartOnboarding(
|
||||||
|
documentFileIds: const ['a', 'b'],
|
||||||
|
website: 'https://example.com',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(firstOutcome, AiOnboardingOutcome.started);
|
||||||
|
expect(secondOutcome, AiOnboardingOutcome.skippedUnchanged);
|
||||||
|
expect(service.startOnboardingCalls, 1);
|
||||||
|
expect(
|
||||||
|
prefs.getString(PrefKeys.onboardingFingerprint),
|
||||||
|
'a,b|https://example.com',
|
||||||
|
);
|
||||||
|
expect(prefs.getInt(PrefKeys.onboardingStartedAt), isNotNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeCompanyAiService extends CompanyAiService {
|
||||||
|
_FakeCompanyAiService(SharedPreferences prefs)
|
||||||
|
: super(networkManager: NetworkManager(prefs));
|
||||||
|
|
||||||
|
int startOnboardingCalls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<OnboardingResponse>> startOnboarding() async {
|
||||||
|
startOnboardingCalls += 1;
|
||||||
|
return const Result.ok(
|
||||||
|
OnboardingResponse(
|
||||||
|
data: null,
|
||||||
|
error: null,
|
||||||
|
message: 'started',
|
||||||
|
success: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<RecommendResponse>> getRecommendations() async {
|
||||||
|
return const Result.ok(
|
||||||
|
RecommendResponse(data: [], error: null, message: 'ok', success: true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:tm_app/core/network/app_exceptions.dart';
|
||||||
|
import 'package:tm_app/core/network/network_manager.dart';
|
||||||
|
import 'package:tm_app/core/utils/result.dart';
|
||||||
|
import 'package:tm_app/data/repositories/company_ai_repository.dart';
|
||||||
|
import 'package:tm_app/data/repositories/home_repository.dart';
|
||||||
|
import 'package:tm_app/data/services/company_ai_service.dart';
|
||||||
|
import 'package:tm_app/data/services/home_service.dart';
|
||||||
|
import 'package:tm_app/data/services/liked_tenders_service.dart';
|
||||||
|
import 'package:tm_app/data/services/model/recommend_response/recommend_response.dart';
|
||||||
|
import 'package:tm_app/data/services/model/recommendation_item/recommendation_item.dart';
|
||||||
|
import 'package:tm_app/data/services/model/recommended_tenders_data/recommended_tenders_data.dart';
|
||||||
|
import 'package:tm_app/data/services/model/recommended_tenders_response/recommended_tenders_response.dart';
|
||||||
|
import 'package:tm_app/data/services/your_tenders_service.dart';
|
||||||
|
import 'package:tm_app/view_models/ai_recommendations_view_model.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('AiRecommendationsViewModel', () {
|
||||||
|
test('falls back to legacy recommendations on HTTP 503', () async {
|
||||||
|
final prefs = await _prefs();
|
||||||
|
final companyRepository = _FakeCompanyAiRepository(
|
||||||
|
prefs,
|
||||||
|
results: [Result.error(ServerException('unavailable', 503))],
|
||||||
|
);
|
||||||
|
final homeRepository = _FakeHomeRepository(
|
||||||
|
prefs,
|
||||||
|
result: const Result.ok(
|
||||||
|
RecommendedTendersResponse(
|
||||||
|
succes: true,
|
||||||
|
message: 'ok',
|
||||||
|
error: null,
|
||||||
|
data: RecommendedTendersData(tenders: [], metadata: null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final viewModel = AiRecommendationsViewModel(
|
||||||
|
companyAiRepository: companyRepository,
|
||||||
|
homeRepository: homeRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
await viewModel.loadRecommendations();
|
||||||
|
|
||||||
|
expect(viewModel.hasLoaded, isTrue);
|
||||||
|
expect(viewModel.usingFallback, isTrue);
|
||||||
|
expect(viewModel.errorMessage, isNull);
|
||||||
|
expect(homeRepository.recommendedTendersCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps failed transient loads retryable for loadIfNeeded', () async {
|
||||||
|
final prefs = await _prefs();
|
||||||
|
final companyRepository = _FakeCompanyAiRepository(
|
||||||
|
prefs,
|
||||||
|
results: [
|
||||||
|
Result.error(FetchDataException('network failed')),
|
||||||
|
const Result.ok(
|
||||||
|
RecommendResponse(
|
||||||
|
data: [
|
||||||
|
RecommendationItem(
|
||||||
|
rank: '1',
|
||||||
|
tenderId: 'tender-1',
|
||||||
|
analysis: 'Strong match',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
error: null,
|
||||||
|
message: 'ok',
|
||||||
|
success: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final viewModel = AiRecommendationsViewModel(
|
||||||
|
companyAiRepository: companyRepository,
|
||||||
|
homeRepository: _FakeHomeRepository(prefs),
|
||||||
|
);
|
||||||
|
|
||||||
|
await viewModel.loadRecommendations();
|
||||||
|
await viewModel.loadIfNeeded();
|
||||||
|
|
||||||
|
expect(companyRepository.recommendationCalls, 2);
|
||||||
|
expect(viewModel.hasLoaded, isTrue);
|
||||||
|
expect(viewModel.errorMessage, isNull);
|
||||||
|
expect(viewModel.items.single.tenderId, 'tender-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'marks empty results as still learning after recent onboarding',
|
||||||
|
() async {
|
||||||
|
final prefs = await _prefs();
|
||||||
|
final viewModel = AiRecommendationsViewModel(
|
||||||
|
companyAiRepository: _FakeCompanyAiRepository(
|
||||||
|
prefs,
|
||||||
|
lastOnboardingStartedAt: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
results: [
|
||||||
|
const Result.ok(
|
||||||
|
RecommendResponse(
|
||||||
|
data: [],
|
||||||
|
error: null,
|
||||||
|
message: 'ok',
|
||||||
|
success: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
homeRepository: _FakeHomeRepository(prefs),
|
||||||
|
);
|
||||||
|
|
||||||
|
await viewModel.loadRecommendations();
|
||||||
|
|
||||||
|
expect(viewModel.hasLoaded, isTrue);
|
||||||
|
expect(viewModel.stillLearning, isTrue);
|
||||||
|
expect(viewModel.isEmpty, isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SharedPreferences> _prefs() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
return SharedPreferences.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeCompanyAiRepository extends CompanyAiRepository {
|
||||||
|
_FakeCompanyAiRepository(
|
||||||
|
SharedPreferences prefs, {
|
||||||
|
required List<Result<RecommendResponse>> results,
|
||||||
|
int? lastOnboardingStartedAt,
|
||||||
|
}) : _results = results,
|
||||||
|
_lastOnboardingStartedAt = lastOnboardingStartedAt,
|
||||||
|
super(companyAiService: _UnusedCompanyAiService(prefs), prefs: prefs);
|
||||||
|
|
||||||
|
final List<Result<RecommendResponse>> _results;
|
||||||
|
final int? _lastOnboardingStartedAt;
|
||||||
|
int recommendationCalls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<RecommendResponse>> getRecommendations() async {
|
||||||
|
final index = recommendationCalls;
|
||||||
|
recommendationCalls += 1;
|
||||||
|
return _results[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int? lastOnboardingStartedAt() => _lastOnboardingStartedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeHomeRepository extends HomeRepository {
|
||||||
|
_FakeHomeRepository(
|
||||||
|
SharedPreferences prefs, {
|
||||||
|
this.result = const Result.ok(
|
||||||
|
RecommendedTendersResponse(
|
||||||
|
succes: true,
|
||||||
|
message: 'ok',
|
||||||
|
error: null,
|
||||||
|
data: RecommendedTendersData(tenders: [], metadata: null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}) : super(
|
||||||
|
homeService: HomeService(networkManager: NetworkManager(prefs)),
|
||||||
|
yourTendersService: YourTendersService(
|
||||||
|
networkManager: NetworkManager(prefs),
|
||||||
|
),
|
||||||
|
likedTendersService: LikedTendersService(
|
||||||
|
networkManager: NetworkManager(prefs),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Result<RecommendedTendersResponse> result;
|
||||||
|
int recommendedTendersCalls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<RecommendedTendersResponse>> getHomeRecommendedTenders({
|
||||||
|
required int limit,
|
||||||
|
required int offset,
|
||||||
|
}) async {
|
||||||
|
recommendedTendersCalls += 1;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UnusedCompanyAiService extends CompanyAiService {
|
||||||
|
_UnusedCompanyAiService(SharedPreferences prefs)
|
||||||
|
: super(networkManager: NetworkManager(prefs));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user