FBLA24/fbla_ui/lib/pages/export_data.dart

851 lines
29 KiB
Dart

// import 'dart:html' as html;
import 'dart:convert';
import 'dart:io';
import 'package:fbla_ui/api_logic.dart';
import 'package:fbla_ui/shared.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:open_filex/open_filex.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import 'package:rive/rive.dart';
class ExportData extends StatefulWidget {
final Map<JobType, List<Business>> groupedBusinesses;
const ExportData({super.key, required this.groupedBusinesses});
@override
State<ExportData> createState() => _ExportDataState();
}
class _ExportDataState extends State<ExportData> {
String documentType = 'Business';
late Future refreshBusinessDataFuture;
bool _isPreviousData = false;
late Map<JobType, List<Business>> overviewBusinesses;
Set<JobType> jobTypeFilters = <JobType>{};
String searchQuery = '';
Set<DataTypeJob> selectedDataTypesJob = <DataTypeJob>{};
Set<DataTypeBusiness> selectedDataTypesBusiness = <DataTypeBusiness>{};
Future<void> _setFilters(Set<JobType> filters) async {
setState(() {
jobTypeFilters = filters;
});
_updateOverviewBusinesses();
}
Future<void> _updateOverviewBusinesses() async {
var refreshedData =
fetchBusinessDataOverview(typeFilters: jobTypeFilters.toList());
await refreshedData;
setState(() {
refreshBusinessDataFuture = refreshedData;
});
}
Future<void> _setSearch(String search) async {
setState(() {
searchQuery = search;
});
_updateOverviewBusinesses();
}
Map<JobType, List<Business>> _filterBySearch(
Map<JobType, List<Business>> businesses) {
Map<JobType, List<Business>> filteredBusinesses = businesses;
for (JobType jobType in businesses.keys) {
filteredBusinesses[jobType]!.removeWhere((tmpBusiness) => !tmpBusiness
.name
.replaceAll(RegExp(r'[^a-zA-Z]'), '')
.toLowerCase()
.contains(searchQuery
.replaceAll(RegExp(r'[^a-zA-Z]'), '')
.toLowerCase()
.trim()));
}
filteredBusinesses.removeWhere((key, value) => value.isEmpty);
return filteredBusinesses;
}
@override
void initState() {
super.initState();
refreshBusinessDataFuture = fetchBusinessDataOverview();
selectedBusinesses = <Business>{};
}
void _setStateCallbackReset() {
setState(() {
selectedDataTypesBusiness = <DataTypeBusiness>{};
selectedDataTypesJob = <DataTypeJob>{};
documentType = 'Business';
});
}
void _setStateCallbackApply(String docType, Set<DataTypeJob> dataFiltersJob,
Set<DataTypeBusiness> dataFiltersBusiness) {
setState(() {
selectedDataTypesBusiness = dataFiltersBusiness;
selectedDataTypesJob = dataFiltersJob;
documentType = docType;
});
}
@override
Widget build(BuildContext context) {
bool widescreen = MediaQuery.sizeOf(context).width >= 1000;
return Scaffold(
floatingActionButton: _FAB(
groupedBusinesses: widget.groupedBusinesses,
documentType: documentType,
selectedDataTypesBusiness: selectedDataTypesBusiness,
selectedDataTypesJob: selectedDataTypesJob,
),
body: CustomScrollView(
slivers: [
SliverAppBar(
forceMaterialTransparency: false,
title: const Text('Export Data'),
toolbarHeight: 70,
pinned: true,
centerTitle: true,
expandedHeight: 120,
backgroundColor: Theme.of(context).colorScheme.surface,
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
showDialog(
context: context,
builder: (BuildContext context) {
Set<DataTypeBusiness> dataFiltersBusinessTmp =
Set<DataTypeBusiness>.from(
selectedDataTypesBusiness);
Set<DataTypeJob> dataFiltersJobTmp =
Set<DataTypeJob>.from(selectedDataTypesJob);
String docTypeTmp = documentType;
return StatefulBuilder(builder: (context, setState) {
void segmentedCallback(String docType) {
setState(() {
docTypeTmp = docType;
});
}
void chipsCallback(
{Set<DataTypeJob>? selectedDataTypesJob,
Set<DataTypeBusiness>?
selectedDataTypesBusiness}) {
if (selectedDataTypesJob != null) {
dataFiltersJobTmp = selectedDataTypesJob;
}
if (selectedDataTypesBusiness != null) {
dataFiltersBusinessTmp =
selectedDataTypesBusiness;
}
}
return AlertDialog(
// DO NOT MOVE TO SEPARATE WIDGET, setState is needed in main tree
backgroundColor:
Theme.of(context).colorScheme.surface,
title: const Text('Export Settings'),
content: SizedBox(
width: 450,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Document Type:'),
_SegmentedButton(
callback: segmentedCallback,
docType: docTypeTmp,
),
const Text(
'Data Columns you would like to show on the datasheet:'),
Padding(
padding: const EdgeInsets.all(8.0),
child: _FilterDataTypeChips(
docTypeTmp,
dataFiltersJobTmp,
dataFiltersBusinessTmp,
chipsCallback),
),
],
),
),
actions: [
TextButton(
child: const Text('Reset'),
onPressed: () {
_setStateCallbackReset();
Navigator.of(context).pop();
}),
TextButton(
child: const Text('Cancel'),
onPressed: () {
Navigator.of(context).pop();
}),
TextButton(
child: const Text('Apply'),
onPressed: () {
_setStateCallbackApply(
docTypeTmp,
dataFiltersJobTmp,
dataFiltersBusinessTmp);
Navigator.of(context).pop();
}),
],
);
});
});
},
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(0),
child: SizedBox(
height: 70,
width: 1000,
child: Padding(
padding: const EdgeInsets.all(10),
child: BusinessSearchBar(
filters: jobTypeFilters,
setFiltersCallback: _setFilters,
setSearchCallback: _setSearch),
),
),
),
),
FutureBuilder(
future: refreshBusinessDataFuture,
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: () {
_updateOverviewBusinesses();
},
),
),
]),
));
}
overviewBusinesses = snapshot.data;
_isPreviousData = true;
return BusinessDisplayPanel(
groupedBusinesses: _filterBySearch(overviewBusinesses),
widescreen: widescreen,
selectable: true);
} 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),
widescreen: widescreen,
selectable: true);
} 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,
),
),
);
}),
const SliverToBoxAdapter(
child: SizedBox(
height: 100,
),
),
],
),
);
}
}
class _SegmentedButton extends StatefulWidget {
final void Function(String) callback;
final String docType;
const _SegmentedButton({required this.callback, required this.docType});
@override
State<_SegmentedButton> createState() => _SegmentedButtonState();
}
class _SegmentedButtonState extends State<_SegmentedButton> {
Set<String> _selected = {};
void updateSelected(Set<String> newSelection) {
setState(() {
_selected = newSelection;
});
widget.callback(newSelection.first);
}
@override
void initState() {
super.initState();
_selected = {widget.docType};
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: SegmentedButton(
segments: const <ButtonSegment<String>>[
ButtonSegment<String>(
value: 'Business',
label: Text('Businesses'),
icon: Icon(Icons.business)),
ButtonSegment<String>(
value: 'Job Listing',
label: Text('Job Listings'),
icon: Icon(Icons.work))
],
selected: _selected,
onSelectionChanged: updateSelected,
style: SegmentedButton.styleFrom(
side: BorderSide(color: Theme.of(context).colorScheme.secondary),
)),
);
}
}
class _FAB extends StatefulWidget {
final String documentType;
final Map<JobType, List<Business>> groupedBusinesses;
final Set<DataTypeJob> selectedDataTypesJob;
final Set<DataTypeBusiness> selectedDataTypesBusiness;
const _FAB(
{required this.groupedBusinesses,
required this.documentType,
required this.selectedDataTypesJob,
required this.selectedDataTypesBusiness});
@override
State<_FAB> createState() => _FABState();
}
class _FABState extends State<_FAB> {
List<Business> allBusinesses = [];
bool _isLoading = false;
@override
void initState() {
super.initState();
for (JobType type in widget.groupedBusinesses.keys) {
allBusinesses.addAll(widget.groupedBusinesses[type]!);
}
}
@override
Widget build(BuildContext context) {
return FloatingActionButton(
child: _isLoading
? const Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3.0,
),
)
: const Icon(Icons.save_alt),
onPressed: () async {
setState(() {
_isLoading = true;
});
Set<Business> generateBusinesses = <Business>{};
if (selectedBusinesses.isEmpty) {
generateBusinesses = Set<Business>.from(allBusinesses);
} else {
generateBusinesses = selectedBusinesses;
}
await _generatePDF(context, widget.documentType, generateBusinesses,
widget.selectedDataTypesBusiness, widget.selectedDataTypesJob);
setState(() {
_isLoading = false;
});
});
}
}
class _FilterDataTypeChips extends StatefulWidget {
final String documentType;
final Set<DataTypeJob> selectedDataTypesJob;
final Set<DataTypeBusiness> selectedDataTypesBusiness;
final void Function(
{Set<DataTypeJob>? selectedDataTypesJob,
Set<DataTypeBusiness>? selectedDataTypesBusiness}) updateCallback;
const _FilterDataTypeChips(this.documentType, this.selectedDataTypesJob,
this.selectedDataTypesBusiness, this.updateCallback);
@override
State<_FilterDataTypeChips> createState() => _FilterDataTypeChipsState();
}
class _FilterDataTypeChipsState extends State<_FilterDataTypeChips> {
List<Padding> filterDataTypeChips() {
List<Padding> chips = [];
if (widget.documentType == 'Business') {
for (var type in DataTypeBusiness.values) {
chips.add(Padding(
padding: const EdgeInsets.only(
left: 3.0, right: 3.0, bottom: 3.0, top: 3.0),
child: FilterChip(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(
color: Theme.of(context).colorScheme.secondary)),
label: Text(dataTypeFriendlyBusiness[type]!),
showCheckmark: false,
selected: widget.selectedDataTypesBusiness.contains(type),
onSelected: (bool selected) {
setState(() {
if (selected) {
widget.selectedDataTypesBusiness.add(type);
} else {
widget.selectedDataTypesBusiness.remove(type);
}
});
widget.updateCallback(
selectedDataTypesBusiness:
widget.selectedDataTypesBusiness);
}),
));
}
} else if (widget.documentType == 'Job Listing') {
for (var type in DataTypeJob.values) {
chips.add(Padding(
padding: const EdgeInsets.only(
left: 3.0, right: 3.0, bottom: 3.0, top: 3.0),
child: FilterChip(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(
color: Theme.of(context).colorScheme.secondary)),
label: Text(dataTypeFriendlyJob[type]!),
showCheckmark: false,
selected: widget.selectedDataTypesJob.contains(type),
onSelected: (bool selected) {
setState(() {
if (selected) {
widget.selectedDataTypesJob.add(type);
} else {
widget.selectedDataTypesJob.remove(type);
}
});
widget.updateCallback(
selectedDataTypesJob: widget.selectedDataTypesJob);
}),
));
}
}
return chips;
}
@override
Widget build(BuildContext context) {
return Wrap(
children: filterDataTypeChips(),
);
}
}
Future<void> _generatePDF(
BuildContext context,
String documentType,
Set<Business>? selectedBusinesses,
Set<DataTypeBusiness>? dataTypesBusinessInput,
Set<DataTypeJob>? dataTypesJobInput) async {
Set<DataTypeBusiness> dataTypesBusiness = {};
Set<DataTypeJob> dataTypesJob = {};
List<pw.Widget> headerColumns = [];
List<pw.TableRow> tableRows = [];
List<Business> businesses = await fetchBusinesses(
selectedBusinesses!.map((business) => business.id).toList());
if (documentType == 'Business') {
dataTypesBusiness = Set.from(dataTypesBusinessInput!);
if (dataTypesBusiness.isEmpty) {
dataTypesBusiness.addAll(DataTypeBusiness.values);
}
dataTypesBusiness = sortDataTypesBusiness(dataTypesBusiness);
for (Business business in businesses) {
List<pw.Widget> businessRow = [];
if (dataTypesBusiness.contains(DataTypeBusiness.logo)) {
var apiLogo = await getLogo(business.id);
if (apiLogo.runtimeType != String) {
businessRow.add(pw.Padding(
child: pw.ClipRRect(
child:
pw.Image(pw.MemoryImage(apiLogo), height: 24, width: 24),
horizontalRadius: 4,
verticalRadius: 4),
padding: const pw.EdgeInsets.all(4.0)));
} else {
businessRow.add(pw.Padding(
child: pw.Icon(const pw.IconData(0xe0af), size: 24),
padding: const pw.EdgeInsets.all(4.0)));
}
}
for (DataTypeBusiness dataType in dataTypesBusiness) {
if (dataType != DataTypeBusiness.logo) {
businessRow.add(pw.Padding(
child: pw.Text(businessValueFromDataType(business, dataType)),
padding: const pw.EdgeInsets.all(4.0)));
}
}
tableRows.add(pw.TableRow(children: businessRow));
}
for (var filter in dataTypesBusiness) {
headerColumns.add(pw.Padding(
child: pw.Text(dataTypeFriendlyBusiness[filter]!,
style: const pw.TextStyle(fontSize: 10)),
padding: const pw.EdgeInsets.all(4.0)));
}
} else if (documentType == 'Job Listing') {
dataTypesJob = Set.from(dataTypesJobInput!);
if (dataTypesJob.isEmpty) {
dataTypesJob.addAll(DataTypeJob.values);
}
List<DataTypeJob> dataTypesJobList =
sortDataTypesJob(dataTypesJob).toList();
List<Map<String, dynamic>> nameMapping = await fetchBusinessNames();
for (Business business in businesses) {
for (JobListing job in business.listings!) {
List<pw.Widget> jobRow = [];
for (DataTypeJob dataType in dataTypesJobList) {
jobRow.add(pw.Padding(
child: pw.Text(jobValueFromDataType(job, dataType, nameMapping)),
padding: const pw.EdgeInsets.all(4.0)));
}
tableRows.add(pw.TableRow(children: jobRow));
}
}
for (var filter in dataTypesJobList) {
headerColumns.add(pw.Padding(
child: pw.Text(dataTypeFriendlyJob[filter]!,
style: const pw.TextStyle(fontSize: 10)),
padding: const pw.EdgeInsets.all(4.0)));
}
} else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text(
'Could not identify document type! Please select a type in the generation settings.')));
return;
}
// Final Generation
DateTime dateTime = DateTime.now();
String minute = '00';
if (dateTime.minute.toString().length < 2) {
minute = '0${dateTime.minute}';
} else {
minute = dateTime.minute.toString();
}
String time = dateTime.hour <= 12
? '${dateTime.hour}:${minute}AM'
: '${dateTime.hour - 12}:${minute}PM';
String fileName =
'$documentType Data - ${dateTime.month}-${dateTime.day}-${dateTime.year} $time.pdf';
final pdf = pw.Document();
var svgBytes = await marinoDevLogo();
var themeIcon = pw.ThemeData.withFont(
base: await PdfGoogleFonts.notoSansDisplayMedium(),
icons: await PdfGoogleFonts.materialIcons());
var finalTheme = themeIcon.copyWith(
defaultTextStyle: const pw.TextStyle(fontSize: 9),
);
pdf.addPage(pw.MultiPage(
theme: finalTheme,
pageFormat: PdfPageFormat.letter,
orientation: pw.PageOrientation.landscape,
margin: const pw.EdgeInsets.all(24),
build: (pw.Context context) {
return [
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.SvgImage(svg: utf8.decode(svgBytes), height: 40),
pw.Padding(
padding: const pw.EdgeInsets.all(8.0),
child: pw.Text('$documentType Datasheet',
style: pw.TextStyle(
fontSize: 32, fontWeight: pw.FontWeight.bold)),
),
pw.Text(
'Generated on ${dateTime.month}/${dateTime.day}/${dateTime.year} at $time',
style: const pw.TextStyle(fontSize: 12),
textAlign: pw.TextAlign.right),
//
]),
pw.Table(
columnWidths: documentType == 'Business'
? _businessColumnSizes(dataTypesBusiness)
: _jobColumnSizes(dataTypesJob),
border: const pw.TableBorder(
bottom: pw.BorderSide(),
left: pw.BorderSide(),
right: pw.BorderSide(),
top: pw.BorderSide(),
horizontalInside: pw.BorderSide(),
verticalInside: pw.BorderSide()),
children: [
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.blue400),
children: headerColumns,
repeat: true,
),
...tableRows,
])
];
}));
Uint8List pdfBytes = await pdf.save();
if (kIsWeb) {
// List<int> fileInts = List.from(pdfBytes);
// html.AnchorElement(
// href:
// 'data:application/octet-stream;charset=utf-16le;base64,${base64.encode(fileInts)}')
// ..setAttribute('download', fileName)
// ..click();
await Printing.sharePdf(
bytes: await pdf.save(),
filename: fileName,
);
} else {
var dir = await getTemporaryDirectory();
var tempDir = dir.path;
File pdfFile = File('$tempDir/$fileName');
pdfFile.writeAsBytesSync(pdfBytes);
OpenFilex.open(pdfFile.path);
}
}
Map<int, pw.TableColumnWidth> _businessColumnSizes(
Set<DataTypeBusiness> dataTypes) {
double space = 744.0;
Map<int, pw.TableColumnWidth> map = {};
if (dataTypes.contains(DataTypeBusiness.logo)) {
space -= 28;
map.addAll({
dataTypePriorityBusiness[DataTypeBusiness.logo]!:
const pw.FixedColumnWidth(28)
});
}
if (dataTypes.contains(DataTypeBusiness.contactName)) {
space -= 72;
map.addAll({
dataTypePriorityBusiness[DataTypeBusiness.contactName]!:
const pw.FixedColumnWidth(72)
});
}
if (dataTypes.contains(DataTypeBusiness.contactPhone)) {
space -= 76;
map.addAll({
dataTypePriorityBusiness[DataTypeBusiness.contactPhone]!:
const pw.FixedColumnWidth(76)
});
}
double leftNum = 0;
if (dataTypes.contains(DataTypeBusiness.name)) {
leftNum += 1;
}
if (dataTypes.contains(DataTypeBusiness.website)) {
leftNum += 1;
}
if (dataTypes.contains(DataTypeBusiness.contactEmail)) {
leftNum += 1;
}
if (dataTypes.contains(DataTypeBusiness.notes)) {
leftNum += 2;
}
if (dataTypes.contains(DataTypeBusiness.description)) {
leftNum += 3;
}
leftNum = space / leftNum;
if (dataTypes.contains(DataTypeBusiness.name)) {
map.addAll({
dataTypePriorityBusiness[DataTypeBusiness.name]!:
pw.FixedColumnWidth(leftNum)
});
}
if (dataTypes.contains(DataTypeBusiness.website)) {
map.addAll({
dataTypePriorityBusiness[DataTypeBusiness.website]!:
pw.FixedColumnWidth(leftNum)
});
}
if (dataTypes.contains(DataTypeBusiness.contactEmail)) {
map.addAll({
dataTypePriorityBusiness[DataTypeBusiness.contactEmail]!:
pw.FixedColumnWidth(leftNum)
});
}
if (dataTypes.contains(DataTypeBusiness.notes)) {
map.addAll({
dataTypePriorityBusiness[DataTypeBusiness.notes]!:
pw.FixedColumnWidth(leftNum * 2)
});
}
if (dataTypes.contains(DataTypeBusiness.description)) {
map.addAll({
dataTypePriorityBusiness[DataTypeBusiness.description]!:
pw.FixedColumnWidth(leftNum * 3)
});
}
return map;
}
Map<int, pw.TableColumnWidth> _jobColumnSizes(Set<DataTypeJob> dataTypes) {
Map<int, pw.TableColumnWidth> map = {};
List<DataTypeJob> sortedDataTypes = sortDataTypesJob(dataTypes).toList();
if (dataTypes.contains(DataTypeJob.businessName)) {
map.addAll({
sortedDataTypes.indexOf(sortedDataTypes
.where((element) => element == DataTypeJob.businessName)
.first): const pw.FractionColumnWidth(0.2)
});
}
if (dataTypes.contains(DataTypeJob.name)) {
map.addAll({
sortedDataTypes.indexOf(sortedDataTypes
.where((element) => element == DataTypeJob.name)
.first): const pw.FractionColumnWidth(0.2)
});
}
if (dataTypes.contains(DataTypeJob.description)) {
map.addAll({
sortedDataTypes.indexOf(sortedDataTypes
.where((element) => element == DataTypeJob.description)
.first): const pw.FractionColumnWidth(0.4)
});
}
if (dataTypes.contains(DataTypeJob.wage)) {
map.addAll({
sortedDataTypes.indexOf(sortedDataTypes
.where((element) => element == DataTypeJob.wage)
.first): const pw.FractionColumnWidth(0.15)
});
}
if (dataTypes.contains(DataTypeJob.link)) {
map.addAll({
sortedDataTypes.indexOf(sortedDataTypes
.where((element) => element == DataTypeJob.link)
.first): const pw.FractionColumnWidth(0.2)
});
}
return map;
}
dynamic businessValueFromDataType(
Business business, DataTypeBusiness dataType) {
switch (dataType) {
case DataTypeBusiness.name:
return business.name;
case DataTypeBusiness.description:
return business.description;
case DataTypeBusiness.website:
return business.website;
case DataTypeBusiness.contactName:
return business.contactName;
case DataTypeBusiness.contactEmail:
return business.contactEmail;
case DataTypeBusiness.contactPhone:
return business.contactPhone;
case DataTypeBusiness.notes:
return business.notes;
case DataTypeBusiness.logo:
return null;
}
}
dynamic jobValueFromDataType(JobListing job, DataTypeJob dataType,
List<Map<String, dynamic>> nameMapping) {
switch (dataType) {
case DataTypeJob.name:
return job.name;
case DataTypeJob.description:
return job.description;
case DataTypeJob.wage:
return job.wage;
case DataTypeJob.link:
return job.link;
case DataTypeJob.businessName:
return nameMapping
.where((element) => element['id'] == job.businessId)
.first['name'];
}
}