import 'package:fbla_ui/pages/create_edit_listing.dart'; import 'package:fbla_ui/pages/listing_detail.dart'; import 'package:fbla_ui/shared/api_logic.dart'; import 'package:fbla_ui/shared/export.dart'; import 'package:fbla_ui/shared/global_vars.dart'; import 'package:fbla_ui/shared/utils.dart'; import 'package:fbla_ui/shared/widgets.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_sticky_header/flutter_sticky_header.dart'; import 'package:rive/rive.dart'; import 'package:sliver_tools/sliver_tools.dart'; import 'package:url_launcher/url_launcher.dart'; class JobsOverview extends StatefulWidget { final String searchQuery; final Future refreshJobDataOverviewFuture; final Future Function(Set?, Set?) updateBusinessesCallback; final void Function() themeCallback; final void Function(bool) updateLoggedIn; const JobsOverview({ super.key, required this.searchQuery, required this.refreshJobDataOverviewFuture, required this.updateBusinessesCallback, required this.themeCallback, required this.updateLoggedIn, }); @override State createState() => _JobsOverviewState(); } class _JobsOverviewState extends State { bool _isPreviousData = false; late Map> overviewBusinesses; Set jobTypeFilters = {}; Set offerTypeFilters = {}; String searchQuery = ''; ScrollController controller = ScrollController(); bool _extended = true; double prevPixelPosition = 0; bool _isRetrying = false; Map> _filterBySearch( Map> businesses, String query) { Map> filteredBusinesses = {}; for (JobType jobType in businesses.keys) { filteredBusinesses[jobType] = List.from(businesses[jobType]!.where( (element) => element.listings![0].name .replaceAll(RegExp(r'[^a-zA-Z]'), '') .toLowerCase() .contains(query .replaceAll(RegExp(r'[^a-zA-Z]'), '') .toLowerCase() .trim()))); } filteredBusinesses.removeWhere((key, value) => value.isEmpty); return filteredBusinesses; } void _setSearch(String search) async { setState(() { searchQuery = search; }); } void _setFilters(Set? newJobTypeFilters, Set? newOfferTypeFilters) async { if (newJobTypeFilters != null) { jobTypeFilters = Set.from(newJobTypeFilters); } if (newOfferTypeFilters != null) { offerTypeFilters = Set.from(newOfferTypeFilters); } widget.updateBusinessesCallback(jobTypeFilters, offerTypeFilters); } void _scrollListener() { if ((prevPixelPosition - controller.position.pixels).abs() > 10) { setState(() { _extended = controller.position.userScrollDirection == ScrollDirection.forward; }); } prevPixelPosition = controller.position.pixels; } void _generatePDF() { List allJobs = []; for (List businesses in _filterBySearch(overviewBusinesses, searchQuery).values) { allJobs.addAll(businesses); } generatePDF( context: context, documentTypeIndex: 1, selectedJobs: Set.from(allJobs)); } @override void initState() { super.initState(); controller.addListener(_scrollListener); } @override Widget build(BuildContext context) { bool widescreen = MediaQuery.sizeOf(context).width >= widescreenWidth; return Scaffold( floatingActionButton: _getFAB(widescreen), body: CustomScrollView( controller: controller, slivers: [ MainSliverAppBar( widescreen: widescreen, setSearch: _setSearch, searchHintText: 'Search Job Listings', themeCallback: widget.themeCallback, filterIconButton: _filterIconButton(jobTypeFilters, offerTypeFilters), updateLoggedIn: widget.updateLoggedIn, generatePDF: _generatePDF, ), FutureBuilder( future: widget.refreshJobDataOverviewFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { if (snapshot.hasData) { if (snapshot.data.runtimeType == String) { _isPreviousData = false; return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16.0), child: Column(children: [ Center( child: Text(snapshot.data, textAlign: TextAlign.center)), Padding( padding: const EdgeInsets.all(8.0), child: FilledButton( child: const Text('Retry'), onPressed: () async { if (!_isRetrying) { setState(() { _isRetrying = true; }); await widget.updateBusinessesCallback(null, null); } }, ), ), ]), )); } overviewBusinesses = snapshot.data; _isPreviousData = true; return JobDisplayPanel( jobGroupedBusinesses: _filterBySearch(overviewBusinesses, searchQuery), widescreen: widescreen, ); } else if (snapshot.hasError) { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(left: 16.0, right: 16.0), child: Text( 'Error when loading data! Error: ${snapshot.error}'), )); } } else if (snapshot.connectionState == ConnectionState.waiting) { if (_isPreviousData) { return JobDisplayPanel( jobGroupedBusinesses: _filterBySearch(overviewBusinesses, searchQuery), widescreen: widescreen, ); } else { return SliverToBoxAdapter( child: Container( padding: const EdgeInsets.all(8.0), alignment: Alignment.center, child: const SizedBox( width: 75, height: 75, child: RiveAnimation.asset( 'assets/mdev_triangle_loading.riv'), ), )); } } return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(8.0), child: Text( '\nError: ${snapshot.error}', style: const TextStyle(fontSize: 18), textAlign: TextAlign.center, ), ), ); }), ], ), ); } Widget _filterIconButton( Set jobTypeFilters, Set offerTypeFilters) { Set selectedJobTypeChips = Set.from(jobTypeFilters); Set selectedOfferTypeChips = Set.from(offerTypeFilters); return IconButton( icon: Icon( Icons.filter_list, color: jobTypeFilters.isNotEmpty ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onSurface, ), onPressed: () { selectedJobTypeChips = Set.from(jobTypeFilters); selectedOfferTypeChips = Set.from(offerTypeFilters); showDialog( context: context, builder: (BuildContext context) { return StatefulBuilder( builder: (BuildContext context, StateSetter setState) { void setDialogState(Set? newJobTypeFilters, Set? newOfferTypeFilters) { if (newJobTypeFilters != null) { setState(() { selectedJobTypeChips = newJobTypeFilters; }); } if (newOfferTypeFilters != null) { setState(() { selectedOfferTypeChips = newOfferTypeFilters; }); } } List jobTypeChips = []; for (JobType type in JobType.values) { jobTypeChips.add(FilterChip( materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, showCheckmark: false, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), side: BorderSide( color: Theme.of(context).colorScheme.secondary)), selectedColor: Theme.of(context).colorScheme.secondary, label: Text( getNameFromJobType(type), style: TextStyle( color: selectedJobTypeChips.contains(type) ? Theme.of(context).colorScheme.onSecondary : Theme.of(context).colorScheme.onSurface), ), selected: selectedJobTypeChips.contains(type), onSelected: (bool selected) { if (selected) { selectedJobTypeChips.add(type); } else { selectedJobTypeChips.remove(type); } setDialogState(selectedJobTypeChips, null); })); } List offerTypeChips = []; for (OfferType type in OfferType.values) { offerTypeChips.add(FilterChip( materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, showCheckmark: false, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), side: BorderSide( color: Theme.of(context).colorScheme.secondary)), selectedColor: Theme.of(context).colorScheme.secondary, label: Text( getNameFromOfferType(type), style: TextStyle( color: selectedOfferTypeChips.contains(type) ? Theme.of(context).colorScheme.onSecondary : Theme.of(context).colorScheme.onSurface), ), selected: selectedOfferTypeChips.contains(type), onSelected: (bool selected) { if (selected) { selectedOfferTypeChips.add(type); } else { selectedOfferTypeChips.remove(type); } setDialogState(null, selectedOfferTypeChips); })); } return AlertDialog( title: const Text('Filter Options'), content: SizedBox( width: 400, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ const Text('Job Type Filters:'), Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Wrap( spacing: 8, runSpacing: 8, children: jobTypeChips, ), ), const Text('Offer Type Filters:'), Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Wrap( spacing: 8, runSpacing: 8, children: offerTypeChips, ), ), ], ), ), actions: [ TextButton( child: const Text('Reset'), onPressed: () { _setFilters({}, {}); Navigator.of(context).pop(); }, ), TextButton( child: const Text('Cancel'), onPressed: () { // setDialogState(jobTypeFilters, offerTypeFilters); Navigator.of(context).pop(); }, ), TextButton( child: const Text('Apply'), onPressed: () { _setFilters( selectedJobTypeChips, selectedOfferTypeChips); Navigator.of(context).pop(); }, ) ], ); }); }); }); } Widget? _getFAB(bool widescreen) { if (!widescreen && loggedIn) { return FloatingActionButton.extended( extendedIconLabelSpacing: _extended ? 8.0 : 0, extendedPadding: const EdgeInsets.symmetric(horizontal: 16), icon: const Icon(Icons.add), label: AnimatedSize( curve: Easing.standard, duration: const Duration(milliseconds: 300), child: _extended ? const Text('Add Job Listing') : Container(), ), onPressed: () { Navigator.push( context, MaterialPageRoute( builder: (context) => const CreateEditJobListing())); }, ); } return null; } } class JobDisplayPanel extends StatefulWidget { final Map> jobGroupedBusinesses; final bool widescreen; const JobDisplayPanel({ super.key, required this.jobGroupedBusinesses, required this.widescreen, }); @override State createState() => _JobDisplayPanelState(); } class _JobDisplayPanelState extends State { @override Widget build(BuildContext context) { if (widget.jobGroupedBusinesses.keys.isEmpty) { return const SliverToBoxAdapter( child: Center( child: Padding( padding: EdgeInsets.all(16.0), child: Text( 'No results found!\nPlease change your search filters.', textAlign: TextAlign.center, style: TextStyle(fontSize: 18), ), ), ), ); } List<_JobHeader> headers = []; for (JobType jobType in widget.jobGroupedBusinesses.keys) { headers.add(_JobHeader( jobType: jobType, widescreen: widget.widescreen, businesses: widget.jobGroupedBusinesses[jobType]!)); } headers.sort((a, b) => a.jobType.index.compareTo(b.jobType.index)); return MultiSliver(children: headers); } } class _JobHeader extends StatefulWidget { final JobType jobType; final List businesses; final bool widescreen; const _JobHeader({ required this.jobType, required this.businesses, required this.widescreen, }); @override State<_JobHeader> createState() => _JobHeaderState(); } class _JobHeaderState extends State<_JobHeader> { @override Widget build(BuildContext context) { return SliverStickyHeader( header: Container( height: 55.0, color: Theme.of(context).colorScheme.primary, padding: const EdgeInsets.symmetric(horizontal: 16.0), alignment: Alignment.centerLeft, child: _getHeaderRow(), ), sliver: _getChildSliver(widget.businesses, widget.widescreen), ); } Widget _getHeaderRow() { return Row( children: [ Padding( padding: const EdgeInsets.only(left: 4.0, right: 12.0), child: Icon( getIconFromJobType(widget.jobType), color: Theme.of(context).colorScheme.onPrimary, )), Text(getNameFromJobType(widget.jobType), style: TextStyle(color: Theme.of(context).colorScheme.onPrimary)), ], ); } Widget _getChildSliver(List businesses, bool widescreen) { if (widescreen) { return SliverPadding( padding: const EdgeInsets.all(4), sliver: SliverGrid( gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( mainAxisExtent: 250.0, maxCrossAxisExtent: 400.0, mainAxisSpacing: 4.0, crossAxisSpacing: 4.0, ), delegate: SliverChildBuilderDelegate( childCount: businesses.length, (BuildContext context, int index) { return _jobBusinessTile( businesses[index], widget.jobType, ); }, ), ), ); } else { return SliverList( delegate: SliverChildBuilderDelegate( childCount: businesses.length, (BuildContext context, int index) { return _jobBusinessListItem( businesses[index], widget.jobType, ); }, ), ); } } /// A desktop widget that displays basic info about a job Widget _jobBusinessTile(Business business, JobType jobType) { return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () { Navigator.of(context).push(MaterialPageRoute( builder: (context) => JobListingDetail( listing: business.listings![0], fromBusiness: business, ))); }, child: Card( clipBehavior: Clip.antiAlias, child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ Row( children: [ Padding( padding: const EdgeInsets.all(8.0), child: Badge( label: Text( getLetterFromOfferType( business.listings![0].offerType!), style: const TextStyle(fontSize: 16), ), largeSize: 24, offset: const Offset(12, -3), textColor: Colors.white, backgroundColor: getColorFromOfferType( business.listings![0].offerType!), child: ClipRRect( borderRadius: BorderRadius.circular(6.0), child: Image.network( '$apiAddress/logos/${business.id}', height: 48, width: 48, errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) { return Icon( getIconFromJobType(business.listings![0].type!), size: 48); }), ), )), Flexible( child: Padding( padding: const EdgeInsets.all(8.0), child: Text( '${business.listings![0].name} (${getNameFromOfferType(business.listings![0].offerType!)})', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold), maxLines: 2, overflow: TextOverflow.ellipsis, ), ), ), ], ), Padding( padding: const EdgeInsets.all(8.0), child: Text( business.listings![0].description, maxLines: 5, overflow: TextOverflow.ellipsis, ), ), const Spacer(), Padding( padding: const EdgeInsets.all(8.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ if (business.listings![0].link != null && business.listings![0].link!.isNotEmpty) IconButton( icon: const Icon(Icons.link), onPressed: () { launchUrl(Uri.parse( 'https://${business.listings![0].link!}')); }, ), IconButton( icon: const Icon(Icons.location_on), onPressed: () { launchUrl(Uri.parse(Uri.encodeFull( 'https://www.google.com/maps/search/?api=1&query=${business.locationName}'))); }, ), if (business.contactPhone != null) IconButton( icon: const Icon(Icons.phone), onPressed: () { showDialog( context: context, builder: (BuildContext context) { return AlertDialog( backgroundColor: Theme.of(context).colorScheme.surface, title: Text('Contact ${business.contactName}'), content: Text( 'Would you like to call or text ${business.contactName}?'), actions: [ TextButton( child: const Text('Text'), onPressed: () { launchUrl(Uri.parse( 'sms:${business.contactPhone}')); Navigator.of(context).pop(); }), TextButton( child: const Text('Call'), onPressed: () async { launchUrl(Uri.parse( 'tel:${business.contactPhone}')); Navigator.of(context).pop(); }), ], ); }); }, ), if (business.contactEmail != null) IconButton( icon: const Icon(Icons.email), onPressed: () { launchUrl( Uri.parse('mailto:${business.contactEmail}')); }, ), ], )), ], ), ), ), ); } /// A mobile widget that displays basic info about a job Widget _jobBusinessListItem(Business business, JobType? jobType) { return Card( child: ListTile( leading: Badge( label: Text(getLetterFromOfferType(business.listings![0].offerType!)), textColor: Colors.white, isLabelVisible: true, offset: const Offset(7, -5), alignment: Alignment.topRight, padding: business.listings![0].offerType! == OfferType.internship ? const EdgeInsets.symmetric(horizontal: 5) : null, backgroundColor: getColorFromOfferType(business.listings![0].offerType!), child: ClipRRect( borderRadius: BorderRadius.circular(3.0), child: Image.network('$apiAddress/logos/${business.id}', height: 24, width: 24, errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) { return Icon( getIconFromJobType(business.listings![0].type!), ); })), ), title: Text( '${business.listings![0].name} (${getNameFromOfferType(business.listings![0].offerType!)})'), subtitle: Text(business.listings![0].description, maxLines: 2, overflow: TextOverflow.ellipsis), onTap: () { Navigator.of(context).push(MaterialPageRoute( builder: (context) => JobListingDetail( listing: business.listings![0], fromBusiness: business, ))); }, ), ); } }