600 lines
22 KiB
Dart
600 lines
22 KiB
Dart
import 'package:fbla_ui/pages/business_detail.dart';
|
|
import 'package:fbla_ui/pages/create_edit_business.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 BusinessesOverview extends StatefulWidget {
|
|
final String searchQuery;
|
|
final Future refreshBusinessDataOverviewFuture;
|
|
final Future<void> Function(Set<BusinessType>) updateBusinessesCallback;
|
|
final void Function() themeCallback;
|
|
final void Function(bool) updateLoggedIn;
|
|
|
|
const BusinessesOverview({
|
|
super.key,
|
|
required this.searchQuery,
|
|
required this.refreshBusinessDataOverviewFuture,
|
|
required this.updateBusinessesCallback,
|
|
required this.themeCallback,
|
|
required this.updateLoggedIn,
|
|
});
|
|
|
|
@override
|
|
State<BusinessesOverview> createState() => _BusinessesOverviewState();
|
|
}
|
|
|
|
class _BusinessesOverviewState extends State<BusinessesOverview> {
|
|
bool _isPreviousData = false;
|
|
late Map<BusinessType, List<Business>> overviewBusinesses;
|
|
Set<BusinessType> businessTypeFilters = <BusinessType>{};
|
|
String searchQuery = '';
|
|
ScrollController controller = ScrollController();
|
|
bool _extended = true;
|
|
double prevPixelPosition = 0;
|
|
bool _isRetrying = false;
|
|
|
|
Map<BusinessType, List<Business>> _filterBySearch(
|
|
Map<BusinessType, List<Business>> businesses, String query) {
|
|
Map<BusinessType, List<Business>> filteredBusinesses = {};
|
|
|
|
for (BusinessType businessType in businesses.keys) {
|
|
filteredBusinesses[businessType] = List.from(businesses[businessType]!
|
|
.where((element) => element.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<BusinessType> filters) async {
|
|
businessTypeFilters = Set.from(filters);
|
|
widget.updateBusinessesCallback(businessTypeFilters);
|
|
}
|
|
|
|
void _scrollListener() {
|
|
if ((prevPixelPosition - controller.position.pixels).abs() > 10) {
|
|
setState(() {
|
|
_extended =
|
|
controller.position.userScrollDirection == ScrollDirection.forward;
|
|
});
|
|
}
|
|
prevPixelPosition = controller.position.pixels;
|
|
}
|
|
|
|
void _generatePDF() {
|
|
List<Business> allBusinesses = [];
|
|
for (List<Business> businessList
|
|
in _filterBySearch(overviewBusinesses, searchQuery).values) {
|
|
allBusinesses.addAll(businessList);
|
|
}
|
|
|
|
generatePDF(
|
|
context: context,
|
|
documentTypeIndex: 0,
|
|
selectedBusinesses: Set.from(allBusinesses));
|
|
}
|
|
|
|
@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 Businesses',
|
|
themeCallback: widget.themeCallback,
|
|
filterIconButton: _filterIconButton(
|
|
businessTypeFilters,
|
|
),
|
|
updateLoggedIn: widget.updateLoggedIn,
|
|
generatePDF: _generatePDF,
|
|
),
|
|
FutureBuilder(
|
|
future: widget.refreshBusinessDataOverviewFuture,
|
|
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(
|
|
businessTypeFilters);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
]),
|
|
));
|
|
}
|
|
|
|
overviewBusinesses = snapshot.data;
|
|
_isPreviousData = true;
|
|
|
|
return BusinessDisplayPanel(
|
|
groupedBusinesses:
|
|
_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 BusinessDisplayPanel(
|
|
groupedBusinesses:
|
|
_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<BusinessType> filters) {
|
|
Set<BusinessType> selectedChips = Set.from(filters);
|
|
|
|
return IconButton(
|
|
icon: Icon(
|
|
Icons.filter_list,
|
|
color: filters.isNotEmpty
|
|
? Theme.of(context).colorScheme.primary
|
|
: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
onPressed: () {
|
|
selectedChips = Set.from(businessTypeFilters);
|
|
showDialog(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return StatefulBuilder(
|
|
builder: (BuildContext context, StateSetter setState) {
|
|
void setDialogState(Set<BusinessType> newFilters) {
|
|
setState(() {
|
|
filters = newFilters;
|
|
});
|
|
}
|
|
|
|
List<Widget> chips = [];
|
|
for (var type in BusinessType.values) {
|
|
chips.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(
|
|
getNameFromBusinessType(type),
|
|
style: TextStyle(
|
|
color: selectedChips.contains(type)
|
|
? Theme.of(context).colorScheme.onSecondary
|
|
: Theme.of(context).colorScheme.onSurface),
|
|
),
|
|
selected: selectedChips.contains(type),
|
|
onSelected: (bool selected) {
|
|
if (selected) {
|
|
selectedChips.add(type);
|
|
} else {
|
|
selectedChips.remove(type);
|
|
}
|
|
setDialogState(filters);
|
|
}));
|
|
}
|
|
|
|
return AlertDialog(
|
|
title: const Text('Filter Options'),
|
|
content: SizedBox(
|
|
width: 400,
|
|
child: Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: chips,
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
child: const Text('Reset'),
|
|
onPressed: () {
|
|
_setFilters(<BusinessType>{});
|
|
// selectedChips = <BusinessType>{};
|
|
Navigator.of(context).pop();
|
|
},
|
|
),
|
|
TextButton(
|
|
child: const Text('Cancel'),
|
|
onPressed: () {
|
|
// selectedChips = Set.from(filters);
|
|
Navigator.of(context).pop();
|
|
},
|
|
),
|
|
TextButton(
|
|
child: const Text('Apply'),
|
|
onPressed: () {
|
|
_setFilters(selectedChips);
|
|
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 Business') : Container(),
|
|
),
|
|
onPressed: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => const CreateEditBusiness()));
|
|
},
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
class BusinessDisplayPanel extends StatefulWidget {
|
|
final Map<BusinessType, List<Business>> groupedBusinesses;
|
|
final bool widescreen;
|
|
|
|
const BusinessDisplayPanel({
|
|
super.key,
|
|
required this.groupedBusinesses,
|
|
required this.widescreen,
|
|
});
|
|
|
|
@override
|
|
State<BusinessDisplayPanel> createState() => _BusinessDisplayPanelState();
|
|
}
|
|
|
|
class _BusinessDisplayPanelState extends State<BusinessDisplayPanel> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (widget.groupedBusinesses.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<_BusinessHeader> headers = [];
|
|
for (BusinessType businessType in widget.groupedBusinesses.keys) {
|
|
headers.add(_BusinessHeader(
|
|
businessType: businessType,
|
|
widescreen: widget.widescreen,
|
|
businesses: widget.groupedBusinesses[businessType]!));
|
|
}
|
|
headers
|
|
.sort((a, b) => a.businessType.index.compareTo(b.businessType.index));
|
|
return MultiSliver(children: headers);
|
|
}
|
|
}
|
|
|
|
class _BusinessHeader extends StatefulWidget {
|
|
final BusinessType businessType;
|
|
final List<Business> businesses;
|
|
final bool widescreen;
|
|
|
|
const _BusinessHeader({
|
|
required this.businessType,
|
|
required this.businesses,
|
|
required this.widescreen,
|
|
});
|
|
|
|
@override
|
|
State<_BusinessHeader> createState() => _BusinessHeaderState();
|
|
}
|
|
|
|
class _BusinessHeaderState extends State<_BusinessHeader> {
|
|
@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(
|
|
getIconFromBusinessType(widget.businessType),
|
|
color: Theme.of(context).colorScheme.onPrimary,
|
|
)),
|
|
Text(getNameFromBusinessType(widget.businessType),
|
|
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.businessType,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
return SliverList(
|
|
delegate: SliverChildBuilderDelegate(
|
|
childCount: businesses.length,
|
|
(BuildContext context, int index) {
|
|
return _businessListItem(
|
|
businesses[index],
|
|
widget.businessType,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _businessTile(Business business, BusinessType jobType) {
|
|
return MouseRegion(
|
|
cursor: SystemMouseCursors.click,
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
Navigator.of(context).push(MaterialPageRoute(
|
|
builder: (context) => BusinessDetail(
|
|
id: business.id,
|
|
name: business.name!,
|
|
)));
|
|
},
|
|
child: Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
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(getIconFromBusinessType(business.type!),
|
|
size: 48);
|
|
}),
|
|
)),
|
|
Flexible(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: Text(
|
|
business.name!,
|
|
style: const TextStyle(
|
|
fontSize: 18, fontWeight: FontWeight.bold),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: Text(
|
|
business.description!,
|
|
maxLines: 5,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
if (business.website != null)
|
|
IconButton(
|
|
icon: const Icon(Icons.link),
|
|
onPressed: () {
|
|
launchUrl(Uri.parse('https://${business.website}'));
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.location_on),
|
|
onPressed: () {
|
|
launchUrl(Uri.parse(Uri.encodeFull(
|
|
'https://www.google.com/maps/search/?api=1&query=${business.locationName} ${business.locationAddress}')));
|
|
},
|
|
),
|
|
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, BusinessType? jobType) {
|
|
return Card(
|
|
child: ListTile(
|
|
leading: 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(getIconFromBusinessType(business.type!));
|
|
})),
|
|
title: Text(business.name!),
|
|
subtitle: Text(business.description!,
|
|
maxLines: 2, overflow: TextOverflow.ellipsis),
|
|
onTap: () {
|
|
Navigator.of(context).push(MaterialPageRoute(
|
|
builder: (context) => BusinessDetail(
|
|
id: business.id,
|
|
name: business.name!,
|
|
)));
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|