Skip to content
GitLab
Menu
Projects
Groups
Snippets
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
Menu
Open sidebar
Hoàng Văn Đạt
mypoint_flutter_app
Commits
6fcbfba8
Commit
6fcbfba8
authored
Apr 25, 2025
by
DatHV
Browse files
update voucher tab
parent
d86c3328
Changes
41
Hide whitespace changes
Inline
Side-by-side
lib/screen/voucher/models/product_model.g.dart
0 → 100644
View file @
6fcbfba8
// GENERATED CODE - DO NOT MODIFY BY HAND
part of
'product_model.dart'
;
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ProductModel
_$ProductModelFromJson
(
Map
<
String
,
dynamic
>
json
)
=>
ProductModel
(
quantityAvailable:
(
json
[
'quantity_available'
]
as
num
?)?.
toInt
(),
content:
json
[
'content'
]
==
null
?
null
:
ProductContentModel
.
fromJson
(
json
[
'content'
]
as
Map
<
String
,
dynamic
>,
),
price:
json
[
'price'
]
==
null
?
null
:
ProductPriceModel
.
fromJson
(
json
[
'price'
]
as
Map
<
String
,
dynamic
>),
brand:
json
[
'brand'
]
==
null
?
null
:
ProductBrandModel
.
fromJson
(
json
[
'brand'
]
as
Map
<
String
,
dynamic
>),
properties:
json
[
'voucher_properties'
]
==
null
?
null
:
ProductPropertiesModel
.
fromJson
(
json
[
'voucher_properties'
]
as
Map
<
String
,
dynamic
>,
),
media:
(
json
[
'media'
]
as
List
<
dynamic
>?)
?.
map
((
e
)
=>
ProductMediaItem
.
fromJson
(
e
as
Map
<
String
,
dynamic
>))
.
toList
(),
);
Map
<
String
,
dynamic
>
_$ProductModelToJson
(
ProductModel
instance
)
=>
<
String
,
dynamic
>{
'quantity_available'
:
instance
.
quantityAvailable
,
'content'
:
instance
.
content
,
'price'
:
instance
.
price
,
'brand'
:
instance
.
brand
,
'voucher_properties'
:
instance
.
properties
,
'media'
:
instance
.
media
,
};
lib/screen/voucher/models/product_price_model.dart
0 → 100644
View file @
6fcbfba8
import
'package:json_annotation/json_annotation.dart'
;
import
'cash_type.dart'
;
// Enum CashType
part
'product_price_model.g.dart'
;
@JsonSerializable
()
class
ProductPriceModel
{
@JsonKey
(
name:
"payment_method"
)
final
String
?
paymentMethod
;
@JsonKey
(
name:
"sale_price"
)
final
int
?
salePrice
;
@JsonKey
(
name:
"last_price"
)
final
int
?
lastPrice
;
ProductPriceModel
({
this
.
paymentMethod
,
this
.
salePrice
,
this
.
lastPrice
,
});
CashType
get
method
=>
CashTypeExt
.
from
(
paymentMethod
);
int
?
get
value
=>
lastPrice
??
salePrice
;
String
?
get
displayPriceType
{
if
(
value
==
null
)
return
null
;
return
value
!.
makeDisplayPrice
(
method
);
}
String
?
get
displayPriceCommon
{
if
(
value
==
null
)
return
null
;
return
"
${value!.toString()}
đ"
;
// Replace with your number formatting
}
factory
ProductPriceModel
.
fromJson
(
Map
<
String
,
dynamic
>
json
)
=>
_$ProductPriceModelFromJson
(
json
);
Map
<
String
,
dynamic
>
toJson
()
=>
_$ProductPriceModelToJson
(
this
);
}
lib/screen/voucher/models/product_price_model.g.dart
0 → 100644
View file @
6fcbfba8
// GENERATED CODE - DO NOT MODIFY BY HAND
part of
'product_price_model.dart'
;
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ProductPriceModel
_$ProductPriceModelFromJson
(
Map
<
String
,
dynamic
>
json
)
=>
ProductPriceModel
(
paymentMethod:
json
[
'payment_method'
]
as
String
?,
salePrice:
(
json
[
'sale_price'
]
as
num
?)?.
toInt
(),
lastPrice:
(
json
[
'last_price'
]
as
num
?)?.
toInt
(),
);
Map
<
String
,
dynamic
>
_$ProductPriceModelToJson
(
ProductPriceModel
instance
)
=>
<
String
,
dynamic
>{
'payment_method'
:
instance
.
paymentMethod
,
'sale_price'
:
instance
.
salePrice
,
'last_price'
:
instance
.
lastPrice
,
};
lib/screen/voucher/models/product_properties_model.dart
0 → 100644
View file @
6fcbfba8
import
'package:json_annotation/json_annotation.dart'
;
part
'product_properties_model.g.dart'
;
@JsonSerializable
()
class
ProductPropertiesModel
{
final
String
?
voucherType
;
final
double
?
voucherValue
;
ProductPropertiesModel
({
this
.
voucherType
,
this
.
voucherValue
});
String
?
get
title
{
if
(
voucherValue
==
null
||
voucherValue
==
0
)
return
null
;
if
(
voucherType
==
"VOUCHER_TYPE_DISCOUNT"
)
{
return
"
${voucherValue!.toStringAsFixed(0)}
%"
;
}
return
"
$voucherValue
đ"
;
}
factory
ProductPropertiesModel
.
fromJson
(
Map
<
String
,
dynamic
>
json
)
=>
_$ProductPropertiesModelFromJson
(
json
);
Map
<
String
,
dynamic
>
toJson
()
=>
_$ProductPropertiesModelToJson
(
this
);
}
lib/screen/voucher/models/product_properties_model.g.dart
0 → 100644
View file @
6fcbfba8
// GENERATED CODE - DO NOT MODIFY BY HAND
part of
'product_properties_model.dart'
;
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ProductPropertiesModel
_$ProductPropertiesModelFromJson
(
Map
<
String
,
dynamic
>
json
,
)
=>
ProductPropertiesModel
(
voucherType:
json
[
'voucherType'
]
as
String
?,
voucherValue:
(
json
[
'voucherValue'
]
as
num
?)?.
toDouble
(),
);
Map
<
String
,
dynamic
>
_$ProductPropertiesModelToJson
(
ProductPropertiesModel
instance
,
)
=>
<
String
,
dynamic
>{
'voucherType'
:
instance
.
voucherType
,
'voucherValue'
:
instance
.
voucherValue
,
};
lib/screen/voucher/models/product_type.dart
0 → 100644
View file @
6fcbfba8
enum
ProductType
{
voucher
,
topupMobile
,
topupData
,
mobileCard
,
offline
,
typeCard
,
vnTra
,
}
extension
ProductTypeExt
on
ProductType
{
String
get
value
{
switch
(
this
)
{
case
ProductType
.
voucher
:
return
'PRODUCT_TYPE_VOUCHER'
;
case
ProductType
.
topupMobile
:
return
'PRODUCT_TYPE_TOPUP_MOBILE'
;
case
ProductType
.
topupData
:
return
'PRODUCT_TYPE_TOPUP_DATA'
;
case
ProductType
.
mobileCard
:
return
'PRODUCT_TYPE_MOBILE_CARD'
;
case
ProductType
.
offline
:
return
'PRODUCT_TYPE_OFFLINE'
;
case
ProductType
.
typeCard
:
return
'PRODUCT_TYPE_CARD'
;
case
ProductType
.
vnTra
:
return
'PRODUCT_TYPE_VNTRA_PACKAGE'
;
}
}
/// Parse từ String về enum
static
ProductType
?
from
(
String
?
raw
)
{
switch
(
raw
)
{
case
'PRODUCT_TYPE_VOUCHER'
:
return
ProductType
.
voucher
;
case
'PRODUCT_TYPE_TOPUP_MOBILE'
:
return
ProductType
.
topupMobile
;
case
'PRODUCT_TYPE_TOPUP_DATA'
:
return
ProductType
.
topupData
;
case
'PRODUCT_TYPE_MOBILE_CARD'
:
return
ProductType
.
mobileCard
;
case
'PRODUCT_TYPE_OFFLINE'
:
return
ProductType
.
offline
;
case
'PRODUCT_TYPE_CARD'
:
return
ProductType
.
typeCard
;
case
'PRODUCT_TYPE_VNTRA_PACKAGE'
:
return
ProductType
.
vnTra
;
default
:
return
null
;
}
}
}
lib/screen/voucher/models/search_product_response_model.dart
0 → 100644
View file @
6fcbfba8
import
'package:json_annotation/json_annotation.dart'
;
import
'package:mypoint_flutter_app/screen/voucher/models/product_model.dart'
;
part
'search_product_response_model.g.dart'
;
@JsonSerializable
()
class
SearchProductResponseModel
{
final
List
<
ProductModel
>?
products
;
final
int
?
total
;
SearchProductResponseModel
({
this
.
products
,
this
.
total
});
factory
SearchProductResponseModel
.
fromJson
(
Map
<
String
,
dynamic
>
json
)
=>
_$SearchProductResponseModelFromJson
(
json
);
Map
<
String
,
dynamic
>
toJson
()
=>
_$SearchProductResponseModelToJson
(
this
);
}
\ No newline at end of file
lib/screen/voucher/models/search_product_response_model.g.dart
0 → 100644
View file @
6fcbfba8
// GENERATED CODE - DO NOT MODIFY BY HAND
part of
'search_product_response_model.dart'
;
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SearchProductResponseModel
_$SearchProductResponseModelFromJson
(
Map
<
String
,
dynamic
>
json
,
)
=>
SearchProductResponseModel
(
products:
(
json
[
'products'
]
as
List
<
dynamic
>?)
?.
map
((
e
)
=>
ProductModel
.
fromJson
(
e
as
Map
<
String
,
dynamic
>))
.
toList
(),
total:
(
json
[
'total'
]
as
num
?)?.
toInt
(),
);
Map
<
String
,
dynamic
>
_$SearchProductResponseModelToJson
(
SearchProductResponseModel
instance
,
)
=>
<
String
,
dynamic
>{
'products'
:
instance
.
products
,
'total'
:
instance
.
total
};
lib/screen/voucher/sub_widget/voucher_action_menu.dart
0 → 100644
View file @
6fcbfba8
import
'package:flutter/material.dart'
;
import
'../../../resouce/base_color.dart'
;
class
VoucherActionMenu
extends
StatelessWidget
{
const
VoucherActionMenu
({
super
.
key
});
@override
Widget
build
(
BuildContext
context
)
{
final
screenWidth
=
MediaQuery
.
of
(
context
).
size
.
width
;
final
itemWidth
=
screenWidth
/
4
;
return
Padding
(
padding:
const
EdgeInsets
.
symmetric
(
vertical:
12
),
child:
Row
(
children:
const
[
_ActionItem
(
icon:
Icons
.
phone_android
,
label:
'Nạp tiền
\n
diện thoại'
),
_ActionItem
(
icon:
Icons
.
credit_card
,
label:
'Đổi mã
\n
thẻ nạp'
),
_ActionItem
(
icon:
Icons
.
wifi
,
label:
'Gói cước
\n
nhà mạng'
),
_ActionItem
(
icon:
Icons
.
card_giftcard
,
label:
'Ưu đãi
\n
Data'
),
],
),
);
}
}
class
_ActionItem
extends
StatelessWidget
{
final
IconData
icon
;
final
String
label
;
const
_ActionItem
({
required
this
.
icon
,
required
this
.
label
});
@override
Widget
build
(
BuildContext
context
)
{
final
screenWidth
=
MediaQuery
.
of
(
context
).
size
.
width
;
final
itemWidth
=
screenWidth
/
4
;
return
SizedBox
(
width:
itemWidth
,
child:
Column
(
mainAxisAlignment:
MainAxisAlignment
.
center
,
children:
[
Icon
(
icon
,
size:
40
,
color:
BaseColor
.
primary400
),
const
SizedBox
(
height:
8
),
Text
(
label
,
style:
const
TextStyle
(
fontSize:
12
),
textAlign:
TextAlign
.
center
,
maxLines:
2
,
overflow:
TextOverflow
.
ellipsis
,
),
],
),
);
}
}
lib/screen/voucher/sub_widget/voucher_item_grid.dart
0 → 100644
View file @
6fcbfba8
import
'package:flutter/material.dart'
;
import
'../../../widgets/image_loader.dart'
;
import
'../models/product_model.dart'
;
class
VoucherItemGrid
extends
StatelessWidget
{
final
List
<
ProductModel
>
items
;
const
VoucherItemGrid
({
super
.
key
,
required
this
.
items
});
@override
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
),
),
);
}
}
class
_VoucherGridItem
extends
StatelessWidget
{
final
ProductModel
product
;
final
double
itemWidth
;
const
_VoucherGridItem
({
super
.
key
,
required
this
.
product
,
required
this
.
itemWidth
,
});
@override
Widget
build
(
BuildContext
context
)
{
final
hasDiscount
=
product
.
properties
?.
title
!=
null
;
final
priceText
=
product
.
price
?.
displayPriceType
??
''
;
final
brandName
=
product
.
brand
?.
name
??
''
;
final
brandLogo
=
product
.
brand
?.
logo
??
""
;
final
String
?
bgImage
=
product
.
banner
?.
url
;
return
Container
(
width:
itemWidth
,
decoration:
BoxDecoration
(
border:
Border
.
all
(
color:
Colors
.
grey
.
shade200
),
borderRadius:
BorderRadius
.
circular
(
8
),
),
child:
Column
(
crossAxisAlignment:
CrossAxisAlignment
.
start
,
children:
[
Stack
(
children:
[
ClipRRect
(
borderRadius:
const
BorderRadius
.
only
(
topLeft:
Radius
.
circular
(
8
),
topRight:
Radius
.
circular
(
8
),
),
child:
SizedBox
(
width:
itemWidth
,
height:
itemWidth
/
(
16
/
9
),
child:
loadNetworkImage
(
url:
bgImage
,
placeholderAsset:
"assets/images/sample.png"
),
),
),
if
(
hasDiscount
)
Positioned
(
top:
8
,
right:
8
,
child:
Container
(
padding:
const
EdgeInsets
.
symmetric
(
horizontal:
6
,
vertical:
2
),
decoration:
BoxDecoration
(
color:
Colors
.
red
,
borderRadius:
BorderRadius
.
circular
(
12
),
),
child:
Text
(
product
.
properties
?.
title
??
""
,
style:
const
TextStyle
(
color:
Colors
.
white
,
fontSize:
12
),
),
),
),
],
),
Padding
(
padding:
const
EdgeInsets
.
symmetric
(
horizontal:
8
,
vertical:
0
),
child:
ConstrainedBox
(
constraints:
const
BoxConstraints
(
maxHeight:
70
),
// ✅ Không cứng height, mà giới hạn max
child:
Column
(
crossAxisAlignment:
CrossAxisAlignment
.
start
,
children:
[
// Title: auto co giãn nhưng không tràn
Text
(
product
.
content
?.
name
??
''
,
style:
const
TextStyle
(
fontSize:
13
,
fontWeight:
FontWeight
.
w500
),
maxLines:
2
,
overflow:
TextOverflow
.
ellipsis
,
),
const
Spacer
(),
// ✅ Spacer đẩy info xuống dưới nhưng không gây overflow
Row
(
crossAxisAlignment:
CrossAxisAlignment
.
center
,
children:
[
CircleAvatar
(
radius:
10
,
backgroundColor:
Colors
.
transparent
,
child:
ClipOval
(
child:
loadNetworkImage
(
url:
brandLogo
,
width:
20
,
height:
20
,
fit:
BoxFit
.
cover
,
placeholderAsset:
'assets/images/sample.png'
,
// ⚠️ SVG dùng wrong tại đây!
),
),
),
const
SizedBox
(
width:
4
),
Expanded
(
child:
Text
(
brandName
,
style:
const
TextStyle
(
fontSize:
11
),
overflow:
TextOverflow
.
ellipsis
,
),
),
Container
(
padding:
const
EdgeInsets
.
symmetric
(
horizontal:
8
,
vertical:
2
),
decoration:
BoxDecoration
(
color:
Colors
.
orange
.
shade100
,
borderRadius:
BorderRadius
.
circular
(
12
),
),
child:
Row
(
children:
[
Image
.
asset
(
'assets/images/ic_point.png'
,
width:
12
,
height:
12
),
const
SizedBox
(
width:
4
),
Text
(
priceText
,
style:
const
TextStyle
(
color:
Colors
.
orange
,
fontSize:
12
),
),
],
),
),
],
),
],
),
),
),
],
),
);
}
}
lib/screen/voucher/sub_widget/voucher_item_list.dart
0 → 100644
View file @
6fcbfba8
import
'package:flutter/material.dart'
;
import
'../../../resouce/base_color.dart'
;
import
'../../../widgets/image_loader.dart'
;
import
'../models/product_model.dart'
;
class
VoucherItemList
extends
StatelessWidget
{
final
List
<
ProductModel
>
items
;
const
VoucherItemList
({
super
.
key
,
required
this
.
items
});
@override
Widget
build
(
BuildContext
context
)
{
return
ListView
.
builder
(
itemCount:
items
.
length
,
shrinkWrap:
true
,
physics:
const
NeverScrollableScrollPhysics
(),
itemBuilder:
(
context
,
index
)
{
final
product
=
items
[
index
];
return
VoucherListItem
(
product:
product
);
},
);
}
}
class
VoucherListItem
extends
StatelessWidget
{
final
ProductModel
product
;
const
VoucherListItem
({
super
.
key
,
required
this
.
product
});
@override
Widget
build
(
BuildContext
context
)
{
final
productName
=
product
.
content
?.
name
??
''
;
final
brandName
=
product
.
brand
?.
name
??
''
;
final
brandLogo
=
product
.
brand
?.
logo
??
''
;
final
priceText
=
product
.
price
?.
displayPriceType
??
'Miễn phí'
;
final
isFree
=
priceText
.
contains
(
"Miễn phí"
);
final
String
?
bgImage
=
product
.
banner
?.
url
;
return
Column
(
children:
[
Padding
(
padding:
const
EdgeInsets
.
symmetric
(
horizontal:
16
,
vertical:
8
),
child:
SizedBox
(
height:
112
,
child:
Row
(
children:
[
// Ảnh banner (16:9)
ClipRRect
(
borderRadius:
BorderRadius
.
circular
(
8
),
child:
AspectRatio
(
aspectRatio:
16
/
9
,
child:
loadNetworkImage
(
url:
bgImage
,
fit:
BoxFit
.
cover
,
placeholderAsset:
'assets/images/sample.png'
,
),
),
),
const
SizedBox
(
width:
12
),
Expanded
(
child:
Column
(
crossAxisAlignment:
CrossAxisAlignment
.
start
,
mainAxisAlignment:
MainAxisAlignment
.
start
,
children:
[
Text
(
productName
,
style:
const
TextStyle
(
fontSize:
13
,
fontWeight:
FontWeight
.
w500
),
maxLines:
2
,
overflow:
TextOverflow
.
ellipsis
,
),
const
SizedBox
(
height:
8
),
Row
(
crossAxisAlignment:
CrossAxisAlignment
.
center
,
children:
[
// Logo thương hiệu
CircleAvatar
(
radius:
10
,
backgroundColor:
Colors
.
transparent
,
child:
ClipOval
(
child:
loadNetworkImage
(
url:
brandLogo
,
width:
16
,
height:
16
,
fit:
BoxFit
.
cover
,
placeholderAsset:
'assets/images/ic_logo.png'
,
),
),
),
const
SizedBox
(
width:
4
),
// Tên thương hiệu
Expanded
(
child:
Text
(
brandName
,
style:
const
TextStyle
(
fontSize:
11
),
overflow:
TextOverflow
.
ellipsis
,
),
),
],
),
const
SizedBox
(
height:
8
),
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
,
),
),
],
),
),
],
),
),
],
),
),
),
Padding
(
padding:
const
EdgeInsets
.
symmetric
(
horizontal:
16
),
child:
Divider
(
height:
1
,
thickness:
1
,
color:
BaseColor
.
second200
,
),
),
],
);
}
}
lib/screen/voucher/sub_widget/voucher_section_title.dart
0 → 100644
View file @
6fcbfba8
import
'package:flutter/material.dart'
;
class
VoucherSectionTitle
extends
StatelessWidget
{
final
String
title
;
final
VoidCallback
?
onViewAll
;
// 👈 Optional
const
VoucherSectionTitle
({
super
.
key
,
required
this
.
title
,
this
.
onViewAll
,
// 👈 Nếu null thì không hiển thị button
});
@override
Widget
build
(
BuildContext
context
)
{
return
Padding
(
padding:
const
EdgeInsets
.
symmetric
(
horizontal:
16
,
vertical:
12
),
child:
Row
(
mainAxisAlignment:
MainAxisAlignment
.
spaceBetween
,
children:
[
Text
(
title
,
style:
const
TextStyle
(
fontSize:
16
,
fontWeight:
FontWeight
.
bold
),
),
if
(
onViewAll
!=
null
)
GestureDetector
(
onTap:
onViewAll
,
child:
const
Text
(
'Xem tất cả'
,
style:
TextStyle
(
color:
Colors
.
blue
),
),
),
],
),
);
}
}
lib/screen/voucher/voucher_list/voucher_list_screen.dart
0 → 100644
View file @
6fcbfba8
import
'package:flutter/material.dart'
;
import
'package:get/get.dart'
;
import
'../../../widgets/custom_navigation_bar.dart'
;
import
'../../../widgets/custom_search_navigation_bar.dart'
;
import
'../sub_widget/voucher_item_list.dart'
;
import
'voucher_list_viewmodel.dart'
;
class
VoucherListScreen
extends
StatefulWidget
{
const
VoucherListScreen
({
super
.
key
});
@override
_VoucherListScreenState
createState
()
=>
_VoucherListScreenState
();
}
class
_VoucherListScreenState
extends
State
<
VoucherListScreen
>
{
late
final
Map
<
String
,
dynamic
>
args
;
late
final
bool
enableSearch
;
late
final
bool
isHotProduct
;
late
final
VoucherListViewModel
_viewModel
;
@override
void
initState
()
{
super
.
initState
();
args
=
Get
.
arguments
??
{};
enableSearch
=
args
[
'enableSearch'
]
??
false
;
isHotProduct
=
args
[
'isHotProduct'
]
??
false
;
_viewModel
=
Get
.
put
(
VoucherListViewModel
(
isHotProduct:
isHotProduct
));
}
@override
Widget
build
(
BuildContext
context
)
{
final
String
title
=
isHotProduct
?
'Săn ưu đãi'
:
'Tất cả ưu đãi'
;
return
Scaffold
(
appBar:
enableSearch
?
CustomSearchNavigationBar
(
onSearchChanged:
_viewModel
.
onSearchChanged
,)
:
CustomNavigationBar
(
title:
title
),
body:
Column
(
children:
[
Expanded
(
child:
Obx
(
()
=>
RefreshIndicator
(
onRefresh:
()
=>
_viewModel
.
getProducts
(
reset:
true
),
child:
ListView
.
builder
(
physics:
const
AlwaysScrollableScrollPhysics
(),
itemCount:
_viewModel
.
products
.
length
+
(
_viewModel
.
hasMore
?
1
:
0
),
itemBuilder:
(
context
,
index
)
{
if
(
index
>=
_viewModel
.
products
.
length
)
{
_viewModel
.
getProducts
(
reset:
false
);
return
const
Center
(
child:
Padding
(
padding:
EdgeInsets
.
all
(
16
),
child:
CircularProgressIndicator
()),
);
}
final
product
=
_viewModel
.
products
[
index
];
return
VoucherListItem
(
product:
product
);
},
),
),
),
),
],
),
);
}
}
lib/screen/voucher/voucher_list/voucher_list_viewmodel.dart
0 → 100644
View file @
6fcbfba8
import
'dart:async'
;
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_type.dart'
;
class
VoucherListViewModel
extends
RestfulApiViewModel
{
VoucherListViewModel
({
required
this
.
isHotProduct
});
final
bool
isHotProduct
;
Timer
?
_debounce
;
var
products
=
<
ProductModel
>[].
obs
;
var
isLoading
=
false
.
obs
;
var
isLoadMore
=
false
.
obs
;
int
_currentPage
=
0
;
final
int
_pageSize
=
20
;
bool
_hasMore
=
true
;
bool
get
hasMore
=>
_hasMore
;
String
_searchQuery
=
''
;
var
totalResult
=
0
.
obs
;
@override
void
onInit
()
{
super
.
onInit
();
getProducts
(
reset:
true
);
}
@override
void
onClose
()
{
_debounce
?.
cancel
();
super
.
onClose
();
}
void
onSearchChanged
(
String
value
)
{
if
(
_searchQuery
==
value
)
return
;
_searchQuery
=
value
;
_debounce
?.
cancel
();
_debounce
=
Timer
(
const
Duration
(
seconds:
1
),
()
{
getProducts
(
reset:
true
);
});
}
Future
<
void
>
getProducts
({
bool
reset
=
false
})
async
{
if
(
isLoading
.
value
)
return
;
if
(
reset
)
{
_currentPage
=
0
;
_hasMore
=
true
;
products
.
clear
();
}
else
{
_currentPage
=
products
.
length
;
}
if
(!
_hasMore
)
return
;
final
body
=
{
"type"
:
ProductType
.
voucher
.
value
,
"size"
:
_pageSize
,
"index"
:
_currentPage
,
if
(
isHotProduct
)
"catalog_code"
:
"HOT"
,
if
(
_searchQuery
.
isNotEmpty
)
"keywords"
:
_searchQuery
,
if
(
_searchQuery
.
isNotEmpty
)
"keyword"
:
_searchQuery
,
};
try
{
isLoading
.
value
=
true
;
isLoadMore
.
value
=
true
;
final
result
=
await
client
.
getSearchProducts
(
body
);
final
fetchedData
=
result
.
data
?.
products
??
[];
totalResult
.
value
=
result
.
data
?.
total
??
0
;
if
(
fetchedData
.
isEmpty
||
fetchedData
.
length
<
_pageSize
)
{
_hasMore
=
false
;
}
products
.
addAll
(
fetchedData
);
}
catch
(
error
)
{
print
(
"Error fetching products:
$error
"
);
}
finally
{
isLoading
.
value
=
false
;
isLoadMore
.
value
=
false
;
}
}
}
lib/screen/voucher/voucher_screen.dart
deleted
100644 → 0
View file @
d86c3328
import
'package:flutter/material.dart'
;
class
VoucherScreen
extends
StatelessWidget
{
const
VoucherScreen
({
super
.
key
});
@override
Widget
build
(
BuildContext
context
)
{
return
const
Center
(
child:
Text
(
'Ưu đãi'
));
}
}
\ No newline at end of file
lib/screen/voucher/voucher_tab_screen.dart
0 → 100644
View file @
6fcbfba8
import
'package:flutter/material.dart'
;
import
'package:get/get.dart'
;
import
'package:mypoint_flutter_app/screen/voucher/voucher_list/voucher_list_screen.dart'
;
import
'../../shared/router_gage.dart'
;
import
'voucher_tab_viewmodel.dart'
;
import
'sub_widget/voucher_action_menu.dart'
;
import
'sub_widget/voucher_item_grid.dart'
;
import
'sub_widget/voucher_item_list.dart'
;
import
'sub_widget/voucher_section_title.dart'
;
import
'../../widgets/custom_navigation_bar.dart'
;
class
VoucherTabScreen
extends
StatelessWidget
{
const
VoucherTabScreen
({
super
.
key
});
@override
Widget
build
(
BuildContext
context
)
{
final
VoucherTabViewModel
viewModel
=
Get
.
put
(
VoucherTabViewModel
());
return
Scaffold
(
appBar:
CustomNavigationBar
(
title:
"Ưu đãi"
,
showBackButton:
false
,
rightButtons:
[
IconButton
(
icon:
const
Icon
(
Icons
.
search
,
color:
Colors
.
white
),
onPressed:
()
{
Get
.
toNamed
(
vouchersScreen
,
arguments:
{
"enableSearch"
:
true
});
},
),
],
),
body:
Obx
(()
{
if
(
viewModel
.
isLoading
.
value
)
{
return
const
Center
(
child:
CircularProgressIndicator
());
}
return
RefreshIndicator
(
onRefresh:
viewModel
.
refreshData
,
child:
NotificationListener
<
ScrollNotification
>(
onNotification:
(
ScrollNotification
scrollInfo
)
{
if
(
scrollInfo
is
ScrollUpdateNotification
&&
scrollInfo
.
metrics
.
axis
==
Axis
.
vertical
&&
// ✅ Chỉ check vertical
!
viewModel
.
isLoadMore
.
value
&&
viewModel
.
hasMore
&&
scrollInfo
.
metrics
.
pixels
>=
scrollInfo
.
metrics
.
maxScrollExtent
-
200
)
{
viewModel
.
getAllProducts
();
}
return
false
;
},
child:
ListView
(
physics:
const
AlwaysScrollableScrollPhysics
(),
children:
[
const
VoucherSectionTitle
(
title:
'Ưu đãi từ nhà mạng'
),
const
VoucherActionMenu
(),
VoucherSectionTitle
(
title:
'Săn ưu đãi'
,
onViewAll:
()
{
Get
.
toNamed
(
vouchersScreen
,
arguments:
{
"isHotProduct"
:
true
});
},
),
VoucherItemGrid
(
items:
viewModel
.
hotProducts
),
const
VoucherSectionTitle
(
title:
'Tất cả ưu đãi'
),
VoucherItemList
(
items:
viewModel
.
allProducts
),
if
(
viewModel
.
isLoadMore
.
value
)
const
Padding
(
padding:
EdgeInsets
.
symmetric
(
vertical:
16
),
child:
Center
(
child:
CircularProgressIndicator
()),
),
],
),
),
);
}),
);
}
}
lib/screen/voucher/voucher_tab_viewmodel.dart
0 → 100644
View file @
6fcbfba8
import
'package:flutter/material.dart'
;
import
'package:get/get.dart'
;
import
'package:mypoint_flutter_app/networking/restful_api_request.dart'
;
import
'package:mypoint_flutter_app/screen/voucher/models/product_type.dart'
;
import
'../../base/restful_api_viewmodel.dart'
;
import
'models/product_model.dart'
;
class
VoucherTabViewModel
extends
RestfulApiViewModel
{
final
RxList
<
ProductModel
>
hotProducts
=
<
ProductModel
>[].
obs
;
final
RxList
<
ProductModel
>
allProducts
=
<
ProductModel
>[].
obs
;
final
RxBool
isLoading
=
false
.
obs
;
final
RxBool
isLoadMore
=
false
.
obs
;
int
_currentPage
=
0
;
final
int
_pageSize
=
20
;
bool
_hasMore
=
true
;
bool
get
hasMore
=>
_hasMore
;
@override
void
onInit
()
{
super
.
onInit
();
refreshData
();
}
Future
<
void
>
refreshData
()
async
{
isLoading
.
value
=
true
;
await
Future
.
wait
([
getHotProducts
(),
getAllProducts
(
reset:
true
),
]);
isLoading
.
value
=
false
;
}
Future
<
void
>
getHotProducts
()
async
{
final
body
=
{
"type"
:
ProductType
.
voucher
.
value
,
"size"
:
10
,
"index"
:
0
,
"catalog_code"
:
"HOT"
,
};
try
{
final
result
=
await
client
.
getProducts
(
body
);
hotProducts
.
value
=
result
.
data
??
[];
}
catch
(
error
)
{
print
(
"Error fetching hot products:
$error
"
);
}
}
Future
<
void
>
getAllProducts
({
bool
reset
=
false
})
async
{
if
(
reset
)
{
_currentPage
=
0
;
_hasMore
=
true
;
allProducts
.
clear
();
}
else
{
_currentPage
=
allProducts
.
length
;
}
if
(!
_hasMore
)
return
;
final
body
=
{
"type"
:
ProductType
.
voucher
.
value
,
"size"
:
_pageSize
,
"index"
:
_currentPage
,
};
try
{
isLoadMore
.
value
=
true
;
final
result
=
await
client
.
getProducts
(
body
);
final
fetchedData
=
result
.
data
??
[];
if
(
fetchedData
.
isEmpty
||
fetchedData
.
length
<
_pageSize
)
{
_hasMore
=
false
;
}
allProducts
.
addAll
(
fetchedData
);
}
catch
(
error
)
{
print
(
"Error fetching all products:
$error
"
);
}
finally
{
isLoadMore
.
value
=
false
;
}
}
}
\ No newline at end of file
lib/shared/router_gage.dart
View file @
6fcbfba8
...
...
@@ -4,12 +4,14 @@ import '../screen/main_tab_screen/main_tab_screen.dart';
import
'../screen/onboarding/onboarding_screen.dart'
;
import
'../screen/setting/setting_screen.dart'
;
import
'../screen/splash/splash_screen.dart'
;
import
'../screen/voucher/voucher_list/voucher_list_screen.dart'
;
const
splashScreen
=
'/splash'
;
const
onboardingScreen
=
'/onboarding'
;
const
loginScreen
=
'/login'
;
const
mainScreen
=
'/main'
;
const
settingScreen
=
'/setting'
;
const
vouchersScreen
=
'/vouchers'
;
class
RouterPage
{
static
List
<
GetPage
>
pages
()
{
...
...
@@ -25,6 +27,7 @@ class RouterPage {
GetPage
(
name:
loginScreen
,
page:
()
=>
const
LoginScreen
()),
GetPage
(
name:
mainScreen
,
page:
()
=>
const
MainTabScreen
()),
GetPage
(
name:
settingScreen
,
page:
()
=>
const
SettingScreen
()),
GetPage
(
name:
vouchersScreen
,
page:
()
=>
VoucherListScreen
(),),
];
}
}
lib/widgets/custom_navigation_bar.dart
0 → 100644
View file @
6fcbfba8
import
'package:flutter/material.dart'
;
import
'back_button.dart'
;
class
CustomNavigationBar
extends
StatelessWidget
implements
PreferredSizeWidget
{
final
String
title
;
final
String
?
backgroundImage
;
final
bool
showBackButton
;
final
List
<
Widget
>
rightButtons
;
const
CustomNavigationBar
({
super
.
key
,
required
this
.
title
,
this
.
backgroundImage
=
"assets/images/bg_header_navi.png"
,
this
.
showBackButton
=
true
,
this
.
rightButtons
=
const
[],
});
@override
Size
get
preferredSize
=>
const
Size
.
fromHeight
(
kToolbarHeight
);
@override
Widget
build
(
BuildContext
context
)
{
final
double
statusBarHeight
=
MediaQuery
.
of
(
context
).
padding
.
top
;
return
Container
(
height:
statusBarHeight
+
kToolbarHeight
,
decoration:
BoxDecoration
(
image:
backgroundImage
!=
null
?
DecorationImage
(
image:
AssetImage
(
backgroundImage
!),
fit:
BoxFit
.
cover
,
)
:
null
,
color:
backgroundImage
==
null
?
Colors
.
white
:
null
,
),
child:
SafeArea
(
bottom:
false
,
child:
Stack
(
alignment:
Alignment
.
center
,
children:
[
// Title ở giữa
Text
(
title
,
style:
const
TextStyle
(
fontSize:
18
,
fontWeight:
FontWeight
.
bold
,
color:
Colors
.
white
,
),
textAlign:
TextAlign
.
center
,
),
// Back button bên trái
if
(
showBackButton
)
Positioned
(
left:
12
,
child:
CustomBackButton
(),
),
// Buttons bên phải
if
(
rightButtons
!=
null
)
Positioned
(
right:
12
,
child:
Row
(
mainAxisSize:
MainAxisSize
.
min
,
children:
rightButtons
!,
),
),
],
),
),
);
}
}
\ No newline at end of file
lib/widgets/custom_search_navigation_bar.dart
0 → 100644
View file @
6fcbfba8
import
'package:flutter/material.dart'
;
import
'package:get/get.dart'
;
import
'back_button.dart'
;
class
CustomSearchNavigationBar
extends
StatefulWidget
implements
PreferredSizeWidget
{
final
ValueChanged
<
String
>?
onSearchChanged
;
final
String
?
hintText
;
final
String
?
backgroundImage
;
final
bool
showBackButton
;
final
List
<
Widget
>
rightButtons
;
const
CustomSearchNavigationBar
({
super
.
key
,
this
.
onSearchChanged
,
this
.
hintText
=
'Tìm kiếm...'
,
this
.
backgroundImage
=
"assets/images/bg_header_navi.png"
,
this
.
showBackButton
=
true
,
this
.
rightButtons
=
const
[],
});
@override
Size
get
preferredSize
=>
const
Size
.
fromHeight
(
kToolbarHeight
);
@override
_CustomSearchNavigationBarState
createState
()
=>
_CustomSearchNavigationBarState
();
}
class
_CustomSearchNavigationBarState
extends
State
<
CustomSearchNavigationBar
>
{
final
TextEditingController
_controller
=
TextEditingController
();
@override
void
dispose
()
{
_controller
.
dispose
();
super
.
dispose
();
}
@override
Widget
build
(
BuildContext
context
)
{
final
double
statusBarHeight
=
MediaQuery
.
of
(
context
).
padding
.
top
;
return
Container
(
height:
statusBarHeight
+
kToolbarHeight
,
decoration:
BoxDecoration
(
image:
widget
.
backgroundImage
!=
null
?
DecorationImage
(
image:
AssetImage
(
widget
.
backgroundImage
!),
fit:
BoxFit
.
cover
,
)
:
null
,
color:
widget
.
backgroundImage
==
null
?
Colors
.
white
:
null
,
),
child:
SafeArea
(
bottom:
false
,
child:
Stack
(
alignment:
Alignment
.
center
,
children:
[
Positioned
(
left:
widget
.
showBackButton
?
68
:
16
,
right:
widget
.
rightButtons
.
isNotEmpty
?
60
:
16
,
child:
Container
(
height:
36
,
padding:
const
EdgeInsets
.
symmetric
(
horizontal:
8
),
decoration:
BoxDecoration
(
color:
Colors
.
white
,
borderRadius:
BorderRadius
.
circular
(
12
),
),
child:
Row
(
children:
[
const
Icon
(
Icons
.
search
,
size:
20
),
const
SizedBox
(
width:
4
),
Expanded
(
child:
TextField
(
controller:
_controller
,
onChanged:
(
value
)
{
setState
(()
{});
// Update UI for suffix icon
widget
.
onSearchChanged
?.
call
(
value
);
},
decoration:
InputDecoration
(
border:
InputBorder
.
none
,
hintText:
widget
.
hintText
,
isDense:
true
,
contentPadding:
EdgeInsets
.
zero
,
),
),
),
if
(
_controller
.
text
.
isNotEmpty
)
GestureDetector
(
onTap:
()
{
_controller
.
clear
();
widget
.
onSearchChanged
?.
call
(
''
);
setState
(()
{});
},
child:
const
Icon
(
Icons
.
close
,
size:
20
),
),
],
),
),
),
if
(
widget
.
showBackButton
)
Positioned
(
left:
12
,
child:
CustomBackButton
(),
),
if
(
widget
.
rightButtons
.
isNotEmpty
)
Positioned
(
right:
12
,
child:
Row
(
mainAxisSize:
MainAxisSize
.
min
,
children:
widget
.
rightButtons
,
),
),
],
),
),
);
}
}
\ No newline at end of file
Prev
1
2
3
Next
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment