Added support for arbitrary lists and experience in markup

This commit is contained in:
Nathan Anderson 2024-09-10 13:14:00 -06:00
parent 976c3d0679
commit aa71e8a9e6
5 changed files with 346 additions and 198 deletions

View File

@ -2,9 +2,11 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:dartboard_resume/dartboard_parser.dart';
import 'package:dartboard_resume/render.dart'; import 'package:dartboard_resume/render.dart';
import 'package:hotreloader/hotreloader.dart'; import 'package:hotreloader/hotreloader.dart';
import 'package:logging/logging.dart' as logging; import 'package:logging/logging.dart' as logging;
import 'package:toml/toml.dart';
StreamSubscription<FileSystemEvent>? fileStreamSub; StreamSubscription<FileSystemEvent>? fileStreamSub;
StreamSubscription<String>? stdinStreamSub; StreamSubscription<String>? stdinStreamSub;
@ -24,6 +26,12 @@ Future<void> main(List<String> arguments) async {
stdout.writeln("Triggering pdf render..."); stdout.writeln("Triggering pdf render...");
renderPdf(tomlFilePath, force: true); 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);
}
}, },
); );

View File

@ -21,9 +21,10 @@ class DartboardData {
final tomlData = TomlDocument.loadSync(tomlFilePath).toMap(); final tomlData = TomlDocument.loadSync(tomlFilePath).toMap();
final List<DartboardExperience> exps = tomlData.entries final List<DartboardExperience> exps = tomlData.entries
.where((e) => e.value is List && (e.value as List).firstOrNull is Map) .where((e) => DartboardExperience.filter(e.value))
.map((MapEntry<String, dynamic> expsEntry) { .map((MapEntry<String, dynamic> 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<String, dynamic>).toList(); final exps = (expsEntry.value as List).map((e) => e as Map<String, dynamic>).toList();
return exps.map((exp) { return exps.map((exp) {
final TomlLocalDate? start = exp['start'] as TomlLocalDate?; final TomlLocalDate? start = exp['start'] as TomlLocalDate?;
@ -35,34 +36,47 @@ class DartboardData {
start: start == null ? null : DateTime(start.date.year, start.date.month), start: start == null ? null : DateTime(start.date.year, start.date.month),
end: end == null ? null : DateTime(end.date.year, end.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?, location: exp['location'] as String?,
); );
}).toList(); }).toList();
}) })
.expand((i) => i) .expand((i) => i)
.toList(); .toList();
final List<DartboardMisc> misc =
tomlData.entries.where((e) => DartboardMisc.filter(e.value)).map((MapEntry<String, dynamic> 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<String, dynamic>)['attributes'] as List)
.expand((i) => i)
.where((e) => e is String && e.isNotEmpty)
.map((e) => DartboardText(content: e as String))
.toList(),
);
}).toList();
return DartboardData( return DartboardData(
fullName: tomlData['full_name'] as String, fullName: tomlData['full_name'] as String,
phoneNumber: tomlData['phone_number'] as String, phoneNumber: tomlData['phone_number'] as String?,
email: tomlData['email'] as String, email: tomlData['email'] as String?,
imagePath: tomlData['image'] as String, imagePath: tomlData['image'] as String?,
dartboardTheme: DartboardTheme.fromToml(tomlData), dartboardTheme: DartboardTheme.fromToml(tomlData),
experiences: exps, experiences: exps,
miscList: [ miscList: misc,
DartboardList(subsection: 'Skills & Interest', content: 'Proficient in flutter and other'),
],
); );
} }
final String fullName; final String fullName;
final String phoneNumber; final String? phoneNumber;
final String email; final String? email;
// final String address; final String? imagePath;
final String imagePath;
final DartboardTheme dartboardTheme; final DartboardTheme dartboardTheme;
final List<DartboardExperience> experiences; final List<DartboardExperience> experiences;
final List<DartboardList> miscList; final List<DartboardMisc> miscList;
Font get font => dartboardTheme.font; Font get font => dartboardTheme.font;
@ -86,9 +100,20 @@ class DartboardData {
return exps; return exps;
} }
Map<String, List<DartboardMisc>> get groupedMisc {
final Map<String, List<DartboardMisc>> miscs = {};
for (final DartboardMisc misc in miscList) {
if (!miscs.containsKey(misc.subsection)) {
miscs[misc.subsection] = <DartboardMisc>[];
}
miscs[misc.subsection]!.add(misc);
}
return miscs;
}
@override @override
int get hashCode { int get hashCode {
return Object.hashAll([fullName, phoneNumber, email, imagePath, ...experiences]); return Object.hashAll([fullName, phoneNumber, email, imagePath, ...experiences, ...miscList]);
} }
@override @override
@ -99,17 +124,25 @@ class DartboardData {
class DartboardExperience { class DartboardExperience {
DartboardExperience({ DartboardExperience({
required this.subsection,
required this.title, required this.title,
required this.attributes, required this.attributes,
required this.range, this.range,
required this.subsection,
this.location, this.location,
}); });
final String subsection; final String subsection;
final String title; final String title;
String? location;
final List<DartboardText> attributes; final List<DartboardText> 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<Map<String, dynamic>> entries = e.map((f) => f as Map<String, dynamic>).toList();
return entries.every((f) => f['title'] is String && f['attributes'] is List && f.keys.length >= 2);
}
@override @override
int get hashCode { int get hashCode {
@ -153,19 +186,29 @@ class DateRange {
} }
} }
class DartboardList { class DartboardMisc {
DartboardList({required this.subsection, required this.content}); DartboardMisc({required this.subsection, required this.attributes});
final String subsection; final String subsection;
final String content; final List<DartboardText> attributes;
@override @override
int get hashCode { int get hashCode {
return Object.hashAll([subsection, content]); return Object.hashAll([subsection, ...attributes]);
} }
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return super.hashCode == other.hashCode; 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 { class DartboardTheme {
@ -277,5 +320,13 @@ class DartboardText {
String _getSubsectionFromKey(String key) { String _getSubsectionFromKey(String key) {
switch (key) { switch (key) {
case 'exp': case 'exp':
return 'Experience';
case 'misc':
return 'Miscelaneous';
case 'edu':
return 'Education';
case '':
default:
return '';
} }
} }

View File

@ -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<Widget> groupedExperienceList = dartboardData.groupedExperiences.entries.map<Widget>(
(entry) {
final String subsection = entry.key;
final List<DartboardExperience> 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,
],
);
},
);
}

View File

@ -10,7 +10,9 @@ class DartboardFooter extends StatelessWidget {
@override @override
Widget build(Context context) { Widget build(Context context) {
final double renderTimeMs = renderNs.toDouble() / 1000.0; final double renderTimeMs = renderNs.toDouble() / 1000.0;
return Row( return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
Text( Text(
@ -22,6 +24,7 @@ class DartboardFooter extends StatelessWidget {
style: dartboardData.defaultTextStyle.copyWith(fontSize: 10), style: dartboardData.defaultTextStyle.copyWith(fontSize: 10),
), ),
], ],
),
); );
} }
} }
@ -40,24 +43,27 @@ class DartboardExperienceEntry extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Column( Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(exp.title, style: dartboardData.subheaderTextStyle.apply(color: const PdfColorGrey(0.3))), Text(exp.title, style: dartboardData.subheaderTextStyle.apply(color: const PdfColorGrey(0.3))),
Container(height: 1, width: 60, color: const PdfColorGrey(0.75)), Container(height: 1, width: 60, color: const PdfColorGrey(0.75)),
], ],
), ),
),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Text(
exp.range.toString(), 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) if (exp.location != null)
Text( Text(
exp.location!, 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), 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<Widget> _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<Widget> 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. // TODO this lays out long text lines on a newline after a uilink rather than soft wrapping it.
class DartboardTextWithLink extends StatelessWidget { class DartboardTextWithLink extends StatelessWidget {
DartboardTextWithLink({this.bulletString, required this.stringSections, this.style}); DartboardTextWithLink({this.bulletString, required this.stringSections, this.style});
@ -98,24 +175,83 @@ class DartboardTextWithLink extends StatelessWidget {
Text(bulletString == null ? '' : '$bulletString ', style: style), Text(bulletString == null ? '' : '$bulletString ', style: style),
SizedBox( SizedBox(
width: DartboardTheme.width - DartboardTheme.margin * 2 - 20.0, width: DartboardTheme.width - DartboardTheme.margin * 2 - 20.0,
child: Wrap( child: RichText(
text: TextSpan(
children: [ 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) { ...stringSections.map((s) {
switch (s.type) { switch (s.type) {
case DartboardTextType.normal: case DartboardTextType.normal:
return Text(s.text, style: style); return null;
case DartboardTextType.linkText: case DartboardTextType.linkText:
return UrlLink( final number = UrlFootnotes().add(url: s.url!);
destination: 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( child: Text(
s.text, '[$number]',
style: style?.copyWith(color: PdfColors.blue), style: style.copyWith(color: PdfColors.blue, fontSize: 8),
), ),
);
}
}),
],
), ),
Text(
' $url',
style: style.copyWith(fontSize: 8),
), ),
], ],
); );

View File

@ -19,10 +19,11 @@ Future<void> renderPdf(String tomlFilePath, {bool force = false}) async {
stdout.writeln("Detected change:\nRendering with new dartboard data: $lastDartboardHash"); stdout.writeln("Detected change:\nRendering with new dartboard data: $lastDartboardHash");
final pdfFuture = Document()..addPage(_generatePdfPage(dartboardData: dartboardData, renderNs: 0)); final pdfFuture = Document()..addPage(_generatePdfPage(dartboardData: dartboardData, renderNs: 0));
await pdfFuture.save(); await pdfFuture.save();
final renderNs = DateTime.now().microsecondsSinceEpoch - start; final renderNs = DateTime.now().microsecondsSinceEpoch - start;
final pdf = Document(); final pdf = Document();
pdf.addPage(_generatePdfPage(dartboardData: dartboardData, renderNs: renderNs)); pdf.addPage(_generatePdfPage(dartboardData: dartboardData, renderNs: renderNs));
final file = File("example.pdf"); final file = File("example.pdf");
final bytes = await pdf.save(); final bytes = await pdf.save();
@ -30,8 +31,10 @@ Future<void> renderPdf(String tomlFilePath, {bool force = false}) async {
file.writeAsBytesSync(bytes); file.writeAsBytesSync(bytes);
stdout.writeln('Reloading llpp...'); stdout.writeln('Reloading llpp...');
Process.runSync('pkill', ['-HUP', 'llpp']); Process.runSync('pkill', ['-HUP', 'llpp']);
} catch (e) { UrlFootnotes().reset();
} catch (e, st) {
stderr.writeln('Encountered error: $e'); stderr.writeln('Encountered error: $e');
stderr.writeln(st);
try { try {
stderr.writeln('Current toml map:\n${TomlDocument.loadSync(tomlFilePath).toMap()}'); stderr.writeln('Current toml map:\n${TomlDocument.loadSync(tomlFilePath).toMap()}');
} catch (_) { } catch (_) {
@ -41,6 +44,7 @@ Future<void> renderPdf(String tomlFilePath, {bool force = false}) async {
} }
Page _generatePdfPage({required DartboardData dartboardData, required int renderNs}) { Page _generatePdfPage({required DartboardData dartboardData, required int renderNs}) {
UrlFootnotes().style = dartboardData.defaultTextStyle;
final List<Widget> groupedExperienceList = dartboardData.groupedExperiences.entries.map<Widget>( final List<Widget> groupedExperienceList = dartboardData.groupedExperiences.entries.map<Widget>(
(entry) { (entry) {
final String subsection = entry.key; final String subsection = entry.key;
@ -57,17 +61,49 @@ Page _generatePdfPage({required DartboardData dartboardData, required int render
), ),
], ],
), ),
Row(
children: [
Container(height: 2, width: 200, color: const PdfColorGrey(0.7)), Container(height: 2, width: 200, color: const PdfColorGrey(0.7)),
],
),
SizedBox(height: 3),
...experiences.map( ...experiences.map(
(DartboardExperience exp) => DartboardExperienceEntry(dartboardData: dartboardData, exp: exp), (DartboardExperience exp) => DartboardExperienceEntry(dartboardData: dartboardData, exp: exp),
), ),
DartboardFooter(dartboardData: dartboardData, renderNs: renderNs),
], ],
); );
}, },
).toList(); ).toList();
return Page( final List<Widget> groupedMiscList = dartboardData.groupedMisc.entries.map<Widget>((entry) {
pageTheme: const PageTheme(pageFormat: PdfPageFormat.standard), final String subsection = entry.key;
final List<DartboardMisc> 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) { build: (Context context) {
return return
// FullPage( // FullPage(
@ -75,11 +111,24 @@ Page _generatePdfPage({required DartboardData dartboardData, required int render
// child: // child:
Column( Column(
children: [ children: [
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( SizedBox(
height: 120, height: 100,
width: double.infinity, width: double.infinity,
child: Stack( child: Stack(
children: [ children: [
if (dartboardData.imagePath != null)
Positioned( Positioned(
left: 0, left: 0,
child: Container( child: Container(
@ -90,7 +139,7 @@ Page _generatePdfPage({required DartboardData dartboardData, required int render
image: DecorationImage( image: DecorationImage(
fit: BoxFit.contain, fit: BoxFit.contain,
image: MemoryImage( image: MemoryImage(
File(dartboardData.imagePath).readAsBytesSync(), File(dartboardData.imagePath!).readAsBytesSync(),
), ),
), ),
), ),
@ -100,16 +149,21 @@ Page _generatePdfPage({required DartboardData dartboardData, required int render
child: Column( child: Column(
children: [ children: [
Text(dartboardData.fullName, style: dartboardData.headerTextStyle), Text(dartboardData.fullName, style: dartboardData.headerTextStyle),
Text(dartboardData.phoneNumber, style: dartboardData.headerTextStyle), if (dartboardData.phoneNumber != null)
Text(dartboardData.email, style: dartboardData.headerTextStyle), Text(dartboardData.phoneNumber!, style: dartboardData.headerTextStyle),
if (dartboardData.email != null)
Text(dartboardData.email!, style: dartboardData.headerTextStyle),
], ],
), ),
), ),
], ],
), ),
), ),
Container(height: 20),
...groupedExperienceList, ...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),
], ],
// ), // ),
); );