Skip to content

Instantly share code, notes, and snippets.

@mondoktamas
Created June 6, 2022 13:08
Show Gist options
  • Save mondoktamas/f546c08ce2632e9ddcf1b287db399497 to your computer and use it in GitHub Desktop.
Save mondoktamas/f546c08ce2632e9ddcf1b287db399497 to your computer and use it in GitHub Desktop.
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart' hide DateUtils;
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:sas/application/di/injection.dart';
import 'package:sas/application/domain/entity/extended_airport_entity.dart';
import 'package:sas/application/domain/entity/flights/flight_calendar_prices_entity.dart';
import 'package:sas/application/presentation/features/main/features/book/features/booking_flow/cubit/booking_flow_cubit.dart';
import 'package:sas/application/presentation/features/main/features/book/features/booking_flow/features/flights_page/cubit/flights_cubit.dart';
import 'package:sas/application/presentation/resources/redesign/colors.dart';
import 'package:sas/application/presentation/resources/redesign/images.dart';
import 'package:sas/application/presentation/resources/redesign/theme.dart';
import 'package:sas/application/presentation/widgets/redesign/breadcrumb/bread_crumb_widget.dart';
import 'package:sas/application/presentation/widgets/redesign/buttons/circle_icon_button.dart';
import 'package:sas/core/utils/currency_formatter.dart';
import 'package:sas/core/utils/date_utils.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:shimmer/shimmer.dart';
const double _tabBarHeight = 108;
const double _listViewHeight = 260;
const double _collapsedAppBarHeight = _tabBarHeight + kToolbarHeight * 2;
const double _expandedAppBarHeight = _listViewHeight + kToolbarHeight * 2;
const double _listViewItemMaxHeight = 196;
const double _listViewItemMinHeight = 72;
const double _indicatorHeight = 3;
const double _pageViewItemHeight = 92;
const Duration _animationDuration = Duration(milliseconds: 300);
const double collapsedScrollPosition = _expandedAppBarHeight - _collapsedAppBarHeight;
double _selectedItemWidth(final BuildContext context) => MediaQuery.of(context).size.width / 3;
double _notSelectedItemWidth(final BuildContext context) => MediaQuery.of(context).size.width / 6;
class FlightsAppBarWidget extends StatefulWidget {
const FlightsAppBarWidget({final Key? key}) : super(key: key);
@override
State<FlightsAppBarWidget> createState() => _FlightsAppBarWidgetState();
}
class _FlightsAppBarWidgetState extends State<FlightsAppBarWidget> with SingleTickerProviderStateMixin {
final ItemScrollController _itemScrollController = ItemScrollController();
final PageController _pageController = PageController(viewportFraction: 0.9);
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _scrollToSelectedItem(
final List<CalendarDayPriceEntity> models,
final DateTime selectedDate,
) {
if (!_itemScrollController.isAttached) {
return;
}
final index = models.indexWhere((final e) => e.dateTime == selectedDate);
if (index == -1) {
return;
}
_itemScrollController.scrollTo(index: index - 2, duration: _animationDuration);
}
@override
Widget build(final BuildContext context) => MultiBlocListener(
listeners: [
BlocListener<FlightsCubit, FlightsState>(
listenWhen: (final previous, final current) =>
previous.calendarPrices == null && current.calendarPrices != null,
listener: (final context, final state) async {
Future.delayed(
const Duration(milliseconds: 100),
() => _scrollToSelectedItem(state.modelsForExpandedAppBar, state.selectedDate),
);
},
),
BlocListener<FlightsCubit, FlightsState>(
listenWhen: (final previous, final current) => previous.selectedPage != current.selectedPage,
listener: (final context, final state) {
_pageController.animateToPage(
state.selectedPage,
duration: _animationDuration,
curve: Curves.linear,
);
Future.delayed(
const Duration(milliseconds: 100),
() => _scrollToSelectedItem(state.modelsForExpandedAppBar, state.selectedDate),
);
},
),
BlocListener<FlightsCubit, FlightsState>(
listenWhen: (final previous, final current) => previous.selectedDate != current.selectedDate,
listener: (final context, final state) {
Future.delayed(
const Duration(milliseconds: 100),
() => _scrollToSelectedItem(state.modelsForExpandedAppBar, state.selectedDate),
);
},
),
],
child: BlocBuilder<FlightsCubit, FlightsState>(
builder: (final context, final flightsState) => SliverAppBar(
toolbarHeight: _collapsedAppBarHeight,
collapsedHeight: _collapsedAppBarHeight,
expandedHeight: _expandedAppBarHeight,
pinned: true,
backgroundColor: white,
automaticallyImplyLeading: false,
systemOverlayStyle: const SystemUiOverlayStyle(statusBarIconBrightness: Brightness.light),
flexibleSpace: _CustomFlexibleSpaceBar(
onExpanded: () {
if (flightsState.loading) {
return;
}
if (flightsState.calendarPrices != null) {
return;
}
if (!flightsState.firstAttemptSkipped) {
FlightsCubit.of(context).skipFirstAttempt();
return;
}
FlightsCubit.of(context).fetchCalendarPrices(
fromAirportCode: flightsState.searchData.originAirport.code,
toAirportCode: flightsState.searchData.destinationAirport.code,
fromMonth: flightsState.searchData.outboundDate,
toMonth: flightsState.searchData.inboundDate,
bookingType: flightsState.searchData.bookingType,
roundTrip: flightsState.searchData.isRoundTrip,
);
},
pinnedHeader: Stack(
children: [
Column(
children: [
BreadCrumbHeader(
currentStep: flightsState.selectedPage,
steps: [
AppLocalizations.of(context)!.departure_flight,
if (flightsState.searchData.isRoundTrip) AppLocalizations.of(context)!.return_flight,
],
onStepChanged: context.read<FlightsCubit>().onPageChanged,
),
Container(height: kToolbarHeight, color: b2),
],
),
Positioned(
top: 12,
left: 12,
child: CircleIconButton(
onPressed: context.router.pop,
icon: SvgPicture.asset(icArrowLeft, color: white),
backgroundColor: b2LLight,
),
),
],
),
pinnedPageView: SizedBox(
height: _pageViewItemHeight + 16,
child: PageView.builder(
controller: _pageController,
onPageChanged: context.read<FlightsCubit>().onPageChanged,
physics: const PageScrollPhysics(parent: BouncingScrollPhysics()),
itemCount: 1 + (flightsState.searchData.isRoundTrip ? 1 : 0),
itemBuilder: (final context, final index) => _PageViewItemWidget(
origin:
index == 0 ? flightsState.searchData.originAirport : flightsState.searchData.destinationAirport,
destination:
index == 0 ? flightsState.searchData.destinationAirport : flightsState.searchData.originAirport,
onPressed: () => context.read<FlightsCubit>().onPageChanged(index),
),
),
),
collapsedTabBar: _CollapsedTabBar(flightsState: flightsState),
expandedListView: flightsState.modelsForExpandedAppBar.isEmpty
? const _LoadingListView()
: _ExpandedListView(flightsState: flightsState, itemScrollController: _itemScrollController),
),
),
),
);
}
class _LoadingListView extends StatelessWidget {
const _LoadingListView({final Key? key}) : super(key: key);
@override
Widget build(final BuildContext context) => SizedBox(
height: _listViewHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_LpcShimmer(
child: Container(
height: _listViewItemMaxHeight / 5 * 3,
width: _notSelectedItemWidth(context),
color: white,
),
),
_LpcShimmer(
child: Container(
height: _listViewItemMaxHeight / 5 * 4,
width: _notSelectedItemWidth(context),
color: white,
),
),
_LpcShimmer(
child: Container(
height: _listViewItemMaxHeight,
width: _selectedItemWidth(context),
color: white,
),
),
_LpcShimmer(
child: Container(
height: _listViewItemMaxHeight / 5 * 4,
width: _notSelectedItemWidth(context),
color: white,
),
),
_LpcShimmer(
child: Container(
height: _listViewItemMaxHeight / 5 * 3,
width: _notSelectedItemWidth(context),
color: white,
),
),
],
),
);
}
class _LpcShimmer extends StatelessWidget {
const _LpcShimmer({final Key? key, required this.child}) : super(key: key);
final Widget child;
@override
Widget build(final BuildContext context) => Shimmer(
gradient: LinearGradient(
colors: <Color>[white, white, g2.withOpacity(0.7), white, white],
stops: const <double>[0.0, 0.35, 0.5, 0.65, 1.0]),
child: child);
}
class _ExpandedListView extends StatelessWidget {
const _ExpandedListView({
final Key? key,
required this.flightsState,
required this.itemScrollController,
}) : super(key: key);
final FlightsState flightsState;
final ItemScrollController itemScrollController;
@override
Widget build(final BuildContext context) => IgnorePointer(
ignoring: flightsState.loading,
child: SizedBox(
height: _listViewHeight,
child: ScrollablePositionedList.builder(
itemScrollController: itemScrollController,
scrollDirection: Axis.horizontal,
itemCount: flightsState.modelsForExpandedAppBar.length,
itemBuilder: (final context, final index) {
final model = flightsState.modelsForExpandedAppBar[index];
final isDummyItem = index == 0 ||
index == 1 ||
index == flightsState.modelsForExpandedAppBar.length - 1 ||
index == flightsState.modelsForExpandedAppBar.length - 2;
if (isDummyItem) {
return _DummyListViewItemWidget(model: model);
}
final selected = flightsState.selectedDate == model.dateTime;
const tempMaxHeight = _listViewItemMaxHeight - _listViewItemMinHeight;
final currentHeight = ((tempMaxHeight / flightsState.maxPrice) * model.price) + _listViewItemMinHeight;
return Container(
alignment: Alignment.bottomCenter,
child: AnimatedContainer(
duration: _animationDuration,
height: currentHeight,
width: selected ? _selectedItemWidth(context) : _notSelectedItemWidth(context),
decoration: BoxDecoration(
color: selected ? b1LLight : white,
boxShadow: selected
? null
: [BoxShadow(color: Colors.grey.withOpacity(0.1), blurRadius: 5, spreadRadius: 5)],
),
child: selected
? Stack(
children: [
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 52),
child: Container(
width: 50,
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: b1LLight,
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
blurRadius: 15,
offset: const Offset(0, 5),
),
],
),
child: Container(
width: 30,
height: 30,
decoration: const BoxDecoration(shape: BoxShape.circle, color: g1),
),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 74),
child: _SelectedPriceWidget(model: model),
),
),
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
DateUtils.getWeekdayMonthDay(model.dateTime),
maxLines: 1,
style: Theme.of(context).textTheme.titleMediumBlack.copyWith(color: b2),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: Container(
width: _selectedItemWidth(context) - 32,
height: _indicatorHeight,
decoration: const BoxDecoration(
color: accent3,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
),
),
),
),
],
)
: GestureDetector(
onTap: () {
if (selected) {
return;
}
if (flightsState.isInboundSelected) {
context.read<BookingFlowCubit>().resetSelectedInboundFlightPage();
context.read<BookingFlowCubit>().loadFlights(inboundDate: model.dateTime);
} else {
context.read<BookingFlowCubit>().resetSelectedOutboundFlightPage();
context.read<BookingFlowCubit>().loadFlights(outboundDate: model.dateTime);
}
},
child: Container(
width: _notSelectedItemWidth(context),
height: _expandedAppBarHeight,
padding: const EdgeInsets.fromLTRB(0, 16, 0, 16),
color: Colors.white,
child: Column(
children: [
Text(
model.price.toString(),
style: Theme.of(context).textTheme.titleMediumBlack.copyWith(color: secondary2),
),
const Spacer(),
Text(
DateUtils.getDay(model.dateTime),
style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: b3),
)
],
),
),
),
),
);
},
),
),
);
}
class _DummyListViewItemWidget extends StatelessWidget {
const _DummyListViewItemWidget({final Key? key, required this.model}) : super(key: key);
final CalendarDayPriceEntity model;
@override
Widget build(final BuildContext context) => Container(
alignment: Alignment.bottomCenter,
child: Container(
width: _notSelectedItemWidth(context),
height: _listViewItemMaxHeight / 2,
alignment: Alignment.bottomCenter,
padding: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: white,
boxShadow: [
BoxShadow(color: Colors.grey.withOpacity(0.1), blurRadius: 5, spreadRadius: 5),
],
),
child: Text(
DateUtils.getDay(model.dateTime),
style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: b3.withOpacity(0.5)),
),
),
);
}
class _SelectedPriceWidget extends StatefulWidget {
const _SelectedPriceWidget({final Key? key, required this.model}) : super(key: key);
final CalendarDayPriceEntity model;
@override
State<_SelectedPriceWidget> createState() => _SelectedPriceWidgetState();
}
class _SelectedPriceWidgetState extends State<_SelectedPriceWidget> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
duration: _animationDuration,
vsync: this,
)..forward();
late final Animation<double> _animation = Tween<double>(begin: 0.5, end: 1).animate(_controller);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(final BuildContext context) => ScaleTransition(
scale: _animation,
child: FittedBox(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 32,
padding: const EdgeInsets.fromLTRB(12, 0, 12, 0),
decoration: BoxDecoration(
color: b3,
borderRadius: BorderRadius.circular(24),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
AppLocalizations.of(context)!.from_short_form,
style: Theme.of(context).textTheme.bodyMediumBlack.copyWith(color: b1DDark),
),
const SizedBox(width: 2),
Text(
getIt<CurrencyFormatter>().formatPrice(widget.model.price, withSymbol: false),
style: Theme.of(context).textTheme.bodyMediumBlack.copyWith(color: white),
),
const SizedBox(width: 2),
Text(
context.read<BookingFlowCubit>().state.currencySymbol,
style: Theme.of(context).textTheme.bodyMediumBlack.copyWith(color: white),
),
],
),
),
SizedBox(
width: 8,
height: 17,
child: Stack(
children: [
Positioned(
top: 0,
right: 0,
child: SizedBox(
width: 4,
height: 12,
child: CustomPaint(painter: _CurvePainter(true)),
),
),
Positioned(
top: 0,
left: 0,
child: SizedBox(
width: 4,
height: 12,
child: CustomPaint(painter: _CurvePainter(false)),
),
),
Align(
alignment: Alignment.topCenter,
child: Container(width: 2, height: 12, color: b3),
),
Align(
alignment: Alignment.bottomCenter,
child: ClipOval(child: Container(width: 7, height: 7, color: b3)),
),
],
),
)
],
),
),
);
}
class _CurvePainter extends CustomPainter {
_CurvePainter(this._isRight);
final bool _isRight;
@override
void paint(final Canvas canvas, final Size size) {
final paint = Paint();
paint.color = b3;
paint.style = PaintingStyle.fill;
final path = Path();
if (_isRight) {
path.moveTo(4, 0);
path.quadraticBezierTo(-1, 5, 3, 12);
path.lineTo(0, 12);
path.lineTo(0, 0);
} else {
path.moveTo(0, 0);
path.quadraticBezierTo(5, 5, 1, 12);
path.lineTo(4, 12);
path.lineTo(4, 0);
}
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(final CustomPainter oldDelegate) => true;
}
class _LoadingTabBar extends StatelessWidget {
const _LoadingTabBar({final Key? key}) : super(key: key);
@override
Widget build(final BuildContext context) => Container(
color: white,
height: _tabBarHeight,
child: Row(
children: [
for (var i = 0; i < 3; i++)
Expanded(
child: Container(
color: white,
padding: const EdgeInsets.only(bottom: 12, left: 16, right: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Shimmer.fromColors(
baseColor: white,
highlightColor: g2,
child: Container(height: 16, color: white),
),
const SizedBox(height: 4),
Shimmer.fromColors(
baseColor: white,
highlightColor: g2,
child: Container(height: 16, color: white),
),
],
),
),
),
],
),
);
}
class _CollapsedTabBar extends StatelessWidget {
const _CollapsedTabBar({final Key? key, required this.flightsState}) : super(key: key);
final FlightsState flightsState;
@override
Widget build(final BuildContext context) {
if (flightsState.tabsInfo == null) return const _LoadingTabBar();
return Row(
children: List.generate(
flightsState.tabsInfoList.length,
(final index) {
final model = flightsState.tabsInfoList[index];
final selected = flightsState.selectedDate == model.date;
return GestureDetector(
onTap: () {
if (selected) {
return;
}
if (flightsState.isInboundSelected) {
context.read<BookingFlowCubit>().resetSelectedInboundFlightPage();
context.read<BookingFlowCubit>().loadFlights(inboundDate: model.date);
} else {
context.read<BookingFlowCubit>().resetSelectedOutboundFlightPage();
context.read<BookingFlowCubit>().loadFlights(outboundDate: model.date);
}
},
child: Container(
width: _selectedItemWidth(context),
height: _tabBarHeight,
alignment: Alignment.bottomCenter,
color: selected ? b1LLight : white,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedDefaultTextStyle(
duration: _animationDuration,
style: TextStyle(
fontSize: 14,
fontWeight: selected ? FontWeight.w900 : FontWeight.w500,
color: selected ? b2 : secondary2,
),
child: Text(DateUtils.getDayMonth(model.date)),
),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedDefaultTextStyle(
duration: _animationDuration,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w900,
color: selected ? b2 : secondary2,
),
child: Text(getIt<CurrencyFormatter>().formatPrice(model.price, withSymbol: false)),
),
const SizedBox(width: 2),
AnimatedDefaultTextStyle(
duration: _animationDuration,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: selected ? b2 : secondary2,
),
child: Text(context.read<BookingFlowCubit>().state.currencySymbol),
),
],
),
const SizedBox(height: 12),
if (selected)
Container(
width: _selectedItemWidth(context) - 32,
height: _indicatorHeight,
decoration: const BoxDecoration(
color: accent3,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
),
),
)
else
const SizedBox(height: 3),
],
),
),
);
},
),
);
}
}
class _PageViewItemWidget extends StatelessWidget {
const _PageViewItemWidget({
final Key? key,
required this.origin,
required this.destination,
required this.onPressed,
}) : super(key: key);
final ExtendedAirportEntity origin;
final ExtendedAirportEntity destination;
final VoidCallback onPressed;
@override
Widget build(final BuildContext context) => GestureDetector(
onTap: onPressed,
child: Container(
height: _pageViewItemHeight,
padding: const EdgeInsets.fromLTRB(24, 0, 24, 0),
margin: const EdgeInsets.fromLTRB(6, 0, 6, 16),
alignment: Alignment.center,
decoration: const BoxDecoration(
color: white,
borderRadius: BorderRadius.all(Radius.circular(8)),
boxShadow: [BoxShadow(color: defaultCardShadow, blurRadius: 16, offset: Offset(0, 6))],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(
origin.name,
style: Theme.of(context).textTheme.titleMedium!.copyWith(color: g5TextGray),
),
const Spacer(),
Text(
destination.name,
style: Theme.of(context).textTheme.titleMedium!.copyWith(color: g5TextGray),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Text(
origin.code,
style: Theme.of(context).textTheme.headlineSmallBlack.copyWith(
color: b3,
fontSize: 22,
height: 27.5 / 22,
),
),
const SizedBox(width: 16),
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
Container(
height: 0.8,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
flightCardGradient1,
flightCardGradient2,
flightCardGradient2,
flightCardGradient1,
],
stops: [0.0, 0.51, 0.55, 1.0],
),
),
),
SvgPicture.asset(icPlaneRight24),
],
),
),
const SizedBox(width: 16),
Text(
destination.code,
style: Theme.of(context).textTheme.headlineSmallBlack.copyWith(
color: b3,
fontSize: 22,
height: 27.5 / 22,
),
),
],
),
],
),
),
);
}
class _CustomFlexibleSpaceBar extends StatelessWidget {
const _CustomFlexibleSpaceBar({
final Key? key,
required this.pinnedHeader,
required this.pinnedPageView,
required this.collapsedTabBar,
required this.expandedListView,
required this.onExpanded,
}) : super(key: key);
final Widget pinnedHeader;
final Widget pinnedPageView;
final Widget collapsedTabBar;
final Widget expandedListView;
final VoidCallback onExpanded;
@override
Widget build(final BuildContext context) => ColoredBox(
color: b2,
child: SafeArea(
child: LayoutBuilder(
builder: (final BuildContext context, final BoxConstraints constraints) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>()!;
final deltaExtent = settings.maxExtent - settings.minExtent;
final tempCurrentExtent = settings.currentExtent - settings.minExtent;
double collapsedOpacity;
var collapsedCurrentExtent = tempCurrentExtent;
if (collapsedCurrentExtent > (deltaExtent / 2)) {
collapsedOpacity = 0;
} else {
collapsedCurrentExtent = (deltaExtent / 2) - collapsedCurrentExtent;
collapsedOpacity = (collapsedCurrentExtent / deltaExtent) * 2;
}
double expandedOpacity;
var expandedCurrentExtent = tempCurrentExtent;
if (expandedCurrentExtent < (deltaExtent / 2)) {
expandedOpacity = 0;
} else {
expandedCurrentExtent = expandedCurrentExtent - (deltaExtent / 2);
expandedOpacity = (expandedCurrentExtent / deltaExtent) * 2;
}
if (expandedOpacity >= 0.67) {
onExpanded();
}
return Stack(
children: [
Column(
children: [
pinnedHeader,
Expanded(
child: ColoredBox(
color: white,
child: Stack(
children: [
Positioned.fill(
child: AnimatedOpacity(
duration: _animationDuration,
opacity: expandedOpacity,
child: expandedListView,
),
),
Align(
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: _animationDuration,
transitionBuilder: (final child, final animation) => Opacity(
opacity: collapsedOpacity,
child: child,
),
child: collapsedOpacity == 0 ? const SizedBox.shrink() : collapsedTabBar,
),
),
],
),
),
),
],
),
Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 64),
child: pinnedPageView,
),
),
],
);
},
),
),
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment