diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9d584c6..a8690e3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ >}} \\ \faGlobe \hspace{2pt} \href{<>}{Portfolio} \hspace{3pt} \faPhone \hspace{2pt} <> \hspace{3pt} - \faEnvelope \hspace{2pt} \href{mailto:<>}{<>} \hspace{3pt} + \faEnvelope \hspace{2pt} \href{mailto:<>}{\nolinkurl{<>}} \hspace{3pt} \faGithub \hspace{2pt} \href{<>}{<>} \hspace{3pt} \faLinkedin \hspace{2pt} \href{<>}{<>} diff --git a/assets/templates/cover_letter.tex b/assets/templates/cover_letter.tex index b1fedd0..33ca5cf 100644 --- a/assets/templates/cover_letter.tex +++ b/assets/templates/cover_letter.tex @@ -24,7 +24,7 @@ \textbf{\MakeUppercase{<>}} \\ \faGlobe \hspace{2pt} \href{<<PORTFOLIO_URL>>}{Portfolio} \hspace{3pt} \faPhone \hspace{2pt} <<PHONE>> \hspace{3pt} - \faEnvelope \hspace{2pt} \href{mailto:<<EMAIL>>}{<<EMAIL>>} \hspace{3pt} + \faEnvelope \hspace{2pt} \href{mailto:<<EMAIL>>}{\nolinkurl{<<EMAIL>>}} \hspace{3pt} \faGithub \hspace{2pt} \href{<<GITHUB_URL>>}{<<GITHUB_LABEL>>} \hspace{3pt} \faLinkedin \hspace{2pt} \href{<<LINKEDIN_URL>>}{<<LINKEDIN_LABEL>>} diff --git a/assets/templates/resume_classic.tex b/assets/templates/resume_classic.tex index 515c902..9f79f92 100644 --- a/assets/templates/resume_classic.tex +++ b/assets/templates/resume_classic.tex @@ -33,7 +33,7 @@ \faGlobe \hspace{3pt} \href{<<PORTFOLIO_URL>>}{Portfolio}\hspace{3pt} \faPhone \hspace{3pt} <<PHONE>> \hspace{3pt} -\faEnvelope \hspace{3pt} \href{mailto:<<EMAIL>>}{<<EMAIL>>} \hspace{3pt} +\faEnvelope \hspace{3pt} \href{mailto:<<EMAIL>>}{\nolinkurl{<<EMAIL>>}} \hspace{3pt} \faGithub \hspace{3pt} \href{<<GITHUB_URL>>}{<<GITHUB_LABEL>>} \hspace{6pt} \faLinkedin \hspace{3pt} \href{<<LINKEDIN_URL>>}{<<LINKEDIN_LABEL>>} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7473865..022ea79 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -41,6 +41,8 @@ <string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeRight</string> </array> + <key>NSPhotoLibraryUsageDescription</key> + <string>Select a signature image from your photo library to place on your letters.</string> <key>CADisableMinimumFrameDurationOnPhone</key> <true/> <key>UIApplicationSupportsIndirectInputEvents</key> diff --git a/lib/ui/letter_editor/letter_editor_screen.dart b/lib/ui/letter_editor/letter_editor_screen.dart index 18ce03f..51fbb53 100644 --- a/lib/ui/letter_editor/letter_editor_screen.dart +++ b/lib/ui/letter_editor/letter_editor_screen.dart @@ -117,6 +117,7 @@ class _LetterEditorScreenState extends State<LetterEditorScreen> { const SizedBox(height: 8), for (final name in placeholders) EditorField( + key: ValueKey('placeholder-$name'), label: name, value: _data.placeholders[name] ?? '', onChanged: diff --git a/lib/ui/resume_editor/resume_editor_screen.dart b/lib/ui/resume_editor/resume_editor_screen.dart index 093cc1f..339e5c0 100644 --- a/lib/ui/resume_editor/resume_editor_screen.dart +++ b/lib/ui/resume_editor/resume_editor_screen.dart @@ -167,6 +167,7 @@ class _ExperienceSection extends StatelessWidget { children: [ for (var i = 0; i < data.experience.length; i++) _EntryFrame( + key: ObjectKey(data.experience[i]), index: i, count: data.experience.length, onMoveUp: () => edit( @@ -242,6 +243,7 @@ class _SkillsSection extends StatelessWidget { children: [ for (var i = 0; i < data.skills.length; i++) _EntryFrame( + key: ObjectKey(data.skills[i]), index: i, count: data.skills.length, onMoveUp: @@ -292,6 +294,7 @@ class _EducationSection extends StatelessWidget { children: [ for (var i = 0; i < data.education.length; i++) _EntryFrame( + key: ObjectKey(data.education[i]), index: i, count: data.education.length, onMoveUp: () => edit( @@ -345,6 +348,7 @@ class _ProjectsSection extends StatelessWidget { children: [ for (var i = 0; i < data.projects.length; i++) _EntryFrame( + key: ObjectKey(data.projects[i]), index: i, count: data.projects.length, onMoveUp: () => edit( @@ -408,6 +412,7 @@ class _ReferencesSection extends StatelessWidget { else ...[ for (var i = 0; i < data.references.length; i++) _EntryFrame( + key: ObjectKey(data.references[i]), index: i, count: data.references.length, onMoveUp: () => edit( @@ -470,6 +475,7 @@ class _ReferencesSection extends StatelessWidget { class _EntryFrame extends StatelessWidget { const _EntryFrame({ + super.key, required this.index, required this.count, required this.child, diff --git a/lib/ui/widgets/editor_widgets.dart b/lib/ui/widgets/editor_widgets.dart index 92acb93..c152383 100644 --- a/lib/ui/widgets/editor_widgets.dart +++ b/lib/ui/widgets/editor_widgets.dart @@ -75,7 +75,11 @@ class EditorField extends StatelessWidget { } /// Editor for an ordered list of short strings (bullets, skill items, …). -class StringListEditor 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). +class StringListEditor extends StatefulWidget { const StringListEditor({ super.key, required this.label, @@ -91,6 +95,74 @@ class StringListEditor extends StatelessWidget { final String itemHint; final bool multiline; + @override + State<StringListEditor> createState() => _StringListEditorState(); +} + +class _StringListEditorState extends State<StringListEditor> { + late List<TextEditingController> _controllers; + + @override + void initState() { + super.initState(); + _controllers = widget.values.map(_controller).toList(); + } + + @override + void didUpdateWidget(StringListEditor oldWidget) { + super.didUpdateWidget(oldWidget); + // Structural edits made through this widget keep [_controllers] and + // widget.values in lock-step already; only resync when the parent replaced + // the list out from under us (load, undo, section reset). + if (!_inSync()) { + for (final c in _controllers) { + c.dispose(); + } + _controllers = widget.values.map(_controller).toList(); + } + } + + TextEditingController _controller(String text) => + TextEditingController(text: text); + + bool _inSync() { + if (widget.values.length != _controllers.length) return false; + for (var i = 0; i < widget.values.length; i++) { + if (_controllers[i].text != widget.values[i]) return false; + } + return true; + } + + void _emit() => + widget.onChanged(_controllers.map((c) => c.text).toList(growable: false)); + + void _add() { + setState(() => _controllers.add(_controller(''))); + _emit(); + } + + void _removeAt(int i) { + setState(() => _controllers.removeAt(i).dispose()); + _emit(); + } + + void _moveUp(int i) { + if (i == 0) return; + setState(() { + final c = _controllers.removeAt(i); + _controllers.insert(i - 1, c); + }); + _emit(); + } + + @override + void dispose() { + for (final c in _controllers) { + c.dispose(); + } + super.dispose(); + } + @override Widget build(BuildContext context) { return Column( @@ -99,50 +171,35 @@ class StringListEditor extends StatelessWidget { Padding( padding: const EdgeInsets.only(top: 4, bottom: 4), child: Text( - label, + widget.label, style: Theme.of(context).textTheme.labelLarge, ), ), - for (var i = 0; i < values.length; i++) + for (var i = 0; i < _controllers.length; i++) Padding( + key: ObjectKey(_controllers[i]), padding: const EdgeInsets.only(bottom: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: TextFormField( - key: ValueKey('$label-$i-${values.length}'), - initialValue: values[i], + child: TextField( + controller: _controllers[i], minLines: 1, - maxLines: multiline ? 4 : 1, - decoration: InputDecoration(hintText: itemHint), - onChanged: (v) { - final next = [...values]; - next[i] = v; - onChanged(next); - }, + maxLines: widget.multiline ? 4 : 1, + decoration: InputDecoration(hintText: widget.itemHint), + onChanged: (_) => _emit(), ), ), IconButton( tooltip: 'Move up', icon: const Icon(Icons.arrow_upward, size: 18), - onPressed: - i == 0 - ? null - : () { - final next = [...values]; - final item = next.removeAt(i); - next.insert(i - 1, item); - onChanged(next); - }, + onPressed: i == 0 ? null : () => _moveUp(i), ), IconButton( tooltip: 'Remove', icon: const Icon(Icons.close, size: 18), - onPressed: () { - final next = [...values]..removeAt(i); - onChanged(next); - }, + onPressed: () => _removeAt(i), ), ], ), @@ -150,9 +207,9 @@ class StringListEditor extends StatelessWidget { Align( alignment: Alignment.centerLeft, child: TextButton.icon( - onPressed: () => onChanged([...values, '']), + onPressed: _add, icon: const Icon(Icons.add, size: 18), - label: Text('Add ${itemHint.toLowerCase()}'), + label: Text('Add ${widget.itemHint.toLowerCase()}'), ), ), ], diff --git a/test/string_list_editor_test.dart b/test/string_list_editor_test.dart new file mode 100644 index 0000000..6378105 --- /dev/null +++ b/test/string_list_editor_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:resume_generator/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. +void main() { + Future<void> pumpEditor( + WidgetTester tester, + List<String> initial, + void Function(List<String>) onChanged, + ) async { + var values = initial; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => StringListEditor( + label: 'Bullets', + values: values, + onChanged: (v) { + onChanged(v); + setState(() => values = v); + }, + ), + ), + ), + ), + ); + } + + testWidgets('move up reorders the row and its text', (tester) async { + List<String> latest = []; + await pumpEditor(tester, ['alpha', 'beta', 'gamma'], (v) => latest = v); + + // The "Move up" button on the second row (index 1). + final moveUp = find.widgetWithIcon(IconButton, Icons.arrow_upward); + await tester.tap(moveUp.at(1)); + await tester.pumpAndSettle(); + + expect(latest, ['beta', 'alpha', 'gamma']); + + final fields = tester.widgetList<TextField>(find.byType(TextField)).toList(); + expect(fields.map((f) => f.controller!.text), ['beta', 'alpha', 'gamma']); + }); + + testWidgets('removing a middle row keeps the rest aligned', (tester) async { + List<String> latest = []; + await pumpEditor(tester, ['one', 'two', 'three'], (v) => latest = v); + + await tester.tap( + find.widgetWithIcon(IconButton, Icons.close).at(1), + ); + await tester.pumpAndSettle(); + + expect(latest, ['one', 'three']); + final fields = tester.widgetList<TextField>(find.byType(TextField)).toList(); + expect(fields.map((f) => f.controller!.text), ['one', 'three']); + }); + + testWidgets('typing in a row emits the edit', (tester) async { + List<String> latest = []; + await pumpEditor(tester, ['x'], (v) => latest = v); + + await tester.enterText(find.byType(TextField).first, 'xyz'); + await tester.pump(); + + expect(latest, ['xyz']); + }); +}