Commit 29b7f923 authored by DatHV's avatar DatHV
Browse files

update voucher detail

parent 6fcbfba8
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -27,4 +27,5 @@ class APIPaths {
static const String verifyDeleteAccount = "/user/api/v2.0/me/delete/verify";
static const String getProducts = "/product/api/v2.0/products";
static const String getSearchProducts = "/product/api/v2.0/products/search";
static const String getProductDetail = "/product/api/v2.0/products/%@";
}
\ No newline at end of file
class DateFormat {
static const String serverTimezone = "yyyy-MM-dd'T'HH:mm:ssZ";
static const String server = "yyyy-MM-dd HH:mm:ss";
static const String ddMMyyyy = "dd/MM/yyyy";
}
import 'package:intl/intl.dart';
extension NumExtension on num {
String money([CurrencyUnit currency = CurrencyUnit.vnd]) {
final int maximumFractionDigits =
(currency == CurrencyUnit.vnd) ? 0 : 2;
return "${numberFraction(maximumFractionDigits)}${currency.symbol}";
}
String numberFraction(int maxFractionDigits) {
final formatter = NumberFormat.decimalPattern('vi_VN')
..maximumFractionDigits = maxFractionDigits
..minimumFractionDigits = 0;
return formatter.format(this);
}
}
enum CurrencyUnit {
vnd(' đ'),
usd(' USD'),
eur(' EUR'),
none(' '),
point(' điểm');
final String symbol;
const CurrencyUnit(this.symbol);
}
\ No newline at end of file
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart' as intl;
import 'date_format.dart';
extension PhoneValidator on String {
bool isPhoneValid() {
......@@ -30,3 +32,15 @@ Color parseHexColor(String hexString, {Color fallbackColor = Colors.grey}) {
return fallbackColor;
}
}
extension StringDateExtension on String {
DateTime? toDate({required String format}) {
if (trim().isEmpty) return null;
try {
return intl.DateFormat(format).parseStrict(this);
} catch (e) {
print('❌ Date parse failed for "$this" with format "$format": $e');
return null;
}
}
}
\ No newline at end of file
......@@ -277,4 +277,14 @@ extension RestfullAPIClientAllApi on RestfulAPIClient {
(data) =>SearchProductResponseModel.fromJson(data as Json),
);
}
Future<BaseResponseModel<ProductModel>> getProduct(int id) async {
final path = APIPaths.getProductDetail.replaceAll("%@", id.toString());
return requestNormal(
path,
Method.GET,
{},
(data) =>ProductModel.fromJson(data as Json),
);
}
}
import 'package:json_annotation/json_annotation.dart';
import 'package:intl/intl.dart';
part 'preview_flash_sale_model.g.dart';
@JsonSerializable()
class PreviewFlashSale {
final int? id;
@JsonKey(name: 'countdown_second')
final int? countdownSecond;
@JsonKey(name: 'start_time')
final String? startTime;
@JsonKey(name: 'end_time')
final String? endTime;
@JsonKey(name: 'fs_quantity_total')
final int? fsQuantityTotal;
@JsonKey(name: 'fs_quantity_sold')
final int? fsQuantitySold;
@JsonKey(name: 'percent_tag')
final int? percentTag;
@JsonKey(name: 'reward_type')
final String? rewardType;
@JsonKey(name: 'reward_content')
final String? rewardContent;
@JsonKey(name: 'opening_content')
final String? openingContent;
final String? name;
@JsonKey(name: 'reward_popup')
final String? rewardPopup;
final int? price;
@JsonKey(name: 'is_flash_sale')
final bool? isFlashSale;
@JsonKey(name: 'is_flash_sale_price')
final bool? isFlashSalePrice;
@JsonKey(name: 'header_img')
final String? headerImg;
@JsonKey(name: 'fs_quantity_per_person_total')
final int? fsQuantityPerPersonTotal;
@JsonKey(name: 'fs_quantity_per_person_bought')
final int? fsQuantityPerPersonBought;
PreviewFlashSale({
this.id,
this.countdownSecond,
this.startTime,
this.endTime,
this.fsQuantityTotal,
this.fsQuantitySold,
this.percentTag,
this.rewardType,
this.rewardContent,
this.openingContent,
this.name,
this.rewardPopup,
this.price,
this.isFlashSale,
this.isFlashSalePrice,
this.headerImg,
this.fsQuantityPerPersonTotal,
this.fsQuantityPerPersonBought,
});
/// Factory for json_serializable
factory PreviewFlashSale.fromJson(Map<String, dynamic> json) => _$PreviewFlashSaleFromJson(json);
Map<String, dynamic> toJson() => _$PreviewFlashSaleToJson(this);
/// Computed properties (converted from Swift logic)
double? get progress {
if (fsQuantityTotal != null && fsQuantitySold != null && fsQuantityTotal! > 0) {
return fsQuantitySold! / fsQuantityTotal!;
}
return null;
}
bool get isSoldOut => (fsQuantitySold ?? 0) == (fsQuantityTotal ?? 0);
String get textQuantitySold => isSoldOut ? 'Đã bán hết' : 'Đã bán ${fsQuantitySold ?? 0}';
bool get isHasReward => rewardContent != null && rewardContent!.isNotEmpty;
String? get desTime {
if (isGoingOn == null) return null;
return isGoingOn! ? 'Kết thúc trong' : 'Bắt đầu sau';
}
int? get maximumQuantityPurchased {
if (fsQuantityPerPersonTotal != null) {
return (fsQuantityPerPersonTotal! - (fsQuantityPerPersonBought ?? 0)).clamp(0, fsQuantityPerPersonTotal!);
}
return null;
}
DateTime? get startDate => _parseDate(startTime)?.subtract(const Duration(seconds: 1));
DateTime? get endDate => _parseDate(endTime)?.add(const Duration(seconds: 1));
bool? get isGoingOn {
final now = DateTime.now();
if (startDate != null && endDate != null) {
return now.isAfter(startDate!) && now.isBefore(endDate!);
}
return null;
}
Duration? get countdownLocal {
if (isGoingOn == null) return null;
final now = DateTime.now();
if (isGoingOn!) {
return endDate?.difference(now);
} else {
return startDate?.difference(now);
}
}
bool get isHidenOpeningContent =>
openingContent == null || openingContent!.isEmpty || isGoingOn == true || isFlashSalePrice == true;
String? get imageReward {
if (rewardType == 'point') {
return 'assets/images/ic_point.png';
}
return 'assets/images/ic_gift_flash_sale.png';
}
/// Private helper
DateTime? _parseDate(String? dateStr) {
if (dateStr == null) return null;
try {
return DateFormat("yyyy-MM-dd'T'HH:mm:ss").parse(dateStr);
} catch (_) {
return null;
}
}
}
\ No newline at end of file
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'preview_flash_sale_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PreviewFlashSale _$PreviewFlashSaleFromJson(Map<String, dynamic> json) =>
PreviewFlashSale(
id: (json['id'] as num?)?.toInt(),
countdownSecond: (json['countdown_second'] as num?)?.toInt(),
startTime: json['start_time'] as String?,
endTime: json['end_time'] as String?,
fsQuantityTotal: (json['fs_quantity_total'] as num?)?.toInt(),
fsQuantitySold: (json['fs_quantity_sold'] as num?)?.toInt(),
percentTag: (json['percent_tag'] as num?)?.toInt(),
rewardType: json['reward_type'] as String?,
rewardContent: json['reward_content'] as String?,
openingContent: json['opening_content'] as String?,
name: json['name'] as String?,
rewardPopup: json['reward_popup'] as String?,
price: (json['price'] as num?)?.toInt(),
isFlashSale: json['is_flash_sale'] as bool?,
isFlashSalePrice: json['is_flash_sale_price'] as bool?,
headerImg: json['header_img'] as String?,
fsQuantityPerPersonTotal:
(json['fs_quantity_per_person_total'] as num?)?.toInt(),
fsQuantityPerPersonBought:
(json['fs_quantity_per_person_bought'] as num?)?.toInt(),
);
Map<String, dynamic> _$PreviewFlashSaleToJson(PreviewFlashSale instance) =>
<String, dynamic>{
'id': instance.id,
'countdown_second': instance.countdownSecond,
'start_time': instance.startTime,
'end_time': instance.endTime,
'fs_quantity_total': instance.fsQuantityTotal,
'fs_quantity_sold': instance.fsQuantitySold,
'percent_tag': instance.percentTag,
'reward_type': instance.rewardType,
'reward_content': instance.rewardContent,
'opening_content': instance.openingContent,
'name': instance.name,
'reward_popup': instance.rewardPopup,
'price': instance.price,
'is_flash_sale': instance.isFlashSale,
'is_flash_sale_price': instance.isFlashSalePrice,
'header_img': instance.headerImg,
'fs_quantity_per_person_total': instance.fsQuantityPerPersonTotal,
'fs_quantity_per_person_bought': instance.fsQuantityPerPersonBought,
};
import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:mypoint_flutter_app/extensions/date_format.dart';
import 'package:mypoint_flutter_app/extensions/num_extension.dart';
import 'package:mypoint_flutter_app/extensions/string_extension.dart';
import '../../../widgets/back_button.dart';
import '../models/product_model.dart';
import 'voucher_detail_viewmodel.dart';
import 'package:get/get.dart';
class VoucherDetailScreen extends StatefulWidget {
const VoucherDetailScreen({super.key});
@override
_VoucherDetailScreenState createState() => _VoucherDetailScreenState();
}
class _VoucherDetailScreenState extends State<VoucherDetailScreen> {
late final int productId;
late final VoucherDetailViewModel _viewModel;
@override
void initState() {
super.initState();
final args = Get.arguments;
if (args is int) {
productId = args;
} else if (args is Map) {
productId = args['productId'];
}
_viewModel = Get.put(VoucherDetailViewModel(productId: productId));
_viewModel.getProductDetail();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Obx(() {
if (_viewModel.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
final product = _viewModel.product.value;
if (product == null) {
return const Center(child: Text("Không tìm thấy sản phẩm"));
}
return Stack(
children: [
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(product),
_buildInfo(product),
_buildDetailBlock(product),
_buildConditionBlock(product),
],
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.all(8),
child: CustomBackButton(),
),
),
],
);
}),
);
}
Widget _buildHeader(ProductModel product) {
return AspectRatio(
aspectRatio: 16 / 9,
child: Image.network(
product.banner?.url ?? '',
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Image.asset('assets/images/sample.png', fit: BoxFit.cover),
),
);
}
Widget _buildInfo(ProductModel product) {
return Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product.content?.name ?? '', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Row(
children: [
const Text('Hạn dùng: ', style: TextStyle(color: Colors.grey)),
Text(product.expire ?? "", style: const TextStyle(color: Colors.red)),
],
),
if (!(product.inStock ?? true))
Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(color: Colors.grey.shade300, borderRadius: BorderRadius.circular(8)),
child: const Text('Tạm hết', style: TextStyle(color: Colors.red)),
),
const Divider(height: 24),
Row(
children: [
CircleAvatar(
radius: 16,
backgroundImage: NetworkImage(product.brand?.logo ?? ''),
backgroundColor: Colors.transparent,
),
const SizedBox(width: 8),
Expanded(
child: Text(product.brand?.name ?? '', style: const TextStyle(fontSize: 14)),
),
_buildPointTag(product),
],
)
],
),
);
}
Widget _buildPointTag(ProductModel product) {
final priceText = product.amountToBePaid?.money() ?? "";
final isFree = (product.price?.value ?? 0) == 0;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: isFree ? Colors.orange.shade50 : Colors.red.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset('assets/images/ic_point.png', width: 20, height: 20),
const SizedBox(width: 4),
Text(
priceText,
style: TextStyle(
fontSize: 12,
color: isFree ? Colors.orange : Colors.red,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
Widget _buildDetailBlock(ProductModel product) {
return _buildTextBlock("Chi tiết ưu đãi", product.content?.detail);
}
Widget _buildConditionBlock(ProductModel product) {
return _buildTextBlock("Điều kiện áp dụng", product.content?.termAndCondition);
}
Widget _buildTextBlock(String title, String? content) {
if (content == null || content.isEmpty) return const SizedBox();
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
HtmlWidget(content),
],
),
);
}
}
import 'package:get/get.dart';
import 'package:mypoint_flutter_app/networking/restful_api_request.dart';
import '../../../base/restful_api_viewmodel.dart';
import '../models/product_model.dart';
class VoucherDetailViewModel extends RestfulApiViewModel {
final int productId;
VoucherDetailViewModel({required this.productId});
var product = Rxn<ProductModel>();
var isLoading = false.obs;
@override
void onInit() {
super.onInit();
getProductDetail();
}
Future<void> getProductDetail() async {
if (isLoading.value) return;
try {
isLoading.value = true;
final response = await client.getProduct(productId);
product.value = response.data;
} catch (error) {
print("Error fetching product detail: $error");
} finally {
isLoading.value = false;
}
}
}
......@@ -16,11 +16,3 @@ extension CashTypeExt on CashType {
}
}
}
extension IntExtension on int {
String makeDisplayPrice(CashType type) {
if (this == 0) return "Miễn phí";
if (type == CashType.point) return "$this điểm";
return "$this đ";
}
}
enum MyProductStatusType {
waiting,
used,
expired;
static MyProductStatusType fromRaw(int raw) {
switch (raw) {
case 0:
return MyProductStatusType.waiting;
case 1:
return MyProductStatusType.used;
case 2:
return MyProductStatusType.expired;
default:
return MyProductStatusType.waiting; // fallback
}
}
String get rawText {
switch (this) {
case MyProductStatusType.waiting:
return "";
case MyProductStatusType.used:
return "Đã sử dụng";
case MyProductStatusType.expired:
return "Hết hạn";
}
}
String get titleNoData {
switch (this) {
case MyProductStatusType.waiting:
return "voucher.my_voucher_empty";
case MyProductStatusType.used:
return "voucher.used_voucher_empty";
case MyProductStatusType.expired:
return "voucher.expired_voucher_empty";
}
}
// Nếu bạn cần thêm color hoặc các property khác thì viết tiếp ở đây
}
\ No newline at end of file
......@@ -7,6 +7,7 @@ class ProductContentModel {
final String? language;
final String? name;
final String? detail;
@JsonKey(name: 'term_and_condition')
final String? termAndCondition;
ProductContentModel({
......
......@@ -11,7 +11,7 @@ ProductContentModel _$ProductContentModelFromJson(Map<String, dynamic> json) =>
language: json['language'] as String?,
name: json['name'] as String?,
detail: json['detail'] as String?,
termAndCondition: json['termAndCondition'] as String?,
termAndCondition: json['term_and_condition'] as String?,
);
Map<String, dynamic> _$ProductContentModelToJson(
......@@ -20,5 +20,5 @@ Map<String, dynamic> _$ProductContentModelToJson(
'language': instance.language,
'name': instance.name,
'detail': instance.detail,
'termAndCondition': instance.termAndCondition,
'term_and_condition': instance.termAndCondition,
};
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