Commit 560b49dd authored by DatHV's avatar DatHV
Browse files

update voucher detail

parent 00fbcfd0
......@@ -28,4 +28,5 @@ class APIPaths {
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/%@";
static const String getProductStores = "/product/api/v2.0/product/stores";
}
\ No newline at end of file
import 'package:intl/intl.dart';
extension DateTimeFormatExtension on DateTime {
/// Convert DateTime → String theo định dạng truyền vào
String toFormattedString({String format = "dd/MM/yyyy"}) {
final formatter = DateFormat(format);
return formatter.format(this);
}
}
\ No newline at end of file
......@@ -2,7 +2,6 @@ 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() {
......@@ -34,13 +33,13 @@ Color parseHexColor(String hexString, {Color fallbackColor = Colors.grey}) {
}
extension StringDateExtension on String {
DateTime? toDate({required String format}) {
DateTime? toDate() {
if (trim().isEmpty) return null;
try {
return intl.DateFormat(format).parseStrict(this);
return DateTime.parse(this); // 🚀 Dùng Dart core parse luôn đúng
} catch (e) {
print('❌ Date parse failed for "$this" with format "$format": $e');
print('❌ Date parse failed for "$this": $e');
return null;
}
}
}
\ No newline at end of file
}
......@@ -20,6 +20,7 @@ import '../screen/otp/model/otp_verify_response_model.dart';
import '../screen/pageDetail/model/campaign_detail_model.dart';
import '../screen/pageDetail/model/detail_page_rule_type.dart';
import '../screen/splash/splash_screen_viewmodel.dart';
import '../screen/voucher/models/product_store_model.dart';
import '../screen/voucher/models/search_product_response_model.dart';
import 'model_maker.dart';
......@@ -287,4 +288,12 @@ extension RestfullAPIClientAllApi on RestfulAPIClient {
(data) =>ProductModel.fromJson(data as Json),
);
}
}
Future<BaseResponseModel<List<ProductStoreModel>>> getProductStores(int id) async {
final body = {"product_id": id, "size": 20, "index": 0};
return requestNormal(APIPaths.getProductStores, Method.GET, body, (data) {
final list = data as List<dynamic>;
return list.map((e) => ProductStoreModel.fromJson(e)).toList();
});
}
}
\ No newline at end of file
import 'package:flutter/material.dart';
import '../models/product_store_model.dart';
class StoreListSection extends StatelessWidget {
final List<ProductStoreModel> stores;
const StoreListSection({Key? key, required this.stores}) : super(key: key);
@override
Widget build(BuildContext context) {
if (stores.isEmpty) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Địa điểm áp dụng:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 12),
...stores.map((store) => _buildStoreItem(store)).toList(),
],
),
);
}
Widget _buildStoreItem(ProductStoreModel store) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Logo Brand
ClipOval(
child: Image.network(
"",
width: 20,
height: 20,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Image.asset('assets/images/sample.png', width: 20, height: 20),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
store.name ?? '',
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.location_on_outlined, size: 18, color: Colors.grey),
const SizedBox(width: 4),
Expanded(
child: Text(
store.address ?? '',
style: const TextStyle(color: Colors.grey, fontSize: 13),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
],
),
);
}
}
import 'dart:math';
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 'package:mypoint_flutter_app/screen/voucher/detail/store_list_section.dart';
import '../../../resouce/base_color.dart';
import '../../../widgets/back_button.dart';
import '../../../widgets/custom_empty_widget.dart';
import '../../../widgets/custom_price_tag.dart';
import '../../../widgets/dashed_line.dart';
import '../../../widgets/image_loader.dart';
import '../../../widgets/measure_size.dart';
import '../models/product_model.dart';
import 'voucher_detail_viewmodel.dart';
import 'package:get/get.dart';
......@@ -18,6 +23,8 @@ class VoucherDetailScreen extends StatefulWidget {
class _VoucherDetailScreenState extends State<VoucherDetailScreen> {
late final int productId;
late final VoucherDetailViewModel _viewModel;
final GlobalKey _infoKey = GlobalKey();
double _infoHeight = 0;
@override
void initState() {
......@@ -29,7 +36,6 @@ class _VoucherDetailScreenState extends State<VoucherDetailScreen> {
productId = args['productId'];
}
_viewModel = Get.put(VoucherDetailViewModel(productId: productId));
_viewModel.getProductDetail();
}
@override
......@@ -41,113 +47,151 @@ class _VoucherDetailScreenState extends State<VoucherDetailScreen> {
}
final product = _viewModel.product.value;
if (product == null) {
return const Center(child: Text("Không tìm thấy sản phẩm"));
return const Center(child: EmptyWidget());
}
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(),
Container(
color: Colors.grey.shade100,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeaderWithInfo(product),
SizedBox(height: max(_infoHeight - 36, 0)),
_buildDetailBlock(product),
_buildConditionBlock(product),
SizedBox(height: 8),
StoreListSection(stores: _viewModel.stores),
],
),
),
),
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 _buildHeaderWithInfo(ProductModel product) {
final double screenWidth = MediaQuery.of(context).size.width;
final double imageHeight = screenWidth / (16 / 9);
return Stack(
clipBehavior: Clip.none,
children: [
loadNetworkImage(
url: product.banner?.url,
fit: BoxFit.cover,
height: imageHeight,
width: double.infinity,
placeholderAsset: 'assets/images/sample.png',
),
Positioned(
left: 16,
right: 16,
child: MeasureSize(
onChange: (size) {
if (_infoHeight != size.height) {
setState(() {
_infoHeight = size.height;
});
}
},
child: Transform.translate(
offset: Offset(0, imageHeight - 36), // ✅ Lấn lên trên ảnh 36px
child: _buildInfo(product),
),
),
),
],
);
}
Widget _buildInfo(ProductModel product) {
Widget _buildInfo(ProductModel product, {Key? key}) {
return Container(
key: key,
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 8, offset: const Offset(0, 4))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(product.content?.name ?? '', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Text(product.content?.name ?? '', style: const TextStyle(fontSize: 24, 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),
_buildExpireAndStock(product),
const SizedBox(height: 16),
DashedLine(color: Colors.grey.shade400, dashWidth: 3, dashSpacing: 3, height: 1),
const Divider(height: 16),
Row(
children: [
CircleAvatar(
radius: 16,
backgroundImage: NetworkImage(product.brand?.logo ?? ''),
radius: 12,
backgroundColor: Colors.transparent,
child: ClipOval(
child: loadNetworkImage(
url: product.brand?.logo ?? "",
width: 24,
height: 24,
fit: BoxFit.cover,
placeholderAsset: 'assets/images/ic_logo.png',
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(product.brand?.name ?? '', style: const TextStyle(fontSize: 14)),
),
_buildPointTag(product),
Expanded(child: Text(product.brand?.name ?? '', style: const TextStyle(fontSize: 14))),
PriceTagWidget(point: product.amountToBePaid ?? 0),
],
)
),
],
),
);
}
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),
Widget _buildExpireAndStock(ProductModel product) {
final bool isOutOfStock = !(product.inStock ?? true);
final bool hasExpire = product.expire.isNotEmpty;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (hasExpire)
Text(
priceText,
style: TextStyle(
'Hạn dùng: ',
style: const TextStyle(
color: Colors.grey,
fontWeight: FontWeight.bold,
fontSize: 12,
color: isFree ? Colors.orange : Colors.red,
fontWeight: FontWeight.w500,
),
),
],
),
if (hasExpire)
Text(
product.expire,
style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
if (isOutOfStock)
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Tạm hết',
style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
),
),
],
);
}
......@@ -161,8 +205,10 @@ class _VoucherDetailScreenState extends State<VoucherDetailScreen> {
Widget _buildTextBlock(String title, String? content) {
if (content == null || content.isEmpty) return const SizedBox();
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
return Container(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(top: 16, left: 16, right: 16, bottom: 0),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
......
......@@ -2,21 +2,23 @@ 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';
import '../models/product_store_model.dart';
class VoucherDetailViewModel extends RestfulApiViewModel {
final int productId;
VoucherDetailViewModel({required this.productId});
var stores = RxList<ProductStoreModel>();
var product = Rxn<ProductModel>();
var isLoading = false.obs;
@override
void onInit() {
super.onInit();
getProductDetail();
_getProductDetail();
_getProductStores();
}
Future<void> getProductDetail() async {
Future<void> _getProductDetail() async {
if (isLoading.value) return;
try {
isLoading.value = true;
......@@ -29,4 +31,13 @@ class VoucherDetailViewModel extends RestfulApiViewModel {
}
}
Future<void> _getProductStores() async {
try {
final response = await client.getProductStores(productId);
stores.value = response.data ?? [];
} catch (error) {
print("Error fetching product detail: $error");
} finally {
}
}
}
import 'package:json_annotation/json_annotation.dart';
import 'package:mypoint_flutter_app/extensions/date_format.dart';
import 'package:mypoint_flutter_app/extensions/datetime_extensions.dart';
import 'package:mypoint_flutter_app/extensions/string_extension.dart';
import 'package:mypoint_flutter_app/screen/voucher/models/product_brand_model.dart';
import 'package:mypoint_flutter_app/screen/voucher/models/product_content_model.dart';
import 'package:mypoint_flutter_app/screen/voucher/models/product_customer_info_model.dart';
......@@ -49,8 +52,9 @@ class ProductModel {
return content?.name;
}
String? get expire {
return isMyProduct ? itemModel?.expireTime : expireTime;
String get expire {
final ex = (isMyProduct ? itemModel?.expireTime : expireTime) ?? "";
return ex.toDate()?.toFormattedString() ?? "";
}
int? get amountToBePaid {
......
import 'package:json_annotation/json_annotation.dart';
part 'product_store_model.g.dart';
@JsonSerializable()
class ProductStoreModel {
final int? id;
final String? code;
final String? name;
final String? phone;
final String? email;
final String? fax;
final String? address;
final double? longitude;
final double? latitude;
final double? distance;
@JsonKey(name: 'district_id')
final String? districtId;
@JsonKey(name: 'district_name')
final String? districtName;
@JsonKey(name: 'city_id')
final String? cityId;
@JsonKey(name: 'city_name')
final String? cityName;
ProductStoreModel({
this.id,
this.code,
this.name,
this.phone,
this.email,
this.fax,
this.address,
this.longitude,
this.latitude,
this.distance,
this.districtId,
this.districtName,
this.cityId,
this.cityName,
});
factory ProductStoreModel.fromJson(Map<String, dynamic> json) => _$ProductStoreModelFromJson(json);
Map<String, dynamic> toJson() => _$ProductStoreModelToJson(this);
String get displayDistance {
final doubleValue = distance ?? 0;
if (doubleValue == 0) {
return "";
}
return "${doubleValue.toStringAsFixed(1)} km";
}
}
\ No newline at end of file
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'product_store_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ProductStoreModel _$ProductStoreModelFromJson(Map<String, dynamic> json) =>
ProductStoreModel(
id: (json['id'] as num?)?.toInt(),
code: json['code'] as String?,
name: json['name'] as String?,
phone: json['phone'] as String?,
email: json['email'] as String?,
fax: json['fax'] as String?,
address: json['address'] as String?,
longitude: (json['longitude'] as num?)?.toDouble(),
latitude: (json['latitude'] as num?)?.toDouble(),
distance: (json['distance'] as num?)?.toDouble(),
districtId: json['district_id'] as String?,
districtName: json['district_name'] as String?,
cityId: json['city_id'] as String?,
cityName: json['city_name'] as String?,
);
Map<String, dynamic> _$ProductStoreModelToJson(ProductStoreModel instance) =>
<String, dynamic>{
'id': instance.id,
'code': instance.code,
'name': instance.name,
'phone': instance.phone,
'email': instance.email,
'fax': instance.fax,
'address': instance.address,
'longitude': instance.longitude,
'latitude': instance.latitude,
'distance': instance.distance,
'district_id': instance.districtId,
'district_name': instance.districtName,
'city_id': instance.cityId,
'city_name': instance.cityName,
};
import 'package:flutter/material.dart';
import 'package:mypoint_flutter_app/extensions/num_extension.dart';
import 'package:get/get.dart';
import 'package:get/get_core/src/get_main.dart';
import '../../../shared/router_gage.dart';
import '../../../widgets/custom_price_tag.dart';
import '../../../widgets/image_loader.dart';
import '../models/product_model.dart';
......@@ -9,23 +11,34 @@ class VoucherItemGrid extends StatelessWidget {
const VoucherItemGrid({super.key, required this.items});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context) {
final double itemWidth = MediaQuery.of(context).size.width * 0.7;
final double imageHeight = itemWidth / (16 / 9);
final double totalHeight = imageHeight + 80;
return SizedBox(
height: totalHeight,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: items.length,
separatorBuilder: (context, index) => const SizedBox(width: 12),
itemBuilder: (context, index) =>
_VoucherGridItem(product: items[index], itemWidth: itemWidth),
height: totalHeight,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: items.length,
separatorBuilder: (context, index) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final product = items[index];
return GestureDetector(
onTap: () {
Get.toNamed(voucherDetailScreen, arguments: product.id);
},
child: _VoucherGridItem(
product: product,
itemWidth: itemWidth,
),
);
},
),
);
}
}
class _VoucherGridItem extends StatelessWidget {
......
import 'package:flutter/material.dart';
class DashedLine extends StatelessWidget {
final double height;
final double dashWidth;
final double dashSpacing;
final Color color;
const DashedLine({
Key? key,
this.height = 1,
this.dashWidth = 4,
this.dashSpacing = 4,
this.color = Colors.grey,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _DashedLinePainter(
color: color,
dashWidth: dashWidth,
dashSpacing: dashSpacing,
height: height,
),
size: Size(double.infinity, height),
);
}
}
class _DashedLinePainter extends CustomPainter {
final double dashWidth;
final double dashSpacing;
final double height;
final Color color;
_DashedLinePainter({
required this.dashWidth,
required this.dashSpacing,
required this.height,
required this.color,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = height;
double startX = 0;
while (startX < size.width) {
canvas.drawLine(Offset(startX, 0), Offset(startX + dashWidth, 0), paint);
startX += dashWidth + dashSpacing;
}
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
import 'package:flutter/cupertino.dart';
class MeasureSize extends StatefulWidget {
final Widget child;
final ValueChanged<Size> onChange;
const MeasureSize({super.key, required this.child, required this.onChange});
@override
_MeasureSizeState createState() => _MeasureSizeState();
}
class _MeasureSizeState extends State<MeasureSize> {
Size? oldSize;
@override
Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final contextBox = context.findRenderObject() as RenderBox?;
if (contextBox != null && contextBox.hasSize) {
final newSize = contextBox.size;
if (oldSize != newSize) {
oldSize = newSize;
widget.onChange(newSize);
}
}
});
return widget.child;
}
}
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