FBLA24/fbla_ui/lib/pages/listings_overview.dart
2024-06-23 18:56:22 -05:00

693 lines
26 KiB
Dart

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<void> Function(Set<JobType>?, Set<OfferType>?)
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<JobsOverview> createState() => _JobsOverviewState();
}
class _JobsOverviewState extends State<JobsOverview> {
bool _isPreviousData = false;
late Map<JobType, List<Business>> overviewBusinesses;
Set<JobType> jobTypeFilters = <JobType>{};
Set<OfferType> offerTypeFilters = <OfferType>{};
String searchQuery = '';
ScrollController controller = ScrollController();
bool _extended = true;
double prevPixelPosition = 0;
Map<JobType, List<Business>> _filterBySearch(
Map<JobType, List<Business>> businesses, String query) {
Map<JobType, List<Business>> 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<JobType>? newJobTypeFilters,
Set<OfferType>? 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<Business> allJobs = [];
for (List<Business> 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: () {
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<JobType> jobTypeFilters, Set<OfferType> offerTypeFilters) {
Set<JobType> selectedJobTypeChips = Set.from(jobTypeFilters);
Set<OfferType> 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<JobType>? newJobTypeFilters,
Set<OfferType>? newOfferTypeFilters) {
if (newJobTypeFilters != null) {
setState(() {
selectedJobTypeChips = newJobTypeFilters;
});
}
if (newOfferTypeFilters != null) {
setState(() {
selectedOfferTypeChips = newOfferTypeFilters;
});
}
}
List<Widget> 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<Widget> 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(<JobType>{}, <OfferType>{});
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<JobType, List<Business>> jobGroupedBusinesses;
final bool widescreen;
const JobDisplayPanel({
super.key,
required this.jobGroupedBusinesses,
required this.widescreen,
});
@override
State<JobDisplayPanel> createState() => _JobDisplayPanelState();
}
class _JobDisplayPanelState extends State<JobDisplayPanel> {
@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<Business> 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<Business> 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 _businessTile(
businesses[index],
widget.jobType,
);
},
),
),
);
} else {
return SliverList(
delegate: SliverChildBuilderDelegate(
childCount: businesses.length,
(BuildContext context, int index) {
return _businessListItem(
businesses[index],
widget.jobType,
);
},
),
);
}
}
Widget _businessTile(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: 26,
padding: business.listings![0].offerType! ==
OfferType.internship
? const EdgeInsets.symmetric(horizontal: 5)
: null,
offset: const Offset(13, -2),
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}'));
},
),
],
)),
],
),
),
),
);
}
Widget _businessListItem(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,
)));
},
),
);
}
}