Add liked tenders functionality to HomeRepository and ViewModel

- Integrated LikedTendersService into HomeRepository to fetch the count of user-liked tenders.
- Updated HomeViewModel to load and manage the userLikedTendersCount.
- Adjusted UI components across home pages to display the liked tenders count.
- Refactored date handling in date_utils.dart to improve timestamp parsing and validation.
- Enhanced unit tests for date utilities to cover new edge cases.
This commit is contained in:
AmirReza Jamali
2026-05-24 18:23:37 +03:30
parent 2515190576
commit 35af3e94ea
9 changed files with 114 additions and 44 deletions
+1
View File
@@ -121,6 +121,7 @@ List<SingleChildWidget> get repositories {
(context) => HomeRepository( (context) => HomeRepository(
homeService: context.read(), homeService: context.read(),
yourTendersService: context.read(), yourTendersService: context.read(),
likedTendersService: context.read(),
), ),
lazy: true, lazy: true,
), ),
+19 -10
View File
@@ -1,24 +1,33 @@
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
/// Parses API date fields that may arrive as int, double, or string. /// Parses an API timestamp field that may arrive as int, double, or string.
/// Returns null for missing, empty, unparseable, or non-positive values
/// (a non-positive epoch is treated as "no value" by the API).
int? unixTimestampFromJson(dynamic value) { int? unixTimestampFromJson(dynamic value) {
if (value == null) return null; if (value == null) return null;
if (value is int) return value; int? parsed;
if (value is num) return value.toInt(); if (value is int) {
if (value is String) { parsed = value;
} else if (value is num) {
parsed = value.toInt();
} else if (value is String) {
final trimmed = value.trim(); final trimmed = value.trim();
if (trimmed.isEmpty) return null; if (trimmed.isEmpty) return null;
return int.tryParse(trimmed); parsed = int.tryParse(trimmed);
} }
return null; if (parsed == null || parsed <= 0) return null;
return parsed;
} }
/// Converts a Unix timestamp (seconds) from the API to a display string. /// Converts a Unix timestamp from the API to a display string.
/// Mirrors Next.js `unixToDate` using `moment.unix(unix)`. ///
/// The API contract is seconds since epoch, but some legacy fields still
/// emit milliseconds. Anything past the year 2286 in seconds (>1e10) is
/// assumed to be milliseconds and converted down. Remove this branch once
/// every endpoint has been migrated to seconds.
String timeConvertor(int? unix, {bool hasTime = false}) { String timeConvertor(int? unix, {bool hasTime = false}) {
if (unix == null || unix == 0) return '-'; if (unix == null || unix <= 0) return '-';
// Values above ~year 2286 in seconds are treated as milliseconds.
final seconds = unix > 9999999999 ? unix ~/ 1000 : unix; final seconds = unix > 9999999999 ? unix ~/ 1000 : unix;
final date = DateTime.fromMillisecondsSinceEpoch( final date = DateTime.fromMillisecondsSinceEpoch(
seconds * 1000, seconds * 1000,
+26 -8
View File
@@ -1,21 +1,25 @@
import 'package:tm_app/core/utils/result.dart'; import 'package:tm_app/core/utils/result.dart';
import 'package:tm_app/data/services/home_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/recommended_tenders_response/recommended_tenders_response.dart'; import 'package:tm_app/data/services/model/recommended_tenders_response/recommended_tenders_response.dart';
import 'package:tm_app/data/services/model/tender_approvals_response/tender_approvals_response.dart'; import 'package:tm_app/data/services/model/tender_approvals_response/tender_approvals_response.dart';
import 'package:tm_app/data/services/model/tender_approvals_stats_response/tender_approvals_stats_response.dart'; import 'package:tm_app/data/services/model/tender_approvals_stats_response/tender_approvals_stats_response.dart';
import '../services/model/feedback_stats_response/feedback_stat_response.dart';
import '../services/model/request_models/get_tenders_request_model/get_tenders_request_model.dart'; import '../services/model/request_models/get_tenders_request_model/get_tenders_request_model.dart';
import '../services/model/request_models/tender_feedback_request_model/tender_feedback_request_model.dart';
import '../services/your_tenders_service.dart'; import '../services/your_tenders_service.dart';
class HomeRepository { class HomeRepository {
HomeRepository({ HomeRepository({
required HomeService homeService, required HomeService homeService,
required YourTendersService yourTendersService, required YourTendersService yourTendersService,
required LikedTendersService likedTendersService,
}) : _homeService = homeService, }) : _homeService = homeService,
_yourTendersService = yourTendersService; _yourTendersService = yourTendersService,
_likedTendersService = likedTendersService;
final HomeService _homeService; final HomeService _homeService;
final YourTendersService _yourTendersService; final YourTendersService _yourTendersService;
final LikedTendersService _likedTendersService;
Future<Result<RecommendedTendersResponse>> getHomeRecommendedTenders({ Future<Result<RecommendedTendersResponse>> getHomeRecommendedTenders({
required int limit, required int limit,
@@ -34,11 +38,25 @@ class HomeRepository {
return _homeService.getTendersApprovalsStats(); return _homeService.getTendersApprovalsStats();
} }
Future<Result<FeedbackStatResponse>> getFeedbackStats() { /// Returns how many tenders the current user has liked (not company-wide).
return _homeService.getFeedbackStats(); Future<Result<int>> getUserLikedTendersCount() async {
} final result = await _likedTendersService.getLikedTenders(
Future<Result<Map<String, dynamic>>> checkUnreadNotifications() async { requestModel: TenderFeedbackRequestModel(
return _homeService.checkUnreadNotifications(); feedbackType: 'like',
} limit: 1,
offset: 0,
),
);
switch (result) {
case Ok(value: final response):
return Result.ok(response.data?.meta?.total ?? 0);
case Error(error: final error):
return Result.error(error);
}
}
Future<Result<Map<String, dynamic>>> checkUnreadNotifications() async {
return _homeService.checkUnreadNotifications();
}
} }
+16 -10
View File
@@ -1,13 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:tm_app/core/utils/result.dart'; import 'package:tm_app/core/utils/result.dart';
import 'package:tm_app/data/services/model/feedback_stats/feedback_stats.dart';
import 'package:tm_app/data/services/model/recommended_tenders_response/recommended_tenders_response.dart'; import 'package:tm_app/data/services/model/recommended_tenders_response/recommended_tenders_response.dart';
import 'package:tm_app/data/services/model/tender_approvals_response/tender_approvals_response.dart'; import 'package:tm_app/data/services/model/tender_approvals_response/tender_approvals_response.dart';
import 'package:tm_app/data/services/model/tender_approvals_stats_response/tender_approvals_stats_response.dart'; import 'package:tm_app/data/services/model/tender_approvals_stats_response/tender_approvals_stats_response.dart';
import 'package:tm_app/data/services/model/tender_data/tender_data.dart'; import 'package:tm_app/data/services/model/tender_data/tender_data.dart';
import '../data/repositories/home_repository.dart'; import '../data/repositories/home_repository.dart';
import '../data/services/model/feedback_stats_response/feedback_stat_response.dart';
import '../data/services/model/home/home_response/home_response_model.dart'; import '../data/services/model/home/home_response/home_response_model.dart';
import '../data/services/model/request_models/get_tenders_request_model/get_tenders_request_model.dart'; import '../data/services/model/request_models/get_tenders_request_model/get_tenders_request_model.dart';
import '../data/services/notification_state_service.dart'; import '../data/services/notification_state_service.dart';
@@ -46,7 +44,7 @@ class HomeViewModel with ChangeNotifier {
RecommendedTendersResponse? _recommendedTendersResponse; RecommendedTendersResponse? _recommendedTendersResponse;
TenderApprovalsStatsResponse? tenderApprovalsStateResponse; TenderApprovalsStatsResponse? tenderApprovalsStateResponse;
FeedbackStats? feedbackStats; int userLikedTendersCount = 0;
final List<TenderData> _tenders = []; final List<TenderData> _tenders = [];
final int _pageSize = 10; final int _pageSize = 10;
int _currentPage = 0; int _currentPage = 0;
@@ -65,11 +63,19 @@ class HomeViewModel with ChangeNotifier {
void init() async { void init() async {
_isLoading = true; _isLoading = true;
_errorMessage = null;
userLikedTendersCount = 0;
_tenders.clear();
_currentPage = 0;
_hasMore = true;
_isRecommendedMode = false;
data = null;
tenderApprovalsStateResponse = null;
notifyListeners(); notifyListeners();
await getApprovedStates(); await getApprovedStates();
await getYourTenders(reset: true); await getYourTenders(reset: true);
await getFeedbackStats(); await loadUserLikedTendersCount();
if (_errorMessage != null) { if (_errorMessage != null) {
_isLoading = false; _isLoading = false;
@@ -192,15 +198,15 @@ class HomeViewModel with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> getFeedbackStats() async { Future<void> loadUserLikedTendersCount() async {
final result = await _homeRepository.getFeedbackStats(); final result = await _homeRepository.getUserLikedTendersCount();
switch (result) { switch (result) {
case Ok<FeedbackStatResponse>(): case Ok<int>():
feedbackStats = result.value.data; userLikedTendersCount = result.value;
break; break;
case Error<FeedbackStatResponse>(): case Error<int>():
_errorMessage = result.error.toString(); userLikedTendersCount = 0;
break; break;
} }
notifyListeners(); notifyListeners();
@@ -19,6 +19,9 @@ class DeadlineItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (detail.submissionDeadline == null && detail.applicationDeadline == null) {
return const SizedBox.shrink();
}
return isScreenBig return isScreenBig
? Padding( ? Padding(
padding: const EdgeInsets.symmetric(vertical: 0), padding: const EdgeInsets.symmetric(vertical: 0),
+3 -4
View File
@@ -41,8 +41,7 @@ class DesktopHomePage extends StatelessWidget {
} }
if (homeViewModel.tenderApprovalsStateResponse != null && if (homeViewModel.tenderApprovalsStateResponse != null &&
homeViewModel.data != null && homeViewModel.data != null) {
homeViewModel.feedbackStats != null) {
return Expanded( return Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
child: SizedBox( child: SizedBox(
@@ -181,9 +180,9 @@ class DesktopHomePage extends StatelessWidget {
backgroundColor: AppColors.grey10, backgroundColor: AppColors.grey10,
iconPath: AssetsManager.thumbLike, iconPath: AssetsManager.thumbLike,
title: HomeStrings.likedTenders, title: HomeStrings.likedTenders,
amount: homeViewModel.feedbackStats?.totalLikes.toString() ?? '0', amount: homeViewModel.userLikedTendersCount.toString(),
textColor: AppColors.grey50, textColor: AppColors.grey50,
enableTap: (homeViewModel.feedbackStats?.totalLikes ?? 0) != 0, enableTap: homeViewModel.userLikedTendersCount != 0,
width: 178, width: 178,
height: 148, height: 148,
onTap: () { onTap: () {
+3 -4
View File
@@ -33,8 +33,7 @@ class MobileHomePage extends StatelessWidget {
} }
if (homeViewModel.tenderApprovalsStateResponse != null && if (homeViewModel.tenderApprovalsStateResponse != null &&
homeViewModel.data != null && homeViewModel.data != null) {
homeViewModel.feedbackStats != null) {
return SingleChildScrollView( return SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -156,9 +155,9 @@ class MobileHomePage extends StatelessWidget {
backgroundColor: AppColors.grey10, backgroundColor: AppColors.grey10,
iconPath: AssetsManager.thumbLike, iconPath: AssetsManager.thumbLike,
title: HomeStrings.likedTenders, title: HomeStrings.likedTenders,
amount: homeViewModel.feedbackStats?.totalLikes.toString() ?? '0', amount: homeViewModel.userLikedTendersCount.toString(),
textColor: AppColors.grey50, textColor: AppColors.grey50,
enableTap: (homeViewModel.feedbackStats?.totalLikes ?? 0) != 0, enableTap: homeViewModel.userLikedTendersCount != 0,
onTap: () { onTap: () {
const LikedTendersRouteData().push(context).then((value) { const LikedTendersRouteData().push(context).then((value) {
homeViewModel.init(); homeViewModel.init();
+3 -4
View File
@@ -41,8 +41,7 @@ class TabletHomePage extends StatelessWidget {
} }
if (homeViewModel.tenderApprovalsStateResponse != null && if (homeViewModel.tenderApprovalsStateResponse != null &&
homeViewModel.data != null && homeViewModel.data != null) {
homeViewModel.feedbackStats != null) {
return SingleChildScrollView( return SingleChildScrollView(
child: Padding( child: Padding(
padding: EdgeInsetsDirectional.fromSTEB( padding: EdgeInsetsDirectional.fromSTEB(
@@ -186,9 +185,9 @@ class TabletHomePage extends StatelessWidget {
backgroundColor: AppColors.grey10, backgroundColor: AppColors.grey10,
iconPath: AssetsManager.thumbLike, iconPath: AssetsManager.thumbLike,
title: HomeStrings.likedTenders, title: HomeStrings.likedTenders,
amount: homeViewModel.feedbackStats?.totalLikes.toString() ?? '0', amount: homeViewModel.userLikedTendersCount.toString(),
textColor: AppColors.grey50, textColor: AppColors.grey50,
enableTap: (homeViewModel.feedbackStats?.totalLikes ?? 0) != 0, enableTap: homeViewModel.userLikedTendersCount != 0,
width: double.infinity, width: double.infinity,
height: 148, height: 148,
onTap: () { onTap: () {
+40 -4
View File
@@ -3,18 +3,54 @@ import 'package:tm_app/core/utils/date_utils.dart';
void main() { void main() {
group('timeConvertor', () { group('timeConvertor', () {
test('returns dash for null and zero', () { test('returns dash for null, zero, and negative', () {
expect(timeConvertor(null), '-'); expect(timeConvertor(null), '-');
expect(timeConvertor(0), '-'); expect(timeConvertor(0), '-');
expect(timeConvertor(-1), '-');
}); });
test('formats unix seconds like moment.unix', () { test('formats valid seconds as yyyy-MM-dd', () {
expect(timeConvertor(1700000000), '2023-11-14'); final formatted = timeConvertor(1700000000);
expect(formatted, matches(RegExp(r'^\d{4}-\d{2}-\d{2}$')));
}); });
test('parses string timestamps from json', () { test('formats valid seconds with time when hasTime is true', () {
final formatted = timeConvertor(1700000000, hasTime: true);
expect(
formatted,
matches(RegExp(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$')),
);
});
test('treats values above 9999999999 as milliseconds', () {
// 1700000000000 ms and 1700000000 s should format to the same day.
expect(timeConvertor(1700000000000), timeConvertor(1700000000));
});
});
group('unixTimestampFromJson', () {
test('parses int and num', () {
expect(unixTimestampFromJson(1700000000), 1700000000);
expect(unixTimestampFromJson(1700000000.7), 1700000000);
});
test('parses numeric strings', () {
expect(unixTimestampFromJson('1700000000'), 1700000000); expect(unixTimestampFromJson('1700000000'), 1700000000);
expect(unixTimestampFromJson(' 1700000000 '), 1700000000);
});
test('returns null for empty, null, and non-numeric input', () {
expect(unixTimestampFromJson(null), isNull);
expect(unixTimestampFromJson(''), isNull); expect(unixTimestampFromJson(''), isNull);
expect(unixTimestampFromJson('abc'), isNull);
expect(unixTimestampFromJson(true), isNull);
});
test('rejects non-positive values', () {
expect(unixTimestampFromJson(0), isNull);
expect(unixTimestampFromJson(-1), isNull);
expect(unixTimestampFromJson('-1'), isNull);
expect(unixTimestampFromJson('0'), isNull);
}); });
}); });
} }