3rd commit

This commit is contained in:
2025-10-17 16:03:59 +05:30
parent ee53254fe6
commit 6086ae249f
20 changed files with 1621 additions and 5 deletions

View File

@@ -0,0 +1,91 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../models/booking_model.dart';
import '../viewmodels/booking_viewmodel.dart';
abstract class BookingEvent {}
class LoadBookings extends BookingEvent {
final DateTime month;
LoadBookings(this.month);
}
class SelectDate extends BookingEvent {
final DateTime date;
SelectDate(this.date);
}
class ToggleSlotExpand extends BookingEvent {
final int attractionIndex;
ToggleSlotExpand(this.attractionIndex);
}
class BookingState {
final DateTime focusedMonth;
final DateTime? selectedDate;
final List<BookingDay> bookings;
final bool isLoading;
final List<int> expandedAttractions;
BookingState({
required this.focusedMonth,
required this.selectedDate,
required this.bookings,
required this.isLoading,
required this.expandedAttractions,
});
factory BookingState.initial() => BookingState(
focusedMonth: DateTime.now(),
selectedDate: null,
bookings: [],
isLoading: false,
expandedAttractions: [],
);
BookingState copyWith({
DateTime? focusedMonth,
DateTime? selectedDate,
List<BookingDay>? bookings,
bool? isLoading,
List<int>? expandedAttractions,
}) {
return BookingState(
focusedMonth: focusedMonth ?? this.focusedMonth,
selectedDate: selectedDate ?? this.selectedDate,
bookings: bookings ?? this.bookings,
isLoading: isLoading ?? this.isLoading,
expandedAttractions: expandedAttractions ?? this.expandedAttractions,
);
}
}
class BookingBloc extends Bloc<BookingEvent, BookingState> {
final BookingViewModel viewModel;
BookingBloc({required this.viewModel}) : super(BookingState.initial()) {
on<LoadBookings>(_onLoadBookings);
on<SelectDate>(_onSelectDate);
on<ToggleSlotExpand>(_onToggleSlotExpand);
}
Future<void> _onLoadBookings(
LoadBookings event, Emitter<BookingState> emit) async {
emit(state.copyWith(isLoading: true, focusedMonth: event.month));
final data = await viewModel.getBookings(event.month);
emit(state.copyWith(isLoading: false, bookings: data));
}
void _onSelectDate(SelectDate event, Emitter<BookingState> emit) {
emit(state.copyWith(selectedDate: event.date));
}
void _onToggleSlotExpand(ToggleSlotExpand event, Emitter<BookingState> emit) {
final expanded = List<int>.from(state.expandedAttractions);
if (expanded.contains(event.attractionIndex)) {
expanded.remove(event.attractionIndex);
} else {
expanded.add(event.attractionIndex);
}
emit(state.copyWith(expandedAttractions: expanded));
}
}

View File

View File

@@ -0,0 +1,26 @@
class BookingDay {
final DateTime date;
final List<Attraction> attractions;
BookingDay({required this.date, required this.attractions});
}
class Attraction {
final String name;
final String colorHex;
final List<TimeSlot> slots;
Attraction({
required this.name,
required this.colorHex,
required this.slots,
});
}
class TimeSlot {
final String time;
final int booked;
final int total;
TimeSlot({required this.time, required this.booked, required this.total});
}

View File

@@ -0,0 +1,75 @@
import '../models/booking_model.dart';
class BookingRepository {
Future<List<BookingDay>> fetchBookings(DateTime month) async {
await Future.delayed(const Duration(milliseconds: 300));
return [
BookingDay(
date: DateTime(month.year, month.month, 13),
attractions: [
Attraction(
name: "The Enchanted Garden Adventure Park",
colorHex: "#4CAF50",
slots: [
TimeSlot(time: "1:30pm 5:30pm", booked: 15, total: 50),
TimeSlot(time: "5:30pm 7:30pm", booked: 15, total: 50),
TimeSlot(time: "7:30pm 9:30pm", booked: 15, total: 50),
TimeSlot(time: "9:30pm 11:30pm", booked: 15, total: 50),
],
),Attraction(
name: "The Enchanted Garden Adventure Park",
colorHex: "#4CAF50",
slots: [
TimeSlot(time: "1:30pm 5:30pm", booked: 15, total: 50),
TimeSlot(time: "5:30pm 7:30pm", booked: 15, total: 50),
TimeSlot(time: "7:30pm 9:30pm", booked: 15, total: 50),
TimeSlot(time: "9:30pm 11:30pm", booked: 15, total: 50),
],
),Attraction(
name: "The Enchanted Garden Adventure Park",
colorHex: "#4CAF50",
slots: [
TimeSlot(time: "1:30pm 5:30pm", booked: 15, total: 50),
TimeSlot(time: "5:30pm 7:30pm", booked: 15, total: 50),
TimeSlot(time: "7:30pm 9:30pm", booked: 15, total: 50),
TimeSlot(time: "9:30pm 11:30pm", booked: 15, total: 50),
],
),Attraction(
name: "The Enchanted Garden Adventure Park",
colorHex: "#4CAF50",
slots: [
TimeSlot(time: "1:30pm 5:30pm", booked: 15, total: 50),
TimeSlot(time: "5:30pm 7:30pm", booked: 15, total: 50),
TimeSlot(time: "7:30pm 9:30pm", booked: 15, total: 50),
TimeSlot(time: "9:30pm 11:30pm", booked: 15, total: 50),
],
),
Attraction(
name: "Central City Museum",
colorHex: "#F48FB1",
slots: [
TimeSlot(time: "1:30pm 5:30pm", booked: 15, total: 50),
TimeSlot(time: "5:30pm 7:30pm", booked: 15, total: 50),
],
),
],
),
BookingDay(
date: DateTime(month.year, month.month, 14),
attractions: [
Attraction(
name: "Skyline Observatory",
colorHex: "#BA68C8",
slots: [
TimeSlot(time: "1:30pm 5:30pm", booked: 15, total: 50),
TimeSlot(time: "5:30pm 7:30pm", booked: 15, total: 50),
TimeSlot(time: "7:30pm 9:30pm", booked: 15, total: 50),
TimeSlot(time: "9:30pm 11:30pm", booked: 15, total: 50),
],
),
],
),
];
}
}

View File

@@ -0,0 +1,12 @@
import '../repositories/booking_repository.dart';
import '../models/booking_model.dart';
class BookingViewModel {
final BookingRepository repository;
BookingViewModel({required this.repository});
Future<List<BookingDay>> getBookings(DateTime month) async {
return await repository.fetchBookings(month);
}
}

View File

@@ -0,0 +1,181 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import '../models/booking_model.dart';
import '../blocs/booking_bloc.dart';
class BookingBottomSheet extends StatelessWidget {
final DateTime date;
final BookingDay booking;
const BookingBottomSheet({
super.key,
required this.date,
required this.booking,
});
@override
Widget build(BuildContext context) {
final bloc = context.read<BookingBloc>();
final formattedDate = DateFormat('EEEE, MMMM d, yyyy').format(date);
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
minChildSize: 0.6,
maxChildSize: 0.6,
builder: (context, scrollController) {
return BlocBuilder<BookingBloc, BookingState>(
builder: (context, state) {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(formattedDate,
style: GoogleFonts.poppins(
fontWeight: FontWeight.w600, fontSize: 16)),
Text(
"${booking.attractions.length} attractions available",
style: GoogleFonts.poppins(color: Colors.black54)),
const SizedBox(height: 12),
Expanded(
child: RawScrollbar(
thumbColor: Color(0xffF95F62),
trackColor: Color(0xffF9E7E1),
thumbVisibility: true,
trackVisibility: true,
thickness: 10,
radius: const Radius.circular(8),
interactive: true,
// thumbColor: const MaterialStatePropertyAll(Color(0xffF95F62)),
controller: scrollController,
child: Padding(
padding: const EdgeInsets.only(right: 20), child: ListView.builder(
controller: scrollController,
itemCount: booking.attractions.length,
itemBuilder: (context, index) {
final attraction = booking.attractions[index];
final isExpanded = state.expandedAttractions.contains(index);
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Color(int.parse("0x22${attraction.colorHex.substring(1)}")),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: Color(int.parse(
"0xff${attraction.colorHex.substring(1)}")),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(attraction.name,
style: GoogleFonts.poppins(
fontWeight: FontWeight.w600,
fontSize: 13)),
],
),
const Icon(Icons.arrow_forward_ios, size: 14)
],
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: List.generate(
isExpanded
? attraction.slots.length
: (attraction.slots.length > 2
? 2
: attraction.slots.length),
(i) => _slotCard(attraction.slots[i]),
),
),
if (attraction.slots.length > 2)
GestureDetector(
onTap: () =>
bloc.add(ToggleSlotExpand(index)),
child: Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Text(
isExpanded
? "Show less"
: "+${attraction.slots.length - 2} more",
style: GoogleFonts.poppins(
color: Colors.black54,
fontSize: 12,
fontWeight: FontWeight.w500),
),
),
),
],
),
);
},
),
),
),
),
],
),
);
},
);
},
);
}
Widget _slotCard(TimeSlot slot) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(slot.time,
style: GoogleFonts.poppins(fontSize: 12, color: Colors.black87)),
const SizedBox(width: 6),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(6),
),
child: Text("${slot.booked}/${slot.total}",
style: GoogleFonts.poppins(
fontSize: 11, color: Colors.black87)),
),
],
),
);
}
}

View File

@@ -0,0 +1,372 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import 'package:table_calendar/table_calendar.dart';
import '../blocs/booking_bloc.dart';
import '../repositories/booking_repository.dart';
import '../viewmodels/booking_viewmodel.dart';
import '../models/booking_model.dart';
import 'booking_bottom_sheet.dart';
class BookingPage extends StatelessWidget {
const BookingPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => BookingBloc(
viewModel: BookingViewModel(repository: BookingRepository()),
)..add(LoadBookings(DateTime.now())),
child: const _BookingView(),
);
}
}
class _BookingView extends StatelessWidget {
const _BookingView();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: BlocBuilder<BookingBloc, BookingState>(
builder: (context, state) {
final bloc = context.read<BookingBloc>();
return SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 20),
_buildHeader(),
const SizedBox(height: 16),
_buildMonthHeader(bloc, state),
const SizedBox(height: 10),
_buildCalendar(context, bloc, state),
const SizedBox(height: 12),
_buildAttractionLegend(),
const SizedBox(height: 20),
_buildRecurringButton(),
const SizedBox(height: 10),
],
),
);
},
),
);
}
Widget _buildHeader() {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.only(left: 24.0),
child: CircleAvatar(
radius: 20,
backgroundColor: const Color(0xffF95F62),
child: const Icon(Icons.arrow_back, color: Colors.white, size: 22),
),
),
Text(
"Booking",
style: GoogleFonts.poppins(fontSize: 32, fontWeight: FontWeight.w600),
),
const SizedBox(width: 25),
],
),
const SizedBox(height: 16),
Text(
"Easily schedule and manage your bookings anytime,\nanywhere. Fast, simple, and secure.",
textAlign: TextAlign.center,
style: GoogleFonts.poppins(
fontSize: 13,
color: Colors.black,
height: 1.4,
),
),
],
);
}
Widget _buildMonthHeader(BookingBloc bloc, BookingState state) {
final formatter = DateFormat("MMM yyyy");
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
GestureDetector(
onTap: () {
final prevMonth = DateTime(
state.focusedMonth.year, state.focusedMonth.month - 1, 1);
bloc.add(LoadBookings(prevMonth));
},
child: _navButton(Icons.keyboard_arrow_left_rounded),
),
Text(
formatter.format(state.focusedMonth),
style: GoogleFonts.poppins(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
GestureDetector(
onTap: () {
final nextMonth = DateTime(
state.focusedMonth.year, state.focusedMonth.month + 1, 1);
bloc.add(LoadBookings(nextMonth));
},
child: _navButton(Icons.keyboard_arrow_right_rounded),
),
],
),
);
}
Widget _navButton(IconData icon) {
return Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: Color(0xffF6F6F6),
shape: BoxShape.circle,
),
child: Icon(icon, color: Colors.black, size: 25),
);
}
Widget _buildCalendar(BuildContext context, BookingBloc bloc, BookingState state) {
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: TableCalendar(
firstDay: DateTime(2020),
lastDay: DateTime(2030),
focusedDay: state.focusedMonth,
calendarFormat: CalendarFormat.month,
headerVisible: false,
availableGestures: AvailableGestures.none,
startingDayOfWeek: StartingDayOfWeek.monday,
daysOfWeekStyle: DaysOfWeekStyle(
weekdayStyle: GoogleFonts.poppins(
fontSize: 12,
color: Colors.black54,
fontWeight: FontWeight.w500,
),
weekendStyle: GoogleFonts.poppins(
fontSize: 12,
color: Colors.black54,
fontWeight: FontWeight.w500,
),
),
calendarStyle: CalendarStyle(
outsideDaysVisible: false,
markersMaxCount: 0,
defaultTextStyle: GoogleFonts.poppins(color: Colors.black87,fontSize: 22),
weekendTextStyle: GoogleFonts.poppins(color: Colors.black87),
),
eventLoader: (day) {
return state.bookings
.where((b) =>
b.date.year == day.year &&
b.date.month == day.month &&
b.date.day == day.day)
.toList();
},
calendarBuilders: CalendarBuilders(
defaultBuilder: (context, date, _) {
final bookings = state.bookings
.where((b) =>
b.date.year == date.year &&
b.date.month == date.month &&
b.date.day == date.day)
.toList();
return GestureDetector(
onTap: () {
if (bookings.isNotEmpty) {
bloc.add(SelectDate(date));
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (_) => BlocProvider.value(
value: bloc,
child: BookingBottomSheet(
date: date,
booking: bookings.first,
),
),
);
}
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (bookings.isNotEmpty && bookings.first.attractions.length > 3)
Align(
alignment: Alignment.topRight,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
decoration: BoxDecoration(
color: const Color(0xffF95F62),
borderRadius: BorderRadius.circular(10),
),
child: Text(
"+${bookings.first.attractions.length - 3}",
style: GoogleFonts.poppins(
color: Colors.white,
fontSize: 8,
),
),
),
),
const SizedBox(height: 2),
Text(
"${date.day}",
style: GoogleFonts.poppins(
color: Colors.black,
fontWeight: FontWeight.w500,
fontSize: 13,
),
),
const SizedBox(height: 4),
if (bookings.isNotEmpty)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
...List.generate(
bookings.first.attractions.length > 3
? 3
: bookings.first.attractions.length,
(i) => Container(
margin:
const EdgeInsets.symmetric(horizontal: 2),
width: 5,
height: 5,
decoration: BoxDecoration(
color: Color(int.parse(
"0xff${bookings.first.attractions[i].colorHex.substring(1)}")),
shape: BoxShape.circle,
),
),
),
],
),
],
),
);
},
),
),
),
);
}
Widget _buildAttractionLegend() {
final legends = [
{"name": "The Enchanted Garden", "color": "#4CAF50", "percent": "90%"},
{"name": "Central City Museum", "color": "#F48FB1", "percent": "50%"},
{"name": "Skyline Observatory", "color": "#BA68C8", "percent": "50%"},
{"name": "Historic Downtown Walking", "color": "#2196F3", "percent": "30%"},
{"name": "Scenic Riverfront Stroll", "color": "#3F51B5", "percent": "30%"},
{"name": "Cultural Arts District Tour", "color": "#8BC34A", "percent": "40%"},
];
return Container(
margin: const EdgeInsets.symmetric(horizontal: 20),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFFF1EE),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Attraction Legend",
style: GoogleFonts.poppins(
fontWeight: FontWeight.w700,
fontSize: 16,
color: Colors.black,
),
),
const SizedBox(height: 10),
...legends.map((item) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(
children: [
Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color:
Color(int.parse("0xff${item["color"]!.substring(1)}")),
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
item["name"]!,
style: GoogleFonts.poppins(
fontSize: 13,
color: Colors.black87,
),
),
),
Row(
children: [
const Icon(Icons.group, color: Colors.teal, size: 16),
const SizedBox(width: 4),
Text(
item["percent"]!,
style: GoogleFonts.poppins(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.redAccent,
),
),
],
),
],
),
);
}),
],
),
);
}
Widget _buildRecurringButton() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xffF95F62),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: Text(
"Add Recurring Block",
style: GoogleFonts.poppins(
fontWeight: FontWeight.w600,
fontSize: 15,
color: Colors.white,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,258 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class SelectedTimeSlotPage extends StatelessWidget {
const SelectedTimeSlotPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
CircleAvatar(
radius: 20,
backgroundColor: const Color(0xffF95F62),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
const SizedBox(width: 12),
Text(
"Selected Time Slot",
style: GoogleFonts.poppins(
fontWeight: FontWeight.w600,
fontSize: 22,
),
),
],
),
const SizedBox(height: 30),
// Attraction Name Section
_sectionHeader("Attraction Name", true),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
color: const Color(0xffF6F6F6),
borderRadius: BorderRadius.circular(10),
),
child: Text(
"The Enchanted Garden",
style: GoogleFonts.poppins(
fontSize: 14, color: Colors.black87),
),
),
const SizedBox(height: 20),
// Time Slots
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_sectionHeader("Time Slots Available", true),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xffF95F62)),
),
child: Text(
"+ Add timing",
style: GoogleFonts.poppins(
color: const Color(0xffF95F62),
fontWeight: FontWeight.w500,
fontSize: 13,
),
),
),
],
),
const SizedBox(height: 10),
_slotTile("1:30pm - 5:30pm"),
_slotTile("5:30pm - 7:30pm"),
_slotTile("7:30pm - 10:30pm"),
const SizedBox(height: 20),
// Capacity Used
_sectionHeader("Capacity Used", true),
const SizedBox(height: 8),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xffF6F6F6),
borderRadius: BorderRadius.circular(10),
),
child: Column(
children: [
Row(
children: [
const Icon(Icons.group,
size: 18, color: Colors.green),
const SizedBox(width: 6),
Text(
"45/50",
style: GoogleFonts.poppins(
fontWeight: FontWeight.w600,
fontSize: 13,
color: Colors.black87),
),
const Spacer(),
Text(
"Available",
style: GoogleFonts.poppins(
color: Colors.green,
fontSize: 13,
fontWeight: FontWeight.w500),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator(
value: 45 / 50,
backgroundColor: const Color(0xffF9E7E1),
valueColor: const AlwaysStoppedAnimation(
Color(0xffF95F62)),
minHeight: 10,
),
),
],
),
),
const SizedBox(height: 20),
// Date
_sectionHeader("Date", true),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
color: const Color(0xffF6F6F6),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const Icon(Icons.calendar_today,
size: 18, color: Colors.black87),
const SizedBox(width: 10),
Text(
"Sept 15, 2025",
style: GoogleFonts.poppins(
fontSize: 14, color: Colors.black87),
),
],
),
),
const SizedBox(height: 40),
// Save button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xffF95F62),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: Text(
"Save",
style: GoogleFonts.poppins(
fontWeight: FontWeight.w600,
fontSize: 15,
color: Colors.white,
),
),
),
),
const SizedBox(height: 12),
// Remove button
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () {},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
side: const BorderSide(color: Color(0xffF95F62), width: 1.5),
),
child: Text(
"Remove",
style: GoogleFonts.poppins(
fontWeight: FontWeight.w600,
fontSize: 15,
color: const Color(0xffF95F62),
),
),
),
),
],
),
),
),
);
}
Widget _sectionHeader(String title, bool editable) {
return Row(
children: [
Text(
title,
style: GoogleFonts.poppins(
fontWeight: FontWeight.w700,
fontSize: 16,
color: Colors.black),
),
if (editable)
const Padding(
padding: EdgeInsets.only(left: 6),
child: Icon(Icons.edit, color: Color(0xffF95F62), size: 16),
),
],
);
}
Widget _slotTile(String time) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xffF6F6F6),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const Icon(Icons.access_time, color: Color(0xffF95F62), size: 18),
const SizedBox(width: 10),
Text(
time,
style: GoogleFonts.poppins(fontSize: 14, color: Colors.black87),
),
const Spacer(),
const Icon(Icons.edit_outlined, color: Colors.black54, size: 18),
],
),
);
}
}

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import '../booking/views/booking_page.dart';
import '../booking/views/selected_time_slot_page.dart';
import '../login/views/forgot_password_page.dart';
import '../login/views/login_page.dart';
import '../login/views/otp_verification_page.dart';
@@ -6,7 +8,7 @@ import '../login/views/reset_password_page.dart';
import '../onboarding/views/onboarding_page.dart';
import '../profile/views/profile_page.dart';
import '../scan_history/views/scan_history_page.dart';
// Import other screens here as needed
import '../support/view/help_support_page.dart';
class AppRouter {
static const String onboarding = '/onboarding';
@@ -17,6 +19,10 @@ class AppRouter {
static const String otpVerification = '/otp_verification';
static const String resetPassword = '/reset_password';
static const String profileScreen = '/profile_screen';
static const String bookingPage = '/booking_page';
static const String selectedTimeSlotPage = '/selected_time_slot_page';
static const String helpSupportPage = '/help_support_page';
static Route<dynamic> generateRoute(RouteSettings settings) {
switch (settings.name) {
@@ -34,6 +40,12 @@ class AppRouter {
return MaterialPageRoute(builder: (_) => const ResetPasswordPage());
case profileScreen:
return MaterialPageRoute(builder: (_) => const ProfileScreen());
case selectedTimeSlotPage:
return MaterialPageRoute(builder: (_) => const SelectedTimeSlotPage());
case bookingPage:
return MaterialPageRoute(builder: (_) => const BookingPage());
case helpSupportPage:
return MaterialPageRoute(builder: (_) => const HelpSupportPage());
default:
return MaterialPageRoute(
builder: (_) =>

View File

@@ -3,8 +3,6 @@ import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
import 'core/app_router.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
@@ -27,7 +25,7 @@ class MyApp extends StatelessWidget {
Theme.of(context).textTheme,
)
),
initialRoute: AppRouter.profileScreen,
initialRoute: AppRouter.bookingPage,
onGenerateRoute: AppRouter.generateRoute,
);
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../viewmodel/support_model.dart';
abstract class HelpSupportEvent {}
class LoadFAQ extends HelpSupportEvent {}
class OpenQuestion extends HelpSupportEvent {
final SupportItem item;
OpenQuestion(this.item);
}
class HelpSupportState {
final List<SupportItem> faqs;
final bool isLoading;
HelpSupportState({required this.faqs, required this.isLoading});
factory HelpSupportState.initial() => HelpSupportState(faqs: [], isLoading: false);
HelpSupportState copyWith({
List<SupportItem>? faqs,
bool? isLoading,
}) {
return HelpSupportState(
faqs: faqs ?? this.faqs,
isLoading: isLoading ?? this.isLoading,
);
}
}
class HelpSupportBloc extends Bloc<HelpSupportEvent, HelpSupportState> {
HelpSupportBloc() : super(HelpSupportState.initial()) {
on<LoadFAQ>(_onLoadFAQ);
}
Future<void> _onLoadFAQ(LoadFAQ event, Emitter<HelpSupportState> emit) async {
emit(state.copyWith(isLoading: true));
await Future.delayed(const Duration(milliseconds: 500));
final data = [
SupportItem("How to redeem passes?", "assets/icon/material-symbols_policy-rounded.png", "Guide"),
SupportItem("Refund Policy", "assets/icon/mingcute_information-fill.png", "Policy"),
SupportItem("Payment Issues", "assets/icon/solar_dollar-bold.png", "Billing and Payments"),
SupportItem("How to redeem passes?", "assets/icon/material-symbols_policy-rounded.png", "Guide"),
SupportItem("Refund Policy", "assets/icon/mingcute_information-fill.png", "Policy"),
SupportItem("Payment Issues", "assets/icon/solar_dollar-bold.png", "Billing and Payments"),
];
emit(state.copyWith(isLoading: false, faqs: data));
}
}

View File

@@ -0,0 +1,75 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
abstract class TicketEvent {}
class SubjectChanged extends TicketEvent {
final String subject;
SubjectChanged(this.subject);
}
class DescriptionChanged extends TicketEvent {
final String description;
DescriptionChanged(this.description);
}
class UploadFile extends TicketEvent {}
class SubmitTicket extends TicketEvent {}
class TicketState {
final String subject;
final String description;
final PlatformFile? selectedFile;
final bool isLoading;
const TicketState({
required this.subject,
required this.description,
required this.selectedFile,
required this.isLoading,
});
factory TicketState.initial() => const TicketState(
subject: '',
description: '',
selectedFile: null,
isLoading: false,
);
TicketState copyWith({
String? subject,
String? description,
PlatformFile? selectedFile,
bool? isLoading,
}) {
return TicketState(
subject: subject ?? this.subject,
description: description ?? this.description,
selectedFile: selectedFile ?? this.selectedFile,
isLoading: isLoading ?? this.isLoading,
);
}
}
class TicketBloc extends Bloc<TicketEvent, TicketState> {
TicketBloc() : super(TicketState.initial()) {
on<SubjectChanged>((event, emit) => emit(state.copyWith(subject: event.subject)));
on<DescriptionChanged>((event, emit) => emit(state.copyWith(description: event.description)));
on<UploadFile>(_onUploadFile);
on<SubmitTicket>(_onSubmitTicket);
}
Future<void> _onUploadFile(UploadFile event, Emitter<TicketState> emit) async {
final result = await FilePicker.platform.pickFiles();
if (result != null && result.files.isNotEmpty) {
emit(state.copyWith(selectedFile: result.files.first));
}
}
Future<void> _onSubmitTicket(SubmitTicket event, Emitter<TicketState> emit) async {
emit(state.copyWith(isLoading: true));
await Future.delayed(const Duration(seconds: 2)); // mock API call
emit(state.copyWith(isLoading: false));
}
}

View File

@@ -0,0 +1,146 @@
import 'package:citycards_partner_flutter/support/view/support_form_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_fonts/google_fonts.dart';
import '../blocs/help_support_bloc.dart';
class HelpSupportPage extends StatelessWidget {
const HelpSupportPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => HelpSupportBloc()..add(LoadFAQ()),
child: const _HelpSupportView(),
);
}
}
class _HelpSupportView extends StatelessWidget {
const _HelpSupportView();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar:
AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: Padding(
padding: EdgeInsetsGeometry.symmetric(horizontal: 8),
child: Container(
width: 36,
height: 36,
decoration: const BoxDecoration(
color: Color(0xFFF06969),
shape: BoxShape.circle,
),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
),
centerTitle: true,
title: Text("Help & Support",
style: GoogleFonts.poppins(fontWeight: FontWeight.w700, color: Colors.black)),
),
body: BlocBuilder<HelpSupportBloc, HelpSupportState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator(color: Color(0xffF95F62)));
}
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Get assistance with your CityCards experience",
style: GoogleFonts.poppins(color: Colors.black54)),
const SizedBox(height: 16),
Text("Common Questions",
style: GoogleFonts.poppins(fontWeight: FontWeight.w700, fontSize: 16)),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
itemCount: state.faqs.length,
itemBuilder: (context, index) {
final faq = state.faqs[index];
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SupportFormPage()),
);
},
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(faq.title,
style: GoogleFonts.poppins(
fontWeight: FontWeight.w500, fontSize: 14)),
Row(
children: [
Image.asset(faq.icon, width: 18, color: const Color(0xffF95F62)),
const SizedBox(width: 6),
Text(faq.tag,
style: GoogleFonts.poppins(
fontSize: 12, color: Colors.black54)),
],
)
],
),
),
);
},
),
),
ElevatedButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SupportFormPage()),
),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xffF95F62),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(50))),
child: Container(
width: double.infinity,
child: Center(
child: Text("Contact Support",
style: GoogleFonts.poppins(
fontWeight: FontWeight.w600, color: Colors.white)),
),
),
),
const SizedBox(height: 10),
OutlinedButton(
onPressed: () {},
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Color(0xffF95F62)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(50)),
),
child: Container(
width: double.infinity,
child: Center(
child: Text("Browse FAQ",
style: GoogleFonts.poppins(
color: const Color(0xffF95F62),
fontWeight: FontWeight.w600)),
),
),
)
],
),
);
},
),
);
}
}

View File

@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_fonts/google_fonts.dart';
import '../blocs/ticket_bloc.dart';
class SupportFormPage extends StatelessWidget {
const SupportFormPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => TicketBloc(),
child: const _SupportFormView(),
);
}
}
class _SupportFormView extends StatelessWidget {
const _SupportFormView();
@override
Widget build(BuildContext context) {
final bloc = context.read<TicketBloc>();
return Scaffold(
backgroundColor: Colors.white,
bottomNavigationBar: BlocBuilder<TicketBloc, TicketState>(
builder: (context, state) {return
Visibility(
visible: MediaQuery.of(context).viewInsets.bottom == 0.0,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: state.isLoading
? null
: () => bloc.add(SubmitTicket()),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xffF95F62),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
child: state.isLoading
? const CircularProgressIndicator(color: Colors.white)
: Text("Submit Ticket",
style: GoogleFonts.poppins(
color: Colors.white,
fontWeight: FontWeight.w600)),
),
),
),
);
}),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: Padding(
padding: EdgeInsetsGeometry.symmetric(horizontal: 8),
child: Container(
width: 36,
height: 36,
decoration: const BoxDecoration(
color: Color(0xFFF06969),
shape: BoxShape.circle,
),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
),
centerTitle: true,
title: Text("Support",
style: GoogleFonts.poppins(fontWeight: FontWeight.w700, color: Colors.black)),
),
body: BlocBuilder<TicketBloc, TicketState>(
builder: (context, state) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Need help? Were here for you. Raise a ticket and our support team will get back to you shortly",
style: GoogleFonts.poppins(color: Colors.black54, fontSize: 13),
),
const SizedBox(height: 20),
Text("Subject", style: GoogleFonts.poppins(fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
TextField(
onChanged: (v) => bloc.add(SubjectChanged(v)),
decoration: InputDecoration(
fillColor: Colors.black.withOpacity(0.04),
hintText: "Enter Subject",
filled: true,
border: OutlineInputBorder(),
),
),
const SizedBox(height: 20),
Text("Description", style: GoogleFonts.poppins(fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
TextField(
onChanged: (v) => bloc.add(DescriptionChanged(v)),
maxLines: 4,
decoration: InputDecoration(
fillColor: Colors.black.withOpacity(0.04),
hintText: "Enter Description",
filled: true,
border: OutlineInputBorder(),
),
),
const SizedBox(height: 20),
Text("File upload", style: GoogleFonts.poppins(fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
GestureDetector(
onTap: () => bloc.add(UploadFile()),
child: Container(
height: 45,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.04),
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
state.selectedFile != null
? state.selectedFile!.name
: "Upload File",
style: GoogleFonts.poppins(
color: state.selectedFile != null
? Colors.black
: Colors.black54,
),
),
),
const Icon(Icons.upload_outlined, color: Colors.black54),
],
),
),
),
const SizedBox(height: 30),
Text("Contact Details",
style: GoogleFonts.poppins(
fontWeight: FontWeight.w700, fontSize: 15)),
const SizedBox(height: 12),
Divider(),
Row(
children: [
Expanded(
flex: 1,
child: Row(
children: [
const Icon(Icons.email_outlined, color: Colors.black87),
const SizedBox(width: 12),
Text("Email", style: GoogleFonts.poppins(fontSize: 13)),
],
),
),
Expanded(
flex: 2,
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const SizedBox(width: 12),
Text("Lila Hart", style: GoogleFonts.poppins(fontSize: 13,color: Colors.black)),
],
),
),
],
),
Divider(),
const SizedBox(height: 12),
Row(
children: [
Expanded(
flex: 1,
child: Row(
children: [
const Icon(Icons.phone_outlined, color: Colors.black87),
const SizedBox(width: 12),
Text("Phone", style: GoogleFonts.poppins(fontSize: 13)),
],
),
),
Expanded(
flex: 2,
child: Row(
children: [
const SizedBox(width: 12),
Text("(+971) 050 4245 564",
style: GoogleFonts.poppins(fontSize: 13)),
],
),
),
],
),
Divider(),
const SizedBox(height: 80),
],
),
);
},
),
);
}
}

View File

@@ -0,0 +1,7 @@
class SupportItem {
final String title;
final String icon;
final String tag;
SupportItem(this.title, this.icon, this.tag);
}