From 35af3e94ea2ea29e0ec7ab85df34be02f028493a Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Sun, 24 May 2026 18:23:37 +0330 Subject: [PATCH] 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. --- lib/core/config/dependencies.dart | 1 + lib/core/utils/date_utils.dart | 29 +++++++++----- lib/data/repositories/home_repository.dart | 34 ++++++++++++---- lib/view_models/home_view_model.dart | 26 +++++++----- lib/views/detail/widgets/deadline_item.dart | 3 ++ lib/views/home/pages/d_home_page.dart | 7 ++-- lib/views/home/pages/m_home_page.dart | 7 ++-- lib/views/home/pages/t_home_page.dart | 7 ++-- test/core/date_utils_test.dart | 44 +++++++++++++++++++-- 9 files changed, 114 insertions(+), 44 deletions(-) diff --git a/lib/core/config/dependencies.dart b/lib/core/config/dependencies.dart index 3461b4b..3ed519d 100644 --- a/lib/core/config/dependencies.dart +++ b/lib/core/config/dependencies.dart @@ -121,6 +121,7 @@ List get repositories { (context) => HomeRepository( homeService: context.read(), yourTendersService: context.read(), + likedTendersService: context.read(), ), lazy: true, ), diff --git a/lib/core/utils/date_utils.dart b/lib/core/utils/date_utils.dart index f0d6821..3cadf73 100644 --- a/lib/core/utils/date_utils.dart +++ b/lib/core/utils/date_utils.dart @@ -1,24 +1,33 @@ 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) { if (value == null) return null; - if (value is int) return value; - if (value is num) return value.toInt(); - if (value is String) { + int? parsed; + if (value is int) { + parsed = value; + } else if (value is num) { + parsed = value.toInt(); + } else if (value is String) { final trimmed = value.trim(); 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. -/// Mirrors Next.js `unixToDate` using `moment.unix(unix)`. +/// Converts a Unix timestamp from the API to a display string. +/// +/// 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}) { - 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 date = DateTime.fromMillisecondsSinceEpoch( seconds * 1000, diff --git a/lib/data/repositories/home_repository.dart b/lib/data/repositories/home_repository.dart index 1855bb1..50a9a7a 100644 --- a/lib/data/repositories/home_repository.dart +++ b/lib/data/repositories/home_repository.dart @@ -1,21 +1,25 @@ import 'package:tm_app/core/utils/result.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/tender_approvals_response/tender_approvals_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/tender_feedback_request_model/tender_feedback_request_model.dart'; import '../services/your_tenders_service.dart'; class HomeRepository { HomeRepository({ required HomeService homeService, required YourTendersService yourTendersService, + required LikedTendersService likedTendersService, }) : _homeService = homeService, - _yourTendersService = yourTendersService; + _yourTendersService = yourTendersService, + _likedTendersService = likedTendersService; final HomeService _homeService; final YourTendersService _yourTendersService; + final LikedTendersService _likedTendersService; Future> getHomeRecommendedTenders({ required int limit, @@ -34,11 +38,25 @@ class HomeRepository { return _homeService.getTendersApprovalsStats(); } - Future> getFeedbackStats() { - return _homeService.getFeedbackStats(); - } - Future>> checkUnreadNotifications() async { - return _homeService.checkUnreadNotifications(); -} + /// Returns how many tenders the current user has liked (not company-wide). + Future> getUserLikedTendersCount() async { + final result = await _likedTendersService.getLikedTenders( + requestModel: TenderFeedbackRequestModel( + 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>> checkUnreadNotifications() async { + return _homeService.checkUnreadNotifications(); + } } diff --git a/lib/view_models/home_view_model.dart b/lib/view_models/home_view_model.dart index a157e46..978d9a8 100644 --- a/lib/view_models/home_view_model.dart +++ b/lib/view_models/home_view_model.dart @@ -1,13 +1,11 @@ import 'package:flutter/material.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/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_data/tender_data.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/request_models/get_tenders_request_model/get_tenders_request_model.dart'; import '../data/services/notification_state_service.dart'; @@ -46,7 +44,7 @@ class HomeViewModel with ChangeNotifier { RecommendedTendersResponse? _recommendedTendersResponse; TenderApprovalsStatsResponse? tenderApprovalsStateResponse; - FeedbackStats? feedbackStats; + int userLikedTendersCount = 0; final List _tenders = []; final int _pageSize = 10; int _currentPage = 0; @@ -65,11 +63,19 @@ class HomeViewModel with ChangeNotifier { void init() async { _isLoading = true; + _errorMessage = null; + userLikedTendersCount = 0; + _tenders.clear(); + _currentPage = 0; + _hasMore = true; + _isRecommendedMode = false; + data = null; + tenderApprovalsStateResponse = null; notifyListeners(); await getApprovedStates(); await getYourTenders(reset: true); - await getFeedbackStats(); + await loadUserLikedTendersCount(); if (_errorMessage != null) { _isLoading = false; @@ -192,15 +198,15 @@ class HomeViewModel with ChangeNotifier { notifyListeners(); } - Future getFeedbackStats() async { - final result = await _homeRepository.getFeedbackStats(); + Future loadUserLikedTendersCount() async { + final result = await _homeRepository.getUserLikedTendersCount(); switch (result) { - case Ok(): - feedbackStats = result.value.data; + case Ok(): + userLikedTendersCount = result.value; break; - case Error(): - _errorMessage = result.error.toString(); + case Error(): + userLikedTendersCount = 0; break; } notifyListeners(); diff --git a/lib/views/detail/widgets/deadline_item.dart b/lib/views/detail/widgets/deadline_item.dart index 1bd01fc..b198b3e 100644 --- a/lib/views/detail/widgets/deadline_item.dart +++ b/lib/views/detail/widgets/deadline_item.dart @@ -19,6 +19,9 @@ class DeadlineItem extends StatelessWidget { @override Widget build(BuildContext context) { + if (detail.submissionDeadline == null && detail.applicationDeadline == null) { + return const SizedBox.shrink(); + } return isScreenBig ? Padding( padding: const EdgeInsets.symmetric(vertical: 0), diff --git a/lib/views/home/pages/d_home_page.dart b/lib/views/home/pages/d_home_page.dart index 12c5048..300c978 100644 --- a/lib/views/home/pages/d_home_page.dart +++ b/lib/views/home/pages/d_home_page.dart @@ -41,8 +41,7 @@ class DesktopHomePage extends StatelessWidget { } if (homeViewModel.tenderApprovalsStateResponse != null && - homeViewModel.data != null && - homeViewModel.feedbackStats != null) { + homeViewModel.data != null) { return Expanded( child: SingleChildScrollView( child: SizedBox( @@ -181,9 +180,9 @@ class DesktopHomePage extends StatelessWidget { backgroundColor: AppColors.grey10, iconPath: AssetsManager.thumbLike, title: HomeStrings.likedTenders, - amount: homeViewModel.feedbackStats?.totalLikes.toString() ?? '0', + amount: homeViewModel.userLikedTendersCount.toString(), textColor: AppColors.grey50, - enableTap: (homeViewModel.feedbackStats?.totalLikes ?? 0) != 0, + enableTap: homeViewModel.userLikedTendersCount != 0, width: 178, height: 148, onTap: () { diff --git a/lib/views/home/pages/m_home_page.dart b/lib/views/home/pages/m_home_page.dart index c3ae3ad..6d7a17e 100644 --- a/lib/views/home/pages/m_home_page.dart +++ b/lib/views/home/pages/m_home_page.dart @@ -33,8 +33,7 @@ class MobileHomePage extends StatelessWidget { } if (homeViewModel.tenderApprovalsStateResponse != null && - homeViewModel.data != null && - homeViewModel.feedbackStats != null) { + homeViewModel.data != null) { return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -156,9 +155,9 @@ class MobileHomePage extends StatelessWidget { backgroundColor: AppColors.grey10, iconPath: AssetsManager.thumbLike, title: HomeStrings.likedTenders, - amount: homeViewModel.feedbackStats?.totalLikes.toString() ?? '0', + amount: homeViewModel.userLikedTendersCount.toString(), textColor: AppColors.grey50, - enableTap: (homeViewModel.feedbackStats?.totalLikes ?? 0) != 0, + enableTap: homeViewModel.userLikedTendersCount != 0, onTap: () { const LikedTendersRouteData().push(context).then((value) { homeViewModel.init(); diff --git a/lib/views/home/pages/t_home_page.dart b/lib/views/home/pages/t_home_page.dart index 666ad91..daf7d1a 100644 --- a/lib/views/home/pages/t_home_page.dart +++ b/lib/views/home/pages/t_home_page.dart @@ -41,8 +41,7 @@ class TabletHomePage extends StatelessWidget { } if (homeViewModel.tenderApprovalsStateResponse != null && - homeViewModel.data != null && - homeViewModel.feedbackStats != null) { + homeViewModel.data != null) { return SingleChildScrollView( child: Padding( padding: EdgeInsetsDirectional.fromSTEB( @@ -186,9 +185,9 @@ class TabletHomePage extends StatelessWidget { backgroundColor: AppColors.grey10, iconPath: AssetsManager.thumbLike, title: HomeStrings.likedTenders, - amount: homeViewModel.feedbackStats?.totalLikes.toString() ?? '0', + amount: homeViewModel.userLikedTendersCount.toString(), textColor: AppColors.grey50, - enableTap: (homeViewModel.feedbackStats?.totalLikes ?? 0) != 0, + enableTap: homeViewModel.userLikedTendersCount != 0, width: double.infinity, height: 148, onTap: () { diff --git a/test/core/date_utils_test.dart b/test/core/date_utils_test.dart index 1eb151f..16ff633 100644 --- a/test/core/date_utils_test.dart +++ b/test/core/date_utils_test.dart @@ -3,18 +3,54 @@ import 'package:tm_app/core/utils/date_utils.dart'; void main() { group('timeConvertor', () { - test('returns dash for null and zero', () { + test('returns dash for null, zero, and negative', () { expect(timeConvertor(null), '-'); expect(timeConvertor(0), '-'); + expect(timeConvertor(-1), '-'); }); - test('formats unix seconds like moment.unix', () { - expect(timeConvertor(1700000000), '2023-11-14'); + test('formats valid seconds as yyyy-MM-dd', () { + 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); + }); + + test('returns null for empty, null, and non-numeric input', () { + expect(unixTimestampFromJson(null), 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); }); }); }