added offers , offer details and pass details api and more chnages
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 749 KiB |
100
lib/buy_a_pass/bloc/buy_pass_bloc.dart
Normal file
100
lib/buy_a_pass/bloc/buy_pass_bloc.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../repository/buy_pass_repository.dart';
|
||||
import 'buy_pass_event.dart';
|
||||
import 'buy_pass_state.dart';
|
||||
|
||||
class BuyPassBloc extends Bloc<BuyPassEvent, BuyPassState> {
|
||||
final BuyPassRepository repository;
|
||||
|
||||
BuyPassBloc({required this.repository}) : super(BuyPassInitial()) {
|
||||
/// Handle fetch buy pass data event
|
||||
on<FetchBuyPassData>(_onFetchBuyPassData);
|
||||
|
||||
/// Handle change selected card event
|
||||
on<ChangeSelectedCard>(_onChangeSelectedCard);
|
||||
|
||||
/// Handle update adult count event
|
||||
on<UpdateAdultCount>(_onUpdateAdultCount);
|
||||
|
||||
/// Handle update child count event
|
||||
on<UpdateChildCount>(_onUpdateChildCount);
|
||||
|
||||
/// Handle update validity duration event
|
||||
on<UpdateValidityDuration>(_onUpdateValidityDuration); // ✅ Added
|
||||
}
|
||||
|
||||
/// Fetch buy pass data from repository
|
||||
Future<void> _onFetchBuyPassData(
|
||||
FetchBuyPassData event,
|
||||
Emitter<BuyPassState> emit,
|
||||
) async {
|
||||
emit(BuyPassLoading());
|
||||
|
||||
try {
|
||||
final data = await repository.fetchBuyPass();
|
||||
emit(BuyPassLoaded(data: data));
|
||||
} catch (e) {
|
||||
emit(BuyPassError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Change selected card
|
||||
void _onChangeSelectedCard(
|
||||
ChangeSelectedCard event,
|
||||
Emitter<BuyPassState> emit,
|
||||
) {
|
||||
if (state is BuyPassLoaded) {
|
||||
final currentState = state as BuyPassLoaded;
|
||||
final newCard = currentState.data.cards[event.cardIndex];
|
||||
|
||||
emit(currentState.copyWith(
|
||||
selectedCardIndex: event.cardIndex,
|
||||
adultCount: 1, // Reset counts when changing card
|
||||
childCount: 1,
|
||||
validityDuration: newCard.minNumber, // ✅ Reset to new card's minNumber
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Update adult count
|
||||
void _onUpdateAdultCount(
|
||||
UpdateAdultCount event,
|
||||
Emitter<BuyPassState> emit,
|
||||
) {
|
||||
if (state is BuyPassLoaded) {
|
||||
final currentState = state as BuyPassLoaded;
|
||||
if (event.count >= 0) {
|
||||
emit(currentState.copyWith(adultCount: event.count));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update child count
|
||||
void _onUpdateChildCount(
|
||||
UpdateChildCount event,
|
||||
Emitter<BuyPassState> emit,
|
||||
) {
|
||||
if (state is BuyPassLoaded) {
|
||||
final currentState = state as BuyPassLoaded;
|
||||
if (event.count >= 0) {
|
||||
emit(currentState.copyWith(childCount: event.count));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update validity duration (days/attractions)
|
||||
void _onUpdateValidityDuration(
|
||||
UpdateValidityDuration event,
|
||||
Emitter<BuyPassState> emit,
|
||||
) {
|
||||
if (state is BuyPassLoaded) {
|
||||
final currentState = state as BuyPassLoaded;
|
||||
final card = currentState.selectedCard;
|
||||
|
||||
// Validate that duration is within min and max range
|
||||
if (event.duration >= card.minNumber && event.duration <= card.maxNumber) {
|
||||
emit(currentState.copyWith(validityDuration: event.duration));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
lib/buy_a_pass/bloc/buy_pass_event.dart
Normal file
32
lib/buy_a_pass/bloc/buy_pass_event.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
abstract class BuyPassEvent {}
|
||||
|
||||
/// Event to fetch buy pass data from API
|
||||
class FetchBuyPassData extends BuyPassEvent {}
|
||||
|
||||
/// Event to change the selected card pass
|
||||
class ChangeSelectedCard extends BuyPassEvent {
|
||||
final int cardIndex;
|
||||
|
||||
ChangeSelectedCard(this.cardIndex);
|
||||
}
|
||||
|
||||
/// Event to update adult count
|
||||
class UpdateAdultCount extends BuyPassEvent {
|
||||
final int count;
|
||||
|
||||
UpdateAdultCount(this.count);
|
||||
}
|
||||
|
||||
/// Event to update child count
|
||||
class UpdateChildCount extends BuyPassEvent {
|
||||
final int count;
|
||||
|
||||
UpdateChildCount(this.count);
|
||||
}
|
||||
|
||||
/// Event to update validity duration (days/attractions)
|
||||
class UpdateValidityDuration extends BuyPassEvent {
|
||||
final int duration;
|
||||
|
||||
UpdateValidityDuration(this.duration);
|
||||
}
|
||||
59
lib/buy_a_pass/bloc/buy_pass_state.dart
Normal file
59
lib/buy_a_pass/bloc/buy_pass_state.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import '../models/buy_pass_model.dart';
|
||||
|
||||
abstract class BuyPassState {}
|
||||
|
||||
/// Initial state
|
||||
class BuyPassInitial extends BuyPassState {}
|
||||
|
||||
/// Loading state
|
||||
class BuyPassLoading extends BuyPassState {}
|
||||
|
||||
/// Success state with data
|
||||
class BuyPassLoaded extends BuyPassState {
|
||||
final BuyPassModel data;
|
||||
final int selectedCardIndex;
|
||||
final int adultCount;
|
||||
final int childCount;
|
||||
final int validityDuration; // ✅ Added
|
||||
|
||||
BuyPassLoaded({
|
||||
required this.data,
|
||||
this.selectedCardIndex = 0,
|
||||
this.adultCount = 1,
|
||||
this.childCount = 1,
|
||||
int? validityDuration, // ✅ Added as optional parameter
|
||||
}) : validityDuration = validityDuration ?? data.cards[selectedCardIndex].minNumber; // ✅ Initialize with minNumber
|
||||
|
||||
/// Method to copy state with updated values
|
||||
BuyPassLoaded copyWith({
|
||||
BuyPassModel? data,
|
||||
int? selectedCardIndex,
|
||||
int? adultCount,
|
||||
int? childCount,
|
||||
int? validityDuration, // ✅ Added
|
||||
}) {
|
||||
return BuyPassLoaded(
|
||||
data: data ?? this.data,
|
||||
selectedCardIndex: selectedCardIndex ?? this.selectedCardIndex,
|
||||
adultCount: adultCount ?? this.adultCount,
|
||||
childCount: childCount ?? this.childCount,
|
||||
validityDuration: validityDuration ?? this.validityDuration, // ✅ Added
|
||||
);
|
||||
}
|
||||
|
||||
/// Get currently selected card
|
||||
CardPass get selectedCard => data.cards[selectedCardIndex];
|
||||
|
||||
/// Calculate total price
|
||||
double get totalPrice {
|
||||
final card = selectedCard;
|
||||
return ((card.adultPrice * adultCount) + (card.childPrice * childCount)) * validityDuration.toDouble(); // ✅ Multiply by validityDuration
|
||||
}
|
||||
}
|
||||
|
||||
/// Error state
|
||||
class BuyPassError extends BuyPassState {
|
||||
final String message;
|
||||
|
||||
BuyPassError(this.message);
|
||||
}
|
||||
304
lib/buy_a_pass/models/buy_pass_model.dart
Normal file
304
lib/buy_a_pass/models/buy_pass_model.dart
Normal file
@@ -0,0 +1,304 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// ---------- MAIN RESPONSE MODEL ----------
|
||||
BuyPassModel buyPassModelFromJson(String str) =>
|
||||
BuyPassModel.fromJson(json.decode(str));
|
||||
|
||||
String buyPassModelToJson(BuyPassModel data) =>
|
||||
json.encode(data.toJson());
|
||||
|
||||
class BuyPassModel {
|
||||
final City city;
|
||||
final List<Offer> offers;
|
||||
final List<CardPass> cards;
|
||||
final List<Attraction> attractions;
|
||||
|
||||
BuyPassModel({
|
||||
required this.city,
|
||||
required this.offers,
|
||||
required this.cards,
|
||||
required this.attractions,
|
||||
});
|
||||
|
||||
factory BuyPassModel.fromJson(Map<String, dynamic> json) {
|
||||
return BuyPassModel(
|
||||
city: City.fromJson(json['city']),
|
||||
offers: List<Offer>.from(
|
||||
json['offers'].map((x) => Offer.fromJson(x)),
|
||||
),
|
||||
cards: List<CardPass>.from(
|
||||
json['cards'].map((x) => CardPass.fromJson(x)),
|
||||
),
|
||||
attractions: List<Attraction>.from(
|
||||
json['attractions'].map((x) => Attraction.fromJson(x)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"city": city.toJson(),
|
||||
"offers": offers.map((x) => x.toJson()).toList(),
|
||||
"cards": cards.map((x) => x.toJson()).toList(),
|
||||
"attractions": attractions.map((x) => x.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- CITY ----------
|
||||
class City {
|
||||
final int id;
|
||||
final String name;
|
||||
final String slug;
|
||||
final String tagLine;
|
||||
final String description;
|
||||
final String bestTimeToVisit;
|
||||
final String priceRange;
|
||||
final int individualTicketAmount;
|
||||
final int cityCardTicketAmount;
|
||||
final HeroBanner heroBanner;
|
||||
|
||||
City({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.slug,
|
||||
required this.tagLine,
|
||||
required this.description,
|
||||
required this.bestTimeToVisit,
|
||||
required this.priceRange,
|
||||
required this.individualTicketAmount,
|
||||
required this.cityCardTicketAmount,
|
||||
required this.heroBanner,
|
||||
});
|
||||
|
||||
factory City.fromJson(Map<String, dynamic> json) {
|
||||
return City(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
slug: json['slug'],
|
||||
tagLine: json['tagLine'],
|
||||
description: json['description'],
|
||||
bestTimeToVisit: json['bestTimeToVisit'],
|
||||
priceRange: json['priceRange'],
|
||||
individualTicketAmount: json['individualTicketAmount'],
|
||||
cityCardTicketAmount: json['cityCardTicketAmount'],
|
||||
heroBanner: HeroBanner.fromJson(json['heroBanner']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"tagLine": tagLine,
|
||||
"description": description,
|
||||
"bestTimeToVisit": bestTimeToVisit,
|
||||
"priceRange": priceRange,
|
||||
"individualTicketAmount": individualTicketAmount,
|
||||
"cityCardTicketAmount": cityCardTicketAmount,
|
||||
"heroBanner": heroBanner.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- HERO BANNER ----------
|
||||
class HeroBanner {
|
||||
final String title;
|
||||
final String image;
|
||||
|
||||
HeroBanner({
|
||||
required this.title,
|
||||
required this.image,
|
||||
});
|
||||
|
||||
factory HeroBanner.fromJson(Map<String, dynamic> json) {
|
||||
return HeroBanner(
|
||||
title: json['title'],
|
||||
image: json['image'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"title": title,
|
||||
"image": image,
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- OFFER ----------
|
||||
class Offer {
|
||||
final int id;
|
||||
final String title;
|
||||
final String offerCode;
|
||||
final String? description; // ✅ optional
|
||||
final String? redemptionLink; // ✅ optional
|
||||
final String websiteBannerImage;
|
||||
final String mobileBannerImage;
|
||||
final String passType;
|
||||
final DateTime startDateTime;
|
||||
final DateTime endDateTime;
|
||||
final String offerStatus;
|
||||
final bool applyToPasses;
|
||||
|
||||
Offer({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.offerCode,
|
||||
this.description,
|
||||
this.redemptionLink,
|
||||
required this.websiteBannerImage,
|
||||
required this.mobileBannerImage,
|
||||
required this.passType,
|
||||
required this.startDateTime,
|
||||
required this.endDateTime,
|
||||
required this.offerStatus,
|
||||
required this.applyToPasses,
|
||||
});
|
||||
|
||||
factory Offer.fromJson(Map<String, dynamic> json) {
|
||||
return Offer(
|
||||
id: json['id'],
|
||||
title: json['title'],
|
||||
offerCode: json['offerCode'],
|
||||
description: json['description'], // ✅
|
||||
redemptionLink: json['redemptionLink'], // ✅
|
||||
websiteBannerImage: json['websiteBannerImage'],
|
||||
mobileBannerImage: json['mobileBannerImage'],
|
||||
passType: json['passType'],
|
||||
startDateTime: DateTime.parse(json['startDateTime']),
|
||||
endDateTime: DateTime.parse(json['endDateTime']),
|
||||
offerStatus: json['offerStatus'],
|
||||
applyToPasses: json['applyToPasses'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"title": title,
|
||||
"offerCode": offerCode,
|
||||
"description": description,
|
||||
"redemptionLink": redemptionLink,
|
||||
"websiteBannerImage": websiteBannerImage,
|
||||
"mobileBannerImage": mobileBannerImage,
|
||||
"passType": passType,
|
||||
"startDateTime": startDateTime.toIso8601String(),
|
||||
"endDateTime": endDateTime.toIso8601String(),
|
||||
"offerStatus": offerStatus,
|
||||
"applyToPasses": applyToPasses,
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- CARD PASS ----------
|
||||
class CardPass {
|
||||
final int id;
|
||||
final String title;
|
||||
final String description;
|
||||
final int validityDuration;
|
||||
final int adultPrice;
|
||||
final int childPrice;
|
||||
final int minNumber; // ✅ NEW
|
||||
final int maxNumber; // ✅ NEW
|
||||
final CardType cardType;
|
||||
final List<Offer> offers;
|
||||
|
||||
CardPass({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.validityDuration,
|
||||
required this.adultPrice,
|
||||
required this.childPrice,
|
||||
required this.minNumber,
|
||||
required this.maxNumber,
|
||||
required this.cardType,
|
||||
required this.offers,
|
||||
});
|
||||
|
||||
factory CardPass.fromJson(Map<String, dynamic> json) {
|
||||
return CardPass(
|
||||
id: json['id'],
|
||||
title: json['title'],
|
||||
description: json['description'],
|
||||
validityDuration: json['validityDuration'],
|
||||
adultPrice: json['adultPrice'],
|
||||
childPrice: json['childPrice'],
|
||||
minNumber: json['minNumber'], // ✅
|
||||
maxNumber: json['maxNumber'], // ✅
|
||||
cardType: CardType.fromJson(json['cardType']),
|
||||
offers: List<Offer>.from(
|
||||
json['offers'].map((x) => Offer.fromJson(x)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"validityDuration": validityDuration,
|
||||
"adultPrice": adultPrice,
|
||||
"childPrice": childPrice,
|
||||
"minNumber": minNumber,
|
||||
"maxNumber": maxNumber,
|
||||
"cardType": cardType.toJson(),
|
||||
"offers": offers.map((x) => x.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- CARD TYPE ----------
|
||||
class CardType {
|
||||
final int id;
|
||||
final String name;
|
||||
final String displayName;
|
||||
|
||||
CardType({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.displayName,
|
||||
});
|
||||
|
||||
factory CardType.fromJson(Map<String, dynamic> json) {
|
||||
return CardType(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
displayName: json['displayName'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"name": name,
|
||||
"displayName": displayName,
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- ATTRACTION ----------
|
||||
class Attraction {
|
||||
final int id;
|
||||
final String title;
|
||||
final String slug;
|
||||
final String thumbnail;
|
||||
final int? startingFrom;
|
||||
|
||||
Attraction({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.slug,
|
||||
required this.thumbnail,
|
||||
this.startingFrom,
|
||||
});
|
||||
|
||||
factory Attraction.fromJson(Map<String, dynamic> json) {
|
||||
return Attraction(
|
||||
id: json['id'],
|
||||
title: json['title'],
|
||||
slug: json['slug'],
|
||||
thumbnail: json['thumbnail'],
|
||||
startingFrom: json['startingFrom'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"title": title,
|
||||
"slug": slug,
|
||||
"thumbnail": thumbnail,
|
||||
"startingFrom": startingFrom,
|
||||
};
|
||||
}
|
||||
20
lib/buy_a_pass/repository/buy_pass_repository.dart
Normal file
20
lib/buy_a_pass/repository/buy_pass_repository.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:citycards_customer/localPreference/local_preference.dart';
|
||||
|
||||
import '../models/buy_pass_model.dart';
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
|
||||
class BuyPassRepository {
|
||||
final NetworkApiService _apiService = NetworkApiService();
|
||||
|
||||
/// Fetch Buy A Pass data using selected cityId
|
||||
Future<BuyPassModel> fetchBuyPass() async {
|
||||
final int cityId = await LocalPreference.getSelectedCityId();
|
||||
|
||||
final response = await _apiService.getApi(
|
||||
url: '${ApiUrls.buyAPass}/$cityId',
|
||||
);
|
||||
|
||||
return BuyPassModel.fromJson(response.data);
|
||||
}
|
||||
}
|
||||
@@ -6,229 +6,458 @@ import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:citycards_customer/core/route_constants.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../../common_packages/common_app_texts.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
import '../bloc/buy_pass_bloc.dart';
|
||||
import '../bloc/buy_pass_event.dart';
|
||||
import '../bloc/buy_pass_state.dart';
|
||||
import '../repository/buy_pass_repository.dart';
|
||||
|
||||
class BuyPassView extends StatelessWidget {
|
||||
BuyPassView({super.key});
|
||||
const BuyPassView({super.key});
|
||||
|
||||
final availableAttraction = [
|
||||
{"image": "assets/images/aa1.png", "name": "Mystic Falls"},
|
||||
{"image": "assets/images/aa2.png", "name": "Whispering Pines"},
|
||||
{"image": "assets/images/aa3.png", "name": "Enchanted Oasis"},
|
||||
{"image": "assets/images/aa4.png", "name": "Serenity Cove"},
|
||||
];
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => BuyPassBloc(repository: BuyPassRepository())
|
||||
..add(FetchBuyPassData()),
|
||||
child: const BuyPassContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final offers = [
|
||||
{
|
||||
"image": "assets/images/aa1.png",
|
||||
"title": "Astor Hotels Ultra Deluxe",
|
||||
"description": "15% Discount on all treatments for first-time clients",
|
||||
},
|
||||
{
|
||||
"image": "assets/images/aa2.png",
|
||||
"title": "Green Valley Spa Lux",
|
||||
"description": "20% off on spa memberships and treatments",
|
||||
},
|
||||
];
|
||||
class BuyPassContent extends StatelessWidget {
|
||||
const BuyPassContent({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
|
||||
child: CommonAppBar(isWhiteLogo: false, isProfilePage: true,showDivider: true,),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Icon(Icons.arrow_back),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
CustomText(text: "Buy a Pass", size: 12.sp),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 22.h),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 20.0.w),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
PassCardView(themeColor: Color(0xFFF97316)),
|
||||
SizedBox(width: 12.w),
|
||||
PassCardView(themeColor: Color(0xFF1E8AF6),),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 40.h),
|
||||
FeatureTable(),
|
||||
SizedBox(height: 30.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Divider(color: Colors.black.withOpacity(0.1)),
|
||||
),
|
||||
SizedBox(height: 30.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
|
||||
child: CustomText(text: "Available Attractions", size: 18.sp),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
...availableAttraction.map((item) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: 12.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 104.h,
|
||||
width: 104.w,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
child: Image.asset(
|
||||
item["image"]!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
CustomText(text: item["name"]!, size: 12.sp),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: CustomText(
|
||||
text: "View All",
|
||||
size: 12.sp,
|
||||
child: BlocBuilder<BuyPassBloc, BuyPassState>(
|
||||
builder: (context, state) {
|
||||
if (state is BuyPassLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 30.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Divider(color: Colors.black.withOpacity(0.1)),
|
||||
),
|
||||
SizedBox(height: 40.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
);
|
||||
}
|
||||
|
||||
if (state is BuyPassError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CustomText(text: "Card Offers", size: 18.sp),
|
||||
GestureDetector(
|
||||
onTap: (){
|
||||
Navigator.pushNamed(context,RouteConstants.searchOffer);
|
||||
Icon(Icons.error_outline, size: 60.sp, color: Colors.red),
|
||||
SizedBox(height: 16.h),
|
||||
CustomText(
|
||||
text: "Error loading data",
|
||||
size: 16.sp,
|
||||
color: Colors.red,
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
CustomText(
|
||||
text: state.message,
|
||||
size: 12.sp,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<BuyPassBloc>().add(FetchBuyPassData());
|
||||
},
|
||||
child: CustomText(
|
||||
text: "View All",
|
||||
size: 14.sp,
|
||||
color: Color(0xFFFF5757),
|
||||
),
|
||||
child: const Text("Retry"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Container(
|
||||
height: 262.h,
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: GridView.builder(
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16.w,
|
||||
childAspectRatio: 0.66,
|
||||
),
|
||||
itemCount: 2,
|
||||
itemBuilder: (context, index) {
|
||||
final offer = offers[index];
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 6.w,
|
||||
vertical: 6.h,
|
||||
);
|
||||
}
|
||||
|
||||
if (state is BuyPassLoaded) {
|
||||
final data = state.data;
|
||||
final selectedCard = state.selectedCard;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
|
||||
child: CommonAppBar(
|
||||
isWhiteLogo: false,
|
||||
isProfilePage: true,
|
||||
showDivider: true,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Color(0xFFF95F62).withOpacity(.24),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12.sp),
|
||||
),
|
||||
child: Column(
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.sp),
|
||||
child: Image.asset(
|
||||
offer["image"] ?? "",
|
||||
width: double.infinity,
|
||||
height: 120.5.h,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Icon(Icons.arrow_back),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
CustomText(text: offer["title"] ?? "", size: 18.sp),
|
||||
SizedBox(height: 8.h),
|
||||
CustomText(
|
||||
text: offer["description"] ?? "",
|
||||
color: Colors.black.withOpacity(.6),
|
||||
size: 12.sp,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
SizedBox(width: 8.w),
|
||||
CustomText(text: "Buy a Pass", size: 12.sp),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 22.h),
|
||||
|
||||
// Pass Cards Horizontal List
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 20.0.w),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: List.generate(
|
||||
data.cards.length,
|
||||
(index) {
|
||||
final card = data.cards[index];
|
||||
final isSelected = index == state.selectedCardIndex;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
context.read<BuyPassBloc>().add(
|
||||
ChangeSelectedCard(index),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: 12.w),
|
||||
child: PassCardView(
|
||||
themeColor: isSelected
|
||||
? Color(0xFFF97316)
|
||||
: Color(0xFF1E8AF6),
|
||||
city: data.city.name,
|
||||
adultPrice: card.adultPrice,
|
||||
childPrice: card.childPrice,
|
||||
cardType: card.cardType.displayName,
|
||||
description: card.description,
|
||||
isSelected: isSelected,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 30.h),
|
||||
|
||||
// Payment Card
|
||||
Center(
|
||||
// Updated PaymentCard usage with BLoC
|
||||
|
||||
child: PaymentCard(
|
||||
city: data.city.name,
|
||||
cardType: selectedCard.cardType.name,
|
||||
cardDisplayName: selectedCard.cardType.displayName,
|
||||
adultPrice: selectedCard.adultPrice.toDouble(),
|
||||
childPrice: selectedCard.childPrice.toDouble(),
|
||||
adults: state.adultCount,
|
||||
children: state.childCount,
|
||||
totalPrice: state.totalPrice,
|
||||
minNumber: selectedCard.minNumber,
|
||||
maxNumber: selectedCard.maxNumber,
|
||||
selectedValue: state.validityDuration, // Use from BLoC state
|
||||
onAdultChanged: (count) {
|
||||
context.read<BuyPassBloc>().add(
|
||||
UpdateAdultCount(count),
|
||||
);
|
||||
},
|
||||
onChildChanged: (count) {
|
||||
context.read<BuyPassBloc>().add(
|
||||
UpdateChildCount(count),
|
||||
);
|
||||
},
|
||||
onValidityChanged: (duration) {
|
||||
context.read<BuyPassBloc>().add(
|
||||
UpdateValidityDuration(duration),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
FeatureTable(),
|
||||
SizedBox(height: 30.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Divider(color: Colors.black.withOpacity(0.1)),
|
||||
),
|
||||
SizedBox(height: 40.h),
|
||||
|
||||
// Card Offers Section
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomText(text: "Card Offers", size: 18.sp),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pushNamed(
|
||||
context, RouteConstants.searchOffer);
|
||||
},
|
||||
child: CustomText(
|
||||
text: "View All",
|
||||
size: 14.sp,
|
||||
color: Color(0xFFFF5757),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
|
||||
SizedBox(height: 41.h),
|
||||
Center(
|
||||
child: PaymentCard(
|
||||
city: 'Melbourne',
|
||||
tag: '${CommonAppText.selectiveCard} Card',
|
||||
oldPrice: 120,
|
||||
newPrice: 90,
|
||||
// Offers Grid (from selected card's offers)
|
||||
if (selectedCard.offers.isNotEmpty)
|
||||
Container(
|
||||
height: 262.h,
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: GridView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16.w,
|
||||
mainAxisSpacing: 22.h,
|
||||
childAspectRatio: 0.65,
|
||||
),
|
||||
itemCount: selectedCard.offers.length > 2
|
||||
? 2
|
||||
: selectedCard.offers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final offer = selectedCard.offers[index];
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 6.w,
|
||||
vertical: 6.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: const Color(0xFFF95F62).withOpacity(.24),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12.sp),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
/// Image
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.sp),
|
||||
child: offer.mobileBannerImage != null &&
|
||||
offer.mobileBannerImage!.isNotEmpty
|
||||
? Image.network(
|
||||
'${ApiUrls.baseUrl}/${offer.mobileBannerImage}',
|
||||
width: double.infinity,
|
||||
height: 120.5.h,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 120.5.h,
|
||||
color: const Color(0xFFFEE7E7),
|
||||
child: Icon(
|
||||
Icons.local_offer,
|
||||
size: 40.sp,
|
||||
color:
|
||||
const Color(0xFFF95F62).withOpacity(.6),
|
||||
),
|
||||
);
|
||||
},
|
||||
loadingBuilder:
|
||||
(context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 120.5.h,
|
||||
color: const Color(0xFFFEE7E7),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: const Color(0xFFF95F62),
|
||||
value: loadingProgress
|
||||
.expectedTotalBytes !=
|
||||
null
|
||||
? loadingProgress
|
||||
.cumulativeBytesLoaded /
|
||||
loadingProgress
|
||||
.expectedTotalBytes!
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Container(
|
||||
width: double.infinity,
|
||||
height: 120.5.h,
|
||||
color: const Color(0xFFFEE7E7),
|
||||
child: Icon(
|
||||
Icons.local_offer,
|
||||
size: 40.sp,
|
||||
color:
|
||||
const Color(0xFFF95F62).withOpacity(.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 8.h),
|
||||
|
||||
/// Title
|
||||
CustomText(
|
||||
text: offer.title,
|
||||
size: 18.sp,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
SizedBox(height: 8.h),
|
||||
|
||||
/// Offer Code
|
||||
CustomText(
|
||||
text: offer.description??"N/A",
|
||||
color: Colors.black.withOpacity(.6),
|
||||
size: 12.sp,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
height: 100.h,
|
||||
alignment: Alignment.center,
|
||||
child: CustomText(
|
||||
text: "No offers available",
|
||||
size: 14.sp,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 30.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Divider(color: Colors.black.withOpacity(0.1)),
|
||||
),
|
||||
SizedBox(height: 30.h),
|
||||
|
||||
// Available Attractions
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
|
||||
child: CustomText(
|
||||
text: "Available Attractions", size: 18.sp),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
if (data.attractions.isNotEmpty)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: data.attractions.map((attraction) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: 12.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 104.h,
|
||||
width: 104.w,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
child: attraction.thumbnail != null &&
|
||||
attraction.thumbnail!.isNotEmpty
|
||||
? Image.network(
|
||||
attraction.thumbnail!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Icon(
|
||||
Icons.location_on,
|
||||
size: 40.sp,
|
||||
color: Colors.grey[400],
|
||||
);
|
||||
},
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 20.w,
|
||||
height: 20.w,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Icon(
|
||||
Icons.location_on,
|
||||
size: 40.sp,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
SizedBox(
|
||||
width: 104.w,
|
||||
child: CustomText(
|
||||
text: attraction.title,
|
||||
size: 12.sp,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
height: 100.h,
|
||||
alignment: Alignment.center,
|
||||
child: CustomText(
|
||||
text: "No attractions available",
|
||||
size: 14.sp,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: CustomText(
|
||||
text: "View All",
|
||||
size: 12.sp,
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 41.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,24 @@ import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../../common_packages/common_app_texts.dart';
|
||||
|
||||
class PassCardView extends StatelessWidget {
|
||||
final Color? themeColor;
|
||||
final String? city;
|
||||
final int? adultCount;
|
||||
final int? childCount;
|
||||
final int? adultPrice;
|
||||
final int? childPrice;
|
||||
final String? cardType;
|
||||
final String? description;
|
||||
final bool isSelected;
|
||||
|
||||
const PassCardView({
|
||||
super.key,
|
||||
this.themeColor,
|
||||
this.city,
|
||||
this.adultCount,
|
||||
this.childCount,
|
||||
this.adultPrice,
|
||||
this.childPrice,
|
||||
this.cardType,
|
||||
this.description,
|
||||
this.isSelected = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -25,141 +27,161 @@ class PassCardView extends StatelessWidget {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color:( themeColor ?? Color(0xFFF95FAF)).withOpacity(0.24)),
|
||||
border: Border.all(
|
||||
color: (themeColor ?? Color(0xFFF95FAF)).withOpacity(0.24),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
// boxShadow: isSelected
|
||||
// ? [
|
||||
// BoxShadow(
|
||||
// color: (themeColor ?? Color(0xFFF95FAF)).withOpacity(0.3),
|
||||
// blurRadius: 8,
|
||||
// spreadRadius: 1,
|
||||
// )
|
||||
// ]
|
||||
// : [],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(8.r),
|
||||
bottomLeft: Radius.circular(8.r)
|
||||
),
|
||||
child: Image.asset(
|
||||
"assets/images/card_banner.png",
|
||||
scale: 4,
|
||||
width: 103.w,
|
||||
height:140.h,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 6.66.w),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(
|
||||
text: "Melbourne",
|
||||
weight: FontWeight.w500,
|
||||
size: 16.sp,
|
||||
),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"From ",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"\$80",
|
||||
style: TextStyle(
|
||||
color:themeColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 24.sp,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
" /Adult",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.8),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"and ",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"\$10",
|
||||
style: TextStyle(
|
||||
color: themeColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 24.sp,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
" /child",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.8),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(
|
||||
width: 193.w,
|
||||
child: CustomText(
|
||||
text:
|
||||
"Dive into an extensive selection of thrilling destinations!",
|
||||
color: Color(0xFF000000).withOpacity(0.6),
|
||||
size: 11.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Container(
|
||||
width: 35.w,
|
||||
height: 140.h,
|
||||
decoration: BoxDecoration(
|
||||
color: themeColor,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
// Banner Image Placeholder
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomRight: Radius.circular(8.r),
|
||||
topRight: Radius.circular(8.r),
|
||||
topLeft: Radius.circular(8.r),
|
||||
bottomLeft: Radius.circular(8.r),
|
||||
),
|
||||
),
|
||||
child: RotatedBox(
|
||||
quarterTurns: -1,
|
||||
child: Center(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "${CommonAppText.selectiveCard} ",
|
||||
style: TextStyle(color: Colors.white, fontSize: 16.sp),
|
||||
),
|
||||
TextSpan(
|
||||
text: "Card",
|
||||
style: TextStyle(color: Colors.white, fontSize: 12.sp),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Container(
|
||||
width: 103.w,
|
||||
height: 140.h,
|
||||
color: Colors.grey[200],
|
||||
child: Icon(
|
||||
Icons.card_travel,
|
||||
size: 40.sp,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 6.66.w),
|
||||
|
||||
// Card Details
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CustomText(
|
||||
text: city ?? "City",
|
||||
weight: FontWeight.w500,
|
||||
size: 16.sp,
|
||||
),
|
||||
|
||||
// Adult Price
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"From ",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"\$${adultPrice ?? 0}",
|
||||
style: TextStyle(
|
||||
color: themeColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 24.sp,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
" /Adult",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.8),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Child Price
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"and ",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"\$${childPrice ?? 0}",
|
||||
style: TextStyle(
|
||||
color: themeColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 24.sp,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
" /child",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.8),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Description
|
||||
SizedBox(
|
||||
width: 193.w,
|
||||
child: CustomText(
|
||||
text: description ??
|
||||
"Dive into an extensive selection of thrilling destinations!",
|
||||
color: Color(0xFF000000).withOpacity(0.6),
|
||||
size: 11.sp,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Card Type Label (Vertical)
|
||||
Container(
|
||||
width: 35.w,
|
||||
height: 140.h,
|
||||
decoration: BoxDecoration(
|
||||
color: themeColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomRight: Radius.circular(8.r),
|
||||
topRight: Radius.circular(8.r),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: RotatedBox(
|
||||
quarterTurns: -1,
|
||||
child: Center(
|
||||
child: Text(
|
||||
cardType ?? "Pass",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,158 +1,254 @@
|
||||
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:citycards_customer/core/route_constants.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
class PaymentCard extends StatefulWidget {
|
||||
class PaymentCard extends StatelessWidget {
|
||||
final String city;
|
||||
final String tag;
|
||||
final double oldPrice;
|
||||
final double newPrice;
|
||||
final String cardType; // "unlimited_card" or "selective_pass"
|
||||
final String cardDisplayName;
|
||||
final double adultPrice;
|
||||
final double childPrice;
|
||||
final int adults;
|
||||
final int children;
|
||||
final double totalPrice;
|
||||
final int minNumber;
|
||||
final int maxNumber;
|
||||
final int selectedValue; // Current selected value for dropdown
|
||||
final Function(int) onAdultChanged;
|
||||
final Function(int) onChildChanged;
|
||||
final Function(int) onValidityChanged;
|
||||
|
||||
const PaymentCard({
|
||||
super.key,
|
||||
required this.city,
|
||||
required this.tag,
|
||||
required this.oldPrice,
|
||||
required this.newPrice,
|
||||
required this.cardType,
|
||||
required this.cardDisplayName,
|
||||
required this.adultPrice,
|
||||
required this.childPrice,
|
||||
required this.adults,
|
||||
required this.children,
|
||||
required this.totalPrice,
|
||||
required this.minNumber,
|
||||
required this.maxNumber,
|
||||
required this.selectedValue,
|
||||
required this.onAdultChanged,
|
||||
required this.onChildChanged,
|
||||
required this.onValidityChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PaymentCard> createState() => _PaymentCardState();
|
||||
}
|
||||
|
||||
class _PaymentCardState extends State<PaymentCard> {
|
||||
int adults = 1;
|
||||
int children = 1;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 320,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.pinkAccent, width: 1.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.pinkAccent.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
widget.city,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
// Determine if it's unlimited card or selective pass
|
||||
final bool isUnlimitedCard = cardType == "unlimited_card";
|
||||
final bool isSelectivePass = cardType == "selective_pass";
|
||||
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Tag
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFF95FAF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(20.sp),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.pinkAccent, width: 1.2),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.pinkAccent.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
child: Text(
|
||||
widget.tag,
|
||||
style: const TextStyle(
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Title
|
||||
CustomText(
|
||||
text: city,
|
||||
size: 20.sp,
|
||||
weight: FontWeight.bold,
|
||||
),
|
||||
|
||||
SizedBox(height: 6.h),
|
||||
|
||||
// Tag
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFF95FAF),
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
),
|
||||
child: CustomText(
|
||||
text: "$cardDisplayName Card",
|
||||
size: 12.sp,
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(height: 16.h),
|
||||
|
||||
// Adult Counter
|
||||
_buildCounterRow("No. of Adults", adults, (val) {
|
||||
setState(() => adults = val);
|
||||
}),
|
||||
// Adult Counter
|
||||
_buildCounterRow(
|
||||
"No. of Adults",
|
||||
adults,
|
||||
onAdultChanged,
|
||||
),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(height: 10.h),
|
||||
|
||||
// Children Counter
|
||||
_buildCounterRow("No. of Children", children, (val) {
|
||||
setState(() => children = val);
|
||||
}),
|
||||
// Children Counter
|
||||
_buildCounterRow(
|
||||
"No. of Children",
|
||||
children,
|
||||
onChildChanged,
|
||||
),
|
||||
|
||||
const Divider(height: 30, thickness: 1),
|
||||
SizedBox(height: 10.h),
|
||||
|
||||
// Price section
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
"You Pay",
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
// Show days dropdown for unlimited_card OR attractions dropdown for selective_pass
|
||||
if (isUnlimitedCard)
|
||||
_buildDropdownRow(
|
||||
label: "No. of Days",
|
||||
value: selectedValue,
|
||||
onChanged: onValidityChanged,
|
||||
)
|
||||
else if (isSelectivePass)
|
||||
_buildDropdownRow(
|
||||
label: "No. of Attractions",
|
||||
value: selectedValue,
|
||||
onChanged: onValidityChanged,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"\$${widget.oldPrice.toStringAsFixed(0)}",
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 14,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"\$${widget.newPrice.toStringAsFixed(0)}",
|
||||
style: const TextStyle(
|
||||
|
||||
Divider(height: 30.h, thickness: 1),
|
||||
|
||||
// Price section
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomText(
|
||||
text: "You Pay",
|
||||
size: 16.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
// Calculate original price (without any discount logic for now)
|
||||
CustomText(
|
||||
text: "\$${totalPrice.toStringAsFixed(0)}",
|
||||
size: 18.sp,
|
||||
color: Color(0xFFF95F62),
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
weight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
// Proceed Button
|
||||
CustomFilledButton(
|
||||
onTap: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushNamed(RouteConstants.checkout);
|
||||
},
|
||||
label: "Proceed to Pay",
|
||||
),
|
||||
],
|
||||
// Proceed Button
|
||||
CustomFilledButton(
|
||||
onTap: () {
|
||||
Navigator.of(context).pushNamed(RouteConstants.checkout);
|
||||
},
|
||||
label: "Proceed to Pay",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCounterRow(String label, int value, Function(int) onChanged) {
|
||||
/// Dropdown row for days or attractions count
|
||||
Widget _buildDropdownRow({
|
||||
required String label,
|
||||
required int value,
|
||||
required Function(int) onChanged,
|
||||
}) {
|
||||
List<int> numbersList = List.generate(
|
||||
maxNumber - minNumber + 1,
|
||||
(index) => minNumber + index,
|
||||
);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: TextStyle(fontSize: 15.sp)),
|
||||
CustomText(
|
||||
text: label,
|
||||
size: 15.sp,
|
||||
),
|
||||
|
||||
Container(
|
||||
height: 36.h,
|
||||
width: 88.w, // 👈 fixed width for proper spacing
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFF95F62).withValues(alpha: 0.13),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFF95F62),
|
||||
width: 1.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: value,
|
||||
isExpanded: true,
|
||||
icon: Icon(
|
||||
Icons.keyboard_arrow_down_rounded,
|
||||
color: const Color(0xFFF95F62),
|
||||
size: 22.sp,
|
||||
),
|
||||
items: numbersList.map((int number) {
|
||||
return DropdownMenuItem<int>(
|
||||
value: number,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft, // 🔢 number fully left
|
||||
child: CustomText(
|
||||
text: "$number",
|
||||
size: 16.sp,
|
||||
weight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (int? newValue) {
|
||||
if (newValue != null) {
|
||||
onChanged(newValue);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Counter row for adults/children
|
||||
Widget _buildCounterRow(
|
||||
String label,
|
||||
int value,
|
||||
Function(int) onChanged,
|
||||
) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomText(text: label, size: 15.sp),
|
||||
Row(
|
||||
children: [
|
||||
_circleButton(Icons.remove, () {
|
||||
if (value > 0) onChanged(value - 1);
|
||||
}),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
"$value",
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
padding: EdgeInsets.symmetric(horizontal: 10.w),
|
||||
child: CustomText(
|
||||
text: "$value",
|
||||
size: 16.sp,
|
||||
weight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
_circleButton(Icons.add, () {
|
||||
@@ -164,6 +260,7 @@ class _PaymentCardState extends State<PaymentCard> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Circle button for increment/decrement
|
||||
Widget _circleButton(IconData icon, VoidCallback onTap) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
@@ -173,9 +270,9 @@ class _PaymentCardState extends State<PaymentCard> {
|
||||
shape: BoxShape.circle,
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
padding: EdgeInsets.all(4.sp),
|
||||
child: Icon(icon, color: Colors.white, size: 18.sp),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import 'package:citycards_customer/checkout/view/checkout_view.dart';
|
||||
import 'package:citycards_customer/common_bloc/language_selection_bloc.dart';
|
||||
import 'package:citycards_customer/contact_us/contact_us_view.dart';
|
||||
import 'package:citycards_customer/create_account/view/create_account_view.dart';
|
||||
import 'package:citycards_customer/edit_profile/edit_profile_view.dart';
|
||||
import 'package:citycards_customer/esim_offer/esim_offer_view.dart';
|
||||
import 'package:citycards_customer/hotel_offer/hotel_offer_view.dart';
|
||||
import 'package:citycards_customer/intro_screens/views/intro_screen_view.dart';
|
||||
@@ -30,10 +29,13 @@ import '../cart/views/my_cart_view_page.dart';
|
||||
import '../common_bloc/bottom_navigation_bloc.dart';
|
||||
import '../home/views/home_page_view.dart';
|
||||
import '../home/views/registered_user_home_page.dart';
|
||||
import '../profile/view/edit_profile/edit_profile_view.dart';
|
||||
import '../profile/view/faq/faq_view.dart';
|
||||
import '../profile/view/privacy/privacy_view.dart';
|
||||
import '../profile/view/profile_page_view.dart';
|
||||
import '../profile/view/terms_and_condition/terms_and_condition_view.dart';
|
||||
import '../search_offers/bloc/offers_bloc.dart';
|
||||
import '../search_offers/repository/offers_repository.dart';
|
||||
import 'route_constants.dart';
|
||||
|
||||
class AppRouter {
|
||||
@@ -180,8 +182,8 @@ class AppRouter {
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return BlocProvider(
|
||||
create: (_) => OffersBloc(),
|
||||
child: SearchOffersWithListing(),
|
||||
create: (_) => OffersBloc(OffersRepository()),
|
||||
child: OffersScreen(),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -226,12 +228,15 @@ class AppRouter {
|
||||
);
|
||||
|
||||
case RouteConstants.offerPassDetail:
|
||||
final offerId = settings.arguments as int;
|
||||
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return OfferPassDetailView();
|
||||
},
|
||||
builder: (_) => OffersDetailsView(
|
||||
offerId: offerId,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
case RouteConstants.registeredUserHome:
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
|
||||
@@ -22,7 +22,9 @@ import '../my_pass/views/qr_pass_page_view.dart';
|
||||
import '../offer_pass_detail/offer_pass_detail_view.dart';
|
||||
import '../postcard/blocs/postcard_creation_bloc.dart';
|
||||
import '../postcard/views/postcard_creation_page_view.dart';
|
||||
import '../search_offers/bloc/offers_bloc.dart';
|
||||
import '../search_offers/bloc/search_offers_listing_bloc.dart';
|
||||
import '../search_offers/repository/offers_repository.dart';
|
||||
import '../search_offers/view/search_offers_with_listing.dart';
|
||||
import '../your_itinerary/view/your_itinerary_view.dart';
|
||||
|
||||
@@ -80,16 +82,20 @@ Widget buildOffstageNavigator(
|
||||
);
|
||||
|
||||
case RouteConstants.offerPassDetail:
|
||||
return MaterialPageRoute(builder: (_){
|
||||
return OfferPassDetailView();
|
||||
});
|
||||
final offerId = settings.arguments as int;
|
||||
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => OffersDetailsView(
|
||||
offerId: offerId,
|
||||
),
|
||||
);
|
||||
|
||||
case RouteConstants.searchOffer:
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return BlocProvider(
|
||||
create: (_) => OffersBloc(),
|
||||
child: SearchOffersWithListing(),
|
||||
create: (_) => OffersBloc(OffersRepository()),
|
||||
child: OffersScreen(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:citycards_customer/cart/blocs/postcard_bloc.dart';
|
||||
import 'package:citycards_customer/core/route_constants.dart';
|
||||
import 'package:citycards_customer/search_offers/bloc/offers_bloc.dart';
|
||||
import 'package:citycards_customer/trail.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -16,6 +17,8 @@ import 'login/bloc/login/login_bloc.dart';
|
||||
import 'login/repository/login_repository.dart';
|
||||
import 'my_pass/blocs/my_pass_bloc.dart';
|
||||
import 'profile/bloc/profile/profile_bloc.dart';
|
||||
import 'search_offers/repository/offers_repository.dart';
|
||||
import 'search_offers/view/search_offers_with_listing.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -61,7 +64,12 @@ class MyApp extends StatelessWidget {
|
||||
loginRepository: LoginRepository(),
|
||||
),
|
||||
),
|
||||
BlocProvider(create: (context) => ProfileBloc()),
|
||||
BlocProvider(
|
||||
create: (_) => OffersBloc(OffersRepository()),
|
||||
child: const OffersScreen(),
|
||||
),
|
||||
BlocProvider(create: (context) => ProfileBloc()),
|
||||
|
||||
],
|
||||
child: MaterialApp(
|
||||
onGenerateRoute: _appRouter.onGenerateRoute,
|
||||
|
||||
@@ -12,6 +12,9 @@ class ApiUrls {
|
||||
static const home = "$baseUrl/mobile";
|
||||
static const faqPrivacyTerms = "$baseUrl/mobile/user/cms-data";
|
||||
static const userProfile = "$baseUrl/mobile/user";
|
||||
static const offers = "$baseUrl/mobile/list/offers";
|
||||
static const buyAPass = "$baseUrl/mobile/pass";
|
||||
static const offersDetails = "$baseUrl/mobile/list/offers";
|
||||
|
||||
|
||||
//Post Apis
|
||||
|
||||
35
lib/offer_pass_detail/bloc/offer_details_bloc.dart
Normal file
35
lib/offer_pass_detail/bloc/offer_details_bloc.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../repository/offers_details_repository.dart';
|
||||
import 'offer_details_event.dart';
|
||||
import 'offer_details_state.dart';
|
||||
|
||||
class OfferDetailsBloc
|
||||
extends Bloc<OfferDetailsEvent, OfferDetailsState> {
|
||||
final OffersDetailsRepository repository;
|
||||
|
||||
OfferDetailsBloc({required this.repository})
|
||||
: super(OfferDetailsInitial()) {
|
||||
on<FetchOfferDetailsEvent>(_onFetchOfferDetails);
|
||||
}
|
||||
|
||||
Future<void> _onFetchOfferDetails(
|
||||
FetchOfferDetailsEvent event,
|
||||
Emitter<OfferDetailsState> emit,
|
||||
) async {
|
||||
emit(OfferDetailsLoading());
|
||||
|
||||
try {
|
||||
final offerDetails = await repository.fetchOfferDetails(
|
||||
offerId: event.offerId,
|
||||
);
|
||||
|
||||
emit(OfferDetailsLoaded(offerDetails: offerDetails));
|
||||
} catch (e) {
|
||||
emit(
|
||||
OfferDetailsError(
|
||||
message: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
lib/offer_pass_detail/bloc/offer_details_event.dart
Normal file
18
lib/offer_pass_detail/bloc/offer_details_event.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class OfferDetailsEvent extends Equatable {
|
||||
const OfferDetailsEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Fetch offer details by offerId
|
||||
class FetchOfferDetailsEvent extends OfferDetailsEvent {
|
||||
final int offerId;
|
||||
|
||||
const FetchOfferDetailsEvent({required this.offerId});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [offerId];
|
||||
}
|
||||
35
lib/offer_pass_detail/bloc/offer_details_state.dart
Normal file
35
lib/offer_pass_detail/bloc/offer_details_state.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../models/offer_details_model.dart';
|
||||
|
||||
abstract class OfferDetailsState extends Equatable {
|
||||
const OfferDetailsState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Initial state
|
||||
class OfferDetailsInitial extends OfferDetailsState {}
|
||||
|
||||
/// Loading state
|
||||
class OfferDetailsLoading extends OfferDetailsState {}
|
||||
|
||||
/// Success state
|
||||
class OfferDetailsLoaded extends OfferDetailsState {
|
||||
final OfferDetailsModel offerDetails;
|
||||
|
||||
const OfferDetailsLoaded({required this.offerDetails});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [offerDetails];
|
||||
}
|
||||
|
||||
/// Error state
|
||||
class OfferDetailsError extends OfferDetailsState {
|
||||
final String message;
|
||||
|
||||
const OfferDetailsError({required this.message});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
199
lib/offer_pass_detail/models/offer_details_model.dart
Normal file
199
lib/offer_pass_detail/models/offer_details_model.dart
Normal file
@@ -0,0 +1,199 @@
|
||||
// =======================
|
||||
// OFFER DETAILS MODEL
|
||||
// =======================
|
||||
|
||||
class OfferDetailsModel {
|
||||
final int id;
|
||||
final String title;
|
||||
final String description;
|
||||
final int cityXid;
|
||||
final int cardXid;
|
||||
final int cardTypeXid;
|
||||
final int categoryXid;
|
||||
final String partnerName;
|
||||
final String offerCode;
|
||||
final String websiteBannerImage;
|
||||
final String mobileBannerImage;
|
||||
final String redemptionLink;
|
||||
final String passType;
|
||||
final DateTime startDateTime;
|
||||
final DateTime endDateTime;
|
||||
final bool applyToPasses;
|
||||
final String? stepsForBooking;
|
||||
final String offerStatus;
|
||||
final bool isActive;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final City city;
|
||||
final CardInfo card;
|
||||
final CardType cardType;
|
||||
final Category category;
|
||||
|
||||
OfferDetailsModel({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.cityXid,
|
||||
required this.cardXid,
|
||||
required this.cardTypeXid,
|
||||
required this.categoryXid,
|
||||
required this.partnerName,
|
||||
required this.offerCode,
|
||||
required this.websiteBannerImage,
|
||||
required this.mobileBannerImage,
|
||||
required this.redemptionLink,
|
||||
required this.passType,
|
||||
required this.startDateTime,
|
||||
required this.endDateTime,
|
||||
required this.applyToPasses,
|
||||
this.stepsForBooking,
|
||||
required this.offerStatus,
|
||||
required this.isActive,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.city,
|
||||
required this.card,
|
||||
required this.cardType,
|
||||
required this.category,
|
||||
});
|
||||
|
||||
factory OfferDetailsModel.fromJson(Map<String, dynamic> json) {
|
||||
return OfferDetailsModel(
|
||||
id: json['id'] ?? 0,
|
||||
title: json['title'] ?? 'N/A',
|
||||
description: json['description'] ?? 'N/A',
|
||||
cityXid: json['cityXid'] ?? 0,
|
||||
cardXid: json['cardXid'] ?? 0,
|
||||
cardTypeXid: json['cardTypeXid'] ?? 0,
|
||||
categoryXid: json['categoryXid'] ?? 0,
|
||||
partnerName: json['partnerName'] ?? 'N/A',
|
||||
offerCode: json['offerCode'] ?? 'N/A',
|
||||
websiteBannerImage: json['websiteBannerImage'] ?? '',
|
||||
mobileBannerImage: json['mobileBannerImage'] ?? '',
|
||||
redemptionLink: json['redemptionLink'] ?? '',
|
||||
passType: json['passType'] ?? 'N/A',
|
||||
startDateTime: json['startDateTime'] != null
|
||||
? DateTime.parse(json['startDateTime'])
|
||||
: DateTime.now(),
|
||||
endDateTime: json['endDateTime'] != null
|
||||
? DateTime.parse(json['endDateTime'])
|
||||
: DateTime.now(),
|
||||
applyToPasses: json['applyToPasses'] ?? false,
|
||||
stepsForBooking: json['stepsForBooking'],
|
||||
offerStatus: json['offerStatus'] ?? 'N/A',
|
||||
isActive: json['isActive'] ?? false,
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'])
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updatedAt'] != null
|
||||
? DateTime.parse(json['updatedAt'])
|
||||
: DateTime.now(),
|
||||
city: json['city'] != null
|
||||
? City.fromJson(json['city'])
|
||||
: City.empty(),
|
||||
card: json['card'] != null
|
||||
? CardInfo.fromJson(json['card'])
|
||||
: CardInfo.empty(),
|
||||
cardType: json['cardType'] != null
|
||||
? CardType.fromJson(json['cardType'])
|
||||
: CardType.empty(),
|
||||
category: json['category'] != null
|
||||
? Category.fromJson(json['category'])
|
||||
: Category.empty(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =======================
|
||||
// CITY
|
||||
// =======================
|
||||
|
||||
class City {
|
||||
final int id;
|
||||
final String cityName;
|
||||
|
||||
City({
|
||||
required this.id,
|
||||
required this.cityName,
|
||||
});
|
||||
|
||||
factory City.fromJson(Map<String, dynamic> json) {
|
||||
return City(
|
||||
id: json['id'] ?? 0,
|
||||
cityName: json['cityName'] ?? 'N/A',
|
||||
);
|
||||
}
|
||||
|
||||
factory City.empty() => City(id: 0, cityName: 'N/A');
|
||||
}
|
||||
|
||||
// =======================
|
||||
// CARD INFO
|
||||
// =======================
|
||||
|
||||
class CardInfo {
|
||||
final int id;
|
||||
final String title;
|
||||
|
||||
CardInfo({
|
||||
required this.id,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
factory CardInfo.fromJson(Map<String, dynamic> json) {
|
||||
return CardInfo(
|
||||
id: json['id'] ?? 0,
|
||||
title: json['title'] ?? 'N/A',
|
||||
);
|
||||
}
|
||||
|
||||
factory CardInfo.empty() => CardInfo(id: 0, title: 'N/A');
|
||||
}
|
||||
|
||||
// =======================
|
||||
// CARD TYPE
|
||||
// =======================
|
||||
|
||||
class CardType {
|
||||
final int id;
|
||||
final String cardTypeDisplayName;
|
||||
|
||||
CardType({
|
||||
required this.id,
|
||||
required this.cardTypeDisplayName,
|
||||
});
|
||||
|
||||
factory CardType.fromJson(Map<String, dynamic> json) {
|
||||
return CardType(
|
||||
id: json['id'] ?? 0,
|
||||
cardTypeDisplayName: json['cardTypeDisplayName'] ?? 'N/A',
|
||||
);
|
||||
}
|
||||
|
||||
factory CardType.empty() =>
|
||||
CardType(id: 0, cardTypeDisplayName: 'N/A');
|
||||
}
|
||||
|
||||
// =======================
|
||||
// CATEGORY
|
||||
// =======================
|
||||
|
||||
class Category {
|
||||
final int id;
|
||||
final String categoryName;
|
||||
|
||||
Category({
|
||||
required this.id,
|
||||
required this.categoryName,
|
||||
});
|
||||
|
||||
factory Category.fromJson(Map<String, dynamic> json) {
|
||||
return Category(
|
||||
id: json['id'] ?? 0,
|
||||
categoryName: json['categoryName'] ?? 'N/A',
|
||||
);
|
||||
}
|
||||
|
||||
factory Category.empty() =>
|
||||
Category(id: 0, categoryName: 'N/A');
|
||||
}
|
||||
@@ -3,244 +3,298 @@ import 'package:citycards_customer/common_packages/app_bar.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_bullet_points.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
class OfferPassDetailView extends StatelessWidget {
|
||||
const OfferPassDetailView({super.key});
|
||||
import '../networkApiServices/api_urls.dart';
|
||||
import 'bloc/offer_details_bloc.dart';
|
||||
import 'bloc/offer_details_event.dart';
|
||||
import 'bloc/offer_details_state.dart';
|
||||
import 'repository/offers_details_repository.dart';
|
||||
|
||||
class OffersDetailsView extends StatelessWidget {
|
||||
final int offerId;
|
||||
|
||||
const OffersDetailsView({
|
||||
super.key,
|
||||
required this.offerId,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => OfferDetailsBloc(
|
||||
repository: OffersDetailsRepository(), // ← Create directly
|
||||
)..add(FetchOfferDetailsEvent(offerId: offerId)),
|
||||
child: const _OffersDetailsContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OffersDetailsContent extends StatelessWidget {
|
||||
const _OffersDetailsContent();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/koh_rong_samloem_banner.png',
|
||||
height: 377.h,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 20.w,
|
||||
vertical: 10.h,
|
||||
child: BlocBuilder<OfferDetailsBloc, OfferDetailsState>(
|
||||
builder: (context, state) {
|
||||
if (state is OfferDetailsLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state is OfferDetailsError) {
|
||||
return Center(child: Text(state.message));
|
||||
}
|
||||
|
||||
if (state is OfferDetailsLoaded) {
|
||||
final offer = state.offerDetails;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
/// 🔥 Banner Image (API + Placeholder)
|
||||
FadeInImage.assetNetwork(
|
||||
placeholder:
|
||||
'assets/images/koh_rong_samloem_banner.png',
|
||||
image: '${ApiUrls.baseUrl}/''${offer.mobileBannerImage}',
|
||||
height: 377.h,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
imageErrorBuilder:
|
||||
(context, error, stackTrace) {
|
||||
return Image.asset(
|
||||
'assets/images/koh_rong_samloem_banner.png',
|
||||
height: 377.h,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
},
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CommonAppBar(
|
||||
isWhiteLogo: false,
|
||||
isProfilePage: false,
|
||||
showDivider: true,
|
||||
),
|
||||
|
||||
SizedBox(height: 8.h),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Icon(
|
||||
Icons.arrow_back,
|
||||
size: 24.sp,
|
||||
color: Colors.white,
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 20.w,
|
||||
vertical: 10.h,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const CommonAppBar(
|
||||
isWhiteLogo: false,
|
||||
isProfilePage: false,
|
||||
showDivider: true,
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Icon(
|
||||
Icons.arrow_back,
|
||||
size: 24.sp,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
offer.partnerName,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
bottom: 31.h,
|
||||
left: 12.w,
|
||||
child: Text(
|
||||
offer.partnerName,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 48.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
bottom: 31.h,
|
||||
right: 17.w,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => const ShareBottomSheet(),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
height: 36.h,
|
||||
width: 36.w,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.share_sharp,
|
||||
color: Colors.black,
|
||||
size: 18.sp,
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 20.w,
|
||||
vertical: 30.5.h,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"About ${offer.partnerName}",
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
|
||||
Text(
|
||||
offer.title,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
|
||||
Text(
|
||||
offer.description,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
height: 1.4,
|
||||
color: const Color(0xFF656565),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 40.h),
|
||||
|
||||
Text(
|
||||
"How to make a booking?",
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
|
||||
const CustomBulletPoints(
|
||||
text:
|
||||
"Check the expiration date of your coupon to ensure it's still valid.",
|
||||
textColor: Color(0xFF656565),
|
||||
),
|
||||
const CustomBulletPoints(
|
||||
text:
|
||||
"Visit the store or website where the coupon can be redeemed.",
|
||||
textColor: Color(0xFF656565),
|
||||
),
|
||||
const CustomBulletPoints(
|
||||
text:
|
||||
"If shopping online, add items to your cart and proceed to checkout.",
|
||||
textColor: Color(0xFF656565),
|
||||
),
|
||||
const CustomBulletPoints(
|
||||
text:
|
||||
"Look for a field labeled 'Coupon Code' or 'Promo Code' during checkout.",
|
||||
textColor: Color(0xFF656565),
|
||||
),
|
||||
const CustomBulletPoints(
|
||||
text:
|
||||
"Enter your coupon code exactly as it appears, including any special characters.",
|
||||
textColor: Color(0xFF656565),
|
||||
),
|
||||
|
||||
SizedBox(height: 24.h),
|
||||
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 48.h,
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 12.h,
|
||||
horizontal: 24.w,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEE7E7),
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"Aster Hotels",
|
||||
offer.offerCode,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: offer.offerCode),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content:
|
||||
Text("Coupon code copied!"),
|
||||
duration: Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
Icons.copy_outlined,
|
||||
color: const Color(0xFF464646),
|
||||
size: 20.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
bottom: 31.h,
|
||||
left: 12.w,
|
||||
child: Text(
|
||||
"Aster \nHotels",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 48.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
bottom: 31.h,
|
||||
right: 17.w,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => const ShareBottomSheet(),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
height: 36.h,
|
||||
width: 36.w,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.share_sharp,
|
||||
color: Colors.black,
|
||||
size: 18.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 20.0.w,
|
||||
vertical: 30.5.h,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"About Aster Hotels",
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
"20% Off on dining and drinks on purchase upto \$500 T&Cs* apply",
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
Text(
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. "
|
||||
"Convallis condimentum morbi non egestas enim amet sagittis. "
|
||||
"Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac. "
|
||||
"Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non...",
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
height: 1.4,
|
||||
color: const Color(0xFF656565),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 40.h),
|
||||
|
||||
// How to make booking
|
||||
Text(
|
||||
"How to make a booking?",
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
|
||||
CustomBulletPoints(
|
||||
text:
|
||||
"Check the expiration date of your coupon to ensure it's still valid.",
|
||||
textColor: Color(0xFF656565),
|
||||
),
|
||||
CustomBulletPoints(
|
||||
text:
|
||||
"Visit the store or website where the coupon can be redeemed.",
|
||||
textColor: Color(0xFF656565),
|
||||
),
|
||||
CustomBulletPoints(
|
||||
text:
|
||||
"If shopping online, add items to your cart and proceed to checkout.",
|
||||
textColor: Color(0xFF656565),
|
||||
),
|
||||
CustomBulletPoints(
|
||||
text:
|
||||
"Look for a field labeled 'Coupon Code' or 'Promo Code' during checkout.",
|
||||
textColor: Color(0xFF656565),
|
||||
),
|
||||
CustomBulletPoints(
|
||||
text:
|
||||
"Enter your coupon code exactly as it appears, including any special characters.",
|
||||
textColor: Color(0xFF656565),
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
|
||||
// Coupon Box
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 48.h,
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 12.h,
|
||||
horizontal: 24.w,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEE7E7),
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"AFJIJFJ500",
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Clipboard.setData(
|
||||
const ClipboardData(text: "AFJIJFJ500"),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Coupon code copied!"),
|
||||
duration: Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
Icons.copy_outlined,
|
||||
color: Color(0xFF464646),
|
||||
size: 20.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import '../models/offer_details_model.dart';
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
|
||||
class OffersDetailsRepository {
|
||||
final NetworkApiService _apiService = NetworkApiService();
|
||||
|
||||
/// Fetch offer details by offerId
|
||||
Future<OfferDetailsModel> fetchOfferDetails({
|
||||
required int offerId,
|
||||
}) async {
|
||||
final response = await _apiService.getApi(
|
||||
url: '${ApiUrls.offersDetails}/$offerId',
|
||||
);
|
||||
|
||||
return OfferDetailsModel.fromJson(response.data);
|
||||
}
|
||||
}
|
||||
@@ -21,53 +21,86 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
|
||||
Emitter<ProfileState> emit,
|
||||
) async {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
print('🔄 [BLOC] FetchProfileEvent received for userId: ${event.userId}');
|
||||
}
|
||||
|
||||
emit(const ProfileLoading());
|
||||
|
||||
final profile = await _profileRepository.fetchUserProfile();
|
||||
|
||||
emit(ProfileLoaded(profile: profile));
|
||||
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'✅ Profile fetched successfully: ${profile.firstName} ${profile.lastName}',
|
||||
);
|
||||
print('✅ [BLOC] Profile fetched successfully: ${profile.firstName} ${profile.lastName}');
|
||||
print('✅ [BLOC] Profile Image URL: ${profile.profileImage}');
|
||||
}
|
||||
|
||||
emit(ProfileLoaded(profile: profile));
|
||||
} catch (e) {
|
||||
final errorMessage = e.toString();
|
||||
emit(ProfileError(message: errorMessage));
|
||||
|
||||
if (kDebugMode) {
|
||||
print('❌ Error fetching profile: $errorMessage');
|
||||
print('❌ [BLOC] Error fetching profile: $errorMessage');
|
||||
print('❌ [BLOC] Error type: ${e.runtimeType}');
|
||||
}
|
||||
|
||||
emit(ProfileError(message: errorMessage));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle updating user profile
|
||||
/// ⭐ UPDATED: Now passes File to repository
|
||||
Future<void> _onUpdateProfile(
|
||||
UpdateProfileEvent event,
|
||||
Emitter<ProfileState> emit,
|
||||
) async {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
print('🔄 [BLOC] UpdateProfileEvent received');
|
||||
print('🔄 [BLOC] User ID: ${event.userId}');
|
||||
print('🔄 [BLOC] First Name: ${event.firstName}');
|
||||
print('🔄 [BLOC] Last Name: ${event.lastName}');
|
||||
print('🔄 [BLOC] Mobile: ${event.mobileNumber}');
|
||||
print('🔄 [BLOC] Address1: ${event.address1}');
|
||||
print('🔄 [BLOC] Address2: ${event.address2}');
|
||||
|
||||
if (event.profileImageFile != null) {
|
||||
print('🔄 [BLOC] ✅ Profile Image File Present in Event');
|
||||
print('🔄 [BLOC] File Path: ${event.profileImageFile!.path}');
|
||||
final fileSize = await event.profileImageFile!.length();
|
||||
print('🔄 [BLOC] File Size: ${(fileSize / 1024).toStringAsFixed(2)} KB');
|
||||
} else {
|
||||
print('🔄 [BLOC] ⚠️ Profile Image File is NULL in Event');
|
||||
}
|
||||
|
||||
print('🔄 [BLOC] Calling toJson()...');
|
||||
final jsonData = event.toJson();
|
||||
print('🔄 [BLOC] JSON Data: $jsonData');
|
||||
}
|
||||
|
||||
emit(const ProfileUpdating());
|
||||
|
||||
// ⭐ Pass both data and file to repository
|
||||
final updatedProfile = await _profileRepository.updateUserProfile(
|
||||
data: event.toJson(),
|
||||
profileImageFile: event.profileImageFile, // ⭐ NEW: Pass File
|
||||
);
|
||||
|
||||
emit(ProfileUpdated(profile: updatedProfile));
|
||||
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'✅ Profile updated successfully: ${updatedProfile.firstName} ${updatedProfile.lastName}',
|
||||
);
|
||||
print('✅ [BLOC] Profile updated successfully: ${updatedProfile.firstName} ${updatedProfile.lastName}');
|
||||
print('✅ [BLOC] Updated Profile Image: ${updatedProfile.profileImage}');
|
||||
}
|
||||
|
||||
emit(ProfileUpdated(profile: updatedProfile));
|
||||
} catch (e) {
|
||||
final errorMessage = e.toString();
|
||||
emit(ProfileError(message: errorMessage));
|
||||
|
||||
if (kDebugMode) {
|
||||
print('❌ Error updating profile: $errorMessage');
|
||||
print('❌ [BLOC] Error updating profile: $errorMessage');
|
||||
print('❌ [BLOC] Error type: ${e.runtimeType}');
|
||||
print('❌ [BLOC] Stack trace: ${StackTrace.current}');
|
||||
}
|
||||
|
||||
emit(ProfileError(message: errorMessage));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +109,9 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
|
||||
ResetProfileEvent event,
|
||||
Emitter<ProfileState> emit,
|
||||
) {
|
||||
if (kDebugMode) {
|
||||
print('🔄 [BLOC] ResetProfileEvent received');
|
||||
}
|
||||
emit(const ProfileInitial());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class ProfileEvent extends Equatable {
|
||||
@@ -18,6 +19,7 @@ class FetchProfileEvent extends ProfileEvent {
|
||||
}
|
||||
|
||||
/// Event to update user profile
|
||||
/// ⭐ UPDATED: Now accepts File instead of base64 string
|
||||
class UpdateProfileEvent extends ProfileEvent {
|
||||
final int userId;
|
||||
final String firstName;
|
||||
@@ -25,6 +27,7 @@ class UpdateProfileEvent extends ProfileEvent {
|
||||
final String mobileNumber;
|
||||
final String? address1;
|
||||
final String? address2;
|
||||
final File? profileImageFile; // ⭐ CHANGED: File instead of String
|
||||
|
||||
const UpdateProfileEvent({
|
||||
required this.userId,
|
||||
@@ -33,6 +36,7 @@ class UpdateProfileEvent extends ProfileEvent {
|
||||
required this.mobileNumber,
|
||||
this.address1,
|
||||
this.address2,
|
||||
this.profileImageFile, // ⭐ CHANGED
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -43,6 +47,7 @@ class UpdateProfileEvent extends ProfileEvent {
|
||||
mobileNumber,
|
||||
address1,
|
||||
address2,
|
||||
profileImageFile, // ⭐ CHANGED
|
||||
];
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
@@ -52,6 +57,7 @@ class UpdateProfileEvent extends ProfileEvent {
|
||||
'mobileNumber': mobileNumber,
|
||||
if (address1 != null && address1!.isNotEmpty) 'address1': address1,
|
||||
if (address2 != null && address2!.isNotEmpty) 'address2': address2,
|
||||
// ⭐ Note: profileImageFile is handled separately in repository
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ class ProfileModel {
|
||||
final String emailAddress;
|
||||
final String isdCode;
|
||||
final String mobileNumber;
|
||||
final String? profileImage; // ✅ NEW
|
||||
final String? address1;
|
||||
final String? address2;
|
||||
final String? cityName;
|
||||
@@ -27,6 +28,7 @@ class ProfileModel {
|
||||
required this.emailAddress,
|
||||
required this.isdCode,
|
||||
required this.mobileNumber,
|
||||
this.profileImage,
|
||||
this.address1,
|
||||
this.address2,
|
||||
this.cityName,
|
||||
@@ -50,6 +52,7 @@ class ProfileModel {
|
||||
emailAddress: json['emailAddress'] ?? 'N/A',
|
||||
isdCode: json['isdCode'] ?? 'N/A',
|
||||
mobileNumber: json['mobileNumber'] ?? 'N/A',
|
||||
profileImage: json['profileImage'], // ✅ added
|
||||
address1: json['address1'],
|
||||
address2: json['address2'],
|
||||
cityName: json['cityName'],
|
||||
@@ -57,10 +60,10 @@ class ProfileModel {
|
||||
stateName: json['stateName'],
|
||||
country: json['country'],
|
||||
timezone: json['timezone'],
|
||||
lastLogin: json['lastLogin'],
|
||||
lastLogin: json['lastLogin']?.toString(),
|
||||
isActive: json['isActive'] ?? false,
|
||||
createdAt: json['createdAt'] ?? 'N/A',
|
||||
updatedAt: json['updatedAt'] ?? 'N/A',
|
||||
createdAt: json['createdAt'] ?? '',
|
||||
updatedAt: json['updatedAt'] ?? '',
|
||||
role: json['role'] != null ? RoleModel.fromJson(json['role']) : null,
|
||||
);
|
||||
}
|
||||
@@ -74,6 +77,7 @@ class ProfileModel {
|
||||
'emailAddress': emailAddress,
|
||||
'isdCode': isdCode,
|
||||
'mobileNumber': mobileNumber,
|
||||
'profileImage': profileImage,
|
||||
'address1': address1,
|
||||
'address2': address2,
|
||||
'cityName': cityName,
|
||||
@@ -88,50 +92,6 @@ class ProfileModel {
|
||||
if (role != null) 'role': role!.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
ProfileModel copyWith({
|
||||
int? id,
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
int? roleXid,
|
||||
String? emailAddress,
|
||||
String? isdCode,
|
||||
String? mobileNumber,
|
||||
String? address1,
|
||||
String? address2,
|
||||
String? cityName,
|
||||
String? zipCode,
|
||||
String? stateName,
|
||||
String? country,
|
||||
String? timezone,
|
||||
String? lastLogin,
|
||||
bool? isActive,
|
||||
String? createdAt,
|
||||
String? updatedAt,
|
||||
RoleModel? role,
|
||||
}) {
|
||||
return ProfileModel(
|
||||
id: id ?? this.id,
|
||||
firstName: firstName ?? this.firstName,
|
||||
lastName: lastName ?? this.lastName,
|
||||
roleXid: roleXid ?? this.roleXid,
|
||||
emailAddress: emailAddress ?? this.emailAddress,
|
||||
isdCode: isdCode ?? this.isdCode,
|
||||
mobileNumber: mobileNumber ?? this.mobileNumber,
|
||||
address1: address1 ?? this.address1,
|
||||
address2: address2 ?? this.address2,
|
||||
cityName: cityName ?? this.cityName,
|
||||
zipCode: zipCode ?? this.zipCode,
|
||||
stateName: stateName ?? this.stateName,
|
||||
country: country ?? this.country,
|
||||
timezone: timezone ?? this.timezone,
|
||||
lastLogin: lastLogin ?? this.lastLogin,
|
||||
isActive: isActive ?? this.isActive,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
role: role ?? this.role,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RoleModel {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/profile_model.dart';
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
@@ -10,24 +13,113 @@ class ProfileRepository {
|
||||
Future<ProfileModel> fetchUserProfile() async {
|
||||
final int? userId = await LocalPreference.getUserId();
|
||||
|
||||
if (kDebugMode) {
|
||||
print('📥 [FETCH PROFILE] User ID: $userId');
|
||||
print('📥 [FETCH PROFILE] URL: ${ApiUrls.userProfile}/$userId');
|
||||
}
|
||||
|
||||
final response = await _apiService.getApi(
|
||||
url: '${ApiUrls.userProfile}/$userId',
|
||||
);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('📥 [FETCH PROFILE] Response: ${response.data}');
|
||||
print('📥 [FETCH PROFILE] Profile Image: ${response.data['profileImage']}');
|
||||
}
|
||||
|
||||
return ProfileModel.fromJson(response.data);
|
||||
}
|
||||
|
||||
/// Update user profile (userId from local storage)
|
||||
/// ⭐ FIXED: Now uses multipart/form-data for file upload
|
||||
Future<ProfileModel> updateUserProfile({
|
||||
required Map<String, dynamic> data,
|
||||
File? profileImageFile, // ⭐ NEW: Accept File instead of base64
|
||||
}) async {
|
||||
final int? userId = await LocalPreference.getUserId();
|
||||
|
||||
if (kDebugMode) {
|
||||
print('📤 [UPDATE PROFILE] User ID: $userId');
|
||||
print('📤 [UPDATE PROFILE] URL: ${ApiUrls.userProfile}/$userId');
|
||||
print('📤 [UPDATE PROFILE] Data Keys: ${data.keys.toList()}');
|
||||
print('📤 [UPDATE PROFILE] First Name: ${data['firstName']}');
|
||||
print('📤 [UPDATE PROFILE] Last Name: ${data['lastName']}');
|
||||
print('📤 [UPDATE PROFILE] Mobile: ${data['mobileNumber']}');
|
||||
print('📤 [UPDATE PROFILE] Address1: ${data['address1']}');
|
||||
print('📤 [UPDATE PROFILE] Address2: ${data['address2']}');
|
||||
print('📤 [UPDATE PROFILE] Profile Image File: ${profileImageFile?.path}');
|
||||
}
|
||||
|
||||
// ⭐ Create FormData for multipart/form-data upload
|
||||
final formData = FormData();
|
||||
|
||||
// Add text fields
|
||||
formData.fields.addAll([
|
||||
MapEntry('firstName', data['firstName']),
|
||||
MapEntry('lastName', data['lastName']),
|
||||
MapEntry('mobileNumber', data['mobileNumber']),
|
||||
if (data['address1'] != null && data['address1'].toString().isNotEmpty)
|
||||
MapEntry('address1', data['address1']),
|
||||
if (data['address2'] != null && data['address2'].toString().isNotEmpty)
|
||||
MapEntry('address2', data['address2']),
|
||||
]);
|
||||
|
||||
// ⭐ Add profile image file if provided
|
||||
if (profileImageFile != null) {
|
||||
final fileName = profileImageFile.path.split('/').last;
|
||||
formData.files.add(
|
||||
MapEntry(
|
||||
'profileImage',
|
||||
await MultipartFile.fromFile(
|
||||
profileImageFile.path,
|
||||
filename: fileName,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('📤 [UPDATE PROFILE] ✅ Profile Image File Added');
|
||||
print('📤 [UPDATE PROFILE] File Name: $fileName');
|
||||
print('📤 [UPDATE PROFILE] File Path: ${profileImageFile.path}');
|
||||
final fileSize = await profileImageFile.length();
|
||||
print('📤 [UPDATE PROFILE] File Size: ${(fileSize / 1024).toStringAsFixed(2)} KB');
|
||||
}
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
print('📤 [UPDATE PROFILE] ⚠️ No profile image file provided');
|
||||
}
|
||||
}
|
||||
|
||||
// ⭐ Send as multipart/form-data
|
||||
final response = await _apiService.putApi(
|
||||
url: '${ApiUrls.userProfile}/$userId',
|
||||
data: data,
|
||||
data: formData,
|
||||
);
|
||||
|
||||
return ProfileModel.fromJson(response.data);
|
||||
if (kDebugMode) {
|
||||
print('📤 [UPDATE PROFILE] ✅ Response Status: Success');
|
||||
print('📤 [UPDATE PROFILE] Full Response: ${response.data}');
|
||||
|
||||
// Check if response has nested 'user' object
|
||||
if (response.data.containsKey('user')) {
|
||||
print('📤 [UPDATE PROFILE] ✅ Response has nested "user" object');
|
||||
print('📤 [UPDATE PROFILE] User Data: ${response.data['user']}');
|
||||
print('📤 [UPDATE PROFILE] Updated Profile Image: ${response.data['user']['profileImage']}');
|
||||
} else {
|
||||
print('📤 [UPDATE PROFILE] Response structure: ${response.data.keys.toList()}');
|
||||
print('📤 [UPDATE PROFILE] Updated Profile Image: ${response.data['profileImage']}');
|
||||
}
|
||||
}
|
||||
|
||||
// Extract user data from nested response
|
||||
final userData = response.data.containsKey('user')
|
||||
? response.data['user']
|
||||
: response.data;
|
||||
|
||||
if (kDebugMode) {
|
||||
print('📤 [UPDATE PROFILE] Parsing ProfileModel from: $userData');
|
||||
}
|
||||
|
||||
return ProfileModel.fromJson(userData);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import 'dart:io';
|
||||
import 'package:citycards_customer/common_packages/app_bar.dart';
|
||||
import 'package:citycards_customer/common_packages/back_widget.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_textfield.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../localPreference/local_preference.dart';
|
||||
import '../profile/bloc/profile/profile_bloc.dart';
|
||||
import '../profile/bloc/profile/profile_event.dart';
|
||||
import '../profile/bloc/profile/profile_state.dart';
|
||||
import '../profile/models/profile_model.dart';
|
||||
import '../../../localPreference/local_preference.dart';
|
||||
import '../../../networkApiServices/api_urls.dart';
|
||||
import '../../bloc/profile/profile_bloc.dart';
|
||||
import '../../bloc/profile/profile_event.dart';
|
||||
import '../../bloc/profile/profile_state.dart';
|
||||
import '../../models/profile_model.dart';
|
||||
|
||||
class EditProfilePage extends StatefulWidget {
|
||||
const EditProfilePage({super.key});
|
||||
@@ -28,6 +32,11 @@ class _EditProfilePageState extends State<EditProfilePage> {
|
||||
final TextEditingController address2Controller = TextEditingController();
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
// Profile image variables
|
||||
File? _selectedImageFile; // ⭐ Only need File now, no base64
|
||||
String? _currentProfileImageUrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -36,6 +45,10 @@ class _EditProfilePageState extends State<EditProfilePage> {
|
||||
}
|
||||
|
||||
Future<void> _fetchProfile() async {
|
||||
if (kDebugMode) {
|
||||
print('🔵 [EDIT PROFILE] Fetching profile...');
|
||||
}
|
||||
|
||||
final userId = await LocalPreference.getUserId();
|
||||
if (userId != null) {
|
||||
context.read<ProfileBloc>().add(FetchProfileEvent(userId: userId));
|
||||
@@ -43,18 +56,226 @@ class _EditProfilePageState extends State<EditProfilePage> {
|
||||
}
|
||||
|
||||
void _populateFields(ProfileModel profile) {
|
||||
if (kDebugMode) {
|
||||
print('🔵 [EDIT PROFILE] Populating fields');
|
||||
print('🔵 [EDIT PROFILE] Profile Image from API: ${profile.profileImage}');
|
||||
}
|
||||
|
||||
firstNameController.text = profile.firstName;
|
||||
lastNameController.text = profile.lastName;
|
||||
phoneController.text = profile.mobileNumber;
|
||||
address1Controller.text = profile.address1 ?? '';
|
||||
address2Controller.text = profile.address2 ?? '';
|
||||
|
||||
// Store current profile image URL if exists
|
||||
if (profile.profileImage != null && profile.profileImage!.isNotEmpty) {
|
||||
setState(() {
|
||||
_currentProfileImageUrl = profile.profileImage;
|
||||
});
|
||||
|
||||
if (kDebugMode) {
|
||||
print('🔵 [EDIT PROFILE] ✅ Current profile image URL set: $_currentProfileImageUrl');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper method to get full image URL
|
||||
String _getFullImageUrl(String? imageUrl) {
|
||||
if (imageUrl == null || imageUrl.isEmpty) return '';
|
||||
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
final baseUrl = ApiUrls.userProfile.split('/mobile')[0];
|
||||
final fullUrl = '$baseUrl$imageUrl';
|
||||
|
||||
if (kDebugMode) {
|
||||
print('🔵 [EDIT PROFILE] Full URL: $fullUrl');
|
||||
}
|
||||
|
||||
return fullUrl;
|
||||
}
|
||||
|
||||
Future<void> _showImageSourceDialog() async {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)),
|
||||
),
|
||||
builder: (BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 20.h, horizontal: 16.w),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Select Image Source',
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.camera_alt,
|
||||
color: Color(0xFFF95F62),
|
||||
size: 28.sp,
|
||||
),
|
||||
title: Text(
|
||||
'Camera',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_pickImage(ImageSource.camera);
|
||||
},
|
||||
),
|
||||
Divider(),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.photo_library,
|
||||
color: Color(0xFFF95F62),
|
||||
size: 28.sp,
|
||||
),
|
||||
title: Text(
|
||||
'Gallery',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_pickImage(ImageSource.gallery);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickImage(ImageSource source) async {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
print('🔵 [EDIT PROFILE] Picking image from: $source');
|
||||
}
|
||||
|
||||
final XFile? pickedFile = await _picker.pickImage(
|
||||
source: source,
|
||||
maxWidth: 1024,
|
||||
maxHeight: 1024,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (pickedFile != null) {
|
||||
final File imageFile = File(pickedFile.path);
|
||||
final fileSize = await imageFile.length();
|
||||
|
||||
if (kDebugMode) {
|
||||
print('🔵 [EDIT PROFILE] ✅ Image picked: ${pickedFile.path}');
|
||||
print('🔵 [EDIT PROFILE] File size: ${(fileSize / 1024).toStringAsFixed(2)} KB');
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_selectedImageFile = imageFile; // ⭐ Just store the File
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('❌ [EDIT PROFILE] Error picking image: $e');
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to pick image: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildProfileImageWidget() {
|
||||
return Center(
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 76.w,
|
||||
height: 76.w,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Color(0xFFFCE4E5),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: _selectedImageFile != null
|
||||
? Image.file(
|
||||
_selectedImageFile!,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: _currentProfileImageUrl != null && _currentProfileImageUrl!.isNotEmpty
|
||||
? Image.network(
|
||||
_getFullImageUrl(_currentProfileImageUrl),
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Center(
|
||||
child: CircularProgressIndicator(
|
||||
value: loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: null,
|
||||
color: Color(0xFFF95F62),
|
||||
strokeWidth: 2,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
if (kDebugMode) {
|
||||
print('❌ [EDIT PROFILE] Error loading image: $error');
|
||||
}
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(16.w),
|
||||
child: Image.asset(
|
||||
'assets/images/profile_default_img.png',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Padding(
|
||||
padding: EdgeInsets.all(16.w),
|
||||
child: Image.asset(
|
||||
'assets/images/profile_default_img.png',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _saveProfile() async {
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
final userId = await LocalPreference.getUserId();
|
||||
if (userId != null) {
|
||||
// No setState here - BLoC will handle the state
|
||||
if (kDebugMode) {
|
||||
print('🔵 [EDIT PROFILE] Saving profile...');
|
||||
if (_selectedImageFile != null) {
|
||||
print('🔵 [EDIT PROFILE] ✅ New image will be uploaded');
|
||||
print('🔵 [EDIT PROFILE] File path: ${_selectedImageFile!.path}');
|
||||
} else {
|
||||
print('🔵 [EDIT PROFILE] ⚠️ No new image selected');
|
||||
}
|
||||
}
|
||||
|
||||
context.read<ProfileBloc>().add(
|
||||
UpdateProfileEvent(
|
||||
userId: userId,
|
||||
@@ -67,6 +288,7 @@ class _EditProfilePageState extends State<EditProfilePage> {
|
||||
address2: address2Controller.text.trim().isEmpty
|
||||
? null
|
||||
: address2Controller.text.trim(),
|
||||
profileImageFile: _selectedImageFile, // ⭐ Pass File directly
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -90,6 +312,10 @@ class _EditProfilePageState extends State<EditProfilePage> {
|
||||
if (state is ProfileLoaded) {
|
||||
_populateFields(state.profile);
|
||||
} else if (state is ProfileUpdated) {
|
||||
if (kDebugMode) {
|
||||
print('✅ [EDIT PROFILE] Profile updated successfully!');
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
@@ -97,7 +323,6 @@ class _EditProfilePageState extends State<EditProfilePage> {
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
// Return true to indicate successful update
|
||||
Navigator.pop(context, true);
|
||||
} else if (state is ProfileError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -110,11 +335,9 @@ class _EditProfilePageState extends State<EditProfilePage> {
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
// Determine loading state from BLoC
|
||||
final isLoading = state is ProfileLoading || state is ProfileUpdating;
|
||||
final isInitialLoading = state is ProfileLoading;
|
||||
|
||||
// Show loading on initial fetch
|
||||
if (isInitialLoading) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
@@ -132,24 +355,36 @@ class _EditProfilePageState extends State<EditProfilePage> {
|
||||
child: Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding:
|
||||
EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Header
|
||||
CommonAppBar(
|
||||
isWhiteLogo: false,
|
||||
isProfilePage: true,
|
||||
showDivider: true,
|
||||
),
|
||||
|
||||
// Back + title
|
||||
backWidget(context, "Edit Profile", Colors.black),
|
||||
SizedBox(height: 33.h),
|
||||
|
||||
// Profile Picture
|
||||
_buildProfileImageWidget(),
|
||||
SizedBox(height: 12.h),
|
||||
GestureDetector(
|
||||
onTap: isLoading ? null : _showImageSourceDialog,
|
||||
child: Text(
|
||||
'Change Profile Picture',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 33.h),
|
||||
|
||||
// Personal Details
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
@@ -161,7 +396,7 @@ class _EditProfilePageState extends State<EditProfilePage> {
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
|
||||
// First Name
|
||||
// Form fields...
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
@@ -178,7 +413,6 @@ class _EditProfilePageState extends State<EditProfilePage> {
|
||||
),
|
||||
),
|
||||
|
||||
// Last Name
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
@@ -195,7 +429,6 @@ class _EditProfilePageState extends State<EditProfilePage> {
|
||||
),
|
||||
),
|
||||
|
||||
// Phone Number
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
@@ -214,7 +447,6 @@ class _EditProfilePageState extends State<EditProfilePage> {
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
// Location Details
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CustomText(
|
||||
@@ -225,7 +457,6 @@ class _EditProfilePageState extends State<EditProfilePage> {
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
|
||||
// Address 1
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.0.w),
|
||||
child: CustomTextField(
|
||||
@@ -236,7 +467,6 @@ class _EditProfilePageState extends State<EditProfilePage> {
|
||||
),
|
||||
),
|
||||
|
||||
// Address 2
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.0.w),
|
||||
child: CustomTextField(
|
||||
@@ -257,18 +487,13 @@ class _EditProfilePageState extends State<EditProfilePage> {
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFFF95F62),
|
||||
side: const BorderSide(
|
||||
color: Colors.transparent),
|
||||
side: const BorderSide(color: Colors.transparent),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(38.r),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(vertical: 12.h),
|
||||
),
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
onPressed: isLoading ? null : () => Navigator.pop(context),
|
||||
child: Text(
|
||||
"Cancel",
|
||||
style: TextStyle(
|
||||
@@ -316,7 +541,6 @@ class _EditProfilePageState extends State<EditProfilePage> {
|
||||
),
|
||||
),
|
||||
|
||||
// Loading overlay when saving
|
||||
if (state is ProfileUpdating)
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
@@ -4,6 +4,7 @@ import 'package:citycards_customer/common_packages/back_widget.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:citycards_customer/common_packages/language_selection_bottomsheet.dart';
|
||||
import 'package:citycards_customer/core/route_constants.dart';
|
||||
import 'package:citycards_customer/networkApiServices/api_urls.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
@@ -311,44 +312,83 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
|
||||
// Logged In User UI with dynamic data from API
|
||||
Widget _buildLoggedInUI(BuildContext context, ProfileModel? profile) {
|
||||
// Construct full name
|
||||
/// ---------- Full name ----------
|
||||
String fullName = 'User';
|
||||
if (profile != null) {
|
||||
fullName = '${profile.firstName} ${profile.lastName}'.trim();
|
||||
if (fullName.isEmpty) {
|
||||
fullName = 'User';
|
||||
fullName = '${profile.firstName ?? ''} ${profile.lastName ?? ''}'.trim();
|
||||
if (fullName.isEmpty) fullName = 'User';
|
||||
}
|
||||
|
||||
/// ---------- Location ----------
|
||||
String location = 'Not specified';
|
||||
if (profile != null) {
|
||||
final parts = <String>[];
|
||||
if (profile.address1?.isNotEmpty == true) {
|
||||
parts.add(profile.address1!);
|
||||
}
|
||||
if (profile.address2?.isNotEmpty == true) {
|
||||
parts.add(profile.address2!);
|
||||
}
|
||||
if (parts.isNotEmpty) {
|
||||
location = parts.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
// Construct location
|
||||
String location = 'Not specified';
|
||||
if (profile != null) {
|
||||
List<String> locationParts = [];
|
||||
|
||||
if (profile.address1 != null && profile.address1!.isNotEmpty) {
|
||||
locationParts.add(profile.address1!);
|
||||
}
|
||||
if (profile.address2 != null && profile.address2!.isNotEmpty) {
|
||||
locationParts.add(profile.address2!);
|
||||
}
|
||||
|
||||
if (locationParts.isNotEmpty) {
|
||||
location = locationParts.join(', ');
|
||||
}
|
||||
/// ---------- Profile Image URL ----------
|
||||
String? profileImageUrl;
|
||||
if (profile?.profileImage?.isNotEmpty == true) {
|
||||
profileImageUrl = ApiUrls.baseUrl + profile!.profileImage!;
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Profile Image and Name
|
||||
/// ================= Profile Row =================
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 38.r,
|
||||
backgroundImage: AssetImage(
|
||||
"assets/images/profile_img.png",
|
||||
/// -------- Profile Image (NO ZOOM PLACEHOLDER) --------
|
||||
SizedBox(
|
||||
width: 76.w,
|
||||
height: 76.w,
|
||||
child: ClipOval(
|
||||
child: Container(
|
||||
color: const Color(0xFFFCE4E5),
|
||||
child: profileImageUrl != null
|
||||
? Image.network(
|
||||
profileImageUrl,
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (_, __, ___) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(16.w),
|
||||
child: Image.asset(
|
||||
'assets/images/profile_default_img.png',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Padding(
|
||||
padding: EdgeInsets.all(16.w),
|
||||
child: Image.asset(
|
||||
'assets/images/profile_default_img.png',
|
||||
fit: BoxFit.contain, // ✅ NO ZOOM
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(width: 16.w),
|
||||
|
||||
/// -------- Name & Location --------
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -367,7 +407,7 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on_sharp,
|
||||
color: Color(0xFF8E8E8E),
|
||||
color: const Color(0xFF8E8E8E),
|
||||
size: 14.sp,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
@@ -376,7 +416,7 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
location,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: Color(0xFF8E8E8E),
|
||||
color: const Color(0xFF8E8E8E),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -392,7 +432,7 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
|
||||
SizedBox(height: 30.h),
|
||||
|
||||
// Account Settings Section
|
||||
/// ================= Account Settings =================
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CustomText(
|
||||
@@ -401,6 +441,7 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
size: 18.sp,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 10.h),
|
||||
|
||||
_buildListTile(
|
||||
@@ -412,7 +453,6 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
RouteConstants.editProfile,
|
||||
);
|
||||
|
||||
// Refresh profile if edit was successful
|
||||
if (result == true) {
|
||||
final userId = await LocalPreference.getUserId();
|
||||
if (userId != null) {
|
||||
@@ -423,6 +463,7 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
_buildListTile(
|
||||
icon: "assets/icons/change_language.png",
|
||||
title: 'Change language',
|
||||
@@ -442,6 +483,7 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
SizedBox(height: 24.h),
|
||||
],
|
||||
);
|
||||
|
||||
66
lib/search_offers/bloc/offers_bloc.dart
Normal file
66
lib/search_offers/bloc/offers_bloc.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../model/offers_model.dart';
|
||||
import '../repository/offers_repository.dart';
|
||||
import 'offers_event.dart';
|
||||
import 'offers_state.dart';
|
||||
|
||||
class OffersBloc extends Bloc<OffersEvent, OffersState> {
|
||||
final OffersRepository repository;
|
||||
|
||||
List<Offer> _allOffers = [];
|
||||
|
||||
OffersBloc(this.repository) : super(OffersInitial()) {
|
||||
on<LoadOffers>(_onLoadOffers);
|
||||
on<SearchOffers>(_onSearchOffers);
|
||||
}
|
||||
|
||||
Future<void> _onLoadOffers(
|
||||
LoadOffers event,
|
||||
Emitter<OffersState> emit,
|
||||
) async {
|
||||
emit(OffersLoading());
|
||||
|
||||
try {
|
||||
final response = await repository.fetchOffers(
|
||||
categoryXid: event.categoryXid,
|
||||
);
|
||||
|
||||
_allOffers = response.offers;
|
||||
|
||||
emit(
|
||||
OffersLoaded(
|
||||
offers: response.offers,
|
||||
categories: response.categories,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(OffersError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchOffers(
|
||||
SearchOffers event,
|
||||
Emitter<OffersState> emit,
|
||||
) {
|
||||
final filtered = _allOffers
|
||||
.where(
|
||||
(offer) =>
|
||||
offer.title
|
||||
.toLowerCase()
|
||||
.contains(event.query.toLowerCase()) ||
|
||||
offer.description
|
||||
.toLowerCase()
|
||||
.contains(event.query.toLowerCase()),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (state is OffersLoaded) {
|
||||
emit(
|
||||
OffersLoaded(
|
||||
offers: filtered,
|
||||
categories: (state as OffersLoaded).categories,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
lib/search_offers/bloc/offers_event.dart
Normal file
11
lib/search_offers/bloc/offers_event.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
abstract class OffersEvent {}
|
||||
|
||||
class LoadOffers extends OffersEvent {
|
||||
final int? categoryXid;
|
||||
LoadOffers({this.categoryXid});
|
||||
}
|
||||
|
||||
class SearchOffers extends OffersEvent {
|
||||
final String query;
|
||||
SearchOffers(this.query);
|
||||
}
|
||||
22
lib/search_offers/bloc/offers_state.dart
Normal file
22
lib/search_offers/bloc/offers_state.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import '../model/offers_model.dart';
|
||||
|
||||
abstract class OffersState {}
|
||||
|
||||
class OffersInitial extends OffersState {}
|
||||
|
||||
class OffersLoading extends OffersState {}
|
||||
|
||||
class OffersLoaded extends OffersState {
|
||||
final List<Offer> offers;
|
||||
final List<Category> categories;
|
||||
|
||||
OffersLoaded({
|
||||
required this.offers,
|
||||
required this.categories,
|
||||
});
|
||||
}
|
||||
|
||||
class OffersError extends OffersState {
|
||||
final String message;
|
||||
OffersError(this.message);
|
||||
}
|
||||
@@ -1,75 +1,75 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
// ----- Events -----
|
||||
abstract class OffersEvent {}
|
||||
|
||||
class LoadOffers extends OffersEvent {}
|
||||
|
||||
class SearchOffers extends OffersEvent {
|
||||
final String query;
|
||||
SearchOffers(this.query);
|
||||
}
|
||||
|
||||
// ----- State -----
|
||||
class OffersState {
|
||||
final List<Map<String, String>> offers;
|
||||
const OffersState(this.offers);
|
||||
}
|
||||
|
||||
// ----- Bloc -----
|
||||
class OffersBloc extends Bloc<OffersEvent, OffersState> {
|
||||
OffersBloc() : super(const OffersState([])) {
|
||||
on<LoadOffers>(_onLoadOffers);
|
||||
on<SearchOffers>(_onSearchOffers);
|
||||
}
|
||||
|
||||
final List<Map<String, String>> _allOffers = [
|
||||
{
|
||||
"image": "assets/images/aa1.png",
|
||||
"title": "Astor Hotels Ultra Deluxe",
|
||||
"description": "15% Discount on all treatments for first-time clients"
|
||||
},
|
||||
{
|
||||
"image": "assets/images/aa2.png",
|
||||
"title": "Green Valley Spa Lux",
|
||||
"description": "20% off on spa memberships and treatments"
|
||||
},
|
||||
{
|
||||
"image": "assets/images/aa3.png",
|
||||
"title": "Ocean Breeze Resort",
|
||||
"description": "Complimentary breakfast with every booking for first-time guests"
|
||||
},
|
||||
{
|
||||
"image": "assets/images/aa4.png",
|
||||
"title": "Mountain Retreat of Light",
|
||||
"description": "10% Discount on group bookings of 5 or more guests"
|
||||
},
|
||||
{
|
||||
"image": "assets/images/card_banner.png",
|
||||
"title": "Mountain View Retreat",
|
||||
"description": "Free hiking gear rental for all visitors during their stay"
|
||||
},
|
||||
{
|
||||
"image": "assets/images/city_germany.jpg",
|
||||
"title": "Sunny Shores Hotel",
|
||||
"description": "10% Discount on group bookings of 5 or more guests"
|
||||
},
|
||||
|
||||
|
||||
];
|
||||
|
||||
void _onLoadOffers(event,emit) {
|
||||
emit(OffersState(_allOffers));
|
||||
}
|
||||
|
||||
void _onSearchOffers(event,emit) {
|
||||
final filtered = _allOffers
|
||||
.where((offer) =>
|
||||
offer["title"]!.toLowerCase().contains(event.query.toLowerCase()) ||
|
||||
offer["description"]!
|
||||
.toLowerCase()
|
||||
.contains(event.query.toLowerCase()))
|
||||
.toList();
|
||||
emit(OffersState(filtered));
|
||||
}
|
||||
}
|
||||
// import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
//
|
||||
// // ----- Events -----
|
||||
// abstract class OffersEvent {}
|
||||
//
|
||||
// class LoadOffers extends OffersEvent {}
|
||||
//
|
||||
// class SearchOffers extends OffersEvent {
|
||||
// final String query;
|
||||
// SearchOffers(this.query);
|
||||
// }
|
||||
//
|
||||
// // ----- State -----
|
||||
// class OffersState {
|
||||
// final List<Map<String, String>> offers;
|
||||
// const OffersState(this.offers);
|
||||
// }
|
||||
//
|
||||
// // ----- Bloc -----
|
||||
// class OffersBloc extends Bloc<OffersEvent, OffersState> {
|
||||
// OffersBloc() : super(const OffersState([])) {
|
||||
// on<LoadOffers>(_onLoadOffers);
|
||||
// on<SearchOffers>(_onSearchOffers);
|
||||
// }
|
||||
//
|
||||
// final List<Map<String, String>> _allOffers = [
|
||||
// {
|
||||
// "image": "assets/images/aa1.png",
|
||||
// "title": "Astor Hotels Ultra Deluxe",
|
||||
// "description": "15% Discount on all treatments for first-time clients"
|
||||
// },
|
||||
// {
|
||||
// "image": "assets/images/aa2.png",
|
||||
// "title": "Green Valley Spa Lux",
|
||||
// "description": "20% off on spa memberships and treatments"
|
||||
// },
|
||||
// {
|
||||
// "image": "assets/images/aa3.png",
|
||||
// "title": "Ocean Breeze Resort",
|
||||
// "description": "Complimentary breakfast with every booking for first-time guests"
|
||||
// },
|
||||
// {
|
||||
// "image": "assets/images/aa4.png",
|
||||
// "title": "Mountain Retreat of Light",
|
||||
// "description": "10% Discount on group bookings of 5 or more guests"
|
||||
// },
|
||||
// {
|
||||
// "image": "assets/images/card_banner.png",
|
||||
// "title": "Mountain View Retreat",
|
||||
// "description": "Free hiking gear rental for all visitors during their stay"
|
||||
// },
|
||||
// {
|
||||
// "image": "assets/images/city_germany.jpg",
|
||||
// "title": "Sunny Shores Hotel",
|
||||
// "description": "10% Discount on group bookings of 5 or more guests"
|
||||
// },
|
||||
//
|
||||
//
|
||||
// ];
|
||||
//
|
||||
// void _onLoadOffers(event,emit) {
|
||||
// emit(OffersState(_allOffers));
|
||||
// }
|
||||
//
|
||||
// void _onSearchOffers(event,emit) {
|
||||
// final filtered = _allOffers
|
||||
// .where((offer) =>
|
||||
// offer["title"]!.toLowerCase().contains(event.query.toLowerCase()) ||
|
||||
// offer["description"]!
|
||||
// .toLowerCase()
|
||||
// .contains(event.query.toLowerCase()))
|
||||
// .toList();
|
||||
// emit(OffersState(filtered));
|
||||
// }
|
||||
// }
|
||||
|
||||
233
lib/search_offers/model/offers_model.dart
Normal file
233
lib/search_offers/model/offers_model.dart
Normal file
@@ -0,0 +1,233 @@
|
||||
class OffersResponse {
|
||||
final List<Offer> offers;
|
||||
final List<Category> categories;
|
||||
|
||||
OffersResponse({
|
||||
required this.offers,
|
||||
required this.categories,
|
||||
});
|
||||
|
||||
factory OffersResponse.fromJson(Map<String, dynamic> json) {
|
||||
return OffersResponse(
|
||||
offers: (json['offers'] as List<dynamic>? ?? [])
|
||||
.map((e) => Offer.fromJson(e))
|
||||
.toList(),
|
||||
categories: (json['categories'] as List<dynamic>? ?? [])
|
||||
.map((e) => Category.fromJson(e))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'offers': offers.map((e) => e.toJson()).toList(),
|
||||
'categories': categories.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------- OFFER ----------------------- */
|
||||
|
||||
class Offer {
|
||||
final int id;
|
||||
final String title;
|
||||
final String description;
|
||||
final String offerCode;
|
||||
final String partnerName;
|
||||
final String passType;
|
||||
final bool applyToPasses;
|
||||
final String offerStatus;
|
||||
final DateTime startDateTime;
|
||||
final DateTime endDateTime;
|
||||
final String? websiteBannerImage;
|
||||
final String? mobileBannerImage;
|
||||
final String redemptionLink;
|
||||
final City? city;
|
||||
final CardInfo? card;
|
||||
final CardType cardType;
|
||||
final Category category;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
Offer({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.offerCode,
|
||||
required this.partnerName,
|
||||
required this.passType,
|
||||
required this.applyToPasses,
|
||||
required this.offerStatus,
|
||||
required this.startDateTime,
|
||||
required this.endDateTime,
|
||||
this.websiteBannerImage,
|
||||
this.mobileBannerImage,
|
||||
required this.redemptionLink,
|
||||
this.city,
|
||||
this.card,
|
||||
required this.cardType,
|
||||
required this.category,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory Offer.fromJson(Map<String, dynamic> json) {
|
||||
return Offer(
|
||||
id: json['id'],
|
||||
title: json['title'] ?? '',
|
||||
description: json['description'] ?? '',
|
||||
offerCode: json['offerCode'] ?? '',
|
||||
partnerName: json['partnerName'] ?? '',
|
||||
passType: json['passType'] ?? '',
|
||||
applyToPasses: json['applyToPasses'] ?? false,
|
||||
offerStatus: json['offerStatus'] ?? '',
|
||||
startDateTime: DateTime.parse(json['startDateTime']),
|
||||
endDateTime: DateTime.parse(json['endDateTime']),
|
||||
websiteBannerImage: json['websiteBannerImage'],
|
||||
mobileBannerImage: json['mobileBannerImage'],
|
||||
redemptionLink: json['redemptionLink'] ?? '',
|
||||
city: json['city'] != null ? City.fromJson(json['city']) : null,
|
||||
card: json['card'] != null ? CardInfo.fromJson(json['card']) : null,
|
||||
cardType: CardType.fromJson(json['cardType']),
|
||||
category: Category.fromJson(json['category']),
|
||||
createdAt: DateTime.parse(json['createdAt']),
|
||||
updatedAt: DateTime.parse(json['updatedAt']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'offerCode': offerCode,
|
||||
'partnerName': partnerName,
|
||||
'passType': passType,
|
||||
'applyToPasses': applyToPasses,
|
||||
'offerStatus': offerStatus,
|
||||
'startDateTime': startDateTime.toIso8601String(),
|
||||
'endDateTime': endDateTime.toIso8601String(),
|
||||
'websiteBannerImage': websiteBannerImage,
|
||||
'mobileBannerImage': mobileBannerImage,
|
||||
'redemptionLink': redemptionLink,
|
||||
'city': city?.toJson(),
|
||||
'card': card?.toJson(),
|
||||
'cardType': cardType.toJson(),
|
||||
'category': category.toJson(),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------- CITY ----------------------- */
|
||||
|
||||
class City {
|
||||
final int id;
|
||||
final String name;
|
||||
|
||||
City({
|
||||
required this.id,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
factory City.fromJson(Map<String, dynamic> json) {
|
||||
return City(
|
||||
id: json['id'],
|
||||
name: json['name'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------- CARD ----------------------- */
|
||||
|
||||
class CardInfo {
|
||||
final int id;
|
||||
final String title;
|
||||
final int adultPrice;
|
||||
final int childPrice;
|
||||
|
||||
CardInfo({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.adultPrice,
|
||||
required this.childPrice,
|
||||
});
|
||||
|
||||
factory CardInfo.fromJson(Map<String, dynamic> json) {
|
||||
return CardInfo(
|
||||
id: json['id'],
|
||||
title: json['title'] ?? '',
|
||||
adultPrice: json['adultPrice'] ?? 0,
|
||||
childPrice: json['childPrice'] ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'adultPrice': adultPrice,
|
||||
'childPrice': childPrice,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------- CARD TYPE ----------------------- */
|
||||
|
||||
class CardType {
|
||||
final int id;
|
||||
final String displayName;
|
||||
|
||||
CardType({
|
||||
required this.id,
|
||||
required this.displayName,
|
||||
});
|
||||
|
||||
factory CardType.fromJson(Map<String, dynamic> json) {
|
||||
return CardType(
|
||||
id: json['id'],
|
||||
displayName: json['displayName'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'displayName': displayName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------- CATEGORY ----------------------- */
|
||||
|
||||
class Category {
|
||||
final int id;
|
||||
final String categoryName;
|
||||
|
||||
Category({
|
||||
required this.id,
|
||||
required this.categoryName,
|
||||
});
|
||||
|
||||
factory Category.fromJson(Map<String, dynamic> json) {
|
||||
return Category(
|
||||
id: json['id'],
|
||||
categoryName: json['categoryName'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'categoryName': categoryName,
|
||||
};
|
||||
}
|
||||
}
|
||||
22
lib/search_offers/repository/offers_repository.dart
Normal file
22
lib/search_offers/repository/offers_repository.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import '../model/offers_model.dart';
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
|
||||
class OffersRepository {
|
||||
final NetworkApiService _apiService = NetworkApiService();
|
||||
|
||||
/// Fetch offers (optionally by categoryXid)
|
||||
Future<OffersResponse> fetchOffers({
|
||||
int? categoryXid,
|
||||
}) async {
|
||||
final String url = categoryXid != null
|
||||
? '${ApiUrls.offers}?categoryXid=$categoryXid'
|
||||
: ApiUrls.offers;
|
||||
|
||||
final response = await _apiService.getApi(
|
||||
url: url,
|
||||
);
|
||||
|
||||
return OffersResponse.fromJson(response.data);
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,31 @@ import 'package:citycards_customer/common_packages/app_bar.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_search_field.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:citycards_customer/core/route_constants.dart';
|
||||
import 'package:citycards_customer/search_offers/bloc/search_offers_listing_bloc.dart';
|
||||
import 'package:citycards_customer/search_offers/bloc/offers_bloc.dart';
|
||||
import 'package:citycards_customer/search_offers/bloc/offers_event.dart';
|
||||
import 'package:citycards_customer/search_offers/bloc/offers_state.dart';
|
||||
import 'package:citycards_customer/search_offers/repository/offers_repository.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../../common_packages/common_app_texts.dart';
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
|
||||
class SearchOffersWithListing extends StatelessWidget {
|
||||
SearchOffersWithListing({super.key});
|
||||
class OffersScreen extends StatefulWidget {
|
||||
const OffersScreen({super.key});
|
||||
|
||||
final List<String> category = ["Beach", "Hike", "Popular", "Best in Summer"];
|
||||
@override
|
||||
State<OffersScreen> createState() => _OffersScreenState();
|
||||
}
|
||||
|
||||
class _OffersScreenState extends State<OffersScreen> {
|
||||
int? selectedCategoryId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => OffersBloc()..add(LoadOffers()),
|
||||
create: (_) => OffersBloc(OffersRepository())..add(LoadOffers()),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
@@ -25,21 +34,28 @@ class SearchOffersWithListing extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
children: [
|
||||
CommonAppBar(isWhiteLogo: false, isProfilePage: false,showCart: false, showDivider: true,),
|
||||
CommonAppBar(
|
||||
isWhiteLogo: false,
|
||||
isProfilePage: false,
|
||||
showCart: false,
|
||||
showDivider: true,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: (){
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Icon(Icons.arrow_back)),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Icon(Icons.arrow_back),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
CustomText(text: "Offers with ${CommonAppText.selectiveCard} Card", size: 12.sp),
|
||||
CustomText(
|
||||
text: "Offers with ${CommonAppText.selectiveCard} Card",
|
||||
size: 12.sp,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 33.h),
|
||||
|
||||
Builder(
|
||||
builder: (context) => CommonSearchField(
|
||||
hint: "Search offers",
|
||||
@@ -50,108 +66,274 @@ class SearchOffersWithListing extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
/// Dynamic Categories
|
||||
BlocBuilder<OffersBloc, OffersState>(
|
||||
builder: (context, state) {
|
||||
if (state is OffersLoaded) {
|
||||
final categories = state.categories;
|
||||
|
||||
child: Row(
|
||||
children: [
|
||||
...List.generate(category.length, (index) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: 8.0.w),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 6.h,
|
||||
horizontal: 12.w,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFFEE7E7),
|
||||
borderRadius: BorderRadius.circular(100.sp),
|
||||
border: Border.all(color: Color(0xFFFDCDCE)),
|
||||
),
|
||||
child: Center(
|
||||
child: CustomText(text: category[index]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
if (categories.isEmpty) {
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
...List.generate(categories.length, (index) {
|
||||
final category = categories[index];
|
||||
final isSelected =
|
||||
selectedCategoryId == category.id;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: 8.0.w),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (selectedCategoryId == category.id) {
|
||||
// Deselect if already selected
|
||||
selectedCategoryId = null;
|
||||
context
|
||||
.read<OffersBloc>()
|
||||
.add(LoadOffers());
|
||||
} else {
|
||||
// Select new category
|
||||
selectedCategoryId = category.id;
|
||||
context.read<OffersBloc>().add(
|
||||
LoadOffers(
|
||||
categoryXid: category.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 8.h,
|
||||
horizontal: 12.w,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Color(0xFFF95F62)
|
||||
: Color(0xFFFEE7E7),
|
||||
borderRadius:
|
||||
BorderRadius.circular(100.sp),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Color(0xFFF95F62)
|
||||
: Color(0xFFFDCDCE),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: CustomText(
|
||||
text: category.categoryName,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: Color(0xFFF95F62),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
/// Offer list
|
||||
Expanded(
|
||||
child: BlocBuilder<OffersBloc, OffersState>(
|
||||
builder: (context, state) {
|
||||
final offers = state.offers;
|
||||
|
||||
if (offers.isEmpty) {
|
||||
if (state is OffersLoading) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
"No offers found",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 14),
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16.w,
|
||||
mainAxisSpacing: 22.h,
|
||||
childAspectRatio: 0.65,
|
||||
),
|
||||
itemCount: offers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final offer = offers[index];
|
||||
return InkWell(
|
||||
onTap: (){
|
||||
Navigator.of(context).pushNamed(RouteConstants.offerPassDetail);
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 6.w,
|
||||
vertical: 6.h,
|
||||
if (state is OffersError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48.sp,
|
||||
color: Colors.red,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Color(0xFFF95F62).withOpacity(.24),
|
||||
SizedBox(height: 16.h),
|
||||
Text(
|
||||
"Error: ${state.message}",
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
fontSize: 14.sp,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12.sp),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.sp),
|
||||
child: Image.asset(
|
||||
offer["image"] ?? "",
|
||||
width: double.infinity,
|
||||
height: 120.5.h,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is OffersLoaded) {
|
||||
final offers = state.offers;
|
||||
|
||||
if (offers.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.local_offer_outlined,
|
||||
size: 48.sp,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Text(
|
||||
"No offers found",
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 14.sp,
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
CustomText(
|
||||
text: offer["title"] ?? "",
|
||||
size: 18.sp,
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
CustomText(
|
||||
text: offer["description"] ?? "",
|
||||
color: Colors.black.withOpacity(.6),
|
||||
size: 12.sp,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
gridDelegate:
|
||||
SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16.w,
|
||||
mainAxisSpacing: 22.h,
|
||||
childAspectRatio: 0.65,
|
||||
),
|
||||
itemCount: offers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final offer = offers[index];
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).pushNamed(
|
||||
RouteConstants.offerPassDetail,
|
||||
arguments: offer.id, // ✅ pass offerId
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 6.w,
|
||||
vertical: 6.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color:
|
||||
Color(0xFFF95F62).withOpacity(.24),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12.sp),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius:
|
||||
BorderRadius.circular(8.sp),
|
||||
child: offer.mobileBannerImage != null &&
|
||||
offer.mobileBannerImage!
|
||||
.isNotEmpty
|
||||
? Image.network(
|
||||
'${ApiUrls.baseUrl}/${offer.mobileBannerImage}',
|
||||
width: double.infinity,
|
||||
height: 120.5.h,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(context, error, stackTrace) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 120.5.h,
|
||||
color: Color(0xFFFEE7E7),
|
||||
child: Icon(
|
||||
Icons.local_offer,
|
||||
size: 40.sp,
|
||||
color: Color(0xFFF95F62)
|
||||
.withOpacity(.6),
|
||||
),
|
||||
);
|
||||
},
|
||||
loadingBuilder: (context, child,
|
||||
loadingProgress) {
|
||||
if (loadingProgress == null) {
|
||||
return child;
|
||||
}
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 120.5.h,
|
||||
color: Color(0xFFFEE7E7),
|
||||
child: Center(
|
||||
child:
|
||||
CircularProgressIndicator(
|
||||
value: loadingProgress
|
||||
.expectedTotalBytes !=
|
||||
null
|
||||
? loadingProgress
|
||||
.cumulativeBytesLoaded /
|
||||
loadingProgress
|
||||
.expectedTotalBytes!
|
||||
: null,
|
||||
strokeWidth: 2,
|
||||
color:
|
||||
Color(0xFFF95F62),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Container(
|
||||
width: double.infinity,
|
||||
height: 120.5.h,
|
||||
color: Color(0xFFFEE7E7),
|
||||
child: Icon(
|
||||
Icons.local_offer,
|
||||
size: 40.sp,
|
||||
color: Color(0xFFF95F62)
|
||||
.withOpacity(.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
CustomText(
|
||||
text: offer.title,
|
||||
size: 18.sp,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
CustomText(
|
||||
text: offer.description,
|
||||
color: Colors.black.withOpacity(.6),
|
||||
size: 12.sp,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return const Center(
|
||||
child: Text(
|
||||
"No data available",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 14),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -163,4 +345,4 @@ class SearchOffersWithListing extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user