From 3642910f56df9547eeca8425201063b40d5eb4bc Mon Sep 17 00:00:00 2001 From: "[vinald]" Date: Fri, 11 Sep 2026 13:42:00 +0300 Subject: [PATCH 1/8] Rename application/bundle ID from com.vinald.* to me.vinald.resumate --- android/app/build.gradle.kts | 4 ++-- .../vinald/resumate}/MainActivity.kt | 2 +- ios/Runner.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) rename android/app/src/main/kotlin/{com/vinald/resume_generator => me/vinald/resumate}/MainActivity.kt (72%) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e8667f0..d228d07 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } android { - namespace = "com.vinald.resume_generator" + namespace = "me.vinald.resumate" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -21,7 +21,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.vinald.resume_generator" + applicationId = "me.vinald.resumate" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion diff --git a/android/app/src/main/kotlin/com/vinald/resume_generator/MainActivity.kt b/android/app/src/main/kotlin/me/vinald/resumate/MainActivity.kt similarity index 72% rename from android/app/src/main/kotlin/com/vinald/resume_generator/MainActivity.kt rename to android/app/src/main/kotlin/me/vinald/resumate/MainActivity.kt index ff90fb9..ae18033 100644 --- a/android/app/src/main/kotlin/com/vinald/resume_generator/MainActivity.kt +++ b/android/app/src/main/kotlin/me/vinald/resumate/MainActivity.kt @@ -1,4 +1,4 @@ -package com.vinald.resume_generator +package me.vinald.resumate import io.flutter.embedding.android.FlutterActivity diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 9087a23..08d2fc7 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -477,7 +477,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.vinald.resumeGenerator; + PRODUCT_BUNDLE_IDENTIFIER = me.vinald.resumate; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -494,7 +494,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.vinald.resumeGenerator.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = me.vinald.resumate.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -512,7 +512,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.vinald.resumeGenerator.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = me.vinald.resumate.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -528,7 +528,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.vinald.resumeGenerator.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = me.vinald.resumate.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -659,7 +659,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.vinald.resumeGenerator; + PRODUCT_BUNDLE_IDENTIFIER = me.vinald.resumate; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -681,7 +681,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.vinald.resumeGenerator; + PRODUCT_BUNDLE_IDENTIFIER = me.vinald.resumate; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; From 856189b7861f27ca0d6eae9abb9c537fd29696cb Mon Sep 17 00:00:00 2001 From: "[vinald]" Date: Fri, 11 Sep 2026 13:44:29 +0300 Subject: [PATCH 2/8] Sign release APK with a real upload keystore instead of the debug key --- .gitignore | 6 ++++++ README.md | 26 ++++++++++++++++++++++---- android/app/build.gradle.kts | 36 +++++++++++++++++++++++++++++++++--- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index b23aa3c..fde42dd 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,9 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Release signing — never commit the keystore or its passwords. +# See README.md "Release signing" for how to generate your own. +/android/key.properties +/android/app/*.jks +/android/app/*.keystore diff --git a/README.md b/README.md index 4e8b786..509f4b3 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,28 @@ flutter build apk --release --split-per-abi ``` Output: `build/app/outputs/flutter-apk/app-arm64-v8a-release.apk` (~32 MB) for -modern phones. It is **signed with the debug key** — fine for sideloading, -replace with a real signing config before publishing (`android/app/build.gradle.kts`). -The release build strips unused `com.tom_roush.pdfbox` / JPEG-2000 classes via -`android/app/proguard-rules.pro`. +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: + +```sh +keytool -genkeypair -v -keystore android/app/upload-keystore.jks \ + -alias upload -keyalg RSA -keysize 2048 -validity 10957 +cat > android/key.properties < +keyPassword= +keyAlias=upload +storeFile=upload-keystore.jks +EOF +``` + +Both `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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d228d07..dfbe954 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.io.FileInputStream +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") @@ -5,6 +8,17 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +// Release signing: android/key.properties is gitignored and holds the upload +// keystore's credentials. Falls back to debug signing (as before) when it's +// absent, so a fresh checkout without the keystore still builds — it just +// won't produce a Play Store-installable release artifact. See README.md. +val keystorePropertiesFile = rootProject.file("key.properties") +val keystoreProperties = Properties() +val hasReleaseSigning = keystorePropertiesFile.exists() +if (hasReleaseSigning) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + android { namespace = "me.vinald.resumate" compileSdk = flutter.compileSdkVersion @@ -30,11 +44,27 @@ android { versionName = flutter.versionName } + signingConfigs { + if (hasReleaseSigning) { + create("release") { + storeFile = file(keystoreProperties.getProperty("storeFile")) + storePassword = keystoreProperties.getProperty("storePassword") + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfig = + if (hasReleaseSigning) { + signingConfigs.getByName("release") + } else { + // No android/key.properties present — fall back to the debug + // keys so `flutter run --release`/`build apk` still work. + signingConfigs.getByName("debug") + } isMinifyEnabled = true isShrinkResources = true proguardFiles( From 13dd82878736dbb0345ab53d3ad5243bd0eaef2a Mon Sep 17 00:00:00 2001 From: "[vinald]" Date: Fri, 11 Sep 2026 13:50:31 +0300 Subject: [PATCH 3/8] Fix M3/L1/L3/L4/L5/L7: signature validation, settings debounce, dialog leak, PDF page-format selector, LaTeX unsaved-edits warning, export filename collisions --- lib/data/settings_store.dart | 29 ++++++++-- lib/export/export_service.dart | 6 +- lib/export/filename.dart | 18 ++++++ lib/render/classic_blue_template.dart | 8 ++- lib/render/classic_letter_template.dart | 33 ++++++++--- lib/render/document_pdf.dart | 13 +++-- lib/render/letter_template.dart | 8 ++- lib/render/resume_template.dart | 7 ++- lib/ui/home/home_screen.dart | 5 +- lib/ui/latex_editor/latex_editor_screen.dart | 61 ++++++++++++++------ lib/ui/preview/preview_screen.dart | 6 +- lib/ui/widgets/signature_picker.dart | 40 ++++++++++++- pubspec.lock | 2 +- pubspec.yaml | 4 ++ 14 files changed, 192 insertions(+), 48 deletions(-) create mode 100644 lib/export/filename.dart diff --git a/lib/data/settings_store.dart b/lib/data/settings_store.dart index 535a6f4..3dcd613 100644 --- a/lib/data/settings_store.dart +++ b/lib/data/settings_store.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import '../models/app_settings.dart'; @@ -9,6 +11,7 @@ class SettingsStore extends ChangeNotifier { final SettingsRepository _repo; AppSettings _settings = AppSettings(); bool _loading = true; + Timer? _saveDebounce; AppSettings get settings => _settings; bool get loading => _loading; @@ -24,13 +27,29 @@ class SettingsStore extends ChangeNotifier { notifyListeners(); } + /// Updates the in-memory settings (and notifies listeners) synchronously, + /// but debounces the actual disk write: dragging a slider or typing in a + /// text field previously fired one `settings.json` write per frame / + /// keystroke. The trade-off is that the very last edit before a hard kill + /// (rather than a normal app close) can be lost — acceptable for + /// preferences that aren't safety-critical. Future update(AppSettings settings) async { _settings = settings; - try { - await _repo.save(settings); - } catch (e) { - debugPrint('SettingsStore: save skipped ($e)'); - } notifyListeners(); + + _saveDebounce?.cancel(); + _saveDebounce = Timer(const Duration(milliseconds: 400), () async { + try { + await _repo.save(_settings); + } catch (e) { + debugPrint('SettingsStore: save skipped ($e)'); + } + }); + } + + @override + void dispose() { + _saveDebounce?.cancel(); + super.dispose(); } } diff --git a/lib/export/export_service.dart b/lib/export/export_service.dart index ff09577..40bbe50 100644 --- a/lib/export/export_service.dart +++ b/lib/export/export_service.dart @@ -7,6 +7,7 @@ import '../models/app_settings.dart'; import '../models/document.dart'; import '../render/document_pdf.dart'; import 'docx_export.dart'; +import 'filename.dart'; import 'tex_export.dart'; import 'text_export.dart'; @@ -31,8 +32,9 @@ class ExportService { ExportFormat format, ) async { final dir = await getTemporaryDirectory(); - final safeName = doc.title.replaceAll(RegExp(r'[^A-Za-z0-9]+'), '_'); - final file = File('${dir.path}/$safeName.${format.extension}'); + final file = File( + '${dir.path}/${safeExportFilename(doc)}.${format.extension}', + ); switch (format) { case ExportFormat.pdf: diff --git a/lib/export/filename.dart b/lib/export/filename.dart new file mode 100644 index 0000000..1428597 --- /dev/null +++ b/lib/export/filename.dart @@ -0,0 +1,18 @@ +import '../models/document.dart'; + +/// Turns a document's title into a filesystem-safe base name (no extension), +/// guaranteed non-empty and unique per document. +/// +/// The plain slugify-the-title approach used to collide two ways: an +/// all-non-ASCII title (or one that's pure punctuation) slugifies to an empty +/// string, and two differently-punctuated titles — "Resume (Draft)" and +/// "Resume [Draft]" — both slugify to the same "Resume_Draft_". Appending a +/// short slice of the document's own id closes both gaps. +String safeExportFilename(GeneratedDocument doc) { + final slug = doc.title + .replaceAll(RegExp(r'[^A-Za-z0-9]+'), '_') + .replaceAll(RegExp(r'^_+|_+$'), ''); + final base = slug.isEmpty ? 'document' : slug; + final suffix = doc.id.length >= 6 ? doc.id.substring(0, 6) : doc.id; + return '${base}_$suffix'; +} diff --git a/lib/render/classic_blue_template.dart b/lib/render/classic_blue_template.dart index f89ea0a..11223eb 100644 --- a/lib/render/classic_blue_template.dart +++ b/lib/render/classic_blue_template.dart @@ -17,14 +17,18 @@ class ClassicBlueTemplate implements ResumeTemplate { String get name => 'Classic Blue'; @override - pw.Document build(ResumeData data, PdfTheme theme) { + pw.Document build( + ResumeData data, + PdfTheme theme, { + PdfPageFormat pageFormat = PdfPageFormat.a4, + }) { final c = PdfComponents(theme); final doc = pw.Document(); doc.addPage( pw.MultiPage( theme: theme.theme, - pageFormat: PdfPageFormat.a4.copyWith( + pageFormat: pageFormat.copyWith( marginTop: theme.marginPt, marginBottom: theme.marginPt, marginLeft: theme.marginPt, diff --git a/lib/render/classic_letter_template.dart b/lib/render/classic_letter_template.dart index d4aa1ef..5d77fd2 100644 --- a/lib/render/classic_letter_template.dart +++ b/lib/render/classic_letter_template.dart @@ -20,7 +20,12 @@ class ClassicLetterTemplate implements LetterTemplate { String get name => 'Classic Letter'; @override - pw.Document build(LetterData data, PdfTheme theme, {List? signature}) { + pw.Document build( + LetterData data, + PdfTheme theme, { + List? signature, + PdfPageFormat pageFormat = PdfPageFormat.a4, + }) { final c = PdfComponents(theme); final doc = pw.Document(); final k = data.contact; @@ -53,7 +58,7 @@ class ClassicLetterTemplate implements LetterTemplate { doc.addPage( pw.MultiPage( theme: theme.theme, - pageFormat: PdfPageFormat.a4.copyWith( + pageFormat: pageFormat.copyWith( marginTop: theme.marginPt, marginBottom: theme.marginPt, marginLeft: theme.marginPt, @@ -101,12 +106,7 @@ class ClassicLetterTemplate implements LetterTemplate { pw.SizedBox(height: 6), pw.Text(data.closing), pw.SizedBox(height: 6), - if (signature != null) - pw.Image( - pw.MemoryImage(Uint8List.fromList(signature)), - height: 44, - alignment: pw.Alignment.centerLeft, - ), + ?_buildSignatureImage(signature), pw.SizedBox(height: 4), pw.Text( data.signatureName.isEmpty ? k.name : data.signatureName, @@ -117,4 +117,21 @@ class ClassicLetterTemplate implements LetterTemplate { ); return doc; } + + /// Guards against a corrupt/mis-encoded signature file (e.g. one saved by + /// an older build, before `SignaturePicker` started re-encoding to PNG on + /// pick): `pw.MemoryImage` decodes eagerly and throws on bad bytes, which + /// would otherwise take down the whole letter PDF. + pw.Widget? _buildSignatureImage(List? bytes) { + if (bytes == null) return null; + try { + return pw.Image( + pw.MemoryImage(Uint8List.fromList(bytes)), + height: 44, + alignment: pw.Alignment.centerLeft, + ); + } catch (_) { + return null; + } + } } diff --git a/lib/render/document_pdf.dart b/lib/render/document_pdf.dart index a10d73f..b2e1131 100644 --- a/lib/render/document_pdf.dart +++ b/lib/render/document_pdf.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'dart:typed_data'; +import 'package:pdf/pdf.dart'; + import '../models/app_settings.dart'; import '../models/document.dart'; import 'pdf_theme.dart'; @@ -10,13 +12,14 @@ import 'template_registry.dart'; /// and [AppSettings]. Future buildDocumentPdf( GeneratedDocument doc, - AppSettings settings, -) async { + AppSettings settings, { + PdfPageFormat pageFormat = PdfPageFormat.a4, +}) async { final theme = await PdfTheme.create(settings); if (doc.type == DocumentType.resume) { final template = TemplateRegistry.resume(doc.templateId); - return template.build(doc.resume!, theme).save(); + return template.build(doc.resume!, theme, pageFormat: pageFormat).save(); } List? signature; @@ -29,5 +32,7 @@ Future buildDocumentPdf( } final template = TemplateRegistry.letter('classic_letter'); - return template.build(doc.letter!, theme, signature: signature).save(); + return template + .build(doc.letter!, theme, signature: signature, pageFormat: pageFormat) + .save(); } diff --git a/lib/render/letter_template.dart b/lib/render/letter_template.dart index f227d93..fe51ee4 100644 --- a/lib/render/letter_template.dart +++ b/lib/render/letter_template.dart @@ -1,3 +1,4 @@ +import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import '../models/letter.dart'; @@ -8,5 +9,10 @@ abstract class LetterTemplate { String get id; String get name; - pw.Document build(LetterData data, PdfTheme theme, {List? signature}); + pw.Document build( + LetterData data, + PdfTheme theme, { + List? signature, + PdfPageFormat pageFormat = PdfPageFormat.a4, + }); } diff --git a/lib/render/resume_template.dart b/lib/render/resume_template.dart index 5098538..62ee859 100644 --- a/lib/render/resume_template.dart +++ b/lib/render/resume_template.dart @@ -1,3 +1,4 @@ +import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import '../models/resume.dart'; @@ -8,5 +9,9 @@ abstract class ResumeTemplate { String get id; String get name; - pw.Document build(ResumeData data, PdfTheme theme); + pw.Document build( + ResumeData data, + PdfTheme theme, { + PdfPageFormat pageFormat = PdfPageFormat.a4, + }); } diff --git a/lib/ui/home/home_screen.dart b/lib/ui/home/home_screen.dart index cb95153..f4683fb 100644 --- a/lib/ui/home/home_screen.dart +++ b/lib/ui/home/home_screen.dart @@ -361,5 +361,8 @@ Future _promptText( ), ], ), - ); + ).then((value) { + controller.dispose(); + return value; + }); } diff --git a/lib/ui/latex_editor/latex_editor_screen.dart b/lib/ui/latex_editor/latex_editor_screen.dart index 7975f49..f28c995 100644 --- a/lib/ui/latex_editor/latex_editor_screen.dart +++ b/lib/ui/latex_editor/latex_editor_screen.dart @@ -8,9 +8,10 @@ import 'package:share_plus/share_plus.dart'; import '../../data/document_store.dart'; import '../../data/settings_store.dart'; +import '../../export/filename.dart'; +import '../../export/tex_export.dart'; import '../../latex/tokens.dart'; import '../../models/document.dart'; -import '../../export/tex_export.dart'; class LatexEditorScreen extends StatefulWidget { const LatexEditorScreen({super.key, required this.documentId}); @@ -24,6 +25,7 @@ class LatexEditorScreen extends StatefulWidget { class _LatexEditorScreenState extends State { final _controller = TextEditingController(); bool _loading = true; + bool _showUnsavedBanner = true; late GeneratedDocument _doc; @override @@ -42,8 +44,7 @@ class _LatexEditorScreenState extends State { Future _shareTex() async { final sigPath = context.read().settings.signatureImagePath; final dir = await getTemporaryDirectory(); - final safe = _doc.title.replaceAll(RegExp(r'[^A-Za-z0-9]+'), '_'); - final file = File('${dir.path}/$safe.tex'); + final file = File('${dir.path}/${safeExportFilename(_doc)}.tex'); await file.writeAsString(_controller.text); final files = [XFile(file.path)]; @@ -124,23 +125,45 @@ class _LatexEditorScreenState extends State { body: _loading ? const Center(child: CircularProgressIndicator()) - : Padding( - padding: const EdgeInsets.all(8), - child: TextField( - controller: _controller, - maxLines: null, - expands: true, - textAlignVertical: TextAlignVertical.top, - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 12, - height: 1.35, - ), - decoration: const InputDecoration( - border: OutlineInputBorder(), - alignLabelWithHint: true, + : Column( + children: [ + if (_showUnsavedBanner) + MaterialBanner( + content: const Text( + "Edits here aren't saved back to your document — " + 'use Copy or Share to keep changes.', + ), + leading: const Icon(Icons.info_outline), + actions: [ + TextButton( + onPressed: + () => + setState(() => _showUnsavedBanner = false), + child: const Text('Got it'), + ), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8), + child: TextField( + controller: _controller, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + height: 1.35, + ), + decoration: const InputDecoration( + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + ), + ), ), - ), + ], ), ); } diff --git a/lib/ui/preview/preview_screen.dart b/lib/ui/preview/preview_screen.dart index a0af3df..4bbe860 100644 --- a/lib/ui/preview/preview_screen.dart +++ b/lib/ui/preview/preview_screen.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../../data/document_store.dart'; import '../../data/settings_store.dart'; +import '../../export/filename.dart'; import '../../render/document_pdf.dart'; class PreviewScreen extends StatelessWidget { @@ -20,11 +21,10 @@ class PreviewScreen extends StatelessWidget { return Scaffold( appBar: AppBar(title: Text('Preview · ${doc.title}')), body: PdfPreview( - build: (_) => buildDocumentPdf(doc, settings), + build: (format) => buildDocumentPdf(doc, settings, pageFormat: format), canDebug: false, initialPageFormat: PdfPageFormat.a4, - pdfFileName: - '${doc.title.replaceAll(RegExp(r'[^A-Za-z0-9]+'), '_')}.pdf', + pdfFileName: '${safeExportFilename(doc)}.pdf', ), ); } diff --git a/lib/ui/widgets/signature_picker.dart b/lib/ui/widgets/signature_picker.dart index 67e3594..4d86943 100644 --- a/lib/ui/widgets/signature_picker.dart +++ b/lib/ui/widgets/signature_picker.dart @@ -1,12 +1,31 @@ import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart' show compute; import 'package:flutter/material.dart'; +import 'package:image/image.dart' as img; import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import '../../data/settings_store.dart'; +/// Runs off the UI isolate: decodes whatever bytes the gallery handed back — +/// HEIC, WebP, GIF, or a genuine PNG/JPEG — and re-encodes as a real PNG. +/// `image_picker` returns the asset in its original container regardless of +/// what extension we ask for, so without this step a non-PNG file gets saved +/// as `signature.png` and breaks `pw.MemoryImage` / Overleaf later. Returns +/// null if the bytes aren't a decodable image at all. +Uint8List? decodeAndReencodeSignaturePng(Uint8List bytes) { + try { + final decoded = img.decodeImage(bytes); + if (decoded == null) return null; + return Uint8List.fromList(img.encodePng(decoded)); + } catch (_) { + return null; + } +} + /// Pick / preview / remove the signature image. The image is stored once in /// [SettingsStore] and reused by every letter (PDF and `.tex` output). class SignaturePicker extends StatelessWidget { @@ -19,8 +38,27 @@ class SignaturePicker extends StatelessWidget { final picker = ImagePicker(); final file = await picker.pickImage(source: ImageSource.gallery); if (file == null) return; + + final rawBytes = await File(file.path).readAsBytes(); + final pngBytes = await compute(decodeAndReencodeSignaturePng, rawBytes); + if (pngBytes == null) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "That image couldn't be read as a picture — try a different one.", + ), + ), + ); + } + return; + } + final dir = await getApplicationDocumentsDirectory(); - final saved = await File(file.path).copy('${dir.path}/signature.png'); + final saved = await File( + '${dir.path}/signature.png', + ).writeAsBytes(pngBytes); + if (!context.mounted) return; await store.update( store.settings.copyWith(signatureImagePath: saved.path), ); diff --git a/pubspec.lock b/pubspec.lock index b3641ac..6401ca9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -241,7 +241,7 @@ packages: source: hosted version: "4.1.2" image: - dependency: transitive + dependency: "direct main" description: name: image sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d diff --git a/pubspec.yaml b/pubspec.yaml index a52b9a8..7eeaaaf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,6 +31,10 @@ dependencies: # Signature image + accent colour image_picker: ^1.1.2 flutter_colorpicker: ^1.1.0 + # Re-encodes whatever the gallery returns (HEIC/WebP/etc.) to a real PNG — + # was already a transitive dependency of image_picker; made explicit + # because SignaturePicker now imports it directly. + image: ^4.3.0 # Misc uuid: ^4.5.1 From 746483031a216b7c07c1b3917fa111f143e3cedb Mon Sep 17 00:00:00 2001 From: "[vinald]" Date: Fri, 11 Sep 2026 13:50:50 +0300 Subject: [PATCH 4/8] CI: add a debug APK build step to catch real Android build breaks --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d45950f..646521d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,3 +17,8 @@ jobs: - run: flutter pub get - run: flutter analyze - run: flutter test + # Proves the Android toolchain actually resolves and compiles (manifest + # errors, Gradle config typos, plugin version conflicts) — `analyze` + # and `test` alone wouldn't catch a build break before it reaches main. + # Debug build only: no release signing key is available in CI. + - run: flutter build apk --debug From 14efeb9b332c2a1c53211c3897a9d86e710f85a4 Mon Sep 17 00:00:00 2001 From: "[vinald]" Date: Fri, 11 Sep 2026 13:59:32 +0300 Subject: [PATCH 5/8] Bump intl/file_picker/share_plus; migrate to file_picker's static API and share_plus's SharePlus.instance.share --- lib/export/export_service.dart | 4 +++- lib/ui/latex_editor/latex_editor_screen.dart | 4 +++- lib/ui/pdf_combiner/pdf_combiner_screen.dart | 10 +++++--- pubspec.lock | 24 +++++++++++++------- pubspec.yaml | 6 ++--- 5 files changed, 32 insertions(+), 16 deletions(-) diff --git a/lib/export/export_service.dart b/lib/export/export_service.dart index 40bbe50..f3e7a52 100644 --- a/lib/export/export_service.dart +++ b/lib/export/export_service.dart @@ -71,6 +71,8 @@ class ExportService { } } - await Share.shareXFiles(files, subject: doc.title); + await SharePlus.instance.share( + ShareParams(files: files, subject: doc.title), + ); } } diff --git a/lib/ui/latex_editor/latex_editor_screen.dart b/lib/ui/latex_editor/latex_editor_screen.dart index f28c995..1f3f506 100644 --- a/lib/ui/latex_editor/latex_editor_screen.dart +++ b/lib/ui/latex_editor/latex_editor_screen.dart @@ -51,7 +51,9 @@ class _LatexEditorScreenState extends State { if (_doc.type.isLetter && sigPath != null && File(sigPath).existsSync()) { files.add(XFile(sigPath, name: 'signature.png')); } - await Share.shareXFiles(files, subject: '${_doc.title}.tex'); + await SharePlus.instance.share( + ShareParams(files: files, subject: '${_doc.title}.tex'), + ); } void _showTokens() { diff --git a/lib/ui/pdf_combiner/pdf_combiner_screen.dart b/lib/ui/pdf_combiner/pdf_combiner_screen.dart index 5fa1f08..143cc06 100644 --- a/lib/ui/pdf_combiner/pdf_combiner_screen.dart +++ b/lib/ui/pdf_combiner/pdf_combiner_screen.dart @@ -27,7 +27,7 @@ class _PdfCombinerScreenState extends State { String? _resultPath; Future _pick() async { - final result = await FilePicker.platform.pickFiles( + final result = await FilePicker.pickFiles( allowMultiple: true, type: FileType.custom, allowedExtensions: ['pdf'], @@ -69,8 +69,12 @@ class _PdfCombinerScreenState extends State { tooltip: 'Share result', icon: const Icon(Icons.ios_share), onPressed: - () => Share.shareXFiles([XFile(_resultPath!)], - subject: 'Combined PDF'), + () => SharePlus.instance.share( + ShareParams( + files: [XFile(_resultPath!)], + subject: 'Combined PDF', + ), + ), ), ], ), diff --git a/pubspec.lock b/pubspec.lock index 6401ca9..5dee08f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -97,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.dev" + source: hosted + version: "0.7.15" fake_async: dependency: transitive description: @@ -133,10 +141,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + sha256: "29cc1fdb20613876cc7afc529738c1c0f11a9ca159b010edad0c566ac330847e" url: "https://pub.dev" source: hosted - version: "8.3.7" + version: "11.0.3" file_selector_linux: dependency: transitive description: @@ -316,10 +324,10 @@ packages: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.20.3" jni: dependency: transitive description: @@ -596,18 +604,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + sha256: d7dc0630a923883c6328ca31b89aa682bacbf2f8304162d29f7c6aaff03a27a1 url: "https://pub.dev" source: hosted - version: "10.1.4" + version: "11.1.0" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "6.1.0" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 7eeaaaf..a5a0257 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,8 +21,8 @@ dependencies: pdf_combiner: ^3.1.0 # Files, sharing, storage - file_picker: ^8.1.4 - share_plus: ^10.1.2 + file_picker: ^11.0.3 + share_plus: ^11.1.0 path_provider: ^2.1.5 # DOCX (zip of XML) @@ -38,7 +38,7 @@ dependencies: # Misc uuid: ^4.5.1 - intl: ^0.19.0 + intl: ^0.20.3 collection: ^1.19.0 dev_dependencies: From a75bdf809e77a4e907dbecddc26b0092659907cd Mon Sep 17 00:00:00 2001 From: "[vinald]" Date: Fri, 11 Sep 2026 14:04:07 +0300 Subject: [PATCH 6/8] Bump pdf_combiner to 6.2.2 and drop the file_magic_number override it no longer needs --- lib/pdf_tools/pdf_combiner_service.dart | 22 +++++++++------------- pubspec.lock | 10 +++++----- pubspec.yaml | 8 +------- 3 files changed, 15 insertions(+), 25 deletions(-) diff --git a/lib/pdf_tools/pdf_combiner_service.dart b/lib/pdf_tools/pdf_combiner_service.dart index e01db8e..7500219 100644 --- a/lib/pdf_tools/pdf_combiner_service.dart +++ b/lib/pdf_tools/pdf_combiner_service.dart @@ -1,8 +1,9 @@ import 'dart:io'; import 'package:path_provider/path_provider.dart'; +import 'package:pdf_combiner/exception/pdf_combiner_exception.dart'; +import 'package:pdf_combiner/models/merge_input.dart'; import 'package:pdf_combiner/pdf_combiner.dart'; -import 'package:pdf_combiner/responses/pdf_combiner_status.dart'; class PdfCombineResult { PdfCombineResult({required this.ok, this.path, this.error}); @@ -25,24 +26,19 @@ class PdfCombinerService { final outputPath = '${dir.path}/combined_$stamp.pdf'; try { - final response = await PdfCombiner.mergeMultiplePDFs( - inputPaths: inputPaths, + final resolved = await PdfCombiner.mergeMultiplePDFs( + inputs: inputPaths.map(MergeInput.path).toList(), outputPath: outputPath, ); - if (response.status == PdfCombinerStatus.success) { - final resolved = response.response ?? outputPath; - if (await File(resolved).exists()) { - return PdfCombineResult(ok: true, path: resolved); - } - return PdfCombineResult( - ok: false, - error: 'Merge reported success but no file was written.', - ); + if (await File(resolved).exists()) { + return PdfCombineResult(ok: true, path: resolved); } return PdfCombineResult( ok: false, - error: response.message ?? 'Merge failed.', + error: 'Merge reported success but no file was written.', ); + } on PdfCombinerException catch (e) { + return PdfCombineResult(ok: false, error: e.message); } catch (e) { return PdfCombineResult(ok: false, error: '$e'); } diff --git a/pubspec.lock b/pubspec.lock index 5dee08f..c4f1d10 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -130,13 +130,13 @@ packages: source: hosted version: "7.0.1" file_magic_number: - dependency: "direct overridden" + dependency: transitive description: name: file_magic_number - sha256: "5a6ae4bea934b7bde961bb06947743fdb84ca46e60ebc3ca574b848cf24382fd" + sha256: "106a540c015fe8583f06440491cb4e77b819d77e802af7f82a85ce67abe83e5e" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "2.1.0" file_picker: dependency: "direct main" description: @@ -524,10 +524,10 @@ packages: dependency: "direct main" description: name: pdf_combiner - sha256: f1de173144b2970de8be6b1d561b257dcc164bedfa2e35fe1fba720bd5ac4a44 + sha256: "706109e466eafcd6fd5b57eb5369be3ed0e7bf4100f899c741d8d746488af3f6" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "6.2.2" pdf_widget_wrapper: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a5a0257..e522a7f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: printing: ^5.13.3 # PDF merge (native Android + iOS) - pdf_combiner: ^3.1.0 + pdf_combiner: ^6.2.2 # Files, sharing, storage file_picker: ^11.0.3 @@ -46,12 +46,6 @@ dev_dependencies: sdk: flutter flutter_lints: ^6.0.0 -# pdf_combiner 3.4.0 imports file_magic_number/file_magic_number_type.dart, -# which was moved out of the package root in file_magic_number 1.3.0. Pin to the -# last version that still exposes it. -dependency_overrides: - file_magic_number: 1.2.0 - flutter: uses-material-design: true assets: From c5065e4720107d262bd4cf60a14bb8a2d02822b5 Mon Sep 17 00:00:00 2001 From: "[vinald]" Date: Fri, 11 Sep 2026 14:06:42 +0300 Subject: [PATCH 7/8] Add tests for signature re-encoding (M3) and the resume editor's dirty-state flow --- test/resume_editor_screen_test.dart | 68 +++++++++++++++++++++++++++++ test/signature_reencode_test.dart | 49 +++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 test/resume_editor_screen_test.dart create mode 100644 test/signature_reencode_test.dart diff --git a/test/resume_editor_screen_test.dart b/test/resume_editor_screen_test.dart new file mode 100644 index 0000000..921bdf3 --- /dev/null +++ b/test/resume_editor_screen_test.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:resumate/data/document_repository.dart'; +import 'package:resumate/data/document_store.dart'; +import 'package:resumate/models/document.dart'; +import 'package:resumate/ui/resume_editor/resume_editor_screen.dart'; + +/// Regression coverage for the resume editor's dirty-state flow: editing a +/// field should show the "Save" FAB, and it should stay hidden until +/// something actually changes. +/// +/// Real `dart:io` setup happens in `setUp()`, not inside a `testWidgets` +/// body — see `test/home_screen_tutorial_test.dart`'s header comment for why. +/// The FAB *disappearing* after a save isn't asserted here for the same +/// reason that file documents: `_save()` gates its `setState` behind a real +/// file write, which `flutter_test`'s fake clock doesn't reliably resume +/// within a pump budget. +void main() { + late Directory tempDir; + late DocumentStore documentStore; + late String resumeId; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('resumate_editor_test'); + documentStore = DocumentStore(DocumentRepository(baseDir: tempDir)); + await documentStore.load(); + resumeId = + documentStore.documents + .firstWhere((d) => d.type == DocumentType.resume) + .id; + }); + + tearDown(() async { + if (await tempDir.exists()) await tempDir.delete(recursive: true); + }); + + Widget harness() => ChangeNotifierProvider.value( + value: documentStore, + child: MaterialApp(home: ResumeEditorScreen(documentId: resumeId)), + ); + + testWidgets('no unsaved changes: the Save FAB is not shown', ( + tester, + ) async { + await tester.pumpWidget(harness()); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(FloatingActionButton, 'Save'), findsNothing); + }); + + testWidgets('editing a field marks the document dirty and shows Save', ( + tester, + ) async { + await tester.pumpWidget(harness()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Professional summary'), + 'Updated summary text', + ); + await tester.pump(); + + expect(find.widgetWithText(FloatingActionButton, 'Save'), findsOneWidget); + }); +} diff --git a/test/signature_reencode_test.dart b/test/signature_reencode_test.dart new file mode 100644 index 0000000..7ec8d40 --- /dev/null +++ b/test/signature_reencode_test.dart @@ -0,0 +1,49 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:resumate/ui/widgets/signature_picker.dart'; + +/// Regression coverage for M3 (REVIEW.md): `SignaturePicker` used to copy +/// whatever `image_picker` returned straight to `signature.png`, so a HEIC +/// or WebP asset saved under a `.png` name would later crash +/// `pw.MemoryImage` in letter exports. `decodeAndReencodeSignaturePng` is the +/// fix's core: it's a plain function (no platform channels), so it's tested +/// directly here rather than by driving the real gallery picker. +void main() { + group('decodeAndReencodeSignaturePng', () { + test('re-encodes a non-PNG image (e.g. BMP) into a valid, decodable PNG', () { + final source = img.Image(width: 4, height: 3); + img.fill(source, color: img.ColorRgb8(10, 20, 30)); + final bmpBytes = Uint8List.fromList(img.encodeBmp(source)); + + final result = decodeAndReencodeSignaturePng(bmpBytes); + + expect(result, isNotNull); + final decoded = img.decodePng(result!); + expect(decoded, isNotNull); + expect(decoded!.width, 4); + expect(decoded.height, 3); + }); + + test('is a no-op re-encode for bytes that are already a PNG', () { + final source = img.Image(width: 2, height: 2); + final pngBytes = Uint8List.fromList(img.encodePng(source)); + + final result = decodeAndReencodeSignaturePng(pngBytes); + + expect(result, isNotNull); + expect(img.decodePng(result!)?.width, 2); + }); + + test('returns null for bytes that are not a decodable image at all', () { + final garbage = Uint8List.fromList('definitely not an image'.codeUnits); + + expect(decodeAndReencodeSignaturePng(garbage), isNull); + }); + + test('returns null for empty bytes', () { + expect(decodeAndReencodeSignaturePng(Uint8List(0)), isNull); + }); + }); +} From 2cd044264a5627be59b69c9b3fcc3b03b66bb3f6 Mon Sep 17 00:00:00 2001 From: "[vinald]" Date: Fri, 11 Sep 2026 14:10:57 +0300 Subject: [PATCH 8/8] Consolidate CONTRIBUTING.md/docs/ARCHITECTURE.md into README, drop REVIEW.md, add MIT LICENSE --- CONTRIBUTING.md | 59 ----- LICENSE | 21 ++ README.md | 308 ++++++++++++++++++--- REVIEW.md | 383 --------------------------- TUTORIAL.md | 5 +- docs/ARCHITECTURE.md | 167 ------------ lib/ui/settings/settings_screen.dart | 1 - lib/ui/widgets/editor_widgets.dart | 2 +- test/home_screen_tutorial_test.dart | 4 +- test/signature_reencode_test.dart | 2 +- test/string_list_editor_test.dart | 4 +- 11 files changed, 304 insertions(+), 652 deletions(-) delete mode 100644 CONTRIBUTING.md create mode 100644 LICENSE delete mode 100644 REVIEW.md delete mode 100644 docs/ARCHITECTURE.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index c329765..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,59 +0,0 @@ -# Contributing - -## Setup - -```sh -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 -README). - -## Before you push - -```sh -flutter analyze # must be clean -flutter test # must be green -``` - -CI runs exactly these 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. Run `dart 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 in `test/models_serialization_test.dart`. -- Output modules (`render/`, `latex/`, `export/`) must stay pure — take - `GeneratedDocument` + `AppSettings`, return bytes / string, no store access. - -## Where things live - -See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). 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) | - -## Known issues - -[`REVIEW.md`](REVIEW.md) tracks known bugs with severity and suggested fixes. -Picking one off is a good first contribution. - -## Tests - -Add a test with any behaviour change. Repositories and the LaTeX engine have -injection seams (`baseDir:`, `loadAsset:`). There is no widget-test coverage yet -— adding some (starting with the resume editor reorder path, REVIEW.md M1) is -welcome. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..997cb73 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Vinald + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 509f4b3..781fec5 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Edit it in place, or replace `lib/data/seed_data.dart`, to make it yours. | **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 appears above your name on letter PDFs and is shipped alongside `.tex` exports as `signature.png`. | +| **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. | @@ -38,7 +38,8 @@ Requires the Flutter SDK (channel `stable`, version `3.38.5` — see CI). ```sh flutter pub get -flutter run +flutter run # device or emulator +flutter run -d chrome # quick desktop preview (no persistence, no native PDF preview) ``` First-time toolchain setup: @@ -58,9 +59,9 @@ export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-21.jdk/Contents/Home flutter build apk --release --split-per-abi ``` -Output: `build/app/outputs/flutter-apk/app-arm64-v8a-release.apk` (~32 MB) for -modern phones. The release build strips unused `com.tom_roush.pdfbox` / -JPEG-2000 classes via `android/app/proguard-rules.pro`. +Output: `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 @@ -95,6 +96,14 @@ 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. +### Building an iOS app + +```sh +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 only +``` + --- ## Architecture @@ -116,7 +125,7 @@ lib/ 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 + settings_store.dart ChangeNotifier over AppSettings; debounces the disk write seed_data.dart the placeholder CV / letters render/ native PDF @@ -125,7 +134,7 @@ lib/ 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) -> Uint8List + document_pdf.dart buildDocumentPdf(doc, settings, {pageFormat}) -> Uint8List latex/ .tex generation latex_engine.dart latexEscape() + token substitution into the templates @@ -133,6 +142,7 @@ lib/ 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 @@ -144,8 +154,9 @@ lib/ 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) - preview/ PdfPreview of the live document + 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 @@ -156,15 +167,187 @@ assets/ fonts/ EB Garamond (body, instanced from the variable font) + Font Awesome 6 ``` +### Layers at a glance + +``` +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 `ChangeNotifier`s +and write back through `DocumentStore.save` (autosave on back-navigation, plus +an explicit Save FAB). `DocumentStore` / `SettingsStore` are `ChangeNotifier`s 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. -See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for a deeper walkthrough and -[`REVIEW.md`](REVIEW.md) for a catalogue of known bugs and rough edges. +### Models (`lib/models/`) + +Plain mutable classes. Every one has: + +- a positional/named constructor with defaults (so a "blank" instance is valid), +- `toJson()` / `fromJson()` — `fromJson` is 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` — scans `companyName`, `companyAddress`, `salutation`, + `recipientDept` and every body paragraph for `{{token}}` and returns the + distinct names. The letter editor renders one field per name. +- `resolve(text)` — substitutes from the `placeholders` map; 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 `<>` LaTeX token. + +### Persistence (`lib/data/`) + +`DocumentRepository` — one `.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, writes `SeedData.initialDocuments()`, then + loads all. **If any storage call throws** (e.g. web, where `path_provider` + has 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 by `updatedAt` desc. + +`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. + +### Rendering (`lib/render/`) + +`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. + +### LaTeX (`lib/latex/`) + +`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)`: + +1. loads the right template from `assets/templates/` (injectable loader for + tests), +2. builds a `Map` of `TOKEN → value`, running free text through + `latexEscape` and generating the repeated blocks (`_experienceItems`, + `_skillItems`, `_educationItems`, `_projectItems`, `_references`), +3. `template.replaceAll('<<$key>>', value)` for each entry. + +Letter bodies are `resolve()`d (placeholders) **before** escaping. `<>` +falls back to `\today` when blank; `<>` emits an +`\includegraphics` line only when a signature path is set. + +> Known gap: the email contact token is escaped and wrapped in +> `\nolinkurl{}`, but `PORTFOLIO_URL` / `GITHUB_URL` / `LINKEDIN_URL` still go +> straight into `\href{...}` unescaped — `#` or `%` in one of those URLs would +> still break the `.tex`. Low priority (query-string URLs are rare in a CV's +> contact block). + +### Export (`lib/export/`) + +`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. + +### UI (`lib/ui/`) + +`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` — `StatefulWidget`s holding a +`.copy()` of the payload and a `_dirty` flag. `PopScope` autosaves on back. +Every `onChanged` runs through `_edit(() {...})` which mutates the copy and +`setState`s. 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. + +### Testing seams + +- `DocumentRepository(baseDir:)` / `SettingsRepository(baseDir:)` — temp dir. +- `LatexEngine(loadAsset:)` — load templates from disk instead of `rootBundle`. +- Output modules are pure, so most tests are `SeedData → module → assert on the + string/bytes`. ### Adding a template @@ -177,12 +360,14 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for a deeper walkthrough and ## Dependency notes -- **`dependency_overrides: file_magic_number: 1.2.0`** is required — `pdf_combiner` - 3.4.0 imports a file that `file_magic_number` 1.3.0 moved out of the package - root. Pinned to the last version that still exposes it. -- **Web PDF preview** uses `printing`'s pdf.js integration; `web/index.html` pins - the pdf.js version so the API and worker match. The in-app preview is really - meant for Android / iOS (native renderer). +- `pdf`, `printing` and `archive` are pinned below their latest majors because + those majors require Dart SDK `>=3.12.0`, and this project targets + `sdk: ^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.html` + pins 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:` in `pubspec.yaml` and loaded via `rootBundle` in `render/pdf_theme.dart`. @@ -195,29 +380,86 @@ flutter analyze flutter test ``` -27 tests cover: model JSON round-trips, `DocumentRepository` CRUD + sort, +38 tests cover: model JSON round-trips, `DocumentRepository` CRUD + sort, seed-data shape, the LaTeX token engine + escaping, DOCX zip structure + placeholder resolution + XML escaping, plain-text / Markdown output, raw PDF -rendering (resume + letter), and (as of `fix/safe-bugs`) a first slice of widget -coverage for the reorderable list editor. +rendering (resume + letter), the reorderable list editor, the first-launch +tutorial prompt, signature image re-encoding, and the resume editor's +dirty-state flow. + +CI (`.github/workflows/ci.yml`) runs `flutter pub get` / `analyze` / `test` / +a debug APK build on every push to `main` and every PR. -CI (`.github/workflows/ci.yml`) runs `flutter pub get` / `analyze` / `test` on -every push to `main` and every PR. +**Screens still without dedicated widget tests:** `SettingsScreen`, +`PreviewScreen`, `LatexEditorScreen` (loading/copy/share/token sheet), +`PdfCombinerScreen`, `ExportSheet`, and `contact_section.dart`. Not blocking, +but a good place to add coverage next — see the two gotchas documented at the +top of `test/home_screen_tutorial_test.dart` before writing more `testWidgets` +cases that trigger real file I/O. --- ## Known limitations -Short list of what's still open — the full analysis (including what's already -fixed) is in [`REVIEW.md`](REVIEW.md). +- 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`. +- `PORTFOLIO_URL` / `GITHUB_URL` / `LINKEDIN_URL` are not escaped in `.tex` + output (see the LaTeX section above). +- `pdf` / `printing` / `archive` are held below their latest majors pending a + Dart SDK bump (see Dependency notes). +- No `==`/`hashCode` on the data models. + +## Contributing + +```sh +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:** + +```sh +flutter analyze # must be clean +flutter test # must be green +``` + +CI 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. Run `dart 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 in `test/models_serialization_test.dart`. +- Output modules (`render/`, `latex/`, `export/`) must stay pure — take + `GeneratedDocument` + `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) | + +## License -- Signature images are used as-is; a non-PNG/JPEG pick (e.g. HEIC) can fail to - render on export. -- Settings are written to disk on every keystroke / slider frame rather than - debounced. -- A brand-new resume starts pre-filled with the whole seed CV instead of blank. -- LaTeX editor edits are a scratch buffer and are not saved back to the document. -- Release APK is debug-signed. +MIT — see [`LICENSE`](LICENSE). ## Support diff --git a/REVIEW.md b/REVIEW.md deleted file mode 100644 index c1a0dd7..0000000 --- a/REVIEW.md +++ /dev/null @@ -1,383 +0,0 @@ -# Code Review — Resumate - -Living review document. First pass Sep 2026 against commit `f08bb67` -(then "Resume Generator"); this update is a full re-pass against `main` after -PRs #1–#8 (anonymized seed data, renamed to Resumate, visual theme pass, -in-app tutorial, the H1/M1/M2/L8 fixes below) — scope widened to include a -dependency/toolchain audit, a security pass, and a test-coverage gap analysis. -Scope: `lib/`, `test/`, `assets/templates/`, platform config (`android/`, -`ios/`, `web/`), build/CI wiring, `pubspec.yaml`. Third-party `ios/Pods/` is -out of scope. - -`flutter analyze` is clean and `flutter test` (32 tests) passes, on this -machine and in CI. - -Severity key: **High** = crash or data loss for a normal user · **Medium** = -broken output or bad UX in a common path · **Low** = minor / cosmetic / -hardening · **Info** = observation, no action required. - -## Status of the original findings - -| # | Finding | Status | -| --- | --- | --- | -| H1 | iOS signature picker crash (missing `NSPhotoLibraryUsageDescription`) | ✅ Fixed, PR #4 | -| M1 | Editor fields show stale text after reordering | ✅ Fixed, PR #4 | -| M2 | `.tex` export breaks on emails with `_` | ✅ Fixed (email case), PR #4 | -| M3 | Signature image not re-encoded / validated | 🔶 Still open | -| L1 | Settings written to disk on every keystroke | 🔶 Still open | -| L2 | "New resume" pre-filled with the whole seed CV | 🔶 Still open | -| L3 | Controller leak in rename dialog | 🔶 Still open | -| L4 | PDF preview page-format selector is a no-op | 🔶 Still open | -| L5 | LaTeX editor edits are an unsaved scratch buffer | 🔶 Still open | -| L6 | Empty References renders inconsistently across formats | 🔶 Still open | -| L7 | Export temp filenames can collide | 🔶 Still open | -| L8 | Android launcher label was the raw package name | ✅ Fixed, PR #4 (now "Resumate", PR #5) | -| L9 | Leftover Flutter-template web metadata | ✅ Fixed, PR #5 | -| L10–L12 | Zone error handling, double dir scan, combiner re-read | 🔶 Still open | -| I9 | `devtools_options.yaml` untracked/gitignored | ✅ Fixed this pass (see C4 below) — it had been re-added to git by an intervening commit despite the `.gitignore` entry | - -The full text of each original finding is kept below unchanged, for context on -*why*; new material starts at **Fresh findings**. - ---- - -## High - -### H1 · iOS: signature picker crashes — missing `NSPhotoLibraryUsageDescription` — ✅ Fixed (PR #4) - -`ios/Runner/Info.plist` has no photo-library usage string. `SignaturePicker` -(`lib/ui/widgets/signature_picker.dart:19`) calls -`ImagePicker().pickImage(source: ImageSource.gallery)`. On iOS, opening the photo -library without `NSPhotoLibraryUsageDescription` in `Info.plist` is an immediate -hard crash (`SIGABRT`), not a permission denial. Any user who taps **Add -signature** on iOS loses the app. - -**Fix:** added `NSPhotoLibraryUsageDescription` to `Info.plist`. - ---- - -## Medium - -### M1 · Editor text fields show stale text after reordering an entry — ✅ Fixed (PR #4) - -`EditorField` (`lib/ui/widgets/editor_widgets.dart`) was a `StatelessWidget` -wrapping `TextFormField(initialValue: value)` **with no `Key`**. `initialValue` -seeds the field's internal `TextEditingController` **once**; later rebuilds with a -different `value` were ignored, so reordering an entry left the editor showing -stale text until reopened (the underlying model was fine). - -**Fix:** `_EntryFrame` now keyed by `ObjectKey(entry)`; `StringListEditor` -rewritten stateful with one `TextEditingController` per row, reordered in -lock-step with the list. Regression-covered by `test/string_list_editor_test.dart`. - -### M2 · `.tex` export produces invalid LaTeX for contact fields with special characters — ✅ Fixed for the email case (PR #4) - -`lib/latex/latex_engine.dart` escapes most free text through `latexEscape`, but -not the URL/email tokens — `EMAIL` was inserted raw both into `\href{mailto:...}` -*and* as the visible link text, so an address like `jane_doe@example.com` broke -`.tex` compilation with a bare `_` in text mode. - -**Fix applied:** the three `.tex` templates now wrap the visible email in -`\nolinkurl{}` (`\href{mailto:<>}{\nolinkurl{<>}}`), which -hyperref reads verbatim. - -**Still open, narrower:** `PORTFOLIO_URL`, `GITHUB_URL`, `LINKEDIN_URL` still go -straight into `\href{...}` unescaped — `#` and `%` in a URL would still break the -argument. Low priority (query-string URLs are rare in a CV's contact block) but -not actually fixed, just not the case that was addressed. - -### M3 · Signature image is trusted to be a decodable PNG/JPEG — still open - -`SignaturePicker._pick` copies whatever the gallery returns to `signature.png` -verbatim — no re-encode, no MIME check. `image_picker` returns the asset in its -original container, so an iOS HEIC, a WebP or a GIF is now a file named `.png` -whose bytes are not PNG. - -- PDF: `classic_letter_template.dart` feeds those bytes to `pw.MemoryImage`, - which throws while building the document → letter preview and every letter - export fail with an opaque error. -- `.tex`: the non-PNG file is shipped next to the source as `signature.png`; - Overleaf then fails to include it. - -**Fix:** decode + re-encode to PNG on pick (e.g. via the `image` package, -already a transitive dependency via `image_picker`, or `instantiateImageCodec`), -reject/notify on failure, and guard `pw.MemoryImage` with a try/catch so one bad -image can't brick letter rendering. Not included in PR #4 because it needs an -explicit dependency decision (`image` is currently transitive-only). - ---- - -## Low - -*(unchanged from the original pass except L8/L9, marked above; kept for -context — see the status table for what's still open.)* - -### L1 · Settings are written to disk on every keystroke and every slider frame - -`SettingsScreen` calls `store.update(...)` from every `onChanged`. Dragging a -slider issues dozens of overlapping file writes per second. Not corrupting -(last write wins) but wasteful and a battery/flash concern. **Fix:** debounce -the persist (300–500 ms trailing), keep the in-memory update synchronous. - -### L2 · "New resume" is pre-populated with the entire seed CV - -`DocumentStore.create` uses `SeedData.resume()` for a brand-new resume instead -of the existing-but-unused `emptyResume()` helper. New *letters* correctly -start near-empty. Product call on whether that's intended. - -### L3 · `TextEditingController` leak in the rename dialog - -`_promptText` (`ui/home/home_screen.dart`) news a controller per rename and -never disposes it. - -### L4 · PDF preview ignores the page-format selector - -`PreviewScreen` hardcodes `PdfPageFormat.a4` in the template regardless of the -toolbar's format selector. - -### L5 · LaTeX editor edits are a scratch buffer with no save and no warning - -Nothing tells the user that edits in `LatexEditorScreen` aren't saved back to -the document. - -### L6 · Empty "References" section renders inconsistently across formats - -PDF hides the section when empty; `.tex`/DOCX/text always emit something. - -### L7 · Export temp filenames can collide or degenerate - -`ExportService.writeToFile`'s slugify can produce empty/colliding filenames for -non-ASCII or near-identical titles. - -### L10 · No `FlutterError` / zone error handling, fire-and-forget bootstrap - -`main.dart`'s store `.load()` calls aren't awaited and there's no -`runZonedGuarded`/`FlutterError.onError` hook. - -### L11 · `DocumentRepository` scans the directory twice on startup and uses sync I/O - -`isEmpty()` then `loadAll()` both run `dir.listSync()`. - -### L12 · `pdf_combiner_screen` re-reads the merged file on every rebuild - -`_readBytes` is the `PdfPreview.build` callback, so any `setState` re-reads the -whole combined PDF. - ---- - -## Info / observations (original pass) - -- **I1 — Widget test coverage was zero; now partial.** Superseded — see - **Fresh findings → Test coverage** below for the current, more granular - picture. -- **I2 — Model equality.** No `==`/`hashCode` on the data models. Still true, - still low-impact. -- **I3 — Release APK is debug-signed.** Still true; `TODO`s remain in - `android/app/build.gradle.kts`. -- **I4 — `analysis_options.yaml` is the stock template.** Still true. -- **I5 — `assets/fonts/` not listed under `assets:`.** Still true, still works. -- **I6 — `.tex` templates load `fontawesome5`** vs. docs saying "Font Awesome - 6". Still a doc-only mismatch. -- **I7 — misplaced `// ignore: use_build_context_synchronously`** in - `settings_screen.dart`. Still present. -- **I8 — `com.vinald.*` application id / bundle id** left as-is (developer - namespace, not personal data — see C3 below for what *was* addressed). -- **I9 — `devtools_options.yaml`** — see C4 below; it needed fixing twice. - ---- - -## Fresh findings — September 2026 full review - -### Code quality - -**C1 · `cupertino_icons` is an unused dependency** - -`pubspec.yaml` declares `cupertino_icons: ^1.0.8`; nothing in `lib/` imports -`CupertinoIcons` or `package:cupertino_icons`. Leftover from the Flutter -project template (the app is Material 3 throughout, including its icons). -Harmless but adds a dependency and a line to review for no reason. - -**Fix:** remove the line from `pubspec.yaml`. - -**C2 · `main.dart`'s bootstrap is unchanged since the original review — L10 still open** - -Restating for visibility since this review widened scope rather than because -it's new: no crash telemetry seam exists between `ensureInitialized()` and -`runApp()`. Low priority for a fully local app, but worth doing before any -crash-reporting integration. - -**C3 · `com.vinald.*` application id — deliberately not renamed, still worth a decision** - -Both `android/app/build.gradle.kts` (`applicationId`) and -`ios/Runner.xcodeproj/project.pbxproj` (`PRODUCT_BUNDLE_IDENTIFIER`) still read -`com.vinald.resume_generator` / `com.vinald.resumeGenerator`, unrelated to the -display-name rename to "Resumate" in PR #5 (deliberately scoped out — see that -PR's description and I8 above). This is a developer namespace, not personal -data, so it's not a privacy issue as-is. It does mean: (a) anyone else building -this repo needs their own applicationId/bundle id to publish to a store under -their own account, and (b) the identifier doesn't match the product name -anymore, which reads oddly in `flutter doctor`/build output and any future Play -Console / App Store Connect listing. Worth a deliberate rename (Kotlin package -directory move + Xcode project edit) the next time either platform's listing -is actually set up — not urgent before then. - -**C4 · `devtools_options.yaml` was tracked in git despite being gitignored** - -The `.gitignore` fix in PR #3 added `devtools_options.yaml` to the ignore list, -but the file had already been picked up by an earlier `git add -A` (PR #2, before -the ignore rule existed) — adding a pattern to `.gitignore` doesn't untrack a -file already in the index. It stayed tracked and kept showing up as a diff in -every subsequent PR ("1 uncommitted change" warnings throughout this session). - -**Fixed this pass:** `git rm --cached devtools_options.yaml` — the file stays -on disk (still useful locally) but is no longer tracked, and the existing -`.gitignore` entry now actually takes effect. - -### Dependencies & toolchain - -**D1 · Several direct dependencies are multiple major versions behind** - -`flutter pub outdated`: - -| Package | Current | Latest | -| --- | --- | --- | -| `pdf_combiner` | 3.4.0 | 6.2.2 | -| `file_picker` | 8.3.7 | 12.3.0 | -| `share_plus` | 10.1.4 | 13.3.0 | -| `archive` | 3.6.1 | 4.2.0 | -| `printing` | 5.14.3 | 5.15.0 | -| `pdf` | 3.12.0 | 3.13.0 | -| `intl` | 0.19.0 | 0.20.3 | - -`pdf_combiner` is the one most worth a deliberate upgrade: the -`dependency_overrides: file_magic_number: 1.2.0` pin in `pubspec.yaml` exists -*specifically* because `pdf_combiner` 3.4.0 depends on an old -`file_magic_number` API. Later `pdf_combiner` releases likely moved past that -dependency entirely, which would let the override — and the maintenance risk it -carries (it silently pins a transitive package below what anything else in the -tree wants) — be dropped. `file_picker`/`share_plus` are 3–4 majors behind; -each major likely has API changes worth reading through before bumping, so -treat as a deliberate, tested upgrade PR, not a drive-by `pub upgrade ---major-versions`. - -**D2 · Flutter's configured JDK can silently break every Android build, independent of `JAVA_HOME`** - -Not a code issue, but worth recording since it cost real time this session and -will cost a future contributor the same: `flutter config --jdk-dir` (stored in -Flutter's own tool state, not the repo) takes priority over the `JAVA_HOME` -environment variable. If it's set (or a stale value lingers after a JDK is -uninstalled), exporting `JAVA_HOME` in your shell — the README's documented -fix — has **no effect**, and the error message ("JAVA_HOME is set to an invalid -directory: ...") looks exactly like the `JAVA_HOME`-not-set case, sending you -down the wrong path. Compounding this: killing a `flutter run`/`flutter test` -process with `kill -9` can leave a Gradle daemon (and a separate Kotlin compile -daemon) alive in the background, pinned to whichever JDK launched it — a -_correct_ `flutter config --jdk-dir` fix can still fail against a stale daemon -with a `VerifyError` from mixed Kotlin-runtime classpaths until that daemon is -also killed (`ps aux | grep -E "GradleDaemon|KotlinCompileDaemon"`). - -**Fix:** document this explicitly in the README's build section (currently -only mentions `JAVA_HOME`), e.g.: *"If `flutter build apk`/`flutter run` fails -with a JDK error even after `export JAVA_HOME=...`, also run `flutter config ---jdk-dir=$JAVA_HOME` and check for stale `GradleDaemon`/`KotlinCompileDaemon` -processes pinned to a different JDK."* - -**D3 · CI doesn't build the app, only analyzes and unit-tests it** - -`.github/workflows/ci.yml` runs `flutter analyze` + `flutter test` — it would -not have caught the Kotlin/JDK-driven build breakage above (that's -environment-specific, not a real code regression), but it also wouldn't catch -a genuine Android build break (a manifest error, a Gradle config typo, a -plugin version conflict) before it reaches `main`. Consider adding a `flutter -build apk --debug` step (skip the release/signing path — debug build is -sufficient to prove the toolchain resolves and compiles). - -**D4 · CI actions pinned to major-version tags, not SHAs** - -`actions/checkout@v4` and `subosito/flutter-action@v2` — standard, low-risk -practice for most repos (not pinning to a commit SHA), but worth a mention -since it's the kind of thing that changes if this repo's threat model changes -(e.g. if it starts accepting external PRs that trigger CI with secrets). - -### Security review - -The app has a small attack surface: no network calls anywhere in `lib/` (grep -confirmed — the only `http://`/`https://` strings are XML namespace URIs in the -generated DOCX and the pdf.js CDN URL used only by the web build's PDF -preview), no authentication, no backend, and only the plugins needed for local -file/image picking and sharing. - -- **S1 (Info) — Documents are stored unencrypted in app-private storage.** - `DocumentRepository`/`SettingsRepository` write plain JSON to the platform's - app documents directory via `path_provider`. That directory is sandboxed by - the OS (not readable by other apps without root/jailbreak), which matches - the security model of virtually every local-first mobile app — not a - vulnerability. Worth noting explicitly because resumes and letters contain - real PII (name, phone, email, sometimes references' contact details): if a - user's threat model includes "someone else has physical access to my - unlocked phone," that's a device-level concern, not something this app's - storage choice changes. -- **S2 (Info) — No explicit `` in `AndroidManifest.xml`.** - Confirmed nothing is missing: `image_picker`/`file_picker`/`share_plus` - merge their own required permissions into the final manifest via their AARs - (standard Android library manifest merging), so the app manifest itself - correctly declares none directly. -- **S3 (Info) — Web build loads pdf.js from `cdnjs.cloudflare.com` at runtime - with no Subresource Integrity hash.** This is the `printing` package's own - behavior (`web/index.html` only sets the version/base-URL it should fetch), - not something the app controls. Low practical risk since the web target is - explicitly "quick previews on desktop" (README), not the shipped product, - but worth knowing if the web build is ever taken more seriously. -- **S4 (confirmed clean) — no hardcoded secrets, API keys, or tokens anywhere - in `lib/`** (checked via pattern search for `api_key`/`secret`/`token`/ - `password` — the only hits were unrelated `{{token}}` placeholder UI text). -- **S5 (confirmed clean) — no path-traversal / zip-slip surface.** `archive` - is used only to *build* the DOCX zip from in-memory strings, never to - extract an untrusted archive; `pdf_combiner` merges only user-selected local - files (via the system file picker), never a remote or attacker-controlled - path. -- Ties back to **M3** above: the one real input-validation gap (an unvalidated - image file trusted to be PNG/JPEG) is a robustness bug, not a security one — - worst case is a crash/broken export, not code execution or data exposure. - -### Test coverage gaps - -32 tests now cover: model JSON round-trips (incl. `tutorialSeen`), repository -CRUD, seed-data shape, the LaTeX engine + escaping, DOCX structure + -placeholder resolution + XML escaping, plain-text/Markdown output, raw PDF -rendering, `StringListEditor` reorder/insert/remove, and the home screen's -first-launch tutorial prompt (4 tests, with one assertion intentionally -descoped — see that test file's header comment). - -**Screens with zero test coverage:** - -| Screen | What's untested | -| --- | --- | -| `ResumeEditorScreen` / `LetterEditorScreen` | Autosave-on-pop, the dirty-state FAB, the full add/edit/reorder/delete flow end-to-end (only the underlying `StringListEditor` is unit-tested) | -| `SettingsScreen` | Every control — accent colour, body font, margin/spacing sliders, default contact | -| `LatexEditorScreen` | Loading, copy, share, the token reference sheet | -| `PreviewScreen` | Renders at all, reacts to settings changes | -| `PdfCombinerScreen` | Pick, reorder, combine, error path (< 2 files) | -| `ExportSheet` | Format selection → `ExportService.share` wiring, the error snackbar path | -| `SignaturePicker` | Pick, replace, remove — this is also the screen with the open M3 robustness gap, so it's the highest-value screen to add coverage to | -| `contact_section.dart` | Shared by 3 screens, no direct test | - -**Fix:** not all of these need coverage before the next release, but -`SignaturePicker` (feeds directly into M3) and the resume/letter editor's -save/dirty-state flow (the app's core loop) are the two highest-value gaps. -`test/home_screen_tutorial_test.dart`'s header comment documents two real -environment gotchas for writing more of these (`dart:io` in `testWidgets` -bodies, and real I/O triggered as a side effect of pumping) — worth reading -before adding to this list. - ---- - -## Suggested fix batches - -| Batch | Contents | -| --- | --- | -| Done | H1, M1, M2 (email case), L8, L9, C4 | -| Quick wins | C1 (remove unused dep), D2/D4 (docs-only) | -| Needs a product/dependency call | M3, L1, L2, L4, L5, L6, L7 | -| Hardening | L3, L10, L11, L12, D1 (dependency majors), D3 (CI build step) | -| Coverage | `SignaturePicker`, resume/letter editor save flow, then the rest of the table above | diff --git a/TUTORIAL.md b/TUTORIAL.md index 605907f..2c8827a 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -104,6 +104,5 @@ edit, or delete them like anything else. To add a new one from scratch, tap --- -Ran into something that doesn't work as described? Check -[`REVIEW.md`](REVIEW.md) for known issues, or see the **Support** section in -[`README.md`](README.md). +Ran into something that doesn't work as described? Check the **Known +limitations** and **Support** sections in [`README.md`](README.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index 27d3844..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,167 +0,0 @@ -# Architecture - -This is a longer companion to the "Architecture" section of the README. It walks -each layer in the order data flows through it. - -## Layers at a glance - -``` -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. - -## Models (`lib/models/`) - -Plain mutable classes. Every one has: - -- a positional/named constructor with defaults (so a "blank" instance is valid), -- `toJson()` / `fromJson()` — `fromJson` is 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` — scans `companyName`, `companyAddress`, `salutation`, - `recipientDept` and every body paragraph for `{{token}}` and returns the - distinct names. The letter editor renders one field per name. -- `resolve(text)` — substitutes from the `placeholders` map; 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 `<>` LaTeX token. - -## Persistence (`lib/data/`) - -`DocumentRepository` — one `.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, writes `SeedData.initialDocuments()`, then - loads all. **If any storage call throws** (e.g. web, where `path_provider` has - 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 - by `updatedAt` desc. - -`SettingsStore` is the same shape over a single `AppSettings`. - -Both stores are created in `main.dart` and their `load()` is started but **not -awaited** — the UI shows a spinner while `loading` is true. - -## Rendering (`lib/render/`) - -`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` (A4, -margins from settings). 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. - -`buildDocumentPdf(doc, settings)` 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. - -## LaTeX (`lib/latex/`) - -`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)`: - -1. loads the right template from `assets/templates/` (injectable loader for - tests), -2. builds a `Map` of `TOKEN → value`, running free text through - `latexEscape` and generating the repeated blocks (`_experienceItems`, - `_skillItems`, `_educationItems`, `_projectItems`, `_references`), -3. `template.replaceAll('<<$key>>', value)` for each entry. - -Letter bodies are `resolve()`d (placeholders) **before** escaping. `<>` -falls back to `\today` when blank; `<>` emits an -`\includegraphics` line only when a signature path is set. - -> Known gap (REVIEW.md M2): URL and email tokens are **not** escaped and the -> email is also used as visible link text, so `_ # %` in those fields break the -> `.tex`. - -## Export (`lib/export/`) - -`ExportService.writeToFile(doc, settings, format)` slugifies the title, 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. - -## UI (`lib/ui/`) - -`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` — `StatefulWidget`s holding a -`.copy()` of the payload and a `_dirty` flag. `PopScope` autosaves on back. Every -`onChanged` runs through `_edit(() {...})` which mutates the copy and -`setState`s. 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 is no save-back. - -`SettingsScreen` — writes through `SettingsStore.update` on every change -(see REVIEW.md L1 about the write frequency). - -`PdfCombinerScreen` — `ReorderableListView` of picked PDFs, `_combine()` calls -`PdfCombinerService.merge`, shows a `PdfPreview` of the result and a share -action. - -## Testing seams - -- `DocumentRepository(baseDir:)` / `SettingsRepository(baseDir:)` — temp dir. -- `LatexEngine(loadAsset:)` — load templates from disk instead of `rootBundle`. -- Output modules are pure, so most tests are `SeedData → module → assert on the - string/bytes`. diff --git a/lib/ui/settings/settings_screen.dart b/lib/ui/settings/settings_screen.dart index 124cf59..3ae9516 100644 --- a/lib/ui/settings/settings_screen.dart +++ b/lib/ui/settings/settings_screen.dart @@ -68,7 +68,6 @@ class SettingsScreen extends StatelessWidget { ), ); if (ok == true) { - // ignore: use_build_context_synchronously update(s.copyWith(accentColor: picked.toARGB32())); } }, diff --git a/lib/ui/widgets/editor_widgets.dart b/lib/ui/widgets/editor_widgets.dart index c152383..8ad8207 100644 --- a/lib/ui/widgets/editor_widgets.dart +++ b/lib/ui/widgets/editor_widgets.dart @@ -78,7 +78,7 @@ class EditorField extends StatelessWidget { /// /// Backed by one [TextEditingController] per row so that reordering a row keeps /// its text with it (a plain `initialValue` field would show the previous row's -/// text after a move — see REVIEW.md M1). +/// text after a move). class StringListEditor extends StatefulWidget { const StringListEditor({ super.key, diff --git a/test/home_screen_tutorial_test.dart b/test/home_screen_tutorial_test.dart index 55644ed..1802b10 100644 --- a/test/home_screen_tutorial_test.dart +++ b/test/home_screen_tutorial_test.dart @@ -82,8 +82,8 @@ void main() { // (SettingsStore.update → SettingsRepository.save), and in this // environment a genuine dart:io Future triggered as a side effect of // pumping does not reliably resume within a widget test's fake-clock - // budget — not even inside `tester.runAsync()`. See REVIEW.md / git - // history on this file for the investigation. + // budget — not even inside `tester.runAsync()`. See this file's git + // history for the investigation. }); testWidgets('skipping still marks it seen', (tester) async { diff --git a/test/signature_reencode_test.dart b/test/signature_reencode_test.dart index 7ec8d40..8929ada 100644 --- a/test/signature_reencode_test.dart +++ b/test/signature_reencode_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:image/image.dart' as img; import 'package:resumate/ui/widgets/signature_picker.dart'; -/// Regression coverage for M3 (REVIEW.md): `SignaturePicker` used to copy +/// Regression coverage: `SignaturePicker` used to copy /// whatever `image_picker` returned straight to `signature.png`, so a HEIC /// or WebP asset saved under a `.png` name would later crash /// `pw.MemoryImage` in letter exports. `decodeAndReencodeSignaturePng` is the diff --git a/test/string_list_editor_test.dart b/test/string_list_editor_test.dart index 9b9fabb..238ef18 100644 --- a/test/string_list_editor_test.dart +++ b/test/string_list_editor_test.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:resumate/ui/widgets/editor_widgets.dart'; -/// Regression test for REVIEW.md M1: reordering a row must keep its text with it, -/// both in the emitted list and in what the fields display. +/// Regression test: reordering a row must keep its text with it, both in the +/// emitted list and in what the fields display. void main() { Future pumpEditor( WidgetTester tester,