A Flutter app (Android + iOS, with web builds for quick previews) for building resumes, cover letters and application letters on your phone and exporting them to PDF, Word, plain text, Markdown or LaTeX.
It replaces an Overleaf workflow: the same blue-heading, ✓-bullet look is
reproduced natively so you can edit content in structured forms and get a
finished document without touching LaTeX — but the real .tex source is still
one tap away when you want pixel-exact Overleaf output.
The app ships with a generic placeholder CV ("Alex Doe" at fictional companies).
Edit it in place, or replace lib/data/seed_data.dart, to make it yours.
| Structured editing | Contact, summary, experience, skills, education, projects, references — each a form with add / reorder / delete. Letters have recipient, body paragraphs, sign-off and {{placeholder}} fields. |
| Native PDF | Rendered with the pdf package to match the LaTeX template (blue #0000FF headings, accent rules, ✓ bullets, EB Garamond body, Font Awesome contact icons). |
| Exports | PDF · DOCX (hand-built WordprocessingML) · plain text · Markdown · LaTeX .tex (fills the real templates in assets/templates/). |
| LaTeX source view | See and copy/share the generated .tex, with a token reference panel. |
| Signature | Pick one signature image; it's decoded and re-encoded to a real PNG on pick (so a HEIC/WebP asset from the gallery can't silently break exports later), appears above your name on letter PDFs, and ships alongside .tex exports as signature.png. |
| PDF combiner | Merge several PDFs (reorderable) into one, natively. |
| Settings | Default contact, accent colour, body font (EB Garamond / Helvetica / Times), page margin, line spacing. |
| Local-only | Every document is a JSON file in the app documents directory. No account, no network. |
New to the app itself (not the codebase)? See TUTORIAL.md for a
first-run walkthrough.
Requires the Flutter SDK (channel stable, version 3.38.5 — see CI).
flutter pub get
flutter run # device or emulator
flutter run -d chrome # quick desktop preview (no persistence, no native PDF preview)First-time toolchain setup:
- Android:
flutter doctor --android-licensesand accept. - iOS:
brew install cocoapods(orsudo gem install cocoapods), thencd ios && pod install.Podfile.lockis committed; the deployment target is pinned to iOS 13.0. - Web (desktop previews only):
flutter run -d chrome.
Gradle needs JDK 21 — JDK 25/26 on PATH will fail the build:
export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-21.jdk/Contents/Home
flutter build apk --release --split-per-abiOutput: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk for modern
phones. The release build strips unused com.tom_roush.pdfbox / JPEG-2000
classes via android/app/proguard-rules.pro.
Release signing: if android/key.properties doesn't exist, the release
build falls back to the debug key (fine for sideloading, not for a store
listing). To sign with a real upload key:
keytool -genkeypair -v -keystore android/app/upload-keystore.jks \
-alias upload -keyalg RSA -keysize 2048 -validity 10957
cat > android/key.properties <<EOF
storePassword=<the password you just set>
keyPassword=<same password — PKCS12 keystores don't support a separate one>
keyAlias=upload
storeFile=upload-keystore.jks
EOFBoth android/key.properties and android/app/*.jks are gitignored — never
commit them. Back the keystore and its password up somewhere durable (a
password manager, not just this machine): losing it means you can never
publish an update to an existing Play Store listing under the same app.
If a JDK error persists after export JAVA_HOME=...: Flutter's own
flutter config --jdk-dir setting (stored outside the repo, in Flutter's tool
state) takes priority over the JAVA_HOME environment variable and can be
stuck pointing at an uninstalled JDK — the error message looks identical to
the "JAVA_HOME not set" case either way. Fix it directly:
flutter config --jdk-dir=/Library/Java/JavaVirtualMachines/jdk-21.jdk/Contents/Home.
If a build was killed (e.g. kill -9, an interrupted flutter run) rather
than exited cleanly, a Gradle daemon or Kotlin compile daemon can also be left
running in the background pinned to the old JDK, causing the same symptom (or
a VerifyError from mixed Kotlin-runtime classpaths) even after the config is
fixed — check ps aux | grep -E "GradleDaemon|KotlinCompileDaemon" and kill
any stale ones.
cd ios && pod install && cd ..
flutter build ios --release # for the App Store, needs a signing team in Xcode
flutter build ios --release --no-codesign # unsigned .app, for sideloading/dev onlylib/
main.dart bootstrap: build the stores, kick off load(), runApp
app.dart MaterialApp + theme (Material 3, blue seed)
models/ plain data classes with toJson / fromJson / copy
contact_info.dart name, title, phone, email, portfolio, GitHub, LinkedIn
resume.dart ResumeData + Experience / Skill / Education / Project / Reference
letter.dart LetterData (+ {{placeholder}} resolution), LetterKind
document.dart GeneratedDocument (metadata + one of resume/letter), DocumentType
app_settings.dart AppSettings, BodyFont
data/ persistence
document_repository.dart one <id>.json per document under documents/
settings_repository.dart single settings.json
document_store.dart ChangeNotifier: in-memory list, seeds on first run,
falls back to in-memory seed data if storage fails
settings_store.dart ChangeNotifier over AppSettings; debounces the disk write
seed_data.dart the placeholder CV / letters
render/ native PDF
pdf_theme.dart loads bundled TTFs once, builds pw.ThemeData from settings
pdf_components.dart shared widgets (section header, tick bullet, icon+text, link)
template_registry.dart id -> template lookup (the extension point)
resume_template.dart / letter_template.dart abstract interfaces
classic_blue_template.dart / classic_letter_template.dart the defaults
document_pdf.dart buildDocumentPdf(doc, settings, {pageFormat}) -> Uint8List
latex/ .tex generation
latex_engine.dart latexEscape() + token substitution into the templates
tokens.dart <<TOKEN>> catalogue shown in the LaTeX editor
export/
export_service.dart writeToFile() + share() for every format
filename.dart safe, collision-free export base filenames
docx_export.dart WordprocessingML zip via `archive`
tex_export.dart thin wrapper over LatexEngine
text_export.dart plain text + Markdown
pdf_tools/
pdf_combiner_service.dart wraps the `pdf_combiner` plugin
ui/
home/ document list (Resumes / Letters tabs), create / rename / duplicate / delete
resume_editor/ the resume form (contact_section.dart is shared with the letter editor & settings)
letter_editor/ the letter form
latex_editor/ read-mostly .tex view (edits are a scratch buffer — not saved back; the
screen says so)
preview/ PdfPreview of the live document, page-format selector wired through to it
export/ the "Export as" bottom sheet
pdf_combiner/ add / reorder / merge PDFs
settings/ default contact + style + signature
widgets/ EditorField, StringListEditor, EntryControls, SectionCard, SignaturePicker
assets/
templates/ resume_classic.tex, cover_letter.tex, application_letter.tex (<<TOKENS>>)
fonts/ EB Garamond (body, instanced from the variable font) + Font Awesome 6
UI (provider + ChangeNotifier)
│ reads/writes
▼
Stores ── DocumentStore, SettingsStore (in-memory + notify)
│ delegates persistence
▼
Repositories ── DocumentRepository, SettingsRepository (JSON files)
│
▼
Models ── ContactInfo, ResumeData, LetterData, GeneratedDocument, AppSettings
Output modules (pure functions over Model + AppSettings):
render/ → PDF bytes
latex/ → .tex string
export/ → PDF / DOCX / txt / md / tex to a file, then the share sheet
pdf_tools/→ merge N PDFs
Nothing in the output modules touches the stores or the repositories. They take
a GeneratedDocument and an AppSettings and return bytes or a string. The
only I/O they do is rootBundle reads of the bundled .tex templates and
TTFs, and reading the signature image file by path.
Data flow. Editors work on a .copy() of the document, mutate it locally,
and write back through DocumentStore.save (autosave on back-navigation, plus
an explicit Save FAB). DocumentStore / SettingsStore are ChangeNotifiers
provided at the root via provider. Rendering (render/, latex/, export/)
is pure: (GeneratedDocument, AppSettings) -> bytes/string, no I/O beyond
reading the bundled templates and the signature file.
Plain mutable classes. Every one has:
- a positional/named constructor with defaults (so a "blank" instance is valid),
toJson()/fromJson()—fromJsonis defensive: every field has a fallback, unknown enum values fall back to the first case, missing lists become empty lists,copy()/copyWith()— deep copies for the "edit a copy, commit on save" pattern.
GeneratedDocument holds metadata plus exactly one of resume / letter
(nullable, chosen by type). toJson only serialises the non-null one.
LetterData owns the {{placeholder}} mechanism:
placeholderNames— scanscompanyName,companyAddress,salutation,recipientDeptand every body paragraph for{{token}}and returns the distinct names. The letter editor renders one field per name.resolve(text)— substitutes from theplaceholdersmap; an unset/empty value renders as[name]so nothing silently disappears.
AppSettings.accentColor is a single ARGB int. accentRgb splits it into
"R, G, B" for the <<ACCENT_RGB>> LaTeX token.
DocumentRepository — one <uuid>.json per document under a documents/
subdirectory of the app documents directory (path_provider). loadAll skips
files that fail to parse rather than failing the whole load. A baseDir can be
injected for tests.
SettingsRepository — a single settings.json. Missing or corrupt file →
AppSettings() defaults.
DocumentStore (ChangeNotifier):
load()— if the repo is empty, writesSeedData.initialDocuments(), then loads all. If any storage call throws (e.g. web, wherepath_providerhas no backing dir) it logs and runs from an in-memory copy of the seed data — the app still works, it just doesn't persist.save/delete/create/duplicate— mutate the in-memory list, call the repo (swallowing storage errors),notifyListeners, keep the list sorted byupdatedAtdesc.
SettingsStore is the same shape over a single AppSettings, except update
notifies listeners synchronously but debounces the actual settings.json
write by ~400ms, so dragging a slider doesn't fire one file write per frame.
Both stores are created in main.dart and their load() is started but
not awaited — the UI shows a spinner while loading is true.
PdfTheme.create(settings) loads the four EB Garamond faces (or Helvetica /
Times) and the two Font Awesome TTFs once (static cache) and builds a
pw.ThemeData. It also carries the accent PdfColor, the margin in points and
the line spacing.
ClassicBlueTemplate / ClassicLetterTemplate build a pw.MultiPage (default
A4, but the page format is a parameter — PreviewScreen threads the format the
user picks in its toolbar straight through). Section visibility is data-driven:
an empty list means no heading. PdfComponents holds the shared bits — the
centred uppercase accent heading + rule, the ✓ bullet (Font Awesome check
glyph in the accent colour), the icon+text pairs on the contact line, and
UrlLink wrappers. The letter template guards its pw.MemoryImage signature
render in a try/catch, so a corrupt signature file degrades to "no image"
instead of failing the whole PDF.
buildDocumentPdf(doc, settings, {pageFormat}) picks the template from
TemplateRegistry.resume(doc.templateId) for resumes, and always
classic_letter for letters, reading the signature bytes from disk first if a
path is set and the file exists.
latexEscape(input) maps the ten LaTeX-special characters rune-by-rune in a
single pass (so the {} introduced by escaping \ are not re-escaped).
LatexEngine.render(doc, settings):
- loads the right template from
assets/templates/(injectable loader for tests), - builds a
Map<String,String>ofTOKEN → value, running free text throughlatexEscapeand generating the repeated blocks (_experienceItems,_skillItems,_educationItems,_projectItems,_references), template.replaceAll('<<$key>>', value)for each entry.
Letter bodies are resolve()d (placeholders) before escaping. <<DATE>>
falls back to \today when blank; <<SIGNATURE_IMAGE>> emits an
\includegraphics line only when a signature path is set.
PORTFOLIO_URL / GITHUB_URL / LINKEDIN_URL go into \href{...} through
texUrl(), not latexEscape() — LaTeX-escaping a URL (backslash commands
like \%) would break it as a URL. texUrl() instead percent-encodes just
the two characters that break a raw \href URL argument (#, TeX's
macro-parameter character, and an unescaped %, a comment character),
leaving an already-valid %XX escape untouched.
ExportService.writeToFile(doc, settings, format) builds a safe, collision-free
base filename via filename.dart (the title slugified, with a short suffix of
the document's own id — closes both the "all-punctuation title slugifies to
nothing" and "two differently-punctuated titles collide" cases), writes to a
temp file, and dispatches on ExportFormat. share() additionally attaches
signature.png next to a letter .tex so it drops straight into Overleaf.
DocxExport builds the five-part OOXML zip by hand
([Content_Types].xml, _rels/.rels, word/_rels/document.xml.rels,
word/styles.xml, word/document.xml) with archive. Layout is intentionally
plain — headings, bold runs, literal ✓ bullets — so it opens clean and stays
editable in Word. XML text is escaped via _x().
TextExport produces plain text (UPPERCASE headings) or Markdown (#
headings), sharing the section walk.
provider supplies DocumentStore and SettingsStore from the root.
HomeScreen — DefaultTabController (Resumes / Letters), a FAB that opens a
type picker then the matching editor, per-row popup menu (rename / duplicate /
delete with confirm).
ResumeEditorScreen / LetterEditorScreen — StatefulWidgets holding a
.copy() of the payload and a _dirty flag. PopScope autosaves on back.
Every onChanged runs through _edit(() {...}) which mutates the copy and
setStates. List sections use a private _EntryFrame with move-up/down/delete.
contact_section.dart is shared by both editors and the settings screen.
LatexEditorScreen — generates the .tex into a TextEditingController on
open. Copy / Share use the (possibly edited) buffer; there's no save-back, and
the screen shows a dismissible banner saying so.
SettingsScreen — writes through SettingsStore.update on every change; the
store itself debounces the actual disk write (see Persistence above).
PdfCombinerScreen — ReorderableListView of picked PDFs, _combine() calls
PdfCombinerService.merge, shows a PdfPreview of the result and a share
action.
DocumentRepository(baseDir:)/SettingsRepository(baseDir:)— temp dir.LatexEngine(loadAsset:)— load templates from disk instead ofrootBundle.- Output modules are pure, so most tests are
SeedData → module → assert on the string/bytes.
- Implement
ResumeTemplate/LetterTemplateinlib/render/. - Register it in
TemplateRegistry(resumeTemplates/letterTemplates). GeneratedDocument.templateIdselects it per document (defaults toclassic_blue). There is currently no UI to switch templates.
pdf,printingandarchiveare pinned below their latest majors because those majors require Dart SDK>=3.12.0, and this project targetssdk: ^3.10.4(pubspec.yaml). Bumping the SDK constraint (and the Flutter version CI pins) is a deliberate, separately-tested change, not a drive-by dependency bump.- Web PDF preview uses
printing's pdf.js integration;web/index.htmlpins the pdf.js version so the API and worker match. The in-app preview is really meant for Android / iOS (native renderer). - Fonts are declared under
fonts:inpubspec.yamland loaded viarootBundleinrender/pdf_theme.dart. - App icon / splash screen are generated from
assets/icon/(a blue#0000FF"R" monogram in EB Garamond Bold, matching the in-app look) via theflutter_launcher_icons/flutter_native_splashdev tools, configured inpubspec.yaml. To regenerate after changing the source images:dart run flutter_launcher_icons && dart run flutter_native_splash:create.
flutter analyze
flutter test53 tests cover: model JSON round-trips, DocumentRepository CRUD + sort,
seed-data shape, the LaTeX token engine + escaping (including texUrl's
percent-encoding of #/% in contact URLs), DOCX zip structure +
placeholder resolution + XML escaping, plain-text / Markdown output, raw PDF
rendering (resume + letter), the reorderable list editor, the first-launch
tutorial prompt, signature image re-encoding, the resume editor's dirty-state
flow, and a widget-test smoke pass over every remaining screen: Settings,
Preview, LatexEditor (load/banner/tokens/copy), PdfCombiner,
ExportSheet, and contact_section.dart.
CI (.github/workflows/ci.yml) runs flutter pub get / analyze / test /
a debug APK build on every push to main and every PR.
Two non-obvious gotchas beyond the two already documented at the top of
test/home_screen_tutorial_test.dart, hit while adding the screen tests
above:
rootBundle.loadString(asset loading, e.g.LatexEngine's templates) resolves fine within a singletestWidgets, but a second, independenttestWidgetsin the same file/isolate that loads the same asset again can hang forever instead of returning the cached result — see the comment at the top oftest/latex_editor_screen_test.dart. Fixed by covering everything that needs the loaded screen in one combined test rather than onetestWidgetsper scenario.- A screen taller than the default 800×600 test surface (
SettingsScreen) needs either scrolling or a resizedtester.view.physicalSizebefore its lower widgets are hit-testable — seetest/settings_screen_test.dart. PlainscrollUntilVisible()can also pick the wrongScrollablewhen the page hasTextFormFields (each has its own internal horizontal one).
- LaTeX editor edits are a scratch buffer and are not saved back to the document (the screen says so).
- A brand-new resume starts pre-filled with the whole seed CV instead of
blank — a deliberate-but-debatable default in
DocumentStore.create. pdf/printing/archiveare held below their latest majors pending a Dart SDK bump (see Dependency notes).- No
==/hashCodeon the data models.
flutter pub get
flutter run # device or emulator
flutter run -d chrome # quick desktop preview (no persistence, no native PDF preview)Flutter stable 3.38.5 (matches CI). Android release builds need JDK 21
(see above).
Before you push:
flutter analyze # must be clean
flutter test # must be greenCI runs exactly these (plus a debug APK build) on every PR.
Conventions:
- Commits: one-line imperative subject ("Add signature image to letter PDF"). No trailers.
- Branches:
fix/…,chore/…,docs/…,feat/…. One logical change per branch / PR. - Dart style: the repo default (
flutter_lints). Keep trailing commas so the formatter lays widgets out vertically. Rundart format .if in doubt. - Models stay plain: constructor with defaults,
toJson/fromJson(defensive),copy/copyWith. If you add a field, update all four plus any affected test intest/models_serialization_test.dart. - Output modules (
render/,latex/,export/) must stay pure — takeGeneratedDocument+AppSettings, return bytes / string, no store access. - Add a test with any behaviour change. Repositories and the LaTeX engine have
injection seams (
baseDir:,loadAsset:) — see Testing seams above.
Common tasks:
| Task | Start here |
|---|---|
| New export format | export/export_service.dart (ExportFormat enum + switch) |
| New PDF template | implement render/*_template.dart, register in template_registry.dart |
| New LaTeX token | latex/latex_engine.dart (the token map) + latex/tokens.dart (the doc) + the .tex files in assets/templates/ |
| New resume/letter field | models/ → the editor screen → the four renderers (classic_blue_template, classic_letter_template, latex_engine, docx_export, text_export) |
| Change the sample CV | lib/data/seed_data.dart (keep it generic placeholder content) |
MIT — see LICENSE.
Questions, bugs, or feature requests: open an issue on this repo, or reach the maintainer directly at okiror1vinald@gmail.com.