From aa71e8a9e695ebe72b7f8893d150b410aae352a4 Mon Sep 17 00:00:00 2001 From: Nathan Anderson Date: Tue, 10 Sep 2024 13:14:00 -0600 Subject: [PATCH] Added support for arbitrary lists and experience in markup --- bin/dartboard_resume.dart | 8 ++ lib/dartboard_parser.dart | 97 +++++++++++++---- lib/dartboard_resume.dart | 101 ----------------- lib/dartboard_widgets.dart | 214 ++++++++++++++++++++++++++++++------- lib/render.dart | 124 +++++++++++++++------ 5 files changed, 346 insertions(+), 198 deletions(-) delete mode 100644 lib/dartboard_resume.dart diff --git a/bin/dartboard_resume.dart b/bin/dartboard_resume.dart index c314058..d218493 100644 --- a/bin/dartboard_resume.dart +++ b/bin/dartboard_resume.dart @@ -2,9 +2,11 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:dartboard_resume/dartboard_parser.dart'; import 'package:dartboard_resume/render.dart'; import 'package:hotreloader/hotreloader.dart'; import 'package:logging/logging.dart' as logging; +import 'package:toml/toml.dart'; StreamSubscription? fileStreamSub; StreamSubscription? stdinStreamSub; @@ -24,6 +26,12 @@ Future main(List arguments) async { stdout.writeln("Triggering pdf render..."); renderPdf(tomlFilePath, force: true); } + if (event == "p") { + stdout.writeln("Current toml map:"); + stdout.writeln(TomlDocument.loadSync(tomlFilePath).toMap()); + final dartboardData = DartboardData.fromToml(tomlFilePath); + dartboardData.miscList.forEach(stdout.writeln); + } }, ); diff --git a/lib/dartboard_parser.dart b/lib/dartboard_parser.dart index 65783eb..c42ca5d 100644 --- a/lib/dartboard_parser.dart +++ b/lib/dartboard_parser.dart @@ -21,9 +21,10 @@ class DartboardData { final tomlData = TomlDocument.loadSync(tomlFilePath).toMap(); final List exps = tomlData.entries - .where((e) => e.value is List && (e.value as List).firstOrNull is Map) + .where((e) => DartboardExperience.filter(e.value)) .map((MapEntry expsEntry) { - final String subsection = tomlData['${expsEntry.key}_name'] as String? ?? ''; + final String subsection = + tomlData['${expsEntry.key}_name'] as String? ?? _getSubsectionFromKey(expsEntry.key); final exps = (expsEntry.value as List).map((e) => e as Map).toList(); return exps.map((exp) { final TomlLocalDate? start = exp['start'] as TomlLocalDate?; @@ -35,34 +36,47 @@ class DartboardData { start: start == null ? null : DateTime(start.date.year, start.date.month), end: end == null ? null : DateTime(end.date.year, end.date.month), ), - attributes: (exp['attributes'] as List).map((e) => DartboardText(content: e as String)).toList(), + attributes: (exp['attributes'] as List) + .where((e) => e is String && e.isNotEmpty) + .map((e) => DartboardText(content: e as String)) + .toList(), location: exp['location'] as String?, ); }).toList(); }) .expand((i) => i) .toList(); + final List misc = + tomlData.entries.where((e) => DartboardMisc.filter(e.value)).map((MapEntry listEntry) { + final String subsection = tomlData['${listEntry.key}_name'] as String? ?? _getSubsectionFromKey(listEntry.key); + return DartboardMisc( + subsection: subsection, + attributes: (listEntry.value as List) + .map((e) => (e as Map)['attributes'] as List) + .expand((i) => i) + .where((e) => e is String && e.isNotEmpty) + .map((e) => DartboardText(content: e as String)) + .toList(), + ); + }).toList(); return DartboardData( fullName: tomlData['full_name'] as String, - phoneNumber: tomlData['phone_number'] as String, - email: tomlData['email'] as String, - imagePath: tomlData['image'] as String, + phoneNumber: tomlData['phone_number'] as String?, + email: tomlData['email'] as String?, + imagePath: tomlData['image'] as String?, dartboardTheme: DartboardTheme.fromToml(tomlData), experiences: exps, - miscList: [ - DartboardList(subsection: 'Skills & Interest', content: 'Proficient in flutter and other'), - ], + miscList: misc, ); } final String fullName; - final String phoneNumber; - final String email; - // final String address; - final String imagePath; + final String? phoneNumber; + final String? email; + final String? imagePath; final DartboardTheme dartboardTheme; final List experiences; - final List miscList; + final List miscList; Font get font => dartboardTheme.font; @@ -86,9 +100,20 @@ class DartboardData { return exps; } + Map> get groupedMisc { + final Map> miscs = {}; + for (final DartboardMisc misc in miscList) { + if (!miscs.containsKey(misc.subsection)) { + miscs[misc.subsection] = []; + } + miscs[misc.subsection]!.add(misc); + } + return miscs; + } + @override int get hashCode { - return Object.hashAll([fullName, phoneNumber, email, imagePath, ...experiences]); + return Object.hashAll([fullName, phoneNumber, email, imagePath, ...experiences, ...miscList]); } @override @@ -99,17 +124,25 @@ class DartboardData { class DartboardExperience { DartboardExperience({ + required this.subsection, required this.title, required this.attributes, - required this.range, - required this.subsection, + this.range, this.location, }); final String subsection; final String title; - String? location; final List attributes; - final DateRange range; + final DateRange? range; + String? location; + + static bool filter(dynamic e) { + if (e is! List || !e.every((f) => f is Map)) { + return false; + } + final List> entries = e.map((f) => f as Map).toList(); + return entries.every((f) => f['title'] is String && f['attributes'] is List && f.keys.length >= 2); + } @override int get hashCode { @@ -153,19 +186,29 @@ class DateRange { } } -class DartboardList { - DartboardList({required this.subsection, required this.content}); +class DartboardMisc { + DartboardMisc({required this.subsection, required this.attributes}); + final String subsection; - final String content; + final List attributes; + @override int get hashCode { - return Object.hashAll([subsection, content]); + return Object.hashAll([subsection, ...attributes]); } @override bool operator ==(Object other) { return super.hashCode == other.hashCode; } + + static bool filter(dynamic e) { + if (e is! List || e.firstOrNull is! Map) { + return false; + } + final entries = (e.first as Map).entries; + return entries.length == 1 && entries.first.key == 'attributes'; + } } class DartboardTheme { @@ -277,5 +320,13 @@ class DartboardText { String _getSubsectionFromKey(String key) { switch (key) { case 'exp': + return 'Experience'; + case 'misc': + return 'Miscelaneous'; + case 'edu': + return 'Education'; + case '': + default: + return ''; } } diff --git a/lib/dartboard_resume.dart b/lib/dartboard_resume.dart deleted file mode 100644 index 188c842..0000000 --- a/lib/dartboard_resume.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'dart:io'; - -import 'package:dartboard_resume/dartboard_parser.dart'; -import 'package:dartboard_resume/dartboard_widgets.dart'; -import 'package:pdf/pdf.dart'; -import 'package:pdf/widgets.dart'; - -Page generatePdfPage({required DartboardData dartboardData, required int renderNs}) { - final List groupedExperienceList = dartboardData.groupedExperiences.entries.map( - (entry) { - final String subsection = entry.key; - final List experiences = entry.value; - return Column( - children: [ - Row( - children: [ - Text( - subsection, - style: dartboardData.subheaderTextStyle - .merge(const TextStyle(fontSize: 18)) - .apply(color: const PdfColorGrey(0.2)), - ), - ], - ), - Container(height: 2, width: 200, color: const PdfColorGrey(0.7)), - ...experiences.map( - (DartboardExperience exp) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(exp.title, style: dartboardData.subheaderTextStyle.apply(color: const PdfColorGrey(0.3))), - Text( - exp.range.toString(), - style: dartboardData.subheaderTextStyle.apply(color: const PdfColorGrey(0.42)), - ), - ], - ), - ...exp.attributes.map( - (a) => Text( - "${dartboardData.dartboardTheme.bulletPoint} $a", - style: dartboardData.defaultTextStyle.apply( - color: const PdfColorGrey(0.55), - ), - ), - ), - SizedBox(height: 20), - ], - ), - ), - DartboardFooter(dartboardData: dartboardData, renderNs: renderNs), - ], - ); - }, - ).toList(); - return Page( - pageTheme: const PageTheme(pageFormat: PdfPageFormat.standard), - build: (Context context) { - return Column( - children: [ - SizedBox( - height: 120, - width: double.infinity, - child: Stack( - children: [ - Positioned( - left: 0, - child: Container( - height: 100, - width: 100, - decoration: BoxDecoration( - shape: BoxShape.circle, - image: DecorationImage( - fit: BoxFit.contain, - image: MemoryImage( - File(dartboardData.imagePath).readAsBytesSync(), - ), - ), - ), - ), - ), - Center( - child: Column( - children: [ - Text(dartboardData.fullName, style: dartboardData.headerTextStyle), - Text(dartboardData.phoneNumber, style: dartboardData.headerTextStyle), - Text(dartboardData.email, style: dartboardData.headerTextStyle), - ], - ), - ), - ], - ), - ), - Container(height: 20), - ...groupedExperienceList, - ], - ); - }, - ); -} diff --git a/lib/dartboard_widgets.dart b/lib/dartboard_widgets.dart index da5df3e..e6b56ab 100644 --- a/lib/dartboard_widgets.dart +++ b/lib/dartboard_widgets.dart @@ -10,18 +10,21 @@ class DartboardFooter extends StatelessWidget { @override Widget build(Context context) { final double renderTimeMs = renderNs.toDouble() / 1000.0; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Text( - 'This resume was generated with DartBoard Resume', - style: dartboardData.defaultTextStyle.copyWith(fontSize: 10), - ), - Text( - 'Rendered in ${renderTimeMs.toStringAsFixed(2)}ms', - style: dartboardData.defaultTextStyle.copyWith(fontSize: 10), - ), - ], + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Text( + 'This resume was generated with DartBoard Resume', + style: dartboardData.defaultTextStyle.copyWith(fontSize: 10), + ), + Text( + 'Rendered in ${renderTimeMs.toStringAsFixed(2)}ms', + style: dartboardData.defaultTextStyle.copyWith(fontSize: 10), + ), + ], + ), ); } } @@ -40,24 +43,27 @@ class DartboardExperienceEntry extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(exp.title, style: dartboardData.subheaderTextStyle.apply(color: const PdfColorGrey(0.3))), - Container(height: 1, width: 60, color: const PdfColorGrey(0.75)), - ], + Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(exp.title, style: dartboardData.subheaderTextStyle.apply(color: const PdfColorGrey(0.3))), + Container(height: 1, width: 60, color: const PdfColorGrey(0.75)), + ], + ), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( exp.range.toString(), - style: dartboardData.defaultTextStyle.apply(color: const PdfColorGrey(0.42)), + style: dartboardData.defaultTextStyle.copyWith(color: const PdfColorGrey(0.42), fontSize: 8.0), ), if (exp.location != null) Text( exp.location!, - style: dartboardData.defaultTextStyle.apply(color: const PdfColorGrey(0.42)), + style: dartboardData.defaultTextStyle.copyWith(color: const PdfColorGrey(0.42), fontSize: 8.0), ), ], ), @@ -73,15 +79,86 @@ class DartboardExperienceEntry extends StatelessWidget { color: const PdfColorGrey(0.55), ), ), - padding: const EdgeInsets.only(bottom: 4.0), + padding: const EdgeInsets.only(left: 8.0, bottom: 4.0), ), ), - SizedBox(height: 20), + SizedBox(height: 12), ], ); } } +class DartboardMiscEntry extends StatelessWidget { + DartboardMiscEntry({required this.dartboardData, required this.misc}); + final DartboardData dartboardData; + final DartboardMisc misc; + + @override + Widget build(Context context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...misc.attributes.map( + (a) => Padding( + padding: const EdgeInsets.only(left: 8.0, bottom: 4.0), + child: DartboardTextWithLink( + stringSections: a.toTextLinkList(), + bulletString: dartboardData.dartboardTheme.bulletPoint, + style: dartboardData.defaultTextStyle.apply( + color: const PdfColorGrey(0.55), + ), + ), + ), + ), + SizedBox(height: 12), + ], + ); + } +} + +class UrlFootnotes { + // Factory constructor to return the single instance + factory UrlFootnotes() { + return _instance; + } + + // Private constructor + UrlFootnotes._privateConstructor(); + + // Static field to hold the single instance of the class + static final UrlFootnotes _instance = UrlFootnotes._privateConstructor(); + + // Field to hold the number + int _numUrls = 0; + List _urlWidgets = []; + TextStyle? _style; + + // ignore: avoid_setters_without_getters + set style(TextStyle style) => _style = style; + + int add({required String url}) { + _numUrls += 1; + if (_style == null) { + throw Exception('Must provide text style for urls'); + } + _urlWidgets.add(Footnote(number: _numUrls, style: _style!, url: url)); + print(_urlWidgets); + return _numUrls; + } + + List get footnotes { + final widgets = [..._urlWidgets]; + reset(); + return widgets; + } + + // Method to reset the number + void reset() { + _numUrls = 0; + _urlWidgets = []; + } +} + // TODO this lays out long text lines on a newline after a uilink rather than soft wrapping it. class DartboardTextWithLink extends StatelessWidget { DartboardTextWithLink({this.bulletString, required this.stringSections, this.style}); @@ -98,26 +175,85 @@ class DartboardTextWithLink extends StatelessWidget { Text(bulletString == null ? '' : '$bulletString ', style: style), SizedBox( width: DartboardTheme.width - DartboardTheme.margin * 2 - 20.0, - child: Wrap( - children: [ - ...stringSections.map((s) { - switch (s.type) { - case DartboardTextType.normal: - return Text(s.text, style: style); - case DartboardTextType.linkText: - return UrlLink( - destination: s.url!, - child: Text( - s.text, - style: style?.copyWith(color: PdfColors.blue), - ), - ); - } - }), - ], + child: RichText( + text: TextSpan( + children: [ + ...stringSections.map((s) { + return TextSpan(text: s.text, style: style); + // switch (s.type) { + // case DartboardTextType.normal: + // return [Text(s.text, style: style)]; + // case DartboardTextType.linkText: + // final number = UrlFootnotes().add(url: s.url!); + // return [ + // Text(s.text, style: style), + // UrlLink( + // destination: s.url!, + // child: Padding( + // padding: const EdgeInsets.only(bottom: 4.0, left: 2.0, right: 2.0), + // child: Text( + // number.toString(), + // style: style?.copyWith(color: PdfColors.blue, fontSize: 8), + // ), + // ), + // ), + // ]; + // } + }), + ...stringSections.map((s) { + switch (s.type) { + case DartboardTextType.normal: + return null; + case DartboardTextType.linkText: + final number = UrlFootnotes().add(url: s.url!); + return TextSpan( + text: ' [$number]', + style: style?.copyWith(color: PdfColors.blue, fontSize: 8), + ); + // return UrlLink( + // destination: s.url!, + // child: Padding( + // padding: const EdgeInsets.only(bottom: 4.0, left: 2.0, right: 2.0), + // child: Text( + // number.toString(), + // style: style?.copyWith(color: PdfColors.blue, fontSize: 8), + // ), + // ), + // ); + } + }).nonNulls, + ], + ), ), ), ], ); } } + +class Footnote extends StatelessWidget { + Footnote({required this.number, required this.url, required this.style}); + + final String url; + final int number; + final TextStyle style; + + @override + Widget build(Context context) { + return Row( + children: [ + UrlLink( + destination: url, + child: Text( + '[$number]', + style: style.copyWith(color: PdfColors.blue, fontSize: 8), + ), + ), + Text( + ' $url', + style: style.copyWith(fontSize: 8), + ), + ], + ); + } +} diff --git a/lib/render.dart b/lib/render.dart index ec0f188..1a4b7a2 100644 --- a/lib/render.dart +++ b/lib/render.dart @@ -19,10 +19,11 @@ Future renderPdf(String tomlFilePath, {bool force = false}) async { stdout.writeln("Detected change:\nRendering with new dartboard data: $lastDartboardHash"); final pdfFuture = Document()..addPage(_generatePdfPage(dartboardData: dartboardData, renderNs: 0)); await pdfFuture.save(); + final renderNs = DateTime.now().microsecondsSinceEpoch - start; + final pdf = Document(); pdf.addPage(_generatePdfPage(dartboardData: dartboardData, renderNs: renderNs)); - final file = File("example.pdf"); final bytes = await pdf.save(); @@ -30,8 +31,10 @@ Future renderPdf(String tomlFilePath, {bool force = false}) async { file.writeAsBytesSync(bytes); stdout.writeln('Reloading llpp...'); Process.runSync('pkill', ['-HUP', 'llpp']); - } catch (e) { + UrlFootnotes().reset(); + } catch (e, st) { stderr.writeln('Encountered error: $e'); + stderr.writeln(st); try { stderr.writeln('Current toml map:\n${TomlDocument.loadSync(tomlFilePath).toMap()}'); } catch (_) { @@ -41,6 +44,7 @@ Future renderPdf(String tomlFilePath, {bool force = false}) async { } Page _generatePdfPage({required DartboardData dartboardData, required int renderNs}) { + UrlFootnotes().style = dartboardData.defaultTextStyle; final List groupedExperienceList = dartboardData.groupedExperiences.entries.map( (entry) { final String subsection = entry.key; @@ -57,17 +61,49 @@ Page _generatePdfPage({required DartboardData dartboardData, required int render ), ], ), - Container(height: 2, width: 200, color: const PdfColorGrey(0.7)), + Row( + children: [ + Container(height: 2, width: 200, color: const PdfColorGrey(0.7)), + ], + ), + SizedBox(height: 3), ...experiences.map( (DartboardExperience exp) => DartboardExperienceEntry(dartboardData: dartboardData, exp: exp), ), - DartboardFooter(dartboardData: dartboardData, renderNs: renderNs), ], ); }, ).toList(); - return Page( - pageTheme: const PageTheme(pageFormat: PdfPageFormat.standard), + final List groupedMiscList = dartboardData.groupedMisc.entries.map((entry) { + final String subsection = entry.key; + final List miscs = entry.value; + return Column( + children: [ + Row( + children: [ + Text( + subsection, + style: dartboardData.subheaderTextStyle + .merge(const TextStyle(fontSize: 18)) + .apply(color: const PdfColorGrey(0.2)), + ), + ], + ), + Row( + children: [ + Container(height: 2, width: 200, color: const PdfColorGrey(0.7)), + ], + ), + SizedBox(height: 3), + ...miscs.map( + (DartboardMisc misc) => DartboardMiscEntry(dartboardData: dartboardData, misc: misc), + ), + ], + ); + }).toList(); + return FullPage( + pageTheme: PageTheme( + buildBackground: (_) => Container(color: dartboardData.backgroundColor), pageFormat: PdfPageFormat.standard), build: (Context context) { return // FullPage( @@ -75,41 +111,59 @@ Page _generatePdfPage({required DartboardData dartboardData, required int render // child: Column( children: [ - SizedBox( - height: 120, - width: double.infinity, - child: Stack( - children: [ - Positioned( - left: 0, - child: Container( - height: 100, - width: 100, - decoration: BoxDecoration( - shape: BoxShape.circle, - image: DecorationImage( - fit: BoxFit.contain, - image: MemoryImage( - File(dartboardData.imagePath).readAsBytesSync(), + if (dartboardData.imagePath == null) + Center( + child: Column( + children: [ + Text(dartboardData.fullName, style: dartboardData.headerTextStyle), + if (dartboardData.phoneNumber != null) + Text(dartboardData.phoneNumber!, style: dartboardData.headerTextStyle), + if (dartboardData.email != null) Text(dartboardData.email!, style: dartboardData.headerTextStyle), + ], + ), + ) + else + SizedBox( + height: 100, + width: double.infinity, + child: Stack( + children: [ + if (dartboardData.imagePath != null) + Positioned( + left: 0, + child: Container( + height: 100, + width: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + image: DecorationImage( + fit: BoxFit.contain, + image: MemoryImage( + File(dartboardData.imagePath!).readAsBytesSync(), + ), + ), ), ), ), + Center( + child: Column( + children: [ + Text(dartboardData.fullName, style: dartboardData.headerTextStyle), + if (dartboardData.phoneNumber != null) + Text(dartboardData.phoneNumber!, style: dartboardData.headerTextStyle), + if (dartboardData.email != null) + Text(dartboardData.email!, style: dartboardData.headerTextStyle), + ], + ), ), - ), - Center( - child: Column( - children: [ - Text(dartboardData.fullName, style: dartboardData.headerTextStyle), - Text(dartboardData.phoneNumber, style: dartboardData.headerTextStyle), - Text(dartboardData.email, style: dartboardData.headerTextStyle), - ], - ), - ), - ], + ], + ), ), - ), - Container(height: 20), ...groupedExperienceList, + ...groupedMiscList, + // this is quirky and gets evaluated before the actual footnotes get added up, so the original adding is done in the prerender + ...UrlFootnotes().footnotes, + DartboardFooter(dartboardData: dartboardData, renderNs: renderNs), ], // ), );