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:
@@ -121,6 +121,7 @@ List<SingleChildWidget> get repositories {
|
||||
(context) => HomeRepository(
|
||||
homeService: context.read(),
|
||||
yourTendersService: context.read(),
|
||||
likedTendersService: context.read(),
|
||||
),
|
||||
lazy: true,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Result<RecommendedTendersResponse>> getHomeRecommendedTenders({
|
||||
required int limit,
|
||||
@@ -34,11 +38,25 @@ class HomeRepository {
|
||||
return _homeService.getTendersApprovalsStats();
|
||||
}
|
||||
|
||||
Future<Result<FeedbackStatResponse>> getFeedbackStats() {
|
||||
return _homeService.getFeedbackStats();
|
||||
}
|
||||
Future<Result<Map<String, dynamic>>> checkUnreadNotifications() async {
|
||||
return _homeService.checkUnreadNotifications();
|
||||
}
|
||||
/// Returns how many tenders the current user has liked (not company-wide).
|
||||
Future<Result<int>> 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<Result<Map<String, dynamic>>> checkUnreadNotifications() async {
|
||||
return _homeService.checkUnreadNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TenderData> _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<void> getFeedbackStats() async {
|
||||
final result = await _homeRepository.getFeedbackStats();
|
||||
Future<void> loadUserLikedTendersCount() async {
|
||||
final result = await _homeRepository.getUserLikedTendersCount();
|
||||
|
||||
switch (result) {
|
||||
case Ok<FeedbackStatResponse>():
|
||||
feedbackStats = result.value.data;
|
||||
case Ok<int>():
|
||||
userLikedTendersCount = result.value;
|
||||
break;
|
||||
case Error<FeedbackStatResponse>():
|
||||
_errorMessage = result.error.toString();
|
||||
case Error<int>():
|
||||
userLikedTendersCount = 0;
|
||||
break;
|
||||
}
|
||||
notifyListeners();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: () {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: () {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user