Commit 8d264762 authored by DatHV's avatar DatHV
Browse files

update voucher detail

parent 560b49dd
......@@ -29,4 +29,6 @@ class APIPaths {
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";
static const String productCustomerLikes = "/product/api/v2.0/customer/likes";
static const String productCustomerUnlikes = "/product/api/v2.0/customer/likes/%@";
}
\ No newline at end of file
......@@ -7,7 +7,7 @@ import '../configs/constants.dart';
import 'model_maker.dart';
enum Method {
GET, POST, PUT
GET, POST, PUT, DELETE
}
class RestfulAPIClient {
......
......@@ -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/like_product_reponse_model.dart';
import '../screen/voucher/models/product_store_model.dart';
import '../screen/voucher/models/search_product_response_model.dart';
import 'model_maker.dart';
......@@ -296,4 +297,21 @@ extension RestfullAPIClientAllApi on RestfulAPIClient {
return list.map((e) => ProductStoreModel.fromJson(e)).toList();
});
}
Future<BaseResponseModel<LikeProductReponseModel>> likeProduct(int id) async {
final body = {"product_id": id};
return requestNormal(APIPaths.productCustomerLikes, Method.POST, body, (data) {
return LikeProductReponseModel.fromJson(data as Json);
});
}
Future<BaseResponseModel<EmptyCodable>> unlikeProduct(int id) async {
final path = APIPaths.productCustomerUnlikes.replaceAll("%@", id.toString());
return requestNormal(
path,
Method.DELETE,
{},
(data) => EmptyCodable.fromJson(data as Json),
);
}
}
\ No newline at end of file
import 'package:flutter/material.dart';
import 'package:mypoint_flutter_app/shared/direction_google_map.dart';
import '../models/product_store_model.dart';
class StoreListSection extends StatelessWidget {
final List<ProductStoreModel> stores;
final String? brandLogo;
const StoreListSection({Key? key, required this.stores}) : super(key: key);
const StoreListSection({Key? key, required this.stores, this.brandLogo}) : super(key: key);
@override
Widget build(BuildContext context) {
......@@ -16,49 +18,48 @@ class StoreListSection extends StatelessWidget {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(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(),
const Text('Địa điểm áp dụng:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 10),
// ...stores.map((store) => _buildStoreItem(store)).toList(),
...stores.map((store) => InkWell(
onTap: () {
_onTapStore(store);
},
child: _buildStoreItem(store),
)),
],
),
);
}
_onTapStore(ProductStoreModel store) {
showGoogleMap(lat: store.latitude, lng: store.longitude);
}
Widget _buildStoreItem(ProductStoreModel store) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Logo Brand
Row(
children: [
ClipOval(
child: Image.network(
"",
brandLogo ?? "",
width: 20,
height: 20,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Image.asset('assets/images/sample.png', width: 20, height: 20),
errorBuilder: (_, __, ___) => Image.asset('assets/images/ic_logo.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),
Text(store.name ?? '', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 4),
Row(
......@@ -78,9 +79,6 @@ class StoreListSection extends StatelessWidget {
),
],
),
),
],
),
);
}
}
......@@ -2,6 +2,7 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:mypoint_flutter_app/screen/voucher/detail/store_list_section.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../resouce/base_color.dart';
import '../../../widgets/back_button.dart';
import '../../../widgets/custom_empty_widget.dart';
......@@ -9,6 +10,8 @@ import '../../../widgets/custom_price_tag.dart';
import '../../../widgets/dashed_line.dart';
import '../../../widgets/image_loader.dart';
import '../../../widgets/measure_size.dart';
import '../models/cash_type.dart';
import '../models/my_product_status_type.dart';
import '../models/product_model.dart';
import 'voucher_detail_viewmodel.dart';
import 'package:get/get.dart';
......@@ -23,8 +26,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;
final _quantity = 1.obs;
@override
void initState() {
......@@ -41,6 +44,7 @@ class _VoucherDetailScreenState extends State<VoucherDetailScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey.shade100,
body: Obx(() {
if (_viewModel.isLoading.value) {
return const Center(child: CircularProgressIndicator());
......@@ -51,26 +55,45 @@ class _VoucherDetailScreenState extends State<VoucherDetailScreen> {
}
return Stack(
children: [
Container(
color: Colors.grey.shade100,
child: SingleChildScrollView(
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeaderWithInfo(product),
SizedBox(height: max(_infoHeight - 36, 0)),
_buildDetailBlock(product),
_buildConditionBlock(product),
_buildTextBlock("Chi tiết ưu đãi:", product.content?.detail),
_buildTextBlock("Điều kiện áp dụng:", product.content?.termAndCondition),
SizedBox(height: 8),
StoreListSection(stores: _viewModel.stores),
StoreListSection(stores: _viewModel.stores, brandLogo: product.brand?.logo ?? ""),
_buildSupportBlock(product),
Container(color: Colors.grey.shade100, child: SizedBox(height: 64)),
],
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomBackButton(),
_buildFavoriteButton(),
],
),
),
),
SafeArea(child: Padding(padding: const EdgeInsets.all(8), child: CustomBackButton())),
// SafeArea(child: Padding(padding: const EdgeInsets.all(8), child: CustomBackButton())),
],
);
}),
bottomNavigationBar: Obx(() {
final product = _viewModel.product.value;
if (product == null) {
return const SizedBox.shrink();
}
return _buildBottomAction(product);
}),
);
}
......@@ -116,12 +139,16 @@ class _VoucherDetailScreenState extends State<VoucherDetailScreen> {
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 8, offset: const Offset(0, 4))],
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.1), blurRadius: 8, offset: const Offset(0, 4))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(product.content?.name ?? '', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
Text(
product.content?.name ?? '',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)
),
const SizedBox(height: 8),
_buildExpireAndStock(product),
const SizedBox(height: 16),
......@@ -155,37 +182,19 @@ class _VoucherDetailScreenState extends State<VoucherDetailScreen> {
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(
'Hạn dùng: ',
style: const TextStyle(
color: Colors.grey,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
Text('Hạn dùng: ', style: const TextStyle(color: Colors.grey, fontWeight: FontWeight.bold, fontSize: 12)),
if (hasExpire)
Text(
product.expire,
style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
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),
),
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),
......@@ -196,11 +205,11 @@ class _VoucherDetailScreenState extends State<VoucherDetailScreen> {
}
Widget _buildDetailBlock(ProductModel product) {
return _buildTextBlock("Chi tiết ưu đãi", product.content?.detail);
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);
return _buildTextBlock("Điều kiện áp dụng:", product.content?.termAndCondition);
}
Widget _buildTextBlock(String title, String? content) {
......@@ -214,9 +223,249 @@ class _VoucherDetailScreenState extends State<VoucherDetailScreen> {
children: [
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
HtmlWidget(content),
HtmlWidget(content, textStyle: const TextStyle(fontSize: 13, color: Colors.black54)),
],
),
);
}
Widget _buildSupportBlock(ProductModel product) {
final brand = product.brand;
if (brand == null) 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: [
Text('Hỗ trợ:', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(
'Nếu bạn gặp bất kỳ vấn đề gì với voucher này, xin vui lòng liên hệ ${brand.name ?? ''}',
style: const TextStyle(fontSize: 13, color: Colors.black54),
),
const SizedBox(height: 12),
if ((brand.phone ?? '').isNotEmpty)
_buildContactRow(
Icons.phone,
brand.phone ?? '',
onTap: () async {
final Uri phoneUri = Uri.parse('tel:${brand.phone}');
_launchUri(phoneUri);
},
),
if ((brand.email ?? '').isNotEmpty)
_buildContactRow(
Icons.email_outlined,
brand.email ?? '',
onTap: () async {
final Uri emailUri = Uri.parse('mailto:${brand.email}');
_launchUri(emailUri);
},
),
if ((brand.website ?? '').isNotEmpty)
_buildContactRow(
Icons.language,
brand.website ?? '',
onTap: () {
final url = brand.website!.startsWith('http') ? brand.website! : 'https://${brand.website}';
_launchUri(Uri(scheme: url));
},
),
],
),
);
}
_launchUri(Uri uri) async {
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
throw 'Could not launch $uri';
}
}
Widget _buildContactRow(IconData icon, String value, {VoidCallback? onTap}) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Icon(icon, size: 18, color: Colors.black54),
const SizedBox(width: 8),
Expanded(child: Text(value, style: const TextStyle(fontSize: 13, color: Colors.black54))),
const Icon(Icons.chevron_right, color: Colors.black54),
],
),
),
);
}
Widget _buildBottomAction(ProductModel product) {
// if (!(product.isMyProduct
// ? product.customerInfoModel?.status == MyProductStatusType.waiting
// : (product.inStock == true && !(product.expired == true)))) {
// return const SizedBox.shrink();
// }
if (product.isMyProduct) {
return _buildUseButton();
} else if (product.price?.method == CashType.point) {
return _buildExchangeButton();
} else {
return _buildBuyButtonWithCounter();
}
}
Widget _buildBottomActionContainer({required Widget child}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: const BoxDecoration(
color: Colors.white,
boxShadow: [BoxShadow(color: Colors.black54, blurRadius: 8, offset: Offset(0, 4))],
),
child: SafeArea(top: false, child: child),
);
}
Widget _buildUseButton() {
return _buildBottomActionContainer(
child: SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: () {
// TODO: Handle sử dụng voucher
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Sử Dụng',
style: TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.bold)),
),
),
);
}
Widget _buildExchangeButton() {
return _buildBottomActionContainer(
child: SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: () {
// TODO: Handle đổi ưu đãi
},
style: ElevatedButton.styleFrom(
backgroundColor: BaseColor.primary500,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text(
'Đổi Ưu Đãi',
style: TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.bold),
),
),
),
);
}
Widget _buildBuyButtonWithCounter() {
return _buildBottomActionContainer(
child: Row(
children: [
Row(
children: [
Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(50),
),
child: IconButton(
icon: const Icon(Icons.remove, color: Colors.black),
onPressed: () {
if (_quantity.value > 1) {
_quantity.value--;
// TODO: update state
}
},
),
),
const SizedBox(width: 12),
Obx(() => Text(
'${_quantity.value}',
style: const TextStyle(fontSize: 16),
)),
const SizedBox(width: 12),
Container(
decoration: BoxDecoration(
color: BaseColor.primary500,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(50),
),
child: IconButton(
icon: const Icon(Icons.add, color: Colors.white),
onPressed: () {
_quantity.value++;
// TODO: update state
},
),
),
],
),
const SizedBox(width: 36),
Expanded(
child: SizedBox(
height: 48,
child: ElevatedButton(
onPressed: () {
// TODO: Handle mua ngay
},
style: ElevatedButton.styleFrom(
backgroundColor: BaseColor.primary500,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text(
'Mua ngay',
style: TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.bold),
),
),
),
),
],
),
);
}
Widget _buildFavoriteButton() {
return Obx(() {
final isFavorite = _viewModel.liked.value;
return Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.only(top: 8, right: 8),
child: GestureDetector(
onTap: () {
_viewModel.toggleFavorite();
},
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.6),
shape: BoxShape.circle,
),
child: Icon(
Icons.favorite,
color: isFavorite ? BaseColor.primary600 : Colors.white,
size: 24,
),
),
),
),
);
});
}
}
......@@ -10,6 +10,7 @@ class VoucherDetailViewModel extends RestfulApiViewModel {
var stores = RxList<ProductStoreModel>();
var product = Rxn<ProductModel>();
var isLoading = false.obs;
var liked = false.obs;
@override
void onInit() {
......@@ -18,12 +19,33 @@ class VoucherDetailViewModel extends RestfulApiViewModel {
_getProductStores();
}
Future<void> toggleFavorite() async {
// if (liked.value) {
// liked.value = false;
// } else {
// liked.value = true;
// }
final value = product.value;
if (value == null) return;
if (value!.liked == true) {
await client.unlikeProduct(value?.likeId ?? 0);
value?.likeId = 0;
Future.microtask(() => liked.value = false);
} else {
final response = await client.likeProduct(productId);
value?.likeId = response.data?.id;
Future.microtask(() => liked.value = (response.data?.id ?? 0) != 0);
}
// product.refresh();
}
Future<void> _getProductDetail() async {
if (isLoading.value) return;
try {
isLoading.value = true;
final response = await client.getProduct(productId);
product.value = response.data;
liked.value = product.value?.liked == true;
} catch (error) {
print("Error fetching product detail: $error");
} finally {
......
import 'package:json_annotation/json_annotation.dart';
part 'like_product_reponse_model.g.dart';
@JsonSerializable()
class LikeProductReponseModel {
@JsonKey(name: 'like_id')
final int? id;
LikeProductReponseModel({
this.id,
});
factory LikeProductReponseModel.fromJson(Map<String, dynamic> json) => _$LikeProductReponseModelFromJson(json);
Map<String, dynamic> toJson() => _$LikeProductReponseModelToJson(this);
}
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'like_product_reponse_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LikeProductReponseModel _$LikeProductReponseModelFromJson(
Map<String, dynamic> json,
) => LikeProductReponseModel(id: (json['like_id'] as num?)?.toInt());
Map<String, dynamic> _$LikeProductReponseModelToJson(
LikeProductReponseModel instance,
) => <String, dynamic>{'like_id': instance.id};
......@@ -17,6 +17,8 @@ part 'product_model.g.dart';
@JsonSerializable()
class ProductModel {
final int? id;
@JsonKey(name: 'like_id')
late final int? likeId;
@JsonKey(name: 'quantity_available')
final int? quantityAvailable;
final ProductContentModel? content;
......@@ -36,6 +38,7 @@ class ProductModel {
ProductModel({
this.id,
this.likeId,
this.quantityAvailable,
this.content,
this.price,
......@@ -72,6 +75,10 @@ class ProductModel {
return (quantityAvailable ?? 1) != 0;
}
bool get liked {
return (likeId ?? 0) != 0;
}
bool get expired {
if (customerInfoModel != null) {
return customerInfoModel?.status == MyProductStatusType.expired;
......@@ -85,5 +92,6 @@ class ProductModel {
}
factory ProductModel.fromJson(Map<String, dynamic> json) => _$ProductModelFromJson(json);
Map<String, dynamic> toJson() => _$ProductModelToJson(this);
}
\ No newline at end of file
......@@ -8,6 +8,7 @@ part of 'product_model.dart';
ProductModel _$ProductModelFromJson(Map<String, dynamic> json) => ProductModel(
id: (json['id'] as num?)?.toInt(),
likeId: (json['like_id'] as num?)?.toInt(),
quantityAvailable: (json['quantity_available'] as num?)?.toInt(),
content:
json['content'] == null
......@@ -57,6 +58,7 @@ ProductModel _$ProductModelFromJson(Map<String, dynamic> json) => ProductModel(
Map<String, dynamic> _$ProductModelToJson(ProductModel instance) =>
<String, dynamic>{
'id': instance.id,
'like_id': instance.likeId,
'quantity_available': instance.quantityAvailable,
'content': instance.content,
'price': instance.price,
......
import 'package:url_launcher/url_launcher.dart';
Future<void> showGoogleMap({required double? lat, required double? lng}) async {
if (lat == null || lng == null) return;
final googleMapsSchemeUrl = Uri.parse('comgooglemaps://');
final googleMapsAppUrl = Uri.parse('comgooglemaps-x-callback://?saddr=&daddr=$lat,$lng&directionsmode=driving');
final googleMapsWebUrl = Uri.parse('https://www.google.com/maps/dir/?saddr=&daddr=$lat,$lng&directionsmode=driving');
// Kiểm tra xem device có cài app Google Maps không
if (await canLaunchUrl(googleMapsSchemeUrl)) {
// Có app Google Maps -> mở app
if (await canLaunchUrl(googleMapsAppUrl)) {
await launchUrl(googleMapsAppUrl);
}
} else {
// Không có app -> mở trên trình duyệt
if (await canLaunchUrl(googleMapsWebUrl)) {
await launchUrl(googleMapsWebUrl, mode: LaunchMode.externalApplication);
}
}
}
\ No newline at end of file
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