Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="resume_generator"
android:label="Resume Generator"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
Expand Down
2 changes: 1 addition & 1 deletion assets/templates/application_letter.tex
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
\textbf{\MakeUppercase{<<TITLE>>}} \\
\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>>}

Expand Down
2 changes: 1 addition & 1 deletion assets/templates/cover_letter.tex
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
\textbf{\MakeUppercase{<<TITLE>>}} \\
\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>>}

Expand Down
2 changes: 1 addition & 1 deletion assets/templates/resume_classic.tex
Original file line number Diff line number Diff line change
Expand Up @@ -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>>}

Expand Down
2 changes: 2 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
1 change: 1 addition & 0 deletions lib/ui/letter_editor/letter_editor_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions lib/ui/resume_editor/resume_editor_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
113 changes: 85 additions & 28 deletions lib/ui/widgets/editor_widgets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -99,60 +171,45 @@ 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),
),
],
),
),
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()}'),
),
),
],
Expand Down
70 changes: 70 additions & 0 deletions test/string_list_editor_test.dart
Original file line number Diff line number Diff line change
@@ -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']);
});
}
Loading