diff --git a/assets/icons/arrow_up.svg b/assets/icons/arrow_up.svg new file mode 100644 index 0000000..667369c --- /dev/null +++ b/assets/icons/arrow_up.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/arrow_up_right.svg b/assets/icons/arrow_up_right.svg new file mode 100644 index 0000000..ba2fd0d --- /dev/null +++ b/assets/icons/arrow_up_right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/cancel_circle.svg b/assets/icons/cancel_circle.svg new file mode 100644 index 0000000..f9cdf56 --- /dev/null +++ b/assets/icons/cancel_circle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/canvas.svg b/assets/icons/canvas.svg new file mode 100644 index 0000000..e65a06d --- /dev/null +++ b/assets/icons/canvas.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/filter.svg b/assets/icons/filter.svg new file mode 100644 index 0000000..0f119ac --- /dev/null +++ b/assets/icons/filter.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/plug.svg b/assets/icons/plug.svg new file mode 100644 index 0000000..f60cab0 --- /dev/null +++ b/assets/icons/plug.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/lib/di/injection.dart b/lib/di/injection.dart index d3b4bb0..4e87306 100644 --- a/lib/di/injection.dart +++ b/lib/di/injection.dart @@ -33,6 +33,7 @@ import 'package:share_space/domain/usecase/workspace/save_workspace.dart'; import 'package:share_space/presentation/screen/booking_history/state/booking_history_cubit.dart'; import 'package:share_space/presentation/screen/home/state/home_cubit.dart'; import 'package:share_space/presentation/screen/login/state/login_cubit.dart'; +import 'package:share_space/presentation/screen/search/state/search_cubit.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../data/remote/auth_api_service_impl.dart'; @@ -164,6 +165,8 @@ Future setupDependencies() async { getIt.registerFactory(() => BookingCubit(getIt(),getIt())); + getIt.registerFactory(() => SearchCubit(getIt(), getIt())); + getIt.registerFactory( () => CreateAccountUseCase(getIt()), ); diff --git a/lib/presentation/design_system/widget/base_bottom_sheet.dart b/lib/presentation/design_system/widget/base_bottom_sheet.dart new file mode 100644 index 0000000..35d5f1f --- /dev/null +++ b/lib/presentation/design_system/widget/base_bottom_sheet.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:share_space/presentation/design_system/theme/app_theme_provider.dart'; + +import '../theme/app_theme.dart'; + +class BaseBottomSheet extends StatefulWidget { + final Widget child; + final String label; + final Function() onClose; + + const BaseBottomSheet({ + super.key, + required this.child, + required this.label, + required this.onClose, + }); + + @override + State createState() => _BaseBottomSheetState(); +} + +class _BaseBottomSheetState extends State { + @override + Widget build(BuildContext context) { + return AppThemeProvider( + child: Container( + color: AppTheme.of(context).colors.surface, + child: Column( + children: [ + Padding( + padding: EdgeInsetsGeometry.only(left: 16, right: 16, top: 24), + child: Column( + children: [ + Row( + children: [ + Text( + widget.label, + style: AppTheme.of(context) + .typography + .textTheme + .titleSmall + ?.copyWith( + color: AppTheme.of(context).colors.title, + ), + ), + Expanded(child: SizedBox()), + SizedBox( + width: 20, + height: 20, + child: IconButton( + padding: EdgeInsets.zero, + alignment: Alignment.center, + icon: SvgPicture.asset( + 'assets/icons/cancel_circle.svg', + ), + onPressed: () { + widget.onClose(); + Navigator.pop(context); + }, + ), + ), + ], + ), + SizedBox(height: 12), + Divider( + color: AppTheme.of(context).colors.stroke, + thickness: 1, + height: 0, + ), + ], + ), + ), + widget.child, + ], + ), + ), + ); + } +} + +Future showBaseBottomSheet({ + required BuildContext context, + required Widget child, + required String label, + required double height, + VoidCallback? onClose, + bool isScrollControlled = true, +}) { + return showModalBottomSheet( + isScrollControlled: isScrollControlled, + context: context, + backgroundColor: AppTheme.of(context).colors.surface, + builder: (BuildContext context) { + return AppThemeProvider( + child: SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * height, + child: BaseBottomSheet( + label: label, + onClose: onClose ?? () {}, + child: child, + ), + ), + ), + ); + }, + ); +} diff --git a/lib/presentation/design_system/widget/primary_button.dart b/lib/presentation/design_system/widget/primary_button.dart new file mode 100644 index 0000000..058501e --- /dev/null +++ b/lib/presentation/design_system/widget/primary_button.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; + +class PrimaryButton extends StatefulWidget { + final Function() onPressed; + final String label; + final bool isBig; + bool isDisabled; + + PrimaryButton({ + super.key, + required this.onPressed, + required this.label, + this.isBig = true, + this.isDisabled = false, + }); + + @override + State createState() => _PrimaryButtonState(); +} + +class _PrimaryButtonState extends State { + @override + Widget build(BuildContext context) { + final theme = AppTheme.of(context); + return ElevatedButton( + style: ButtonStyle( + padding: WidgetStateProperty.all(EdgeInsets.zero), + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(50)), + ), + shadowColor: WidgetStateProperty.all(Colors.transparent), + ), + onPressed: widget.isDisabled ? null : widget.onPressed, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: widget.isBig ? 52 : 32, + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: widget.isDisabled + ? [theme.colors.disable, theme.colors.disable] + : [Color(0xFF50B5E7), Color(0xFF19C6F9)], + ), + borderRadius: BorderRadius.circular(100), + ), + child: Text( + widget.label, + style: theme.typography.textTheme.labelMedium?.copyWith( + color: theme.colors.onPrimary, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + ; + } +} diff --git a/lib/presentation/design_system/widget/secondary_button.dart b/lib/presentation/design_system/widget/secondary_button.dart new file mode 100644 index 0000000..7010bf9 --- /dev/null +++ b/lib/presentation/design_system/widget/secondary_button.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; + +class SecondaryButton extends StatefulWidget { + final Function() onPressed; + final String label; + final bool isBig; + bool isDisabled; + + SecondaryButton({ + super.key, + required this.onPressed, + required this.label, + this.isBig = true, + this.isDisabled = false, + }); + + @override + State createState() => _SecondaryButtonState(); +} + +class _SecondaryButtonState extends State { + @override + Widget build(BuildContext context) { + final theme = AppTheme.of(context); + return ElevatedButton( + style: ButtonStyle( + padding: WidgetStateProperty.all(EdgeInsets.zero), + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(50)), + ), + shadowColor: WidgetStateProperty.all(Colors.transparent), + ), + onPressed: widget.isDisabled ? null : widget.onPressed, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: widget.isBig ? 52 : 32, + alignment: Alignment.center, + decoration: BoxDecoration( + color: widget.isDisabled + ? theme.colors.blueVariant + : theme.colors.disable, + borderRadius: BorderRadius.circular(100), + border: Border.all(width: 0.5, color: theme.colors.stroke), + ), + child: Text( + widget.label, + style: theme.typography.textTheme.labelMedium?.copyWith( + color: widget.isDisabled + ? theme.colors.primary + : theme.colors.onPrimary, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + ; + } +} diff --git a/lib/presentation/screen/booking_history/booking_history_screen.dart b/lib/presentation/screen/booking_history/booking_history_screen.dart index 8cabc78..124a9a5 100644 --- a/lib/presentation/screen/booking_history/booking_history_screen.dart +++ b/lib/presentation/screen/booking_history/booking_history_screen.dart @@ -13,6 +13,7 @@ import 'package:share_space/presentation/screen/shared/ui_state/workspace_ui_sta import '../../../resources/app_strings.dart'; import '../../design_system/widget/custom_top_snackbar.dart'; +import '../../design_system/widget/error_screen.dart'; class BookingHistoryScreen extends StatefulWidget { const BookingHistoryScreen({super.key}); @@ -142,12 +143,7 @@ class _BookingHistoryScreenState extends State { ), ); } else if (state is BookingHistoryError) { - return Center( - child: Text( - state.message, - style: theme.typography.textTheme.labelSmall, - ), - ); + return const ErrorScreen(hasAppBar: false); } return const SizedBox.shrink(); }, diff --git a/lib/presentation/screen/search/filter_bottomsheet.dart b/lib/presentation/screen/search/filter_bottomsheet.dart new file mode 100644 index 0000000..1a6d179 --- /dev/null +++ b/lib/presentation/screen/search/filter_bottomsheet.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:share_space/presentation/design_system/theme/app_theme.dart'; +import 'package:share_space/presentation/screen/search/widget/range_chip.dart'; +import 'package:share_space/presentation/screen/search/widget/range_slider.dart'; +import 'package:share_space/presentation/screen/search/widget/rating_filter.dart'; +import 'package:share_space/presentation/screen/search/widget/services_filter.dart'; +import 'package:share_space/presentation/screen/shared/ui_state/workspace_ui_state.dart'; + +import '../../design_system/widget/primary_button.dart'; +import '../../design_system/widget/secondary_button.dart'; + +class FilterBottomSheet extends StatefulWidget { + final RangeValues initialRange; + RangeValues currentRange; + final List rateOptions; + final int rateSelectedIndex; + List selectedRateIndices; + final List servicesOptions; + List selectedServicesIndices; + + FilterBottomSheet({ + super.key, + this.initialRange = const RangeValues(10, 500), + required this.currentRange, + required this.rateSelectedIndex, + this.rateOptions = const ["All", "1", "2", "3", "4", "5"], + required this.selectedRateIndices, + this.servicesOptions = ServicesUiState.values, + required this.selectedServicesIndices, + }); + + @override + State createState() => _FilterBottomSheetState(); +} + +class _FilterBottomSheetState extends State { + + @override + Widget build(BuildContext context) { + final theme = AppTheme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + children: [ + Align( + alignment: Alignment.topLeft, + child: Text( + "Rate", + style: theme.typography.textTheme.titleSmall?.copyWith( + color: theme.colors.title, + ), + ), + ), + SizedBox(height: 12), + RatingFilter( + options: widget.rateOptions, + selectedIndices: widget.selectedRateIndices, + onSelect: (index) { + setState(() { + if (widget.selectedRateIndices.contains(index)) { + widget.selectedRateIndices.remove(index); + if (widget.selectedRateIndices.isEmpty) { + widget.selectedRateIndices.add(0); + } + } else { + if (index == 0) { + widget.selectedRateIndices.clear(); + } + if (widget.selectedRateIndices.length == 1 && + widget.selectedRateIndices.first == 0) { + widget.selectedRateIndices.clear(); + } + widget.selectedRateIndices.add(index); + } + }); + }, + ), + SizedBox(height: 16), + Align( + alignment: Alignment.topLeft, + child: Text( + "Price Range", + style: theme.typography.textTheme.titleSmall?.copyWith( + color: theme.colors.title, + ), + ), + ), + SizedBox(height: 12), + RangeSliderWidget( + maxRange: widget.initialRange, + currentRange: widget.currentRange, + onChange: (newRange) { + setState(() { + widget.currentRange = newRange; + }); + }, + currency: "/hr", + ), + SizedBox(height: 20), + Row( + children: [ + Expanded( + child: RangeChip( + label: "Min", + value: widget.currentRange.start.toInt(), + image: "assets/icons/arrow_down.svg", + unit: "IQD", + ), + ), + const SizedBox(width: 8), + Expanded( + child: RangeChip( + label: "Max", + value: widget.currentRange.end.toInt(), + image: "assets/icons/arrow_up.svg", + unit: "IQD", + ), + ), + ], + ), + SizedBox(height: 16), + Align( + alignment: Alignment.topLeft, + child: Text( + "Services", + style: theme.typography.textTheme.titleSmall?.copyWith( + color: theme.colors.title, + ), + ), + ), + SizedBox(height: 12), + ServicesFilter( + options: widget.servicesOptions.map((e) => e.toString()).toList(), + selectedIndices: widget.selectedServicesIndices, + onSelect: (index) { + setState(() { + if (widget.selectedServicesIndices.contains(index)) { + widget.selectedServicesIndices.remove(index); + if (widget.selectedServicesIndices.isEmpty) { + widget.selectedServicesIndices.add(0); + } + } else { + if (index == 0) { + widget.selectedServicesIndices.clear(); + } + if (widget.selectedServicesIndices.length == 1 && + widget.selectedServicesIndices.first == 0) { + widget.selectedServicesIndices.clear(); + } + widget.selectedServicesIndices.add(index); + } + }); + }, + ), + // SizedBox(height: 16), + // Align( + // alignment: Alignment.topLeft, + // child: Text( + // "Location", + // style: theme.typography.textTheme.titleSmall?.copyWith( + // color: theme.colors.title, + // ), + // ), + // ), + SizedBox(height: 12), + // TODO: Add Location Filter Widget here + SizedBox(height: 24), + Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: SecondaryButton( + onPressed: () {}, + label: "Clear", + isDisabled: true, + ), + ), + SizedBox(width: 8), + Expanded( + child: PrimaryButton( + onPressed: () {}, + label: "Apply Filters", + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/presentation/screen/search/search_screen.dart b/lib/presentation/screen/search/search_screen.dart index 6a4a003..ee117b1 100644 --- a/lib/presentation/screen/search/search_screen.dart +++ b/lib/presentation/screen/search/search_screen.dart @@ -1,13 +1,142 @@ +import 'dart:ffi'; + import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:share_space/presentation/screen/search/state/search_cubit.dart'; +import 'package:share_space/presentation/screen/search/state/search_state.dart'; +import 'package:share_space/presentation/screen/search/widget/search_field.dart'; import 'package:share_space/resources/app_strings.dart'; -class SearchScreen extends StatelessWidget { +import '../../../di/injection.dart'; +import '../../design_system/theme/app_theme.dart'; +import '../../design_system/widget/error_screen.dart'; +import '../../design_system/widget/loading_screen.dart'; +import '../../design_system/widget/workspace_details_card.dart'; +import '../../routes/routes.dart'; + +class SearchScreen extends StatefulWidget { const SearchScreen({super.key}); + @override + State createState() => _SearchScreenState(); +} + +class _SearchScreenState extends State { @override Widget build(BuildContext context) { - return const Scaffold( - body: Center(child: Text(AppStrings.searchScreenTitle)), + final theme = AppTheme.of(context); + return Material( + color: theme.colors.surfaceLow, + child: BlocProvider( + create: (context) => getIt()..fetchLastViewed(), + child: BlocBuilder( + builder: (context, state) { + if (state is SearchLoading) { + return LoadingScreen(); + } else if (state is SearchLoaded) { + final rooms = (state.searchResults.isEmpty) + ? state.lastViewed + : state.searchResults; + + return CustomScrollView( + slivers: [ + SliverAppBar( + leading: const SizedBox.shrink(), + leadingWidth: 0, + title: Text( + AppStrings.searchScreenTitle, + style: theme.typography.textTheme.titleMedium?.copyWith( + color: theme.colors.title, + ), + ), + backgroundColor: theme.colors.surface, + surfaceTintColor: theme.colors.surface, + elevation: 0, + ), + SliverToBoxAdapter( + child: SearchField( + onQueryChanged: (query) { + context.read().updateQuery(query); + }, + suggestions: [], + history: [], + initialRange: RangeValues(state.priceStartRange.toDouble() , state.priceEndRange.toDouble()), + currentRange: RangeValues(state.priceStart.toDouble() , state.priceEnd.toDouble()), + rateOptions: const ["All", "1", "2", "3", "4", "5"], + rateSelectedIndex: state.selectedRate, + selectedRateIndices: [0], + selectedServicesIndices: [0], + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Text( + (state.searchResults.isEmpty) + ? AppStrings.mostSearched + : AppStrings.resultsFound( + state.searchResults.length, + ), + style: theme.typography.textTheme.titleSmall?.copyWith( + color: theme.colors.title, + ), + ), + ), + ), + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: rooms + .map( + (room) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () { + Navigator.pushNamed( + context, + Routes.roomDetailsScreen, + arguments: room.id.toString(), + ); + }, + child: WorkspaceDetailsCard( + title: room.name, + imageUrl: room.imageUrls.isNotEmpty + ? room.imageUrls[0] + : '', + rating: room.rate, + price: + "${room.pricePerHour.toInt().toString()}/h", + location: room.locationName, + amenities: room.services + .map((e) => e.toString()) + .toList(), + ), + ), + const SizedBox(height: 12), + ], + ), + ), + ) + .toList(), + ), + ), + ], + ); + } else if (state is SearchError) { + return const ErrorScreen(hasAppBar: false); + } + return const SizedBox.shrink(); + }, + ), + ), ); } } diff --git a/lib/presentation/screen/search/state/search_cubit.dart b/lib/presentation/screen/search/state/search_cubit.dart new file mode 100644 index 0000000..18e1626 --- /dev/null +++ b/lib/presentation/screen/search/state/search_cubit.dart @@ -0,0 +1,270 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:share_space/presentation/screen/shared/ui_state/workspace_ui_state.dart'; + +import '../../../../domain/entity/search.dart'; +import '../../../../domain/usecase/search/search_workspaces.dart'; +import '../../../../domain/usecase/workspace/get_best.dart'; +import '../../shared/ui_state/mapper/workspace_ui_mapper.dart'; +import 'search_state.dart'; + +class SearchCubit extends Cubit { + final GetBestUseCase _getBestUseCase; + final SearchWorkspacesUseCase _searchWorkspacesUseCase; + + SearchCubit(this._getBestUseCase, this._searchWorkspacesUseCase) + : super(SearchInitial()); + + Future fetchSearch( + String keyword, + ) async { + final currentState = state; + if (currentState is SearchLoaded) { + try { + emit(SearchLoading()); + final searchResults = await _searchWorkspacesUseCase.execute( + SearchRequest( + keyword: keyword, + minPrice: currentState.priceStart.toDouble(), + maxPrice: currentState.priceEnd.toDouble(), + rating: currentState.selectedRate.toDouble(), + services: currentState.selectedServicesIndices.map((e) => ServicesUiState.values[e].toString()).toList(), + // latitude: latitude, + // longitude: longitude, + // location: location, + ), + ); + emit( + SearchLoaded( + currentState.query, + currentState.isSearching, + currentState.selectedRate, + currentState.priceStart, + currentState.priceEnd, + currentState.priceStart, + currentState.priceEnd, + currentState.selectedRateIndices, + currentState.selectedServicesIndices, + currentState.services, + currentState.lastViewed, + searchResults.map((e) => mapWorkToUiState(e)).toList(), + ), + ); + } catch (e) { + emit(SearchError(e.toString())); + } + } + } + + Future fetchLastViewed() async { + final currentState = state; + emit(SearchLoading()); + try { + final lastViewed = await _getBestUseCase(); + double priceStart = 20; + double priceEnd = 30; + if (lastViewed.isNotEmpty) { + final prices = lastViewed + .map((e) => (e.pricePerHour).toDouble()) + .toList(); + priceStart = prices.reduce((a, b) => a < b ? a : b); + priceEnd = prices.reduce((a, b) => a > b ? a : b); + } + + emit( + SearchLoaded( + '', + false, + 0, + (priceStart * 0.5).toInt(), + (priceEnd * 1.5).toInt(), + priceStart.toInt(), + priceEnd.toInt(), + [], + [], + [], + lastViewed.map((e) => mapWorkToUiState(e)).toList(), + [], + ), + ); + } catch (e) { + emit(SearchError(e.toString())); + } + } + + void updateQuery(String query) { + final currentState = state; + if (currentState is SearchLoaded) { + emit( + SearchLoaded( + query, + currentState.isSearching, + currentState.selectedRate, + currentState.priceStartRange, + currentState.priceEndRange, + currentState.priceStart, + currentState.priceEnd, + currentState.selectedRateIndices, + currentState.selectedServicesIndices, + currentState.services, + currentState.lastViewed, + currentState.searchResults, + ), + ); + } + fetchSearch(query); + } + + void updateIsSearching(bool isSearching) { + final currentState = state; + if (currentState is SearchLoaded) { + emit( + SearchLoaded( + currentState.query, + isSearching, + currentState.selectedRate, + currentState.priceStartRange, + currentState.priceEndRange, + currentState.priceStart, + currentState.priceEnd, + currentState.selectedRateIndices, + currentState.selectedServicesIndices, + currentState.services, + currentState.lastViewed, + currentState.searchResults, + ), + ); + } + } + + void updateSelectedRate(double selectedRate) { + final currentState = state; + if (currentState is SearchLoaded) { + emit( + SearchLoaded( + currentState.query, + currentState.isSearching, + selectedRate.toInt(), + currentState.priceStartRange, + currentState.priceEndRange, + currentState.priceStart, + currentState.priceEnd, + currentState.selectedRateIndices, + currentState.selectedServicesIndices, + currentState.services, + currentState.lastViewed, + currentState.searchResults, + ), + ); + } + } + + void updatePriceStart(int priceStart) { + final currentState = state; + if (currentState is SearchLoaded) { + emit( + SearchLoaded( + currentState.query, + currentState.isSearching, + currentState.selectedRate, + currentState.priceStartRange, + currentState.priceEndRange, + priceStart, + currentState.priceEnd, + currentState.selectedRateIndices, + currentState.selectedServicesIndices, + currentState.services, + currentState.lastViewed, + currentState.searchResults, + ), + ); + } + } + + void updatePriceEnd(int priceEnd) { + final currentState = state; + if (currentState is SearchLoaded) { + emit( + SearchLoaded( + currentState.query, + currentState.isSearching, + currentState.selectedRate, + currentState.priceStartRange, + currentState.priceEndRange, + currentState.priceStart, + priceEnd, + currentState.selectedRateIndices, + currentState.selectedServicesIndices, + currentState.services, + currentState.lastViewed, + currentState.searchResults, + ), + ); + } + } + + void updateServices(List services) { + final currentState = state; + if (currentState is SearchLoaded) { + emit( + SearchLoaded( + currentState.query, + currentState.isSearching, + currentState.selectedRate, + currentState.priceStartRange, + currentState.priceEndRange, + currentState.priceStart, + currentState.priceEnd, + currentState.selectedRateIndices, + currentState.selectedServicesIndices, + services, + currentState.lastViewed, + currentState.searchResults, + ), + ); + } + } + + void updateSelectedServices(List services) { + final currentState = state; + if (currentState is SearchLoaded) { + emit( + SearchLoaded( + currentState.query, + currentState.isSearching, + currentState.selectedRate, + currentState.priceStartRange, + currentState.priceEndRange, + currentState.priceStart, + currentState.priceEnd, + currentState.selectedRateIndices, + services, + currentState.services, + currentState.lastViewed, + currentState.searchResults, + ), + ); + } + } + + void updateSelectedRates(List rates) { + final currentState = state; + if (currentState is SearchLoaded) { + emit( + SearchLoaded( + currentState.query, + currentState.isSearching, + currentState.selectedRate, + currentState.priceStartRange, + currentState.priceEndRange, + currentState.priceStart, + currentState.priceEnd, + rates, + currentState.selectedServicesIndices, + currentState.services, + currentState.lastViewed, + currentState.searchResults, + ), + ); + } + } +} diff --git a/lib/presentation/screen/search/state/search_state.dart b/lib/presentation/screen/search/state/search_state.dart new file mode 100644 index 0000000..0d22310 --- /dev/null +++ b/lib/presentation/screen/search/state/search_state.dart @@ -0,0 +1,43 @@ +import 'package:share_space/presentation/screen/shared/ui_state/workspace_ui_state.dart'; + +abstract class SearchState {} + +class SearchInitial extends SearchState {} + +class SearchLoading extends SearchState {} + +class SearchLoaded extends SearchState { + final String query; + final bool isSearching; + final int selectedRate; + final int priceStartRange; + final int priceEndRange; + final int priceStart; + final int priceEnd; + final List selectedRateIndices; + final List selectedServicesIndices; + final List services; + final List lastViewed; + final List searchResults; + + SearchLoaded( + this.query, + this.isSearching, + this.selectedRate, + this.priceStartRange, + this.priceEndRange, + this.priceStart, + this.priceEnd, + this.selectedRateIndices, + this.selectedServicesIndices, + this.services, + this.lastViewed, + this.searchResults, + ); +} + +class SearchError extends SearchState { + final String message; + + SearchError(this.message); +} diff --git a/lib/presentation/screen/search/widget/custom_chip.dart b/lib/presentation/screen/search/widget/custom_chip.dart new file mode 100644 index 0000000..9e4ab72 --- /dev/null +++ b/lib/presentation/screen/search/widget/custom_chip.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:share_space/presentation/design_system/theme/app_theme.dart'; + +class CustomChip extends StatefulWidget { + final String label; + Color labelColor; + final String icon; + bool isSelected; + final VoidCallback? onSelect; + + CustomChip({ + super.key, + required this.label, + this.labelColor = const Color(0x991F1F1F), + this.icon = '', + this.isSelected = false, + this.onSelect, + }); + + @override + State createState() => _CustomChipState(); +} + +class _CustomChipState extends State { + @override + Widget build(BuildContext context) { + final theme = AppTheme.of(context); + return GestureDetector( + onTap: () => setState(() { + widget.onSelect?.call(); + }), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: widget.isSelected + ? theme.colors.blueVariant + : theme.colors.surface, + border: Border.all( + color: widget.isSelected + ? theme.colors.primary + : theme.colors.stroke, + width: 0.5, + ), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.icon.isNotEmpty) ...[ + SvgPicture.asset( + widget.icon, + colorFilter: widget.isSelected + ? ColorFilter.mode(theme.colors.primary, BlendMode.srcIn) + : null, + width: 16, + height: 16, + ), + const SizedBox(width: 8), + ], + Text( + widget.label, + style: theme.typography.textTheme.labelSmall?.copyWith( + color: widget.isSelected ? theme.colors.primary : widget.labelColor, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/screen/search/widget/filter_button.dart b/lib/presentation/screen/search/widget/filter_button.dart new file mode 100644 index 0000000..0ddd42a --- /dev/null +++ b/lib/presentation/screen/search/widget/filter_button.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:share_space/presentation/design_system/theme/app_theme.dart'; + +import '../../../design_system/widget/base_bottom_sheet.dart'; +import '../../shared/ui_state/workspace_ui_state.dart'; +import '../filter_bottomsheet.dart'; + +class FilterButton extends StatelessWidget { + final RangeValues initialRange; + RangeValues currentRange; + final List rateOptions; + final int rateSelectedIndex; + List selectedRateIndices; + final List servicesOptions; + List selectedServicesIndices; + + FilterButton({super.key, + this.initialRange = const RangeValues(10, 500), + required this.currentRange, + required this.rateSelectedIndex, + this.rateOptions = const ["All", "1", "2", "3", "4", "5"], + required this.selectedRateIndices, + this.servicesOptions = ServicesUiState.values, + required this.selectedServicesIndices, + }); + + @override + Widget build(BuildContext context) { + final theme = AppTheme.of(context); + return ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(100)), + padding: const EdgeInsets.all(0), + minimumSize: const Size(48, 48), + elevation: 0, + ), + onPressed: () { + showBaseBottomSheet( + context: context, + child: FilterBottomSheet( + initialRange: initialRange, + currentRange: currentRange, + rateSelectedIndex: rateSelectedIndex, + rateOptions: rateOptions, + selectedRateIndices: selectedRateIndices, + servicesOptions: servicesOptions, + selectedServicesIndices: selectedServicesIndices, + ), + label: "Filter", + onClose: () {}, + height: 0.65, + ); + }, + child: Ink( + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF50B5E7), Color(0xFF19C6F9)], + ), + borderRadius: BorderRadius.circular(100), + ), + child: Container( + padding: const EdgeInsets.all(14), + constraints: const BoxConstraints(minWidth: 48, minHeight: 48), + alignment: Alignment.center, + child: SvgPicture.asset( + 'assets/icons/filter.svg', + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + theme.colors.surfaceLow, + BlendMode.srcIn, + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/screen/search/widget/range_chip.dart b/lib/presentation/screen/search/widget/range_chip.dart new file mode 100644 index 0000000..8a250b0 --- /dev/null +++ b/lib/presentation/screen/search/widget/range_chip.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import '../../../design_system/theme/app_theme.dart'; + +class RangeChip extends StatefulWidget { + final String label; + final int value; + final String image; + final String unit; + + const RangeChip({ + super.key, + required this.label, + required this.value, + required this.image, + required this.unit, + }); + + @override + State createState() => _RangeChipState(); +} + +class _RangeChipState extends State { + @override + Widget build(BuildContext context) { + final theme = AppTheme.of(context); + return Chip( + backgroundColor: theme.colors.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(100.0)), + label: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SvgPicture.asset(widget.image, width: 24, height: 24), + + Expanded( + flex: 1, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.label, + style: theme.typography.textTheme.labelSmall?.copyWith( + color: theme.colors.body, + ), + ), + Text( + widget.value.toString(), + style: theme.typography.textTheme.labelLarge?.copyWith( + color: theme.colors.body, + ), + ), + ], + ), + ), + ), + Text( + widget.unit, + style: theme.typography.textTheme.labelSmall?.copyWith( + color: theme.colors.primary, + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/screen/search/widget/range_slider.dart b/lib/presentation/screen/search/widget/range_slider.dart new file mode 100644 index 0000000..2476497 --- /dev/null +++ b/lib/presentation/screen/search/widget/range_slider.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import '../../../design_system/theme/app_theme.dart'; + +class RangeSliderWidget extends StatefulWidget { + final RangeValues maxRange; + RangeValues? currentRange; + final String currency; + final Function(RangeValues) onChange; + + RangeSliderWidget({ + super.key, + required this.maxRange, + required this.currency, + this.currentRange, + required this.onChange, + }); + + @override + State createState() => _RangeSliderWidgetState(); +} + +class _RangeSliderWidgetState extends State { + @override + Widget build(BuildContext context) { + final theme = AppTheme.of(context); + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${widget.maxRange.start.round()} ${widget.currency}', + style: theme.typography.textTheme.labelSmall?.copyWith( + color: theme.colors.body, + ), + ), + Text( + '${widget.maxRange.end.round()} ${widget.currency}', + style: theme.typography.textTheme.labelSmall?.copyWith( + color: theme.colors.body, + ), + ), + ], + ), + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: theme.colors.secondary, + thumbColor: theme.colors.secondary, + overlayShape: RoundSliderOverlayShape(overlayRadius: 20), + overlappingShapeStrokeColor: theme.colors.stroke, + inactiveTrackColor: theme.colors.surface, + trackHeight: 8, + overlayColor: Colors.transparent, + padding: const EdgeInsets.symmetric(horizontal: 0), + ), + child: RangeSlider( + values: widget.currentRange ?? widget.maxRange, + min: widget.maxRange.start, + max: widget.maxRange.end, + onChanged: widget.onChange, + ), + ), + ], + ); + } +} diff --git a/lib/presentation/screen/search/widget/rating_filter.dart b/lib/presentation/screen/search/widget/rating_filter.dart new file mode 100644 index 0000000..52d5b69 --- /dev/null +++ b/lib/presentation/screen/search/widget/rating_filter.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import '../../../design_system/theme/app_theme.dart'; +import 'custom_chip.dart'; + +class RatingFilter extends StatefulWidget { + List options; + List selectedIndices; + final Function(int) onSelect; + + RatingFilter({ + super.key, + required this.options, + required this.selectedIndices, + required this.onSelect, + }); + + @override + State createState() => _RatingFilterState(); +} + +class _RatingFilterState extends State { + @override + Widget build(BuildContext context) { + final theme = AppTheme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var index = 0; index < widget.options.length; index++) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: CustomChip( + label: widget.options[index], + icon: (index != 0) ? "assets/icons/star.svg" : "", + labelColor: theme.colors.yellow, + isSelected: widget.selectedIndices.contains(index), + onSelect: () { + widget.onSelect(index); + }, + ), + ), + ], + ); + } +} diff --git a/lib/presentation/screen/search/widget/search_field.dart b/lib/presentation/screen/search/widget/search_field.dart new file mode 100644 index 0000000..558e943 --- /dev/null +++ b/lib/presentation/screen/search/widget/search_field.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:share_space/presentation/design_system/theme/app_theme.dart'; +import 'package:share_space/presentation/screen/search/widget/filter_button.dart'; + +import '../../shared/ui_state/workspace_ui_state.dart'; + +class SearchField extends StatefulWidget { + final ValueChanged onQueryChanged; + final List suggestions; + final List history; + final RangeValues initialRange; + RangeValues currentRange; + final List rateOptions; + final int rateSelectedIndex; + List selectedRateIndices; + final List servicesOptions; + List selectedServicesIndices; + + SearchField({ + super.key, + required this.onQueryChanged, + required this.suggestions, + required this.history, + this.initialRange = const RangeValues(10, 500), + required this.currentRange, + required this.rateSelectedIndex, + this.rateOptions = const ["All", "1", "2", "3", "4", "5"], + required this.selectedRateIndices, + this.servicesOptions = ServicesUiState.values, + required this.selectedServicesIndices, + }); + + @override + State createState() => _SearchFieldState(); +} + +class _SearchFieldState extends State { + @override + Widget build(BuildContext context) { + final theme = AppTheme.of(context); + + return Row( + children: [ + Expanded( + flex: 1, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SearchAnchor( + viewConstraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.3, + ), + viewBackgroundColor: theme.colors.surfaceLow, + dividerColor: Colors.transparent, + isFullScreen: false, + builder: (BuildContext context, SearchController controller) { + return SearchBar( + hintText: "Search by room, location...", + hintStyle: WidgetStatePropertyAll( + theme.typography.textTheme.labelMedium?.copyWith( + color: theme.colors.body, + ), + ), + elevation: WidgetStatePropertyAll(0.0), + backgroundColor: WidgetStatePropertyAll( + theme.colors.surfaceLow, + ), + controller: controller, + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(100), + side: BorderSide(color: theme.colors.stroke, width: 0.5), + ), + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 16.0), + ), + onTap: () { + // controller.openView(); + }, + onChanged: (value) { + // controller.openView(); + // widget.onQueryChanged(value); + }, + onSubmitted: (value) { + // controller.openView(); + widget.onQueryChanged(value); + }, + leading: SvgPicture.asset( + 'assets/icons/search_filled.svg', + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + theme.colors.body, + BlendMode.srcIn, + ), + ), + ); + }, + suggestionsBuilder: + (BuildContext context, SearchController controller) { + return List.generate(5, (int index) { + final String item = 'item $index'; + return ListTile( + title: Text(item), + onTap: () { + setState(() { + controller.closeView(item); + }); + }, + ); + }); + }, + ), + ), + ), + FilterButton( + initialRange: widget.initialRange, + currentRange: widget.currentRange, + rateSelectedIndex: widget.rateSelectedIndex, + rateOptions: widget.rateOptions, + selectedRateIndices: widget.selectedRateIndices, + servicesOptions: widget.servicesOptions, + selectedServicesIndices: widget.selectedServicesIndices, + ), + ], + ); + } +} diff --git a/lib/presentation/screen/search/widget/services_filter.dart b/lib/presentation/screen/search/widget/services_filter.dart new file mode 100644 index 0000000..540bd27 --- /dev/null +++ b/lib/presentation/screen/search/widget/services_filter.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import 'custom_chip.dart'; + +class ServicesFilter extends StatefulWidget { + List options; + List selectedIndices; + final Function(int) onSelect; + + ServicesFilter({ + super.key, + required this.options, + required this.selectedIndices, + required this.onSelect, + }); + + @override + State createState() => _ServicesFilterState(); +} + +class _ServicesFilterState extends State { + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.topLeft, + child: Wrap( + alignment: WrapAlignment.start, + spacing: 8.0, + runSpacing: 8.0, + children: [ + for (var index = 0; index < widget.options.length; index++) + CustomChip( + label: widget.options[index], + icon: iconPathFor(widget.options[index]), + isSelected: widget.selectedIndices.contains(index), + onSelect: () { + widget.onSelect(index); + }, + ), + ], + ), + ); + } + + String iconPathFor(String label) { + final key = label.trim().toLowerCase(); + const icons = { + 'wifi': 'assets/icons/wifi.svg', + 'whiteboard': 'assets/icons/canvas.svg', + 'call': 'assets/icons/phone.svg', + 'power backup': 'assets/icons/plug.svg', + }; + return icons[key] ?? ''; + } +} diff --git a/lib/resources/app_strings.dart b/lib/resources/app_strings.dart index e0bed06..d83e895 100644 --- a/lib/resources/app_strings.dart +++ b/lib/resources/app_strings.dart @@ -90,7 +90,9 @@ class AppStrings { static const homeCategoriesTitle = 'Categories'; static const homePopularTitle = 'Popular workspaces'; - static const searchScreenTitle = 'Search Screen'; + static const searchScreenTitle = 'Search'; + static const mostSearched = 'Most Searched'; + static String resultsFound(int count) => 'Results found ($count)'; static const bookingScreenTitle = 'Booking Screen'; static const bookingHistoryScreenTitle = 'Booking History Screen'; static const chatScreenTitle = 'Chat Screen'; diff --git a/pubspec.yaml b/pubspec.yaml index 2d2a0d4..c6ade16 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -66,6 +66,7 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^5.0.0 + flutter_svg: ^2.2.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec