Refactors

This commit is contained in:
Nathan Anderson 2024-09-10 15:09:15 -06:00
parent aa71e8a9e6
commit fe40731e09
19 changed files with 791 additions and 708 deletions

View File

@ -1,55 +1,21 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:developer' as dev;
import 'dart:io'; import 'dart:io';
import 'package:dartboard_resume/dartboard_parser.dart'; import 'package:dartboard_resume/dartboard_runner.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<String>? stdinStreamSub;
Future<void> main(List<String> arguments) async { Future<void> main(List<String> arguments) async {
const String tomlFilePath = "resume.toml";
logging.hierarchicalLoggingEnabled = true; logging.hierarchicalLoggingEnabled = true;
HotReloader.logLevel = logging.Level.INFO; HotReloader.logLevel = logging.Level.INFO;
final HotReloader reloader = await HotReloader.create(); HotReloader? reloader;
if ((await dev.Service.getInfo()).serverUri != null) {
stdin.lineMode = false; reloader = await HotReloader.create();
stdin.echoMode = false;
stdin.echoNewlineMode = false;
stdinStreamSub = stdin.transform(const Utf8Decoder()).transform(const LineSplitter()).listen(
(event) {
if (event == "r") {
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);
}
},
);
ProcessSignal.sigint.watch().listen((_) {
stdout.writeln('SIGINT received. Exiting gracefully...');
fileStreamSub?.cancel();
stdinStreamSub?.cancel();
// Perform cleanup or other necessary actions here
reloader.stop();
exit(0); // Exit with code 0 to indicate a successful termination
});
if (FileSystemEntity.isWatchSupported) {
final fileStream = File(tomlFilePath).watch(events: FileSystemEvent.modify);
fileStreamSub = fileStream.listen((e) {
renderPdf(tomlFilePath);
});
stdout.writeln('Watching for file changes.');
} else { } else {
renderPdf(tomlFilePath); stdout.writeln(
"Dartboard can hot reload if run with dart's VM service.\n`dart run --enable-vm-service bin/dartboard_resume.dart`",
);
} }
dartboardRun(reloader);
} }

View File

@ -0,0 +1,44 @@
import 'package:dartboard_resume/widgets/footnote.dart';
import 'package:pdf/widgets.dart';
class AnnotationManager {
// Factory constructor to return the single instance
factory AnnotationManager() {
return _instance;
}
// Private constructor
AnnotationManager._privateConstructor();
// Static field to hold the single instance of the class
static final AnnotationManager _instance = AnnotationManager._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));
return _numUrls;
}
List<Widget> get footnotes {
final widgets = [..._urlWidgets];
reset();
return widgets;
}
// Method to reset the number
void reset() {
_numUrls = 0;
_urlWidgets = [];
}
}

View File

@ -1,332 +0,0 @@
import 'dart:io';
import 'package:intl/intl.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart';
import 'package:toml/toml.dart';
class DartboardData {
DartboardData({
required this.fullName,
required this.phoneNumber,
required this.email,
// required this.address,
required this.imagePath,
required this.dartboardTheme,
required this.experiences,
required this.miscList,
});
factory DartboardData.fromToml(String tomlFilePath) {
final tomlData = TomlDocument.loadSync(tomlFilePath).toMap();
final List<DartboardExperience> exps = tomlData.entries
.where((e) => DartboardExperience.filter(e.value))
.map((MapEntry<String, dynamic> expsEntry) {
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();
return exps.map((exp) {
final TomlLocalDate? start = exp['start'] as TomlLocalDate?;
final TomlLocalDate? end = exp['end'] as TomlLocalDate?;
return DartboardExperience(
title: exp['title'] as String,
subsection: subsection,
range: DateRange(
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)
.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<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(
fullName: tomlData['full_name'] 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: misc,
);
}
final String fullName;
final String? phoneNumber;
final String? email;
final String? imagePath;
final DartboardTheme dartboardTheme;
final List<DartboardExperience> experiences;
final List<DartboardMisc> miscList;
Font get font => dartboardTheme.font;
PdfColor get primaryColor => dartboardTheme.primaryColor;
PdfColor get accentColor => dartboardTheme.accentColor;
PdfColor get backgroundColor => dartboardTheme.backgroundColor;
TextStyle get headerTextStyle =>
TextStyle(fontSize: 18, font: font, fontWeight: FontWeight.bold, color: const PdfColorGrey(0.18));
TextStyle get subheaderTextStyle => TextStyle(fontSize: 14, font: font, color: const PdfColorGrey(0.3));
TextStyle get defaultTextStyle => TextStyle(fontSize: 10, font: font, color: const PdfColorGrey(0.45));
Map<String, List<DartboardExperience>> get groupedExperiences {
final Map<String, List<DartboardExperience>> exps = {};
for (final DartboardExperience exp in experiences) {
if (!exps.containsKey(exp.subsection)) {
exps[exp.subsection] = <DartboardExperience>[];
}
exps[exp.subsection]!.add(exp);
}
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
int get hashCode {
return Object.hashAll([fullName, phoneNumber, email, imagePath, ...experiences, ...miscList]);
}
@override
bool operator ==(Object other) {
return super.hashCode == other.hashCode;
}
}
class DartboardExperience {
DartboardExperience({
required this.subsection,
required this.title,
required this.attributes,
this.range,
this.location,
});
final String subsection;
final String title;
final List<DartboardText> attributes;
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
int get hashCode {
return Object.hashAll([subsection, title, ...attributes, range]);
}
@override
bool operator ==(Object other) {
return super.hashCode == other.hashCode;
}
}
class DateRange {
DateRange({required this.start, required this.end});
final DateTime? start;
final DateTime? end;
@override
String toString() {
final DateFormat dateFormat = DateFormat("MMM yyyy");
if (start == null && end == null) {
return '';
}
if (start == null && end != null) {
return dateFormat.format(end!);
}
if (start != null && end == null) {
return "${dateFormat.format(start!)} - Current";
}
return "${dateFormat.format(start!)} - ${dateFormat.format(end!)}";
}
@override
int get hashCode {
return Object.hashAll([start, end]);
}
@override
bool operator ==(Object other) {
return super.hashCode == other.hashCode;
}
}
class DartboardMisc {
DartboardMisc({required this.subsection, required this.attributes});
final String subsection;
final List<DartboardText> attributes;
@override
int get hashCode {
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 {
DartboardTheme({
required this.primaryHex,
required this.accentHex,
required this.backgroundHex,
required this.fontPath,
required this.bulletPoint,
});
factory DartboardTheme.fromToml(Map<String, dynamic> toml) => DartboardTheme(
primaryHex: (toml['theme'] as Map)['primary_hex'] as String,
accentHex: (toml['theme'] as Map)['accent_hex'] as String,
backgroundHex: (toml['theme'] as Map)['background_hex'] as String,
fontPath: (toml['theme'] as Map)['font'] as String,
bulletPoint: (toml['theme'] as Map)['bullet_point'] as String? ?? '-',
);
factory DartboardTheme.retro() => DartboardTheme(
primaryHex: "00AA00",
accentHex: "44EE66",
backgroundHex: "FFFFFF",
fontPath: "nerd.ttf",
bulletPoint: '-',
);
static const double inch = 72.0;
static const double cm = inch / 2.54;
static const double mm = inch / 25.4;
static const width = 21.0 * cm;
static const height = 29.7 * cm;
static const margin = 2.0 * cm;
final String primaryHex;
final String accentHex;
final String backgroundHex;
final String fontPath;
final String bulletPoint;
Font? _font;
PdfColor get primaryColor => PdfColor.fromHex(primaryHex);
PdfColor get accentColor => PdfColor.fromHex(accentHex);
PdfColor get backgroundColor => PdfColor.fromHex(backgroundHex);
Font get font {
_font ??= Font.ttf(File(fontPath).readAsBytesSync().buffer.asByteData());
return _font!;
}
@override
int get hashCode {
return Object.hashAll([primaryHex, accentHex, backgroundHex, fontPath, bulletPoint]);
}
@override
bool operator ==(Object other) {
return super.hashCode == other.hashCode;
}
}
enum DartboardTextType { normal, linkText }
typedef DartboardTextLinkData = ({String text, String? url, DartboardTextType type});
/// Automatically detects and parses strings with hypertext in a markdown format
class DartboardText {
DartboardText({required this.content});
final String content;
static final _markdownLinkRegex = RegExp(r'\[(.*?)\]\((.*?)\)');
bool get hasLinkMarkup => _markdownLinkRegex.hasMatch(content);
List<DartboardTextLinkData> toTextLinkList() {
final markdownLinkRegex = RegExp(r'\[(.*?)\]\((.*?)\)');
if (markdownLinkRegex.hasMatch(content)) {
final matches = markdownLinkRegex.allMatches(content).toList();
final List<DartboardTextLinkData> stringSections = [];
int prevStartIndex = 0;
while (matches.isNotEmpty) {
final match = matches.removeAt(0);
stringSections
.add((text: content.substring(prevStartIndex, match.start), url: null, type: DartboardTextType.normal));
stringSections.add((text: match.group(1)!, url: match.group(2), type: DartboardTextType.linkText));
prevStartIndex = match.end;
}
stringSections
.add((text: content.substring(prevStartIndex, content.length), url: null, type: DartboardTextType.normal));
return stringSections;
} else {
return [(text: content, url: null, type: DartboardTextType.normal)];
// return Text("${bulletString != null ? '$bulletString ' : ''}$content", style: style);
}
}
@override
int get hashCode {
return Object.hashAll([content]);
}
@override
bool operator ==(Object other) {
return super.hashCode == other.hashCode;
}
}
String _getSubsectionFromKey(String key) {
switch (key) {
case 'exp':
return 'Experience';
case 'misc':
return 'Miscelaneous';
case 'edu':
return 'Education';
case '':
default:
return '';
}
}

82
lib/dartboard_runner.dart Normal file
View File

@ -0,0 +1,82 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dartboard_resume/render.dart';
import 'package:hotreloader/hotreloader.dart';
import 'package:toml/toml.dart';
StreamSubscription<FileSystemEvent>? fileStreamSub;
StreamSubscription<String>? stdinStreamSub;
Future<void> dartboardRun(HotReloader? reloader) async {
const String tomlFilePath = "resume.toml";
if (reloader != null) {
stdout.writeln('Hot reload enabled!');
}
ProcessSignal.sigint.watch().listen((_) {
stdout.writeln('SIGINT received. Exiting gracefully...');
fileStreamSub?.cancel();
stdinStreamSub?.cancel();
// Perform cleanup or other necessary actions here
reloader?.stop();
exit(0); // Exit with code 0 to indicate a successful termination
});
stdinStreamSub = getUserInputStream().listen(
(event) {
if (event == "r") {
stdout.writeln("Triggering pdf render...");
createDocument(tomlFilePath);
}
if (event == "p") {
stdout.writeln("Current toml map:");
stdout.writeln(TomlDocument.loadSync(tomlFilePath).toMap());
}
if (event == "q") {
stdout.writeln('Exiting...');
fileStreamSub?.cancel();
stdinStreamSub?.cancel();
// Perform cleanup or other necessary actions here
reloader?.stop();
exit(0); // Exit with code 0 to indicate a successful termination
}
},
);
if (FileSystemEntity.isWatchSupported) {
final fileStream = File(tomlFilePath).watch(events: FileSystemEvent.modify);
fileStreamSub = fileStream.listen((e) {
createDocument(tomlFilePath);
});
stdout.writeln('Watching for file changes.');
} else {
stdout.writeln('File watch is not supported. Exiting upon completion.');
createDocument(tomlFilePath);
}
}
Stream<String> getUserInputStream() {
stdin.lineMode = false;
stdin.echoMode = false;
stdin.echoNewlineMode = false;
return stdin.transform(const Utf8Decoder()).transform(const LineSplitter());
}
void refreshViewer() {
final result = Process.runSync('pgrep', ['llpp']);
if (result.exitCode != 0) {
stdout.writeln(
'Unable to refresh the viewer\nDartboard Resume can refresh your pdf viewer! Here are the supported programs:\n\t- llpp',
);
return;
}
// Send the HUP signal to `llpp`
Process.runSync('pkill', ['-HUP', 'llpp']);
}
void createDocument(String tomlFilePath) {
renderPdf(tomlFilePath, force: true);
refreshViewer();
}

View File

@ -1,259 +0,0 @@
import 'package:dartboard_resume/dartboard_parser.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart';
class DartboardFooter extends StatelessWidget {
DartboardFooter({required this.dartboardData, required this.renderNs});
final int renderNs;
final DartboardData dartboardData;
@override
Widget build(Context context) {
final double renderTimeMs = renderNs.toDouble() / 1000.0;
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),
),
],
),
);
}
}
class DartboardExperienceEntry extends StatelessWidget {
DartboardExperienceEntry({required this.dartboardData, required this.exp});
final DartboardData dartboardData;
final DartboardExperience exp;
@override
Widget build(Context context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
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.copyWith(color: const PdfColorGrey(0.42), fontSize: 8.0),
),
if (exp.location != null)
Text(
exp.location!,
style: dartboardData.defaultTextStyle.copyWith(color: const PdfColorGrey(0.42), fontSize: 8.0),
),
],
),
],
),
SizedBox(height: 6),
...exp.attributes.map(
(a) => Padding(
child: DartboardTextWithLink(
stringSections: a.toTextLinkList(),
bulletString: dartboardData.dartboardTheme.bulletPoint,
style: dartboardData.defaultTextStyle.apply(
color: const PdfColorGrey(0.55),
),
),
padding: const EdgeInsets.only(left: 8.0, bottom: 4.0),
),
),
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.
class DartboardTextWithLink extends StatelessWidget {
DartboardTextWithLink({this.bulletString, required this.stringSections, this.style});
final String? bulletString;
final List<DartboardTextLinkData> stringSections;
final TextStyle? style;
@override
Widget build(Context context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(bulletString == null ? '' : '$bulletString ', style: style),
SizedBox(
width: DartboardTheme.width - DartboardTheme.margin * 2 - 20.0,
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),
),
],
);
}
}

View File

@ -0,0 +1,139 @@
import 'package:dartboard_resume/models/dartboard_misc.dart';
import 'package:dartboard_resume/models/date_range.dart';
import 'package:dartboard_resume/models/document_text.dart';
import 'package:dartboard_resume/models/experience.dart';
import 'package:dartboard_resume/models/theme.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart';
import 'package:toml/toml.dart';
class DartboardData {
DartboardData({
required this.fullName,
required this.phoneNumber,
required this.email,
// required this.address,
required this.imagePath,
required this.dartboardTheme,
required this.experiences,
required this.miscList,
});
factory DartboardData.fromToml(String tomlFilePath) {
final tomlData = TomlDocument.loadSync(tomlFilePath).toMap();
final List<Experience> exps = tomlData.entries
.where((e) => Experience.filter(e.value))
.map((MapEntry<String, dynamic> expsEntry) {
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();
return exps.map((exp) {
final TomlLocalDate? start = exp['start'] as TomlLocalDate?;
final TomlLocalDate? end = exp['end'] as TomlLocalDate?;
return Experience(
title: exp['title'] as String,
subsection: subsection,
range: DateRange(
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)
.where((e) => e is String && e.isNotEmpty)
.map((e) => DocumentText(content: e as String))
.toList(),
location: exp['location'] as String?,
);
}).toList();
})
.expand((i) => i)
.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) => DocumentText(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?,
dartboardTheme: DocumentTheme.fromToml(tomlData),
experiences: exps,
miscList: misc,
);
}
final String fullName;
final String? phoneNumber;
final String? email;
final String? imagePath;
final DocumentTheme dartboardTheme;
final List<Experience> experiences;
final List<DartboardMisc> miscList;
Font get font => dartboardTheme.font;
PdfColor get primaryColor => dartboardTheme.primaryColor;
PdfColor get accentColor => dartboardTheme.accentColor;
PdfColor get backgroundColor => dartboardTheme.backgroundColor;
TextStyle get headerTextStyle =>
TextStyle(fontSize: 18, font: font, fontWeight: FontWeight.bold, color: const PdfColorGrey(0.18));
TextStyle get subheaderTextStyle => TextStyle(fontSize: 14, font: font, color: const PdfColorGrey(0.3));
TextStyle get defaultTextStyle => TextStyle(fontSize: 10, font: font, color: const PdfColorGrey(0.45));
Map<String, List<Experience>> get groupedExperiences {
final Map<String, List<Experience>> exps = {};
for (final Experience exp in experiences) {
if (!exps.containsKey(exp.subsection)) {
exps[exp.subsection] = <Experience>[];
}
exps[exp.subsection]!.add(exp);
}
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
int get hashCode {
return Object.hashAll([fullName, phoneNumber, email, imagePath, dartboardTheme, ...experiences, ...miscList]);
}
@override
bool operator ==(Object other) {
return super.hashCode == other.hashCode;
}
}
String _getSubsectionFromKey(String key) {
switch (key) {
case 'exp':
return 'Experience';
case 'misc':
return 'Miscelaneous';
case 'edu':
return 'Education';
case '':
default:
return '';
}
}

View File

@ -0,0 +1,26 @@
import 'package:dartboard_resume/models/document_text.dart';
class DartboardMisc {
DartboardMisc({required this.subsection, required this.attributes});
final String subsection;
final List<DocumentText> attributes;
@override
int get hashCode {
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';
}
}

View File

@ -0,0 +1,32 @@
import 'package:intl/intl.dart';
class DateRange {
DateRange({required this.start, required this.end});
final DateTime? start;
final DateTime? end;
@override
String toString() {
final DateFormat dateFormat = DateFormat("MMM yyyy");
if (start == null && end == null) {
return '';
}
if (start == null && end != null) {
return dateFormat.format(end!);
}
if (start != null && end == null) {
return "${dateFormat.format(start!)} - Current";
}
return "${dateFormat.format(start!)} - ${dateFormat.format(end!)}";
}
@override
int get hashCode {
return Object.hashAll([start, end]);
}
@override
bool operator ==(Object other) {
return super.hashCode == other.hashCode;
}
}

View File

@ -0,0 +1,45 @@
enum DocumentTextType { normal, linkText }
typedef DocumentTextLinkData = ({String text, String? url, DocumentTextType type});
/// Automatically detects and parses strings with hypertext in a markdown format
class DocumentText {
DocumentText({required this.content});
final String content;
static final _markdownLinkRegex = RegExp(r'\[(.*?)\]\((.*?)\)');
bool get hasLinkMarkup => _markdownLinkRegex.hasMatch(content);
List<DocumentTextLinkData> toTextLinkList() {
final markdownLinkRegex = RegExp(r'\[(.*?)\]\((.*?)\)');
if (markdownLinkRegex.hasMatch(content)) {
final matches = markdownLinkRegex.allMatches(content).toList();
final List<DocumentTextLinkData> stringSections = [];
int prevStartIndex = 0;
while (matches.isNotEmpty) {
final match = matches.removeAt(0);
stringSections
.add((text: content.substring(prevStartIndex, match.start), url: null, type: DocumentTextType.normal));
stringSections.add((text: match.group(1)!, url: match.group(2), type: DocumentTextType.linkText));
prevStartIndex = match.end;
}
stringSections
.add((text: content.substring(prevStartIndex, content.length), url: null, type: DocumentTextType.normal));
return stringSections;
} else {
return [(text: content, url: null, type: DocumentTextType.normal)];
// return Text("${bulletString != null ? '$bulletString ' : ''}$content", style: style);
}
}
@override
int get hashCode {
return Object.hashAll([content]);
}
@override
bool operator ==(Object other) {
return super.hashCode == other.hashCode;
}
}

View File

@ -0,0 +1,35 @@
import 'package:dartboard_resume/models/date_range.dart';
import 'package:dartboard_resume/models/document_text.dart';
class Experience {
Experience({
required this.subsection,
required this.title,
required this.attributes,
this.range,
this.location,
});
final String subsection;
final String title;
final List<DocumentText> attributes;
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
int get hashCode {
return Object.hashAll([subsection, title, ...attributes, range]);
}
@override
bool operator ==(Object other) {
return super.hashCode == other.hashCode;
}
}

64
lib/models/theme.dart Normal file
View File

@ -0,0 +1,64 @@
import 'dart:io';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart';
class DocumentTheme {
DocumentTheme({
required this.primaryHex,
required this.accentHex,
required this.backgroundHex,
required this.fontPath,
required this.bulletPoint,
});
factory DocumentTheme.fromToml(Map<String, dynamic> toml) => DocumentTheme(
primaryHex: (toml['theme'] as Map)['primary_hex'] as String,
accentHex: (toml['theme'] as Map)['accent_hex'] as String,
backgroundHex: (toml['theme'] as Map)['background_hex'] as String,
fontPath: (toml['theme'] as Map)['font'] as String,
bulletPoint: (toml['theme'] as Map)['bullet_point'] as String? ?? '-',
);
factory DocumentTheme.retro() => DocumentTheme(
primaryHex: "00AA00",
accentHex: "44EE66",
backgroundHex: "FFFFFF",
fontPath: "nerd.ttf",
bulletPoint: '-',
);
static const double inch = 72.0;
static const double cm = inch / 2.54;
static const double mm = inch / 25.4;
static const width = 21.0 * cm;
static const height = 29.7 * cm;
static const margin = 2.0 * cm;
final String primaryHex;
final String accentHex;
final String backgroundHex;
final String fontPath;
final String bulletPoint;
Font? _font;
PdfColor get primaryColor => PdfColor.fromHex(primaryHex);
PdfColor get accentColor => PdfColor.fromHex(accentHex);
PdfColor get backgroundColor => PdfColor.fromHex(backgroundHex);
Font get font {
_font ??= Font.ttf(File(fontPath).readAsBytesSync().buffer.asByteData());
return _font!;
}
@override
int get hashCode {
return Object.hashAll([primaryHex, accentHex, backgroundHex, fontPath, bulletPoint]);
}
@override
bool operator ==(Object other) {
return super.hashCode == other.hashCode;
}
}

View File

@ -1,7 +1,13 @@
import 'dart:io'; import 'dart:io';
import 'package:dartboard_resume/dartboard_parser.dart'; import 'package:dartboard_resume/annotation_manager.dart';
import 'package:dartboard_resume/dartboard_widgets.dart'; import 'package:dartboard_resume/models/dartboard_data.dart';
import 'package:dartboard_resume/models/dartboard_misc.dart';
import 'package:dartboard_resume/models/experience.dart';
import 'package:dartboard_resume/models/theme.dart';
import 'package:dartboard_resume/widgets/dartboard_misc_entry.dart';
import 'package:dartboard_resume/widgets/experience_entry.dart';
import 'package:dartboard_resume/widgets/footer.dart';
import 'package:pdf/pdf.dart'; import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart'; import 'package:pdf/widgets.dart';
import 'package:toml/toml.dart'; import 'package:toml/toml.dart';
@ -29,9 +35,7 @@ Future<void> renderPdf(String tomlFilePath, {bool force = false}) async {
stdout.writeln('New pdf file saved.'); stdout.writeln('New pdf file saved.');
file.writeAsBytesSync(bytes); file.writeAsBytesSync(bytes);
stdout.writeln('Reloading llpp...'); AnnotationManager().reset();
Process.runSync('pkill', ['-HUP', 'llpp']);
UrlFootnotes().reset();
} catch (e, st) { } catch (e, st) {
stderr.writeln('Encountered error: $e'); stderr.writeln('Encountered error: $e');
stderr.writeln(st); stderr.writeln(st);
@ -44,11 +48,11 @@ 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; AnnotationManager().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;
final List<DartboardExperience> experiences = entry.value; final List<Experience> experiences = entry.value;
return Column( return Column(
children: [ children: [
Row( Row(
@ -68,7 +72,7 @@ Page _generatePdfPage({required DartboardData dartboardData, required int render
), ),
SizedBox(height: 3), SizedBox(height: 3),
...experiences.map( ...experiences.map(
(DartboardExperience exp) => DartboardExperienceEntry(dartboardData: dartboardData, exp: exp), (Experience exp) => ExperienceEntry(dartboardData: dartboardData, exp: exp),
), ),
], ],
); );
@ -101,71 +105,76 @@ Page _generatePdfPage({required DartboardData dartboardData, required int render
], ],
); );
}).toList(); }).toList();
return FullPage( return Page(
pageTheme: PageTheme( pageTheme: PageTheme(
buildBackground: (_) => Container(color: dartboardData.backgroundColor), pageFormat: PdfPageFormat.standard), buildBackground: (_) => Container(color: dartboardData.backgroundColor),
pageFormat: PdfPageFormat.standard,
margin: EdgeInsets.zero,
),
build: (Context context) { build: (Context context) {
return return FullPage(
// FullPage( ignoreMargins: true,
// ignoreMargins: true, child: Padding(
// child: padding: const EdgeInsets.all(DocumentTheme.cm * 2),
Column( child: Column(
children: [ children: [
if (dartboardData.imagePath == null) if (dartboardData.imagePath == null)
Center( Center(
child: Column( child: Column(
children: [ children: [
Text(dartboardData.fullName, style: dartboardData.headerTextStyle), Text(dartboardData.fullName, style: dartboardData.headerTextStyle),
if (dartboardData.phoneNumber != null) if (dartboardData.phoneNumber != null)
Text(dartboardData.phoneNumber!, style: dartboardData.headerTextStyle), Text(dartboardData.phoneNumber!, style: dartboardData.headerTextStyle),
if (dartboardData.email != null) Text(dartboardData.email!, style: dartboardData.headerTextStyle), if (dartboardData.email != null) Text(dartboardData.email!, style: dartboardData.headerTextStyle),
], ],
), ),
) )
else else
SizedBox( SizedBox(
height: 100, height: 100,
width: double.infinity, width: double.infinity,
child: Stack( child: Stack(
children: [ children: [
if (dartboardData.imagePath != null) if (dartboardData.imagePath != null)
Positioned( Positioned(
left: 0, left: 0,
child: Container( child: Container(
height: 100, height: 100,
width: 100, width: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
image: DecorationImage( image: DecorationImage(
fit: BoxFit.contain, fit: BoxFit.contain,
image: MemoryImage( image: MemoryImage(
File(dartboardData.imagePath!).readAsBytesSync(), 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),
if (dartboardData.phoneNumber != null)
Text(dartboardData.phoneNumber!, style: dartboardData.headerTextStyle),
if (dartboardData.email != null)
Text(dartboardData.email!, style: dartboardData.headerTextStyle),
],
),
), ),
], ),
), ...groupedExperienceList,
), ...groupedMiscList,
...groupedExperienceList, // this is quirky and gets evaluated before the actual footnotes get added up, so the original adding is done in the prerender
...groupedMiscList, ...AnnotationManager().footnotes,
// this is quirky and gets evaluated before the actual footnotes get added up, so the original adding is done in the prerender DartboardFooter(dartboardData: dartboardData, renderNs: renderNs),
...UrlFootnotes().footnotes, ],
DartboardFooter(dartboardData: dartboardData, renderNs: renderNs), // ),
], ),
// ), ),
); );
}, },
); );

View File

@ -0,0 +1,77 @@
// TODO this lays out long text lines on a newline after a uilink rather than soft wrapping it.
import 'package:dartboard_resume/annotation_manager.dart';
import 'package:dartboard_resume/models/document_text.dart';
import 'package:dartboard_resume/models/theme.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart';
class AnnotatedText extends StatelessWidget {
AnnotatedText({this.bulletString, required this.stringSections, this.style});
final String? bulletString;
final List<DocumentTextLinkData> stringSections;
final TextStyle? style;
@override
Widget build(Context context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(bulletString == null ? '' : '$bulletString ', style: style),
SizedBox(
width: DocumentTheme.width - DocumentTheme.margin * 2 - 20.0,
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 DocumentTextType.normal:
return null;
case DocumentTextType.linkText:
final number = AnnotationManager().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,
],
),
),
),
],
);
}
}

View File

@ -0,0 +1,33 @@
import 'package:dartboard_resume/models/dartboard_data.dart';
import 'package:dartboard_resume/models/dartboard_misc.dart';
import 'package:dartboard_resume/widgets/annotated_text.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart';
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: AnnotatedText(
stringSections: a.toTextLinkList(),
bulletString: dartboardData.dartboardTheme.bulletPoint,
style: dartboardData.defaultTextStyle.apply(
color: const PdfColorGrey(0.55),
),
),
),
),
SizedBox(height: 12),
],
);
}
}

View File

@ -0,0 +1,64 @@
import 'package:dartboard_resume/models/dartboard_data.dart';
import 'package:dartboard_resume/models/experience.dart';
import 'package:dartboard_resume/widgets/annotated_text.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart';
class ExperienceEntry extends StatelessWidget {
ExperienceEntry({required this.dartboardData, required this.exp});
final DartboardData dartboardData;
final Experience exp;
@override
Widget build(Context context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
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.copyWith(color: const PdfColorGrey(0.42), fontSize: 8.0),
),
if (exp.location != null)
Text(
exp.location!,
style: dartboardData.defaultTextStyle.copyWith(color: const PdfColorGrey(0.42), fontSize: 8.0),
),
],
),
],
),
SizedBox(height: 6),
...exp.attributes.map(
(a) => Padding(
child: AnnotatedText(
stringSections: a.toTextLinkList(),
bulletString: dartboardData.dartboardTheme.bulletPoint,
style: dartboardData.defaultTextStyle.apply(
color: const PdfColorGrey(0.55),
),
),
padding: const EdgeInsets.only(left: 8.0, bottom: 4.0),
),
),
SizedBox(height: 12),
],
);
}
}

29
lib/widgets/footer.dart Normal file
View File

@ -0,0 +1,29 @@
import 'package:dartboard_resume/models/dartboard_data.dart';
import 'package:pdf/widgets.dart';
class DartboardFooter extends StatelessWidget {
DartboardFooter({required this.dartboardData, required this.renderNs});
final int renderNs;
final DartboardData dartboardData;
@override
Widget build(Context context) {
final double renderTimeMs = renderNs.toDouble() / 1000.0;
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),
),
],
),
);
}
}

29
lib/widgets/footnote.dart Normal file
View File

@ -0,0 +1,29 @@
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart';
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),
),
],
);
}
}

View File

@ -5,23 +5,23 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: _fe_analyzer_shared name: _fe_analyzer_shared
sha256: "5aaf60d96c4cd00fe7f21594b5ad6a1b699c80a27420f8a837f4d68473ef09e3" sha256: "45cfa8471b89fb6643fe9bf51bd7931a76b8f5ec2d65de4fb176dba8d4f22c77"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "68.0.0" version: "73.0.0"
_macros: _macros:
dependency: transitive dependency: transitive
description: dart description: dart
source: sdk source: sdk
version: "0.1.0" version: "0.3.2"
analyzer: analyzer:
dependency: transitive dependency: transitive
description: description:
name: analyzer name: analyzer
sha256: "21f1d3720fd1c70316399d5e2bccaebb415c434592d778cce8acb967b8578808" sha256: "4959fec185fe70cce007c57e9ab6983101dbe593d2bf8bbfb4453aaec0cf470a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.5.0" version: "6.8.0"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@ -218,10 +218,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: macros name: macros
sha256: "12e8a9842b5a7390de7a781ec63d793527582398d16ea26c60fed58833c9ae79" sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.0-main.0" version: "0.1.2-main.4"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:

View File

@ -8,6 +8,7 @@ environment:
# Add regular dependencies here. # Add regular dependencies here.
dependencies: dependencies:
hotreloader: ^4.2.0
intl: ^0.19.0 intl: ^0.19.0
lint: ^2.3.0 lint: ^2.3.0
logging: ^1.2.0 logging: ^1.2.0
@ -18,5 +19,4 @@ dependencies:
dev_dependencies: dev_dependencies:
lints: ^3.0.0 lints: ^3.0.0
test: ^1.24.0 test: ^1.24.0
hotreloader: ^4.2.0