Commit f0334970 authored by DatHV's avatar DatHV
Browse files

update flash sale

parent b93b2948
...@@ -96,6 +96,8 @@ class APIPaths {//sandbox ...@@ -96,6 +96,8 @@ class APIPaths {//sandbox
static const String getCampaignMissions = "/campaign/api/v3.0/%@/missions"; static const String getCampaignMissions = "/campaign/api/v3.0/%@/missions";
static const String getCampaignReward = "/campaign/api/v3.0/%@/reward"; static const String getCampaignReward = "/campaign/api/v3.0/%@/reward";
static const String submitCampaignMission = "/campaign/api/v3.0/%@/mission/%@/submit"; static const String submitCampaignMission = "/campaign/api/v3.0/%@/mission/%@/submit";
static const String getFlashSaleGroup = "/campaign/api/v3.0/flash_sale/group/%@";
static const String getFlashSaleDetail = "/campaign/api/v3.0/flash_sale/detail/%@";
static const String getQuizCampaign = "/quiz/api/v1.0/quiz/%@/"; static const String getQuizCampaign = "/quiz/api/v1.0/quiz/%@/";
static const String quizSubmitCampaign = "/quiz/api/v1.0/quiz/%@/submit/"; static const String quizSubmitCampaign = "/quiz/api/v1.0/quiz/%@/submit/";
static const String getCurrentDevice = "/user/api/v2.0/devices/current"; static const String getCurrentDevice = "/user/api/v2.0/devices/current";
......
...@@ -77,9 +77,15 @@ class AppInitializer { ...@@ -77,9 +77,15 @@ class AppInitializer {
static Future<void> _configureWebSdkHeader() async { static Future<void> _configureWebSdkHeader() async {
try { try {
final response = await webConfigUIApp({ final response = await webConfigUIApp({
'mode': 'mini',
'iconNavigationColor': '#000000',
'navigationColor': '#FFFFFF',
'iconNavigationPosision': 'right',
'headerTitle': 'MyPoint', 'headerTitle': 'MyPoint',
'headerColor': '#E71D28', // 'headerSubTitle': 'Tích điểm - đổi quà nhanh chóng',
'headerTextColor': '#ffffff', // 'headerColor': '#ffffff',
// 'headerTextColor': '#000000',
// 'headerIcon': 'https://cdn.mypoint.vn/app_assets/mypoint_icon.png',
}); });
if (response != null && kDebugMode) { if (response != null && kDebugMode) {
print('🧭 x-app-sdk header configured: $response'); print('🧭 x-app-sdk header configured: $response');
......
...@@ -101,29 +101,6 @@ enum DirectionalScreenName { ...@@ -101,29 +101,6 @@ enum DirectionalScreenName {
linkMBPAccount, linkMBPAccount,
} }
extension DirectionalScreenRouterExtension on DirectionalScreenName {
String get router {
switch (this) {
case DirectionalScreenName.setting:
return settingScreen;
case DirectionalScreenName.notifications:
return notificationScreen;
case DirectionalScreenName.home:
return mainScreen;
case DirectionalScreenName.productOwnVoucher:
return voucherDetailScreen;
case DirectionalScreenName.customerSupport:
return supportScreen;
case DirectionalScreenName.pointHunting:
return achievementListScreen;
case DirectionalScreenName.orderMenu:
return orderMenuScreen;
default:
return '';
}
}
}
extension DirectionalScreenNameExtension on DirectionalScreenName { extension DirectionalScreenNameExtension on DirectionalScreenName {
String get rawValue { String get rawValue {
switch (this) { switch (this) {
......
...@@ -6,6 +6,7 @@ import 'package:mypoint_flutter_app/extensions/string_extension.dart'; ...@@ -6,6 +6,7 @@ import 'package:mypoint_flutter_app/extensions/string_extension.dart';
import 'package:mypoint_flutter_app/networking/restful_api_client_all_request.dart'; import 'package:mypoint_flutter_app/networking/restful_api_client_all_request.dart';
import 'package:mypoint_flutter_app/preference/data_preference.dart'; import 'package:mypoint_flutter_app/preference/data_preference.dart';
import 'package:mypoint_flutter_app/widgets/alert/popup_data_model.dart'; import 'package:mypoint_flutter_app/widgets/alert/popup_data_model.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../configs/constants.dart'; import '../configs/constants.dart';
...@@ -34,12 +35,11 @@ class DirectionalScreen { ...@@ -34,12 +35,11 @@ class DirectionalScreen {
DirectionalScreen._({this.clickActionType, this.clickActionParam, this.popup}); DirectionalScreen._({this.clickActionType, this.clickActionParam, this.popup});
factory DirectionalScreen.fromJson(Map<String, dynamic> json) => DirectionalScreen._( factory DirectionalScreen.fromJson(Map<String, dynamic> json) => DirectionalScreen._(
clickActionType: json['click_action_type'] as String?, clickActionType: json[Defines.actionType] as String?,
clickActionParam: json['click_action_param'] as String?, clickActionParam: json[Defines.actionParams] as String?,
popup: json['popup'] != null ? PopupDataModel.fromJson(json['popup'] as Map<String, dynamic>) : null,
); );
Map<String, dynamic> toJson() => {'click_action_type': clickActionType, 'click_action_param': clickActionParam}; Map<String, dynamic> toJson() => {Defines.actionType: clickActionType, Defines.actionParams: clickActionParam};
static DirectionalScreen? build({String? clickActionType, String? clickActionParam}) { static DirectionalScreen? build({String? clickActionType, String? clickActionParam}) {
if ((clickActionType ?? "").isEmpty) return null; if ((clickActionType ?? "").isEmpty) return null;
...@@ -63,6 +63,10 @@ class DirectionalScreen { ...@@ -63,6 +63,10 @@ class DirectionalScreen {
return false; return false;
} }
switch (type) { switch (type) {
case DirectionalScreenName.flashSale:
if ((clickActionParam ?? '').isEmpty) return false;
Get.toNamed(flashSaleScreen, arguments: {"groupId": clickActionParam});
return true;
case DirectionalScreenName.brand: case DirectionalScreenName.brand:
if ((clickActionParam ?? '').isEmpty) return false; if ((clickActionParam ?? '').isEmpty) return false;
Get.toNamed(affiliateBrandDetailScreen, arguments: {"brandId": clickActionParam}); Get.toNamed(affiliateBrandDetailScreen, arguments: {"brandId": clickActionParam});
...@@ -77,19 +81,9 @@ class DirectionalScreen { ...@@ -77,19 +81,9 @@ class DirectionalScreen {
final storeUrl = 'https://itunes.apple.com/app/id${Constants.appStoreId}?action=write-review'; final storeUrl = 'https://itunes.apple.com/app/id${Constants.appStoreId}?action=write-review';
openStringUrlExternally(storeUrl); openStringUrlExternally(storeUrl);
return true; return true;
// case DirectionalScreenName.historyInvitedFriend:
// case DirectionalScreenName.screenAddInvitationCode:
// // TODO: Lịch sử mời bạn – cần màn tương ứng
// return false;
case DirectionalScreenName.rateStorePopup: case DirectionalScreenName.rateStorePopup:
_requestAppReview(); _requestAppReview();
return false; return false;
// return false;
// case DirectionalScreenName.shoppingOnline:
// case DirectionalScreenName.partnerRedirect:
// return false;
// case DirectionalScreenName.brandOffline:
// return false;
case DirectionalScreenName.pipiScreen: case DirectionalScreenName.pipiScreen:
Get.bottomSheet(const PipiDetailScreen(), isScrollControlled: true, backgroundColor: Colors.transparent); Get.bottomSheet(const PipiDetailScreen(), isScrollControlled: true, backgroundColor: Colors.transparent);
return true; return true;
...@@ -158,9 +152,7 @@ class DirectionalScreen { ...@@ -158,9 +152,7 @@ class DirectionalScreen {
final body = Uri.encodeComponent(contentDecoded); final body = Uri.encodeComponent(contentDecoded);
// iOS: &body=..., Android: ?body=... // iOS: &body=..., Android: ?body=...
final isIOS = defaultTargetPlatform == TargetPlatform.iOS; final isIOS = defaultTargetPlatform == TargetPlatform.iOS;
final urlStr = isIOS final urlStr = isIOS ? 'sms:$phone&body=$body' : 'sms:$phone?body=$body';
? 'sms:$phone&body=$body'
: 'sms:$phone?body=$body';
final uri = Uri.parse(urlStr); final uri = Uri.parse(urlStr);
print('Mở SMS: $uri phone=$phone, content=$content'); print('Mở SMS: $uri phone=$phone, content=$content');
_openUrlExternally(uri); _openUrlExternally(uri);
...@@ -252,7 +244,7 @@ class DirectionalScreen { ...@@ -252,7 +244,7 @@ class DirectionalScreen {
case DirectionalScreenName.dailyCheckin || DirectionalScreenName.dailyCheckinScreen: case DirectionalScreenName.dailyCheckin || DirectionalScreenName.dailyCheckinScreen:
Get.toNamed(dailyCheckInScreen); Get.toNamed(dailyCheckInScreen);
return true; return true;
case DirectionalScreenName.favorite: case DirectionalScreenName.favorite || DirectionalScreenName.productVoucherLike:
Get.toNamed(vouchersScreen, arguments: {"favorite": true}); Get.toNamed(vouchersScreen, arguments: {"favorite": true});
return true; return true;
case DirectionalScreenName.transactionHistories: case DirectionalScreenName.transactionHistories:
...@@ -315,12 +307,69 @@ class DirectionalScreen { ...@@ -315,12 +307,69 @@ class DirectionalScreen {
if ((clickActionParam ?? '').isEmpty) return false; if ((clickActionParam ?? '').isEmpty) return false;
_handleLinkMBPAccount(); _handleLinkMBPAccount();
return true; return true;
case DirectionalScreenName.notifications:
Get.toNamed(notificationScreen);
return true;
case DirectionalScreenName.notification:
if ((clickActionParam ?? '').isEmpty) {
final title = (extraData?['title'] as String?).orEmpty;
final body = (extraData?['body'] as String?).orEmpty;
if (title.isNotEmpty && body.isNotEmpty) {
Get.toNamed(notificationDetailScreen, arguments: {"title": title, "body": body});
return true;
}
return false;
}
Get.toNamed(notificationDetailScreen, arguments: {"notificationId": clickActionParam});
return true;
case DirectionalScreenName.detailTrafficService:
if ((clickActionParam ?? '').isEmpty) return false;
Get.toNamed(trafficServiceDetailScreen, arguments: {"serviceId": clickActionParam});
return true;
case DirectionalScreenName.appScreen:
if ((clickActionParam ?? '').isEmpty) return false;
final direction = DirectionalScreen.build(clickActionType: clickActionParam);
direction?.begin();
return true;
case DirectionalScreenName.transaction:
if ((clickActionParam ?? '').isEmpty) return false;
Get.toNamed(transactionHistoryDetailScreen, arguments: {"orderId": clickActionParam});
return true;
case DirectionalScreenName.applicationSetting:
openAppSettings();
return true;
case DirectionalScreenName.cashBackPointPartnerDetail:
if ((clickActionParam ?? '').isEmpty) return false;
Get.toNamed(affiliateBrandDetailScreen, arguments: {"brandId": clickActionParam});
return true;
case DirectionalScreenName.viewGift ||
DirectionalScreenName.feedback ||
DirectionalScreenName.ranking ||
DirectionalScreenName.inputReferralCode ||
DirectionalScreenName.shoppingOnline ||
DirectionalScreenName.partnerRedirect ||
DirectionalScreenName.brandOffline ||
DirectionalScreenName.customerTransferPoint ||
DirectionalScreenName.home ||
DirectionalScreenName.brandList ||
DirectionalScreenName.brandLike ||
DirectionalScreenName.register ||
DirectionalScreenName.walkingCampaign ||
DirectionalScreenName.gameWorldCup2022 ||
DirectionalScreenName.vietlott ||
DirectionalScreenName.unknown:
_logUnsupported(type);
return false;
default: default:
print("Không nhận diện được action type: $clickActionType"); print("Không nhận diện được action type: $clickActionType");
return false; return false;
} }
} }
void _logUnsupported(DirectionalScreenName type) {
print("⚠️ Chưa hỗ trợ điều hướng action type: ${type.rawValue}");
}
void _handleLinkMBPAccount() { void _handleLinkMBPAccount() {
final phone = clickActionParam.orEmpty; final phone = clickActionParam.orEmpty;
if (phone.isEmpty) return; if (phone.isEmpty) return;
...@@ -357,6 +406,13 @@ class DirectionalScreen { ...@@ -357,6 +406,13 @@ class DirectionalScreen {
await DataPreference.instance.clearData(); await DataPreference.instance.clearData();
Get.offAllNamed(loginScreen, arguments: {"phone": phone, 'password': password}); Get.offAllNamed(loginScreen, arguments: {"phone": phone, 'password': password});
} }
Future<void> openSystemSettings() async {
final opened = await openAppSettings();
if (!opened) {
debugPrint('⚠️ Không mở được trang cài đặt hệ thống');
}
}
} }
Future<bool> forceOpen({required Uri url, LaunchMode mode = LaunchMode.platformDefault}) async { Future<bool> forceOpen({required Uri url, LaunchMode mode = LaunchMode.platformDefault}) async {
...@@ -405,10 +461,7 @@ Future<bool> _safeOpenUrl(Uri url, {LaunchMode preferred = LaunchMode.platformDe ...@@ -405,10 +461,7 @@ Future<bool> _safeOpenUrl(Uri url, {LaunchMode preferred = LaunchMode.platformDe
final ok = await launchUrl( final ok = await launchUrl(
url, url,
mode: mode, mode: mode,
webViewConfiguration: const WebViewConfiguration( webViewConfiguration: const WebViewConfiguration(enableJavaScript: true, headers: <String, String>{}),
enableJavaScript: true,
headers: <String, String>{},
),
); );
if (ok) return true; if (ok) return true;
} catch (_) { } catch (_) {
......
...@@ -28,7 +28,7 @@ extension NumExtension on num { ...@@ -28,7 +28,7 @@ extension NumExtension on num {
} }
enum CurrencyUnit { enum CurrencyUnit {
vnd(' đ'), vnd('đ'),
usd(' USD'), usd(' USD'),
eur(' EUR'), eur(' EUR'),
none(' '), none(' '),
......
...@@ -74,16 +74,22 @@ class PushNotification { ...@@ -74,16 +74,22 @@ class PushNotification {
if (kDebugMode) print('From info - name: $name, param: $param'); if (kDebugMode) print('From info - name: $name, param: $param');
} }
DirectionalScreen? screen;
if (name != null || param != null) { if (name != null || param != null) {
if (kDebugMode) print('Building DirectionalScreen with name: $name, param: $param'); if (kDebugMode) print('Building DirectionalScreen with name: $name, param: $param');
return DirectionalScreen.build(clickActionType: name, clickActionParam: param); screen = DirectionalScreen.build(clickActionType: name, clickActionParam: param);
} }
if (kDebugMode) { if (kDebugMode) {
print('No action data found, using default notification screen with ID: $id'); print('No action data found, using default notification screen with ID: $id');
print('Title: $title, Body: $body'); print('Title: $title, Body: $body');
} }
return DirectionalScreen.buildByName(name: DirectionalScreenName.notification, clickActionParam: id); screen ??= DirectionalScreen.buildByName(name: DirectionalScreenName.notification, clickActionParam: id);
screen?.extraData = {
'title': title,
'body': body,
};
return screen;
// TODO handel title + body // TODO handel title + body
} }
......
...@@ -36,6 +36,8 @@ import '../screen/device_manager/device_manager_model.dart'; ...@@ -36,6 +36,8 @@ import '../screen/device_manager/device_manager_model.dart';
import '../screen/electric_payment/models/customer_contract_object_model.dart'; import '../screen/electric_payment/models/customer_contract_object_model.dart';
import '../screen/electric_payment/models/electric_payment_response_model.dart'; import '../screen/electric_payment/models/electric_payment_response_model.dart';
import '../screen/faqs/faqs_model.dart'; import '../screen/faqs/faqs_model.dart';
import '../screen/flash_sale/models/flash_sale_category_model.dart';
import '../screen/flash_sale/models/flash_sale_detail_response.dart';
import '../screen/game/models/game_bundle_item_model.dart'; import '../screen/game/models/game_bundle_item_model.dart';
import '../screen/history_point_cashback/models/history_point_cashback_model.dart'; import '../screen/history_point_cashback/models/history_point_cashback_model.dart';
import '../screen/home/models/achievement_model.dart'; import '../screen/home/models/achievement_model.dart';
...@@ -886,6 +888,51 @@ extension RestfulAPIClientAllRequest on RestfulAPIClient { ...@@ -886,6 +888,51 @@ extension RestfulAPIClientAllRequest on RestfulAPIClient {
}); });
} }
Future<BaseResponseModel<List<FlashSaleCategoryModel>>> getFlashSaleCategories(String groupId) async {
final path = APIPaths.getFlashSaleGroup.replaceFirst('%@', groupId);
return requestNormal(path, Method.GET, const {}, (data) {
if (data is List) {
return data.whereType<Map<String, dynamic>>().map(FlashSaleCategoryModel.fromJson).toList();
}
if (data is Map<String, dynamic>) {
final categories = data['categories'] ?? data['data'] ?? data['items'];
if (categories is List) {
return categories.whereType<Map<String, dynamic>>().map(FlashSaleCategoryModel.fromJson).toList();
}
if (data.containsKey('_id')) {
return [FlashSaleCategoryModel.fromJson(Map<String, dynamic>.from(data))];
}
}
return <FlashSaleCategoryModel>[];
});
}
Future<BaseResponseModel<FlashSaleDetailResponse>> getFlashSaleDetail({
required String groupId,
int index = 0,
int size = 10,
int? categoryId,
}) async {
final path = APIPaths.getFlashSaleDetail.replaceFirst('%@', groupId);
final params = <String, dynamic>{
'index': index,
'size': size,
if (categoryId != null) 'category_id': categoryId,
};
return requestNormal(path, Method.GET, params, (data) {
if (data is Map<String, dynamic>) {
return FlashSaleDetailResponse.fromJson(data);
}
if (data is List && data.isNotEmpty) {
final first = data.first;
if (first is Map<String, dynamic>) {
return FlashSaleDetailResponse.fromJson(first);
}
}
return FlashSaleDetailResponse(products: <ProductModel>[]);
});
}
Future<BaseResponseModel<DeviceItemModel>> getCurrentDevice() async { Future<BaseResponseModel<DeviceItemModel>> getCurrentDevice() async {
return requestNormal(APIPaths.getCurrentDevice, Method.GET, {}, (data) { return requestNormal(APIPaths.getCurrentDevice, Method.GET, {}, (data) {
return DeviceItemModel.fromJson(data as Json); return DeviceItemModel.fromJson(data as Json);
......
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:mypoint_flutter_app/base/base_screen.dart';
import 'package:mypoint_flutter_app/base/basic_state.dart';
import 'package:mypoint_flutter_app/extensions/num_extension.dart';
import 'package:mypoint_flutter_app/screen/flash_sale/flash_sale_viewmodel.dart';
import 'package:mypoint_flutter_app/screen/flash_sale/models/flash_sale_category_model.dart';
import 'package:mypoint_flutter_app/screen/voucher/models/product_model.dart';
import 'package:mypoint_flutter_app/widgets/custom_empty_widget.dart';
import 'package:mypoint_flutter_app/widgets/image_loader.dart';
import '../../shared/router_gage.dart';
import '../../widgets/custom_navigation_bar.dart';
class FlashSaleScreen extends BaseScreen {
const FlashSaleScreen({super.key});
@override
State<StatefulWidget> createState() => _FlashSaleScreenState();
}
class _FlashSaleScreenState extends BaseState<FlashSaleScreen> with BasicState {
late final FlashSaleViewModel _viewModel;
@override
void initState() {
super.initState();
final args = Get.arguments;
final dynamic rawId = args is Map ? (args['groupId'] ?? args['id']) : args;
final String groupId = (rawId == null || rawId.toString().isEmpty) ? '294' : rawId.toString();
_viewModel = Get.put(FlashSaleViewModel(groupId: groupId));
_viewModel.onShowAlertError ??= (message) => showAlertError(content: message);
}
@override
Widget createBody() {
return Scaffold(
backgroundColor: Colors.grey[200],
appBar: const CustomNavigationBar(title: 'Flash Sale'),
body: NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollUpdateNotification &&
notification.metrics.axis == Axis.vertical &&
notification.metrics.pixels >= notification.metrics.maxScrollExtent - 200) {
_viewModel.loadMore();
}
return false;
},
child: RefreshIndicator(
onRefresh: _viewModel.refresh,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(child: Obx(() => _buildCategorySection())),
SliverToBoxAdapter(child: Obx(() => _buildCountdownSection())),
Obx(() => _buildProductSliver()),
],
),
),
),
);
}
Widget _buildCategorySection() {
final categories = _viewModel.categories;
if (categories.isEmpty) return const SizedBox.shrink();
final selectedId = _viewModel.selectedCategoryId.value ?? FlashSaleViewModel.allCategory.id;
return Container(
color: Colors.white,
height: 72,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: categories.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (_, index) {
final category = categories[index];
final bool isSelected = selectedId == category.id;
return _buildCategoryChip(category, isSelected);
},
),
);
}
Widget _buildCategoryChip(FlashSaleCategoryModel category, bool isSelected) {
final Color accent = isSelected ? const Color(0xFFE53935) : const Color(0xFFE0E0E0);
final Color textColor = isSelected ? const Color(0xFFE53935) : Colors.black87;
return GestureDetector(
onTap: () => _viewModel.onCategorySelected(category),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: accent, width: 1),
),
child: Center(
child: Text(category.name ?? '', style: TextStyle(color: textColor, fontWeight: FontWeight.w600)),
),
),
);
}
Widget _buildCountdownSection() {
final sale = _viewModel.products.firstOrNull?.previewFlashSale;
if (sale == null) return const SizedBox.shrink();
final duration = _viewModel.remaining.value;
final bool isCounting = duration.inSeconds > 0;
final label = (sale.desTime ?? 'Kết thúc trong').toUpperCase();
return Container(
padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 0),
child: Row(
children: [
const Spacer(),
Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.black54)),
const SizedBox(width: 12),
_buildTimeChip(_formatTwoDigits(duration.inHours)),
const SizedBox(width: 4),
const Text(':', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 4),
_buildTimeChip(_formatTwoDigits(duration.inMinutes.remainder(60))),
const SizedBox(width: 4),
const Text(':', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 4),
_buildTimeChip(_formatTwoDigits(duration.inSeconds.remainder(60))),
if (!isCounting)
const Text(
'Đã kết thúc',
style: TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600),
),
],
),
);
}
Widget _buildTimeChip(String value) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration(color: const Color(0xFF212121), borderRadius: BorderRadius.circular(6)),
child: Text(value, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
);
}
Widget _buildProductSliver() {
final products = _viewModel.products;
if (products.isEmpty) {
return const SliverFillRemaining(hasScrollBody: false, child: EmptyWidget());
}
final double screenWidth = MediaQuery.of(context).size.width;
final double itemWidth = (screenWidth - 36) / 2;
final bool showProgress = products.firstOrNull?.isShowProsessSoldItem ?? false;
// 16 space top-bottom content
// 24 height price
// 48 height name
final double itemHeight = itemWidth * 9 / 16 + 16 + 24 + 48 + (showProgress ? 24 : 0);
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: itemWidth / itemHeight,
),
delegate: SliverChildBuilderDelegate(
(context, index) => _buildProductCard(products[index]),
childCount: products.length,
),
),
);
}
Widget _buildProductCard(ProductModel product) {
final percent = product.percentDiscount;
final bool showProgress = product.isShowProsessSoldItem;
return GestureDetector(
onTap: () => Get.toNamed(voucherDetailScreen, arguments: {"productId": product.id}),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 4))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Stack(
fit: StackFit.expand,
children: [
loadNetworkImage(
url: product.banner?.url ?? '',
fit: BoxFit.cover,
placeholderAsset: 'assets/images/bg_default_169.png',
),
if (percent != null)
Positioned(
right: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFE53935),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'-$percent%',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12),
),
),
),
],
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Image.asset('assets/images/ic_hot_flash_sale.png', width: 20, height: 20),
const SizedBox(width: 4),
Text(
product.amountToBePaid?.money(CurrencyUnit.vnd) ?? '',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Colors.black87),
),
if ((product.price?.value ?? 0) > 0)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'${product.price?.value?.money(CurrencyUnit.noneSpace)}đ',
style: const TextStyle(
fontSize: 14,
color: Colors.grey,
decoration: TextDecoration.lineThrough,
),
),
),
],
),
const SizedBox(height: 6),
Text(
product.name ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
if (showProgress) ...[
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SizedBox(
width: 100,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: product.progress,
minHeight: 6,
backgroundColor: Colors.grey.shade300,
valueColor: AlwaysStoppedAnimation<Color>(product.inStock ? Colors.orange : Colors.red),
),
),
),
const SizedBox(width: 2),
Text(
product.inStock ? 'Đã bán ${product.previewFlashSale?.fsQuantitySold ?? 0}' : 'Đã bán hết',
style: TextStyle(fontSize: 12, color: product.inStock ? Colors.black : Colors.grey),
),
],
),
],
],
),
),
),
],
),
),
);
}
String _formatTwoDigits(int value) => value.toString().padLeft(2, '0');
}
import 'dart:async';
import 'package:get/get.dart';
import 'package:mypoint_flutter_app/networking/restful_api_client_all_request.dart';
import 'package:mypoint_flutter_app/networking/restful_api_viewmodel.dart';
import 'package:mypoint_flutter_app/screen/flash_sale/models/flash_sale_category_model.dart';
import 'package:mypoint_flutter_app/screen/flash_sale/models/flash_sale_detail_response.dart';
import 'package:mypoint_flutter_app/screen/voucher/models/product_model.dart';
class FlashSaleViewModel extends RestfulApiViewModel {
final String groupId;
FlashSaleViewModel({required this.groupId});
static const FlashSaleCategoryModel allCategory = FlashSaleCategoryModel(id: -1, name: 'Tất cả');
final RxList<FlashSaleCategoryModel> categories = <FlashSaleCategoryModel>[].obs;
final RxnInt selectedCategoryId = RxnInt();
final RxList<ProductModel> products = <ProductModel>[].obs;
final Rx<FlashSaleDetailResponse?> flashSale = Rx<FlashSaleDetailResponse?>(null);
final RxBool hasMore = true.obs;
final Rx<Duration> remaining = const Duration().obs;
void Function(String message)? onShowAlertError;
Timer? _timer;
int _offset = 0;
final int _pageSize = 20;
@override
void onInit() {
super.onInit();
_loadInitialData();
}
Future<void> _loadInitialData({bool withLoading = true}) async {
if (withLoading) showLoading();
await fetchCategories();
await loadProducts(reset: true);
if (withLoading) hideLoading();
}
Future<void> fetchCategories() async {
await callApi<List<FlashSaleCategoryModel>>(
withLoading: false,
request: () => client.getFlashSaleCategories(groupId),
onSuccess: (data, _) {
final extended = <FlashSaleCategoryModel>[allCategory, ...data];
categories.assignAll(extended);
selectedCategoryId.value ??= allCategory.id;
},
onFailure: (_, _, _) {
if (categories.isEmpty) {
categories.assignAll(const [allCategory]);
selectedCategoryId.value = allCategory.id;
}
},
);
}
Future<void> loadProducts({bool reset = false}) async {
if (reset) {
_offset = 0;
hasMore.value = true;
} else {
_offset = products.length;
}
final categoryId = selectedCategoryId.value;
final int? categoryParam = (categoryId != null && categoryId != allCategory.id) ? categoryId : null;
await callApi<FlashSaleDetailResponse>(
withLoading: false,
request:
() => client.getFlashSaleDetail(groupId: groupId, index: _offset, size: _pageSize, categoryId: categoryParam),
onSuccess: (data, _) {
final fetched = data.products ?? <ProductModel>[];
if (reset) {
products.assignAll(fetched);
} else {
products.addAll(fetched);
}
hasMore.value = fetched.length >= _pageSize;
flashSale.value = data;
_restartTimer();
},
onFailure: (message, _, _) {
if (reset) {
products.clear();
}
hasMore.value = false;
onShowAlertError?.call(message);
},
);
}
Future<void> refresh() async {
await loadProducts(reset: true);
}
void onCategorySelected(FlashSaleCategoryModel category) {
if (selectedCategoryId.value == category.id) return;
selectedCategoryId.value = category.id;
loadProducts(reset: true);
}
void loadMore() {
if (!hasMore.value) return;
loadProducts(reset: false);
}
void _restartTimer() {
final info = products.firstOrNull?.previewFlashSale;
_timer?.cancel();
final target = info;
final int seconds = target?.countdownLocal?.inSeconds ?? target?.countdownSecond ?? 0;
if (seconds <= 0) {
remaining.value = Duration.zero;
return;
}
remaining.value = Duration(seconds: seconds);
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
final current = remaining.value;
if (current.inSeconds <= 1) {
remaining.value = Duration.zero;
timer.cancel();
_loadInitialData(withLoading: false);
return;
}
remaining.value = Duration(seconds: current.inSeconds - 1);
});
}
@override
void onClose() {
_timer?.cancel();
super.onClose();
}
}
import 'package:json_annotation/json_annotation.dart';
part 'flash_sale_category_model.g.dart';
@JsonSerializable()
class FlashSaleCategoryModel {
@JsonKey(name: '_id')
final int id;
final String? name;
@JsonKey(name: 'code')
final String? code;
const FlashSaleCategoryModel({required this.id, this.name, this.code});
factory FlashSaleCategoryModel.fromJson(Map<String, dynamic> json) =>
_$FlashSaleCategoryModelFromJson(json);
Map<String, dynamic> toJson() => _$FlashSaleCategoryModelToJson(this);
}
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'flash_sale_category_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
FlashSaleCategoryModel _$FlashSaleCategoryModelFromJson(
Map<String, dynamic> json,
) => FlashSaleCategoryModel(
id: (json['_id'] as num).toInt(),
name: json['name'] as String?,
code: json['code'] as String?,
);
Map<String, dynamic> _$FlashSaleCategoryModelToJson(
FlashSaleCategoryModel instance,
) => <String, dynamic>{
'_id': instance.id,
'name': instance.name,
'code': instance.code,
};
import 'package:json_annotation/json_annotation.dart';
import 'package:mypoint_flutter_app/screen/flash_sale/models/preview_flash_sale_model.dart';
import 'package:mypoint_flutter_app/screen/voucher/models/product_model.dart';
part 'flash_sale_detail_response.g.dart';
@JsonSerializable()
class FlashSaleDetailResponse {
final int? id;
@JsonKey(name: 'flash_sale')
final PreviewFlashSale? flashSale;
final List<ProductModel>? products;
final String? name;
@JsonKey(name: 'countdown_second')
final int? countdownSecond;
@JsonKey(name: 'header_img')
final String? headerImg;
@JsonKey(name: 'start_time')
final String? startTime;
@JsonKey(name: 'end_time')
final String? endTime;
@JsonKey(name: 'is_reward')
final bool? isReward;
FlashSaleDetailResponse({
this.id,
this.flashSale,
this.products,
this.name,
this.countdownSecond,
this.headerImg,
this.startTime,
this.endTime,
this.isReward,
});
factory FlashSaleDetailResponse.fromJson(Map<String, dynamic> json) =>
_$FlashSaleDetailResponseFromJson(json);
Map<String, dynamic> toJson() => _$FlashSaleDetailResponseToJson(this);
}
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'flash_sale_detail_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
FlashSaleDetailResponse _$FlashSaleDetailResponseFromJson(
Map<String, dynamic> json,
) => FlashSaleDetailResponse(
id: (json['id'] as num?)?.toInt(),
flashSale:
json['flash_sale'] == null
? null
: PreviewFlashSale.fromJson(
json['flash_sale'] as Map<String, dynamic>,
),
products:
(json['products'] as List<dynamic>?)
?.map((e) => ProductModel.fromJson(e as Map<String, dynamic>))
.toList(),
name: json['name'] as String?,
countdownSecond: (json['countdown_second'] as num?)?.toInt(),
headerImg: json['header_img'] as String?,
startTime: json['start_time'] as String?,
endTime: json['end_time'] as String?,
isReward: json['is_reward'] as bool?,
);
Map<String, dynamic> _$FlashSaleDetailResponseToJson(
FlashSaleDetailResponse instance,
) => <String, dynamic>{
'id': instance.id,
'flash_sale': instance.flashSale,
'products': instance.products,
'name': instance.name,
'countdown_second': instance.countdownSecond,
'header_img': instance.headerImg,
'start_time': instance.startTime,
'end_time': instance.endTime,
'is_reward': instance.isReward,
};
...@@ -2,6 +2,7 @@ import 'package:json_annotation/json_annotation.dart'; ...@@ -2,6 +2,7 @@ import 'package:json_annotation/json_annotation.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
part 'preview_flash_sale_model.g.dart'; part 'preview_flash_sale_model.g.dart';
@JsonSerializable() @JsonSerializable()
class PreviewFlashSale { class PreviewFlashSale {
final int? id; final int? id;
...@@ -126,7 +127,7 @@ class PreviewFlashSale { ...@@ -126,7 +127,7 @@ class PreviewFlashSale {
DateTime? _parseDate(String? dateStr) { DateTime? _parseDate(String? dateStr) {
if (dateStr == null) return null; if (dateStr == null) return null;
try { try {
return DateFormat("yyyy-MM-dd'T'HH:mm:ss").parse(dateStr); return DateFormat("yyyy-MM-dd HH:mm:ss").parse(dateStr);
} catch (_) { } catch (_) {
return null; return null;
} }
......
...@@ -12,7 +12,7 @@ class BrandGridWidget extends StatelessWidget { ...@@ -12,7 +12,7 @@ class BrandGridWidget extends StatelessWidget {
const BrandGridWidget({super.key, required this.brands, this.sectionConfig, this.onTap}); const BrandGridWidget({super.key, required this.brands, this.sectionConfig, this.onTap});
_handleTapRightButton() { void _handleTapRightButton() {
sectionConfig?.buttonViewAll?.directionalScreen?.begin(); sectionConfig?.buttonViewAll?.directionalScreen?.begin();
} }
......
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mypoint_flutter_app/extensions/num_extension.dart'; import 'package:mypoint_flutter_app/extensions/num_extension.dart';
import 'package:mypoint_flutter_app/widgets/image_loader.dart';
import '../../voucher/models/product_model.dart'; import '../../voucher/models/product_model.dart';
import '../../voucher/sub_widget/voucher_section_title.dart'; import '../../voucher/sub_widget/voucher_section_title.dart';
import '../models/main_section_config_model.dart'; import '../models/main_section_config_model.dart';
...@@ -9,15 +10,22 @@ class FlashSaleCarouselWidget extends StatefulWidget { ...@@ -9,15 +10,22 @@ class FlashSaleCarouselWidget extends StatefulWidget {
final List<ProductModel> products; final List<ProductModel> products;
final MainSectionConfigModel? sectionConfig; final MainSectionConfigModel? sectionConfig;
final void Function(ProductModel)? onTap; final void Function(ProductModel)? onTap;
final VoidCallback? onCountdownFinished;
const FlashSaleCarouselWidget({super.key, required this.products, this.sectionConfig, this.onTap}); const FlashSaleCarouselWidget({
super.key,
required this.products,
this.sectionConfig,
this.onTap,
this.onCountdownFinished,
});
@override @override
State<FlashSaleCarouselWidget> createState() => _FlashSaleCarouselWidgetState(); State<FlashSaleCarouselWidget> createState() => _FlashSaleCarouselWidgetState();
} }
class _FlashSaleCarouselWidgetState extends State<FlashSaleCarouselWidget> { class _FlashSaleCarouselWidgetState extends State<FlashSaleCarouselWidget> {
_handleTapRightButton() { void _handleTapRightButton() {
widget.sectionConfig?.buttonViewAll?.directionalScreen?.begin(); widget.sectionConfig?.buttonViewAll?.directionalScreen?.begin();
} }
...@@ -25,13 +33,15 @@ class _FlashSaleCarouselWidgetState extends State<FlashSaleCarouselWidget> { ...@@ -25,13 +33,15 @@ class _FlashSaleCarouselWidgetState extends State<FlashSaleCarouselWidget> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final widthItem = MediaQuery.of(context).size.width / 2.5; final widthItem = MediaQuery.of(context).size.width / 2.5;
final heightItem = widthItem * 9 / 16 + (widget.products.firstOrNull?.extendSpaceFlashSaleItem ?? 130); final heightItem = widthItem * 9 / 16 + (widget.products.firstOrNull?.extendSpaceFlashSaleItem ?? 130);
final flashSale = widget.products.firstOrNull?.previewFlashSale;
if (widget.products.isEmpty) return const SizedBox.shrink(); if (widget.products.isEmpty) return const SizedBox.shrink();
return Column( return Column(
children: [ children: [
if (widget.sectionConfig?.flashSale != null) if (flashSale != null)
FlashSaleHeader( FlashSaleHeader(
flashSale: widget.sectionConfig?.flashSale, flashSale: flashSale,
onViewAll: widget.sectionConfig?.buttonViewAll?.directionalScreen != null ? _handleTapRightButton : null, onViewAll: widget.sectionConfig?.buttonViewAll?.directionalScreen != null ? _handleTapRightButton : null,
onCountdownFinished: widget.onCountdownFinished,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
ConstrainedBox( ConstrainedBox(
...@@ -41,7 +51,7 @@ class _FlashSaleCarouselWidgetState extends State<FlashSaleCarouselWidget> { ...@@ -41,7 +51,7 @@ class _FlashSaleCarouselWidgetState extends State<FlashSaleCarouselWidget> {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: widget.products.length, itemCount: widget.products.length,
separatorBuilder: (_, __) => const SizedBox(width: 12), separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: (context, index) => _buildFlashSaleGridItem(widget.products[index]), itemBuilder: (context, index) => _buildFlashSaleGridItem(widget.products[index]),
), ),
), ),
...@@ -69,11 +79,12 @@ class _FlashSaleCarouselWidgetState extends State<FlashSaleCarouselWidget> { ...@@ -69,11 +79,12 @@ class _FlashSaleCarouselWidgetState extends State<FlashSaleCarouselWidget> {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.only(topLeft: Radius.circular(8), topRight: Radius.circular(8)), borderRadius: BorderRadius.only(topLeft: Radius.circular(8), topRight: Radius.circular(8)),
child: Image.network( child: loadNetworkImage(
product.banner?.url ?? '', url: product.banner?.url ?? '',
width: double.infinity, width: double.infinity,
height: widthItem * 9 / 16, height: widthItem * 9 / 16,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholderAsset: 'assets/images/bg_default_169.png',
), ),
), ),
if (product.percentDiscount != null) if (product.percentDiscount != null)
......
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../flash_sale/preview_flash_sale_model.dart'; import '../../flash_sale/models/preview_flash_sale_model.dart';
class FlashSaleHeader extends StatefulWidget { class FlashSaleHeader extends StatefulWidget {
final PreviewFlashSale? flashSale; final PreviewFlashSale? flashSale;
final VoidCallback? onViewAll; final VoidCallback? onViewAll;
final VoidCallback? onCountdownFinished;
const FlashSaleHeader({ const FlashSaleHeader({super.key, required this.flashSale, this.onViewAll, this.onCountdownFinished});
super.key,
required this.flashSale,
this.onViewAll,
});
@override @override
State<FlashSaleHeader> createState() => _FlashSaleHeaderState(); State<FlashSaleHeader> createState() => _FlashSaleHeaderState();
...@@ -19,19 +16,29 @@ class FlashSaleHeader extends StatefulWidget { ...@@ -19,19 +16,29 @@ class FlashSaleHeader extends StatefulWidget {
class _FlashSaleHeaderState extends State<FlashSaleHeader> { class _FlashSaleHeaderState extends State<FlashSaleHeader> {
late Duration _remaining; late Duration _remaining;
Timer? _timer; Timer? _timer;
bool _didNotify = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_remaining = widget.flashSale?.countdownLocal ?? Duration(seconds: 100000); _remaining = widget.flashSale?.countdownLocal ?? Duration(seconds: 0);
_startTimer(); _startTimer();
} }
void _startTimer() { void _startTimer() {
_timer?.cancel(); _timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (_) { if (_remaining.inSeconds <= 0) return;
if (_remaining.inSeconds <= 0) { _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
_timer?.cancel(); if (!mounted) {
timer.cancel();
return;
}
if (_remaining.inSeconds <= 1) {
setState(() {
_remaining = Duration.zero;
});
timer.cancel();
_notifyCountdownFinished();
} else { } else {
setState(() { setState(() {
_remaining -= const Duration(seconds: 1); _remaining -= const Duration(seconds: 1);
...@@ -40,20 +47,10 @@ class _FlashSaleHeaderState extends State<FlashSaleHeader> { ...@@ -40,20 +47,10 @@ class _FlashSaleHeaderState extends State<FlashSaleHeader> {
}); });
} }
String _formatTime(int value) => value.toString().padLeft(2, '0'); void _notifyCountdownFinished() {
if (_didNotify) return;
Widget _buildTimeBox(String text) { _didNotify = true;
return Container( widget.onCountdownFinished?.call();
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
decoration: BoxDecoration(
color: Colors.red.shade400,
borderRadius: BorderRadius.circular(4),
),
child: Text(
text,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
);
} }
@override @override
...@@ -64,38 +61,61 @@ class _FlashSaleHeaderState extends State<FlashSaleHeader> { ...@@ -64,38 +61,61 @@ class _FlashSaleHeaderState extends State<FlashSaleHeader> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hours = _formatTime(_remaining.inHours);
final minutes = _formatTime(_remaining.inMinutes.remainder(60));
final seconds = _formatTime(_remaining.inSeconds.remainder(60));
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row( child: Row(
children: [ children: [
Image.asset("assets/images/ic_flash_sale.png", height: 24, fit: BoxFit.cover,), Image.asset("assets/images/ic_flash_sale.png", height: 24, fit: BoxFit.cover),
const SizedBox(width: 6), const SizedBox(width: 6),
Text(widget.flashSale?.desTime ?? "", style: TextStyle(fontSize: 14)), Expanded(child: _buildCountdownSection(_remaining)),
const SizedBox(width: 4), const SizedBox(width: 8),
_buildTimeBox(hours),
const SizedBox(width: 2),
const Text(":", style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 2),
_buildTimeBox(minutes),
const SizedBox(width: 2),
const Text(":", style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 2),
_buildTimeBox(seconds),
const Spacer(),
if (widget.onViewAll != null) if (widget.onViewAll != null)
GestureDetector( GestureDetector(
onTap: widget.onViewAll, onTap: widget.onViewAll,
child: Text('Xem tất cả', style: TextStyle(color: Colors.blue[700], fontWeight: FontWeight.bold)),
),
],
),
);
}
Widget _buildCountdownSection(Duration duration) {
final bool isCounting = duration.inSeconds > 0;
final label = (widget.flashSale?.desTime ?? 'Kết thúc trong');
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text( child: Text(
'Xem tất cả', label,
style: TextStyle(color: Colors.blue[700], fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.black54),
), ),
), ),
],
), ),
const SizedBox(width: 4),
_buildTimeChip(_formatTwoDigits(duration.inHours)),
const SizedBox(width: 2),
const Text(':', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 2),
_buildTimeChip(_formatTwoDigits(duration.inMinutes.remainder(60))),
const SizedBox(width: 2),
const Text(':', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 2),
_buildTimeChip(_formatTwoDigits(duration.inSeconds.remainder(60))),
],
); );
} }
Widget _buildTimeChip(String value) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
decoration: BoxDecoration(color: Colors.black, borderRadius: BorderRadius.circular(6)),
child: Text(value, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
);
}
String _formatTwoDigits(int value) => value.toString().padLeft(2, '0');
} }
...@@ -34,6 +34,7 @@ class _HomeScreenState extends State<HomeScreen> with PopupOnInit { ...@@ -34,6 +34,7 @@ class _HomeScreenState extends State<HomeScreen> with PopupOnInit {
final HomeTabViewModel _viewModel = Get.put(HomeTabViewModel()); final HomeTabViewModel _viewModel = Get.put(HomeTabViewModel());
final _headerHomeVM = Get.find<HeaderHomeViewModel>(); final _headerHomeVM = Get.find<HeaderHomeViewModel>();
final RxBool _showHover = true.obs; final RxBool _showHover = true.obs;
bool _isRefreshingFlashSale = false;
@override @override
void initState() { void initState() {
...@@ -43,6 +44,16 @@ class _HomeScreenState extends State<HomeScreen> with PopupOnInit { ...@@ -43,6 +44,16 @@ class _HomeScreenState extends State<HomeScreen> with PopupOnInit {
runPopupCheck(DirectionalScreenName.home); runPopupCheck(DirectionalScreenName.home);
} }
Future<void> _onFlashSaleCountdownFinished() async {
if (_isRefreshingFlashSale) return;
_isRefreshingFlashSale = true;
try {
await _viewModel.refreshFlashSale();
} finally {
_isRefreshingFlashSale = false;
}
}
Widget _buildSliverHeader(double heightHeader) { Widget _buildSliverHeader(double heightHeader) {
return Obx(() { return Obx(() {
final notifyUnreadData = _headerHomeVM.notificationUnreadData.value; final notifyUnreadData = _headerHomeVM.notificationUnreadData.value;
...@@ -57,10 +68,7 @@ class _HomeScreenState extends State<HomeScreen> with PopupOnInit { ...@@ -57,10 +68,7 @@ class _HomeScreenState extends State<HomeScreen> with PopupOnInit {
} }
List<Widget> _buildSectionContent() { List<Widget> _buildSectionContent() {
final sections = _viewModel.sectionLayouts final sections = _viewModel.sectionLayouts.map(_buildSection).whereType<Widget>().toList();
.map(_buildSection)
.whereType<Widget>()
.toList();
if (sections.isEmpty) { if (sections.isEmpty) {
return const [Padding(padding: EdgeInsets.symmetric(vertical: 40), child: EmptyWidget())]; return const [Padding(padding: EdgeInsets.symmetric(vertical: 40), child: EmptyWidget())];
} }
...@@ -123,6 +131,7 @@ class _HomeScreenState extends State<HomeScreen> with PopupOnInit { ...@@ -123,6 +131,7 @@ class _HomeScreenState extends State<HomeScreen> with PopupOnInit {
products: products, products: products,
sectionConfig: _viewModel.getMainSectionConfigModel(HeaderSectionType.flashSale), sectionConfig: _viewModel.getMainSectionConfigModel(HeaderSectionType.flashSale),
onTap: (product) => Get.toNamed(voucherDetailScreen, arguments: product.id), onTap: (product) => Get.toNamed(voucherDetailScreen, arguments: product.id),
onCountdownFinished: _onFlashSaleCountdownFinished,
); );
case HeaderSectionType.brand: case HeaderSectionType.brand:
if (_viewModel.brands.isEmpty) return null; if (_viewModel.brands.isEmpty) return null;
......
...@@ -58,8 +58,7 @@ class HomeTabViewModel extends RestfulApiViewModel { ...@@ -58,8 +58,7 @@ class HomeTabViewModel extends RestfulApiViewModel {
await callApi<List<MainSectionConfigModel>>( await callApi<List<MainSectionConfigModel>>(
request: () => client.getSectionLayoutHome(), request: () => client.getSectionLayoutHome(),
onSuccess: (data, _) => _resolveSectionLayouts(data), onSuccess: (data, _) => _resolveSectionLayouts(data),
onFailure: (message, response, error) async => onFailure: (message, response, error) async => _resolveSectionLayouts(await _loadSectionLayoutHomeFromCache()),
_resolveSectionLayouts(await _loadSectionLayoutHomeFromCache()),
withLoading: showLoading, withLoading: showLoading,
); );
} }
...@@ -147,6 +146,12 @@ class HomeTabViewModel extends RestfulApiViewModel { ...@@ -147,6 +146,12 @@ class HomeTabViewModel extends RestfulApiViewModel {
flashSaleData.value = res.data; flashSaleData.value = res.data;
} }
Future<void> refreshFlashSale() async {
final section = getMainSectionConfigModel(HeaderSectionType.flashSale);
if (section == null) return;
await _loadFlashSale(section);
}
Future<void> _loadBrands(MainSectionConfigModel section) async { Future<void> _loadBrands(MainSectionConfigModel section) async {
final res = await client.fetchList<BrandModel>( final res = await client.fetchList<BrandModel>(
section.apiList ?? '', section.apiList ?? '',
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment