// 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> groupedBusinesses; const ExportData({super.key, required this.groupedBusinesses}); @override State createState() => _ExportDataState(); } class _ExportDataState extends State { String documentType = 'Business'; late Future refreshBusinessDataFuture; bool _isPreviousData = false; late Map> overviewBusinesses; Set jobTypeFilters = {}; String searchQuery = ''; Set selectedDataTypesJob = {}; Set selectedDataTypesBusiness = {}; Future _setFilters(Set filters) async { setState(() { jobTypeFilters = filters; }); _updateOverviewBusinesses(); } Future _updateOverviewBusinesses() async { var refreshedData = fetchBusinessDataOverview(typeFilters: jobTypeFilters.toList()); await refreshedData; setState(() { refreshBusinessDataFuture = refreshedData; }); } Future _setSearch(String search) async { setState(() { searchQuery = search; }); _updateOverviewBusinesses(); } Map> _filterBySearch( Map> businesses) { Map> 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 = {}; } void _setStateCallbackReset() { setState(() { selectedDataTypesBusiness = {}; selectedDataTypesJob = {}; documentType = 'Business'; }); } void _setStateCallbackApply(String docType, Set dataFiltersJob, Set 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 dataFiltersBusinessTmp = Set.from( selectedDataTypesBusiness); Set dataFiltersJobTmp = Set.from(selectedDataTypesJob); String docTypeTmp = documentType; return StatefulBuilder(builder: (context, setState) { void segmentedCallback(String docType) { setState(() { docTypeTmp = docType; }); } void chipsCallback( {Set? selectedDataTypesJob, Set? 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 _selected = {}; void updateSelected(Set 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( value: 'Business', label: Text('Businesses'), icon: Icon(Icons.business)), ButtonSegment( 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> groupedBusinesses; final Set selectedDataTypesJob; final Set 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 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 generateBusinesses = {}; if (selectedBusinesses.isEmpty) { generateBusinesses = Set.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 selectedDataTypesJob; final Set selectedDataTypesBusiness; final void Function( {Set? selectedDataTypesJob, Set? selectedDataTypesBusiness}) updateCallback; const _FilterDataTypeChips(this.documentType, this.selectedDataTypesJob, this.selectedDataTypesBusiness, this.updateCallback); @override State<_FilterDataTypeChips> createState() => _FilterDataTypeChipsState(); } class _FilterDataTypeChipsState extends State<_FilterDataTypeChips> { List filterDataTypeChips() { List 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 _generatePDF( BuildContext context, String documentType, Set? selectedBusinesses, Set? dataTypesBusinessInput, Set? dataTypesJobInput) async { Set dataTypesBusiness = {}; Set dataTypesJob = {}; List headerColumns = []; List tableRows = []; List 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 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 dataTypesJobList = sortDataTypesJob(dataTypesJob).toList(); List> nameMapping = await fetchBusinessNames(); for (Business business in businesses) { for (JobListing job in business.listings!) { List 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 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 _businessColumnSizes( Set dataTypes) { double space = 744.0; Map 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 _jobColumnSizes(Set dataTypes) { Map map = {}; List 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> 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']; } }