Added api of upcoming cities and cities and selection cities
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../repository/first_time_user_home_repository.dart';
|
||||
import '../../model/city_list_model.dart';
|
||||
import 'first_time_user_home_event.dart';
|
||||
import 'first_time_user_home_state.dart';
|
||||
|
||||
class FirstTimeUserHomeBloc
|
||||
extends Bloc<FirstTimeUserHomeEvent, FirstTimeUserHomeState> {
|
||||
final FirstTimeUserHomeRepository repository;
|
||||
|
||||
FirstTimeUserHomeBloc(this.repository)
|
||||
: super(FirstTimeUserHomeInitial()) {
|
||||
on<FetchFirstTimeUserHomeEvent>(_onFetchFirstTimeUserHome);
|
||||
}
|
||||
|
||||
Future<void> _onFetchFirstTimeUserHome(
|
||||
FetchFirstTimeUserHomeEvent event,
|
||||
Emitter<FirstTimeUserHomeState> emit,
|
||||
) async {
|
||||
emit(FirstTimeUserHomeLoading());
|
||||
|
||||
try {
|
||||
final CityList homeData =
|
||||
await repository.fetchFirstTimeUserHome();
|
||||
|
||||
emit(
|
||||
FirstTimeUserHomeLoaded(
|
||||
cities: homeData.cities ?? [],
|
||||
upcomingCities: homeData.upcomingCities ?? [],
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(FirstTimeUserHomeError(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
abstract class FirstTimeUserHomeEvent {}
|
||||
|
||||
class FetchFirstTimeUserHomeEvent extends FirstTimeUserHomeEvent {}
|
||||
@@ -0,0 +1,28 @@
|
||||
import '../../model/city_list_model.dart';
|
||||
|
||||
/// Base State
|
||||
abstract class FirstTimeUserHomeState {}
|
||||
|
||||
/// Initial State
|
||||
class FirstTimeUserHomeInitial extends FirstTimeUserHomeState {}
|
||||
|
||||
/// Loading State
|
||||
class FirstTimeUserHomeLoading extends FirstTimeUserHomeState {}
|
||||
|
||||
/// Success State
|
||||
class FirstTimeUserHomeLoaded extends FirstTimeUserHomeState {
|
||||
final List<Cities> cities;
|
||||
final List<UpcomingCities> upcomingCities;
|
||||
|
||||
FirstTimeUserHomeLoaded({
|
||||
required this.cities,
|
||||
required this.upcomingCities,
|
||||
});
|
||||
}
|
||||
|
||||
/// Error State
|
||||
class FirstTimeUserHomeError extends FirstTimeUserHomeState {
|
||||
final String message;
|
||||
|
||||
FirstTimeUserHomeError(this.message);
|
||||
}
|
||||
@@ -1,42 +1,26 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// --- Events ---
|
||||
abstract class AppStartEvent {}
|
||||
|
||||
class CheckFirstTimeUser extends AppStartEvent {}
|
||||
class StartApp extends AppStartEvent {}
|
||||
class MarkUserAsRegistered extends AppStartEvent {}
|
||||
|
||||
/// --- States ---
|
||||
abstract class AppStartState {}
|
||||
|
||||
class AppStartLoading extends AppStartState {}
|
||||
class AppStartFirstTime extends AppStartState {}
|
||||
class AppStartRegistered extends AppStartState {}
|
||||
|
||||
/// --- Bloc ---
|
||||
class AppStartBloc extends Bloc<AppStartEvent, AppStartState> {
|
||||
AppStartBloc() : super(AppStartLoading()) {
|
||||
on<CheckFirstTimeUser>(_onCheckFirstTimeUser);
|
||||
on<MarkUserAsRegistered>(_onMarkUserAsRegistered);
|
||||
}
|
||||
AppStartBloc() : super(AppStartFirstTime()) {
|
||||
on<StartApp>((event, emit) {
|
||||
emit(AppStartFirstTime()); // always first-time
|
||||
});
|
||||
|
||||
Future<void> _onCheckFirstTimeUser(
|
||||
CheckFirstTimeUser event, Emitter<AppStartState> emit) async {
|
||||
emit(AppStartLoading());
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final isFirstTime = prefs.getBool('isFirstTimeUser') ?? true;
|
||||
if (isFirstTime) {
|
||||
emit(AppStartFirstTime());
|
||||
} else {
|
||||
on<MarkUserAsRegistered>((event, emit) {
|
||||
emit(AppStartRegistered());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onMarkUserAsRegistered(
|
||||
MarkUserAsRegistered event, Emitter<AppStartState> emit) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('isFirstTimeUser', false);
|
||||
emit(AppStartRegistered());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../model/city_selection_model.dart';
|
||||
import '../repository/search_city_repository.dart';
|
||||
|
||||
|
||||
abstract class LoadCityEvent {}
|
||||
|
||||
@@ -6,49 +9,57 @@ class LoadAllCity extends LoadCityEvent {}
|
||||
|
||||
class SearchCity extends LoadCityEvent {
|
||||
final String query;
|
||||
|
||||
SearchCity(this.query);
|
||||
}
|
||||
|
||||
// ----- State -----
|
||||
class CityState {
|
||||
final List<Map<String, String>> offers;
|
||||
abstract class CityState {}
|
||||
|
||||
const CityState(this.offers);
|
||||
class CityInitial extends CityState {}
|
||||
|
||||
class CityLoading extends CityState {}
|
||||
|
||||
class CityLoaded extends CityState {
|
||||
final List<CitySelection> cities;
|
||||
CityLoaded(this.cities);
|
||||
}
|
||||
|
||||
// ----- Bloc -----
|
||||
class CityError extends CityState {
|
||||
final String message;
|
||||
CityError(this.message);
|
||||
}
|
||||
|
||||
|
||||
class SearchCityBloc extends Bloc<LoadCityEvent, CityState> {
|
||||
SearchCityBloc() : super(const CityState([])) {
|
||||
final SearchCityRepository repository;
|
||||
|
||||
SearchCityBloc(this.repository) : super(CityInitial()) {
|
||||
on<LoadAllCity>(_onLoadCity);
|
||||
on<SearchCity>(_onSearchCity);
|
||||
}
|
||||
|
||||
final List<Map<String, String>> _allOffers = [
|
||||
{"image": "assets/images/aa1.png", "title": "Sydney"},
|
||||
{"image": "assets/images/aa2.png", "title": "New York"},
|
||||
{"image": "assets/images/aa3.png", "title": "Abu Dhabi"},
|
||||
{"image": "assets/images/aa4.png", "title": "Dubai"},
|
||||
{
|
||||
"image": "assets/images/card_banner.png",
|
||||
"title": "Tokyo",
|
||||
},
|
||||
{"image": "assets/images/city_germany.jpg", "title": "Ontario"},
|
||||
{"image": "assets/images/aa2.png", "title": "Mumbai"},
|
||||
{"image": "assets/images/aa3.png", "title": "Louisiana"},
|
||||
];
|
||||
|
||||
void _onLoadCity(event, emit) {
|
||||
emit(CityState(_allOffers));
|
||||
Future<void> _onLoadCity(
|
||||
LoadAllCity event,
|
||||
Emitter<CityState> emit,
|
||||
) async {
|
||||
emit(CityLoading());
|
||||
try {
|
||||
final response = await repository.fetchAllCities();
|
||||
emit(CityLoaded(response.cities));
|
||||
} catch (e) {
|
||||
emit(CityError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchCity(event, emit) {
|
||||
final filtered = _allOffers
|
||||
.where(
|
||||
(offer) =>
|
||||
offer["title"]!.toLowerCase().contains(event.query.toLowerCase()),
|
||||
)
|
||||
.toList();
|
||||
emit(CityState(filtered));
|
||||
Future<void> _onSearchCity(
|
||||
SearchCity event,
|
||||
Emitter<CityState> emit,
|
||||
) async {
|
||||
emit(CityLoading());
|
||||
try {
|
||||
final cities = await repository.searchCities(event.query);
|
||||
emit(CityLoaded(cities));
|
||||
} catch (e) {
|
||||
emit(CityError(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
lib/home/model/city_selection_model.dart
Normal file
61
lib/home/model/city_selection_model.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
class CitySelectionResponse {
|
||||
final List<CitySelection> cities;
|
||||
|
||||
CitySelectionResponse({required this.cities});
|
||||
|
||||
factory CitySelectionResponse.fromJson(Map<String, dynamic> json) {
|
||||
return CitySelectionResponse(
|
||||
cities: (json['cities'] as List<dynamic>?)
|
||||
?.map((city) => CitySelection.fromJson(city as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'cities': cities.map((city) => city.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class CitySelection {
|
||||
final int id;
|
||||
final String cityName;
|
||||
final String bannerImage;
|
||||
|
||||
CitySelection({
|
||||
required this.id,
|
||||
required this.cityName,
|
||||
required this.bannerImage,
|
||||
});
|
||||
|
||||
factory CitySelection.fromJson(Map<String, dynamic> json) {
|
||||
return CitySelection(
|
||||
id: json['id'] as int? ?? 0,
|
||||
cityName: json['cityName'] as String? ?? '',
|
||||
bannerImage: json['bannerImage'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'cityName': cityName,
|
||||
'bannerImage': bannerImage,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper method to get the image URL with fallback
|
||||
String getImageUrl() {
|
||||
if (bannerImage.isEmpty || !bannerImage.startsWith('http')) {
|
||||
return 'assets/images/card_banner.png';
|
||||
}
|
||||
return bannerImage;
|
||||
}
|
||||
|
||||
// Helper method to check if image is network image
|
||||
bool isNetworkImage() {
|
||||
return bannerImage.isNotEmpty && bannerImage.startsWith('http');
|
||||
}
|
||||
}
|
||||
36
lib/home/repository/first_time_user_home_repository.dart
Normal file
36
lib/home/repository/first_time_user_home_repository.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:citycards_customer/networkApiServices/api_urls.dart';
|
||||
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../model/city_list_model.dart';
|
||||
|
||||
class FirstTimeUserHomeRepository {
|
||||
final NetworkApiService _apiServices = NetworkApiService();
|
||||
|
||||
/// Fetch full home data (cities + upcoming cities)
|
||||
Future<CityList> fetchFirstTimeUserHome() async {
|
||||
final response = await _apiServices.getApi(
|
||||
url: ApiUrls.cityList,
|
||||
);
|
||||
|
||||
return CityList.fromJson(response.data);
|
||||
}
|
||||
|
||||
/// If you only want Upcoming Cities
|
||||
Future<List<UpcomingCities>> fetchUpcomingCities() async {
|
||||
final response = await _apiServices.getApi(
|
||||
url: ApiUrls.cityList,
|
||||
);
|
||||
|
||||
final cityList = CityList.fromJson(response.data);
|
||||
return cityList.upcomingCities ?? [];
|
||||
}
|
||||
|
||||
Future<List<Cities>> fetchCities() async {
|
||||
final response = await _apiServices.getApi(
|
||||
url: ApiUrls.cityList,
|
||||
);
|
||||
|
||||
final cityList = CityList.fromJson(response.data);
|
||||
return cityList.cities ?? [];
|
||||
}
|
||||
}
|
||||
38
lib/home/repository/search_city_repository.dart
Normal file
38
lib/home/repository/search_city_repository.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:citycards_customer/networkApiServices/api_urls.dart';
|
||||
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../model/city_selection_model.dart';
|
||||
|
||||
class SearchCityRepository {
|
||||
final NetworkApiService _apiServices = NetworkApiService();
|
||||
|
||||
|
||||
Future<CitySelectionResponse> fetchAllCities() async {
|
||||
try {
|
||||
final response = await _apiServices.getApi(
|
||||
url: ApiUrls.searchCityList,
|
||||
);
|
||||
return CitySelectionResponse.fromJson(response.data);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to fetch cities: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Search cities by query
|
||||
Future<List<CitySelection>> searchCities(String query) async {
|
||||
try {
|
||||
final response = await fetchAllCities();
|
||||
|
||||
if (query.isEmpty) {
|
||||
return response.cities;
|
||||
}
|
||||
|
||||
return response.cities
|
||||
.where((city) =>
|
||||
city.cityName.toLowerCase().contains(query.toLowerCase()))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
throw Exception('Failed to search cities: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../common_packages/app_bar.dart';
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
import '../widgets/explore_cities_card.dart';
|
||||
import '../bloc/FirstTimeUserHome/first_time_user_home_bloc.dart';
|
||||
import '../bloc/FirstTimeUserHome/first_time_user_home_event.dart';
|
||||
import '../bloc/FirstTimeUserHome/first_time_user_home_state.dart';
|
||||
|
||||
class FirstTimeUserHomePage extends StatefulWidget {
|
||||
final VoidCallback onContinue;
|
||||
@@ -17,48 +21,11 @@ class _FirstTimeUserHomePageState extends State<FirstTimeUserHomePage> {
|
||||
|
||||
double _scrollProgress = 0.0;
|
||||
|
||||
final List<Map<String, String>> featuredCities = [
|
||||
{
|
||||
"name": "Melbourne",
|
||||
"description": "Australia's cultural capital famous for vibrant...",
|
||||
"individualTicket": "\$350+",
|
||||
"cityCard": "\$199",
|
||||
"savings": "Save \$151+",
|
||||
"image": "assets/images/city_sydney.png",
|
||||
},
|
||||
{
|
||||
"name": "Sydney",
|
||||
"description": "Australia's cultural capital famous for vibrant...",
|
||||
"individualTicket": "\$400+",
|
||||
"cityCard": "\$249",
|
||||
"savings": "Save \$151+",
|
||||
"image": "assets/images/city_sydney.png",
|
||||
},
|
||||
{
|
||||
"name": "Sydney",
|
||||
"description": "Australia's cultural capital famous for vibrant...",
|
||||
"individualTicket": "\$400+",
|
||||
"cityCard": "\$249",
|
||||
"savings": "Save \$151+",
|
||||
"image": "assets/images/city_sydney.png",
|
||||
},
|
||||
];
|
||||
|
||||
final List<Map<String, String>> upcomingCities = [
|
||||
{"image": "assets/images/city_turkey.jpg", "name": "Turkey"},
|
||||
{"image": "assets/images/city_germany.jpg", "name": "Germany"},
|
||||
{"image": "assets/images/city_switz.jpg", "name": "Switzerland"},
|
||||
{"image": "assets/images/city_maldives.jpg", "name": "Maldives"},
|
||||
{"image": "assets/images/city_turkey.jpg", "name": "Turkey"},
|
||||
{"image": "assets/images/city_germany.jpg", "name": "Germany"},
|
||||
{"image": "assets/images/city_switz.jpg", "name": "Switzerland"},
|
||||
{"image": "assets/images/city_maldives.jpg", "name": "Maldives"},
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_updateScrollProgress);
|
||||
context.read<FirstTimeUserHomeBloc>().add(FetchFirstTimeUserHomeEvent());
|
||||
}
|
||||
|
||||
void _updateScrollProgress() {
|
||||
@@ -68,7 +35,7 @@ class _FirstTimeUserHomePageState extends State<FirstTimeUserHomePage> {
|
||||
setState(() {
|
||||
_scrollProgress =
|
||||
(_scrollController.offset /
|
||||
_scrollController.position.maxScrollExtent)
|
||||
_scrollController.position.maxScrollExtent)
|
||||
.clamp(0.0, 1.0);
|
||||
});
|
||||
}
|
||||
@@ -164,25 +131,76 @@ class _FirstTimeUserHomePageState extends State<FirstTimeUserHomePage> {
|
||||
),
|
||||
SizedBox(height: 16.sp),
|
||||
|
||||
// Horizontal cards
|
||||
SizedBox(
|
||||
height: 270.h,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: featuredCities.length,
|
||||
itemBuilder: (context, index) {
|
||||
final city = featuredCities[index];
|
||||
return ExploreCitiesCard(
|
||||
name: city['name']!,
|
||||
description: city['description']!,
|
||||
imageUrl: city['image']!,
|
||||
individualPrice: city['individualTicket']!,
|
||||
cityCardPrice: city['cityCard']!,
|
||||
savingsText: city['savings']!,
|
||||
// Explore Cities - Using BLoC
|
||||
BlocBuilder<FirstTimeUserHomeBloc, FirstTimeUserHomeState>(
|
||||
builder: (context, state) {
|
||||
if (state is FirstTimeUserHomeLoading) {
|
||||
return SizedBox(
|
||||
height: 270.h,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xffF95F62),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
if (state is FirstTimeUserHomeError) {
|
||||
return SizedBox(
|
||||
height: 270.h,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Error: ${state.message}',
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is FirstTimeUserHomeLoaded) {
|
||||
final cities = state.cities;
|
||||
|
||||
if (cities.isEmpty) {
|
||||
return SizedBox(
|
||||
height: 270.h,
|
||||
child: const Center(
|
||||
child: Text('No cities available'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 270.h,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: cities.length,
|
||||
itemBuilder: (context, index) {
|
||||
final city = cities[index];
|
||||
|
||||
// Construct image URL with fallback
|
||||
final imageUrl = city.bannerImage != null && city.bannerImage!.isNotEmpty
|
||||
? city.bannerImage!
|
||||
: 'assets/images/city_sydney.png';
|
||||
|
||||
// Determine if it's a network image or asset
|
||||
final isNetworkImage = imageUrl.startsWith('http');
|
||||
|
||||
return ExploreCitiesCard(
|
||||
name: city.cityName ?? 'N/A',
|
||||
description: city.tagLine ?? 'N/A',
|
||||
imageUrl: imageUrl,
|
||||
individualPrice: '\$${city.indivisualTicketAmt ?? 0}+',
|
||||
cityCardPrice: '\$${city.cityCardTicketAmt ?? 0}',
|
||||
savingsText: city.saveLabel ?? 'Save \$0+',
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
|
||||
SizedBox(height: 10.h),
|
||||
@@ -232,30 +250,78 @@ class _FirstTimeUserHomePageState extends State<FirstTimeUserHomePage> {
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
SizedBox(
|
||||
height: 80.h,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: upcomingCities.length,
|
||||
separatorBuilder: (_, __) => SizedBox(width: 16.w),
|
||||
itemBuilder: (context, index) {
|
||||
return Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28.r,
|
||||
backgroundImage: AssetImage(
|
||||
upcomingCities[index]["image"] ?? "",
|
||||
),
|
||||
|
||||
// Upcoming Cities - Using BLoC
|
||||
BlocBuilder<FirstTimeUserHomeBloc, FirstTimeUserHomeState>(
|
||||
builder: (context, state) {
|
||||
if (state is FirstTimeUserHomeLoading) {
|
||||
return SizedBox(
|
||||
height: 80.h,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xffF95F62),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
upcomingCities[index]["name"] ?? "",
|
||||
style: TextStyle(fontSize: 12.sp),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
if (state is FirstTimeUserHomeError) {
|
||||
return SizedBox(
|
||||
height: 80.h,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Error: ${state.message}',
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is FirstTimeUserHomeLoaded) {
|
||||
final upcomingCities = state.upcomingCities;
|
||||
|
||||
if (upcomingCities.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 80.h,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: upcomingCities.length,
|
||||
separatorBuilder: (_, __) => SizedBox(width: 16.w),
|
||||
itemBuilder: (context, index) {
|
||||
final city = upcomingCities[index];
|
||||
final imageUrl =
|
||||
'${ApiUrls.baseUrl}${city.imgPathName}';
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28.r,
|
||||
backgroundImage: NetworkImage(imageUrl),
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
SizedBox(
|
||||
width: 60.w,
|
||||
child: Text(
|
||||
city.cityName ?? '',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 12.sp),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -266,4 +332,4 @@ class _FirstTimeUserHomePageState extends State<FirstTimeUserHomePage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,34 +18,26 @@ class HomePage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
final _navigatorKeys = [
|
||||
GlobalKey<NavigatorState>(),
|
||||
GlobalKey<NavigatorState>(),
|
||||
GlobalKey<NavigatorState>(),
|
||||
GlobalKey<NavigatorState>(),
|
||||
];
|
||||
final _navigatorKeys = List.generate(4, (_) => GlobalKey<NavigatorState>());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => AppStartBloc()..add(CheckFirstTimeUser()),
|
||||
create: (_) => AppStartBloc()..add(StartApp()),
|
||||
child: BlocBuilder<AppStartBloc, AppStartState>(
|
||||
builder: (context, state) {
|
||||
if (state is AppStartLoading) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
// 🚀 Always first time initially
|
||||
if (state is AppStartFirstTime) {
|
||||
return FirstTimeUserHomePage(
|
||||
onContinue: () {
|
||||
context.read<AppStartBloc>().add(MarkUserAsRegistered());
|
||||
context
|
||||
.read<AppStartBloc>()
|
||||
.add(MarkUserAsRegistered());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Once registered → show normal main home tabs
|
||||
// ✅ Registered user flow
|
||||
return BlocBuilder<NavigationBloc, NavigationState>(
|
||||
builder: (context, navState) {
|
||||
final currentIndex = navState.selectedIndex;
|
||||
@@ -55,14 +47,30 @@ class _HomePageState extends State<HomePage> {
|
||||
child: Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
buildOffstageNavigator(0, currentIndex,
|
||||
const RegisteredUserHomePage(), _navigatorKeys[0]),
|
||||
buildOffstageNavigator(1, currentIndex,
|
||||
const ItineraryCreationStartPage(), _navigatorKeys[1]),
|
||||
buildOffstageNavigator(2, currentIndex,
|
||||
const MyPassesView(), _navigatorKeys[2]),
|
||||
buildOffstageNavigator(3, currentIndex,
|
||||
const PostcardPage(), _navigatorKeys[3]),
|
||||
buildOffstageNavigator(
|
||||
0,
|
||||
currentIndex,
|
||||
const RegisteredUserHomePage(),
|
||||
_navigatorKeys[0],
|
||||
),
|
||||
buildOffstageNavigator(
|
||||
1,
|
||||
currentIndex,
|
||||
const ItineraryCreationStartPage(),
|
||||
_navigatorKeys[1],
|
||||
),
|
||||
buildOffstageNavigator(
|
||||
2,
|
||||
currentIndex,
|
||||
const MyPassesView(),
|
||||
_navigatorKeys[2],
|
||||
),
|
||||
buildOffstageNavigator(
|
||||
3,
|
||||
currentIndex,
|
||||
const PostcardPage(),
|
||||
_navigatorKeys[3],
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: const CustomBottomNavBar(),
|
||||
|
||||
@@ -20,6 +20,9 @@ class ExploreCitiesCard extends StatelessWidget {
|
||||
required this.savingsText,
|
||||
});
|
||||
|
||||
bool get _isNetworkImage =>
|
||||
imageUrl.startsWith('http') || imageUrl.startsWith('https');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
@@ -27,115 +30,144 @@ class ExploreCitiesCard extends StatelessWidget {
|
||||
margin: EdgeInsets.only(right: 16.w),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
image: DecorationImage(image: AssetImage(imageUrl), fit: BoxFit.cover),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [Colors.black.withOpacity(0.2), Colors.transparent],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
/// Background Image with fallback
|
||||
_isNetworkImage
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Image.asset(
|
||||
'assets/images/city_sydney.png',
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
},
|
||||
)
|
||||
: Image.asset(
|
||||
'assets/images/city_sydney.png',
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
top: 10.h,
|
||||
right: 10.w,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 10.w),
|
||||
/// Gradient Overlay
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xffDBFCE7),
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
),
|
||||
child: Text(
|
||||
savingsText,
|
||||
style: GoogleFonts.poppins(
|
||||
color: const Color(0xFF2C8354),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12.sp,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.35),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom text
|
||||
Positioned(
|
||||
bottom: 10.h,
|
||||
left: 10.w,
|
||||
right: 10.w,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 16.sp,
|
||||
),
|
||||
/// Savings Chip
|
||||
Positioned(
|
||||
top: 10.h,
|
||||
right: 10.w,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 6.h,
|
||||
horizontal: 10.w,
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
description,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xffDBFCE7),
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
),
|
||||
child: Text(
|
||||
savingsText,
|
||||
style: GoogleFonts.poppins(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 11.sp,
|
||||
color: const Color(0xFF2C8354),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
|
||||
// Prices
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"Individual tickets :",
|
||||
style: TextStyle(
|
||||
color: Color(0xffFDCDCE),
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
individualPrice,
|
||||
style: TextStyle(
|
||||
color: Color(0xffFDCDCE),
|
||||
fontSize: 12.sp,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
decorationColor: Color(0xffFDCDCE),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"City Card :",
|
||||
style: TextStyle(
|
||||
color: Color(0xffFDCDCE),
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
cityCardPrice,
|
||||
style: TextStyle(
|
||||
color: Color(0xffFDCDCE),
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
/// Bottom Content
|
||||
Positioned(
|
||||
bottom: 10.h,
|
||||
left: 10.w,
|
||||
right: 10.w,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16.sp,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.poppins(
|
||||
color: Colors.white70,
|
||||
fontSize: 11.sp,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
|
||||
/// Prices
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"Individual tickets:",
|
||||
style: TextStyle(
|
||||
color: const Color(0xffFDCDCE),
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
individualPrice,
|
||||
style: TextStyle(
|
||||
color: const Color(0xffFDCDCE),
|
||||
fontSize: 12.sp,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
decorationColor: const Color(0xffFDCDCE),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"City Card:",
|
||||
style: TextStyle(
|
||||
color: const Color(0xffFDCDCE),
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
cityCardPrice,
|
||||
style: TextStyle(
|
||||
color: const Color(0xffFDCDCE),
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:citycards_customer/home/bloc/search_city_bloc.dart';
|
||||
import 'package:citycards_customer/home/repository/search_city_repository.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
@@ -10,7 +11,7 @@ class CitySelectionBottomSheet extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => SearchCityBloc()..add(LoadAllCity()),
|
||||
create: (_) => SearchCityBloc(SearchCityRepository())..add(LoadAllCity()),
|
||||
child: _CitySelectionView(),
|
||||
);
|
||||
}
|
||||
@@ -46,11 +47,10 @@ class _CitySelectionView extends StatelessWidget {
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: const Icon(Icons.arrow_back, size: 18),
|
||||
),
|
||||
SizedBox(width: 4.w,),
|
||||
CustomText(text: "Back", size: 12.sp,)
|
||||
SizedBox(width: 4.w),
|
||||
CustomText(text: "Back", size: 12.sp),
|
||||
],
|
||||
),
|
||||
|
||||
Text(
|
||||
"Select a City",
|
||||
style: TextStyle(
|
||||
@@ -58,8 +58,7 @@ class _CitySelectionView extends StatelessWidget {
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(width: 25.w,)
|
||||
SizedBox(width: 25.w),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -82,28 +81,28 @@ class _CitySelectionView extends StatelessWidget {
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF2B2B2B),
|
||||
fontWeight: FontWeight.w300
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFFFFFFF).withOpacity(.24),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide: BorderSide(
|
||||
color: Color(0xFFF95F62).withOpacity(.40),
|
||||
color: const Color(0xFFF95F62).withOpacity(.40),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide: BorderSide(
|
||||
color: Color(0xFFF95F62).withOpacity(.40),
|
||||
color: const Color(0xFFF95F62).withOpacity(.40),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide: BorderSide(
|
||||
color: Color(0xFFF95F62).withOpacity(.40),
|
||||
color: const Color(0xFFF95F62).withOpacity(.40),
|
||||
width: 1.2,
|
||||
),
|
||||
),
|
||||
@@ -117,23 +116,101 @@ class _CitySelectionView extends StatelessWidget {
|
||||
Expanded(
|
||||
child: BlocBuilder<SearchCityBloc, CityState>(
|
||||
builder: (context, state) {
|
||||
if (state.offers.isEmpty) {
|
||||
return const Center(child: Text("No cities found"));
|
||||
if (state is CityLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
);
|
||||
}
|
||||
return GridView.builder(
|
||||
itemCount: state.offers.length,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12.h,
|
||||
crossAxisSpacing: 12.w,
|
||||
childAspectRatio: 1.2,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final city = state.offers[index];
|
||||
return _cityCard(city["image"]!, city["title"]!);
|
||||
},
|
||||
);
|
||||
|
||||
if (state is CityError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||
SizedBox(height: 16.h),
|
||||
Text(
|
||||
'Error loading cities',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
state.message,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<SearchCityBloc>().add(LoadAllCity());
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFF95F62),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Retry',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is CityLoaded) {
|
||||
if (state.cities.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.location_city_outlined,
|
||||
size: 48,
|
||||
color: Colors.grey[400]),
|
||||
SizedBox(height: 16.h),
|
||||
Text(
|
||||
"No cities found",
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
itemCount: state.cities.length,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12.h,
|
||||
crossAxisSpacing: 12.w,
|
||||
childAspectRatio: 1.2,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final city = state.cities[index];
|
||||
return _cityCard(
|
||||
city.getImageUrl(),
|
||||
city.cityName,
|
||||
city.isNetworkImage(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -142,13 +219,48 @@ class _CitySelectionView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _cityCard(String image, String name) {
|
||||
Widget _cityCard(String imageUrl, String name, bool isNetwork) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.asset(image, fit: BoxFit.cover,width: 170.w,height: 123.h,),
|
||||
// Image with error handling
|
||||
isNetwork
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: 170.w,
|
||||
height: 123.h,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Image.asset(
|
||||
'assets/images/card_banner.png',
|
||||
fit: BoxFit.cover,
|
||||
width: 170.w,
|
||||
height: 123.h,
|
||||
);
|
||||
},
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Container(
|
||||
color: Colors.grey[200],
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFFF95F62),
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Image.asset(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: 170.w,
|
||||
height: 123.h,
|
||||
),
|
||||
|
||||
// Gradient overlay
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
@@ -162,6 +274,8 @@ class _CitySelectionView extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// City name
|
||||
Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Padding(
|
||||
@@ -173,6 +287,8 @@ class _CitySelectionView extends StatelessWidget {
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -180,4 +296,4 @@ class _CitySelectionView extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'core/app_router.dart';
|
||||
import 'home/bloc/FirstTimeUserHome/first_time_user_home_bloc.dart';
|
||||
import 'home/bloc/FirstTimeUserHome/first_time_user_home_event.dart';
|
||||
import 'home/repository/first_time_user_home_repository.dart';
|
||||
import 'my_pass/blocs/my_pass_bloc.dart';
|
||||
|
||||
void main() {
|
||||
@@ -38,6 +41,11 @@ class MyApp extends StatelessWidget {
|
||||
BlocProvider<MyPassBloc>(
|
||||
create: (_) => MyPassBloc()..add(LoadMyPasses()),
|
||||
),
|
||||
BlocProvider<FirstTimeUserHomeBloc>(
|
||||
create: (context) => FirstTimeUserHomeBloc(
|
||||
FirstTimeUserHomeRepository(),
|
||||
)..add(FetchFirstTimeUserHomeEvent()),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
onGenerateRoute: _appRouter.onGenerateRoute,
|
||||
|
||||
@@ -3,4 +3,6 @@ class ApiUrls {
|
||||
static const baseUrl = "https://devapi.citycards.betadelivery.com";
|
||||
|
||||
static const cityList = "$baseUrl/mobile/city_list";
|
||||
static const upcomingCityList = "$baseUrl/mobile/upcoming_cities";
|
||||
static const searchCityList = "$baseUrl/mobile/city-selection";
|
||||
}
|
||||
Reference in New Issue
Block a user