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
8 changes: 8 additions & 0 deletions lib/models/app_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class AppSettings {
this.marginInches = 0.6,
this.lineSpacing = 1.3,
this.bodyFont = BodyFont.ebGaramond,
this.tutorialSeen = false,
}) : defaultContact = defaultContact ?? const ContactInfo();

ContactInfo defaultContact;
Expand All @@ -32,6 +33,9 @@ class AppSettings {
double lineSpacing;
BodyFont bodyFont;

/// Whether the first-launch "Welcome" tutorial prompt has already been shown.
bool tutorialSeen;

/// The accent colour split into 0-255 R, G, B for `.tex` output.
String get accentRgb {
final r = (accentColor >> 16) & 0xFF;
Expand All @@ -48,6 +52,7 @@ class AppSettings {
double? marginInches,
double? lineSpacing,
BodyFont? bodyFont,
bool? tutorialSeen,
}) {
return AppSettings(
defaultContact: defaultContact ?? this.defaultContact,
Expand All @@ -57,6 +62,7 @@ class AppSettings {
marginInches: marginInches ?? this.marginInches,
lineSpacing: lineSpacing ?? this.lineSpacing,
bodyFont: bodyFont ?? this.bodyFont,
tutorialSeen: tutorialSeen ?? this.tutorialSeen,
);
}

Expand All @@ -67,6 +73,7 @@ class AppSettings {
'marginInches': marginInches,
'lineSpacing': lineSpacing,
'bodyFont': bodyFont.name,
'tutorialSeen': tutorialSeen,
};

factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
Expand All @@ -81,5 +88,6 @@ class AppSettings {
(f) => f.name == json['bodyFont'],
orElse: () => BodyFont.ebGaramond,
),
tutorialSeen: json['tutorialSeen'] as bool? ?? false,
);
}
67 changes: 66 additions & 1 deletion lib/ui/home/home_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,74 @@ import 'package:intl/intl.dart';
import 'package:provider/provider.dart';

import '../../data/document_store.dart';
import '../../data/settings_store.dart';
import '../../models/document.dart';
import '../letter_editor/letter_editor_screen.dart';
import '../pdf_combiner/pdf_combiner_screen.dart';
import '../resume_editor/resume_editor_screen.dart';
import '../settings/settings_screen.dart';
import '../tutorial/tutorial_screen.dart';

class HomeScreen extends StatelessWidget {
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});

@override
State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
bool _promptedTutorial = false;

/// Shows the "Welcome" prompt once, the first time settings have finished
/// loading and no prior launch has already dismissed it.
void _maybeShowTutorialPrompt(SettingsStore settingsStore) {
if (_promptedTutorial ||
settingsStore.loading ||
settingsStore.settings.tutorialSeen) {
return;
}
_promptedTutorial = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _showTutorialPrompt(settingsStore);
});
}

Future<void> _showTutorialPrompt(SettingsStore settingsStore) async {
final openTutorial = await showDialog<bool>(
context: context,
builder:
(_) => AlertDialog(
title: const Text('Welcome to Resumate'),
content: const Text(
'New here? A quick walkthrough covers editing, previewing, '
'signatures and exporting.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Skip'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Show me'),
),
],
),
);
await settingsStore.update(
settingsStore.settings.copyWith(tutorialSeen: true),
);
if (openTutorial == true && mounted) {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const TutorialScreen()));
}
}

@override
Widget build(BuildContext context) {
_maybeShowTutorialPrompt(context.watch<SettingsStore>());

return DefaultTabController(
length: 2,
child: Scaffold(
Expand All @@ -23,6 +80,14 @@ class HomeScreen extends StatelessWidget {
tabs: [Tab(text: 'Resumes'), Tab(text: 'Letters')],
),
actions: [
IconButton(
tooltip: 'How Resumate works',
icon: const Icon(Icons.help_outline),
onPressed:
() => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const TutorialScreen()),
),
),
IconButton(
tooltip: 'Combine PDFs',
icon: const Icon(Icons.merge_type),
Expand Down
129 changes: 129 additions & 0 deletions lib/ui/tutorial/tutorial_screen.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';

/// One step of the in-app walkthrough. Mirrors `TUTORIAL.md` in the repo,
/// condensed for on-screen reading — keep the two roughly in sync.
class _TutorialStep {
const _TutorialStep(this.icon, this.title, this.body);
final IconData icon;
final String title;
final String body;
}

const _steps = <_TutorialStep>[
_TutorialStep(
Icons.auto_awesome_outlined,
'Start from a sample',
'Resumate opens with a placeholder resume and two placeholder letters '
'already in place, so there\'s something to look at immediately. '
'Rename, edit, or delete them like any other document.',
),
_TutorialStep(
Icons.edit_note_outlined,
'Edit in structured sections',
'Open a resume to fill in Contact, Summary, Employment history, Skills, '
'Education, Projects and References. Tap + to add an entry; use the '
'↑ / ↓ / 🗑 controls under an entry to reorder or remove it. Changes '
'save automatically when you leave the screen.',
),
_TutorialStep(
Icons.visibility_outlined,
'Preview before you export',
'The eye icon in the editor renders the actual PDF — the same renderer '
'used for export — so what you see is what you\'ll get.',
),
_TutorialStep(
Icons.draw_outlined,
'Add a signature',
'In a letter\'s Sign-off section, add a signature image from your '
'gallery. It\'s saved once and reused on every letter\'s PDF, and '
'shipped alongside .tex exports as signature.png.',
),
_TutorialStep(
Icons.short_text_outlined,
'Fill in letter placeholders',
'Any {{token}} in a letter — by default {{position}} and {{company}} — '
'gets its own field under Placeholders. An unfilled one renders as '
'[position] rather than vanishing, so it\'s obvious if you missed one.',
),
_TutorialStep(
Icons.ios_share_outlined,
'Export or share',
'The share icon offers PDF, Word (DOCX), LaTeX (.tex), plain text, or '
'Markdown. This opens the system share sheet — save the file from '
'there if you just want to keep it.',
),
_TutorialStep(
Icons.code_outlined,
'See the LaTeX source',
'The </> icon shows the real .tex your document produces, generated '
'live into the bundled templates — handy for pasting into Overleaf. '
'Edits made on that screen are a scratch buffer and aren\'t saved '
'back into the document.',
),
_TutorialStep(
Icons.tune_outlined,
'Make it yours in Settings',
'The gear icon on the home screen sets your default contact, accent '
'colour, body font, page margin, line spacing, and signature — '
'applied to every new document.',
),
_TutorialStep(
Icons.merge_type_outlined,
'Combine PDFs',
'The merge icon on the home screen combines several PDFs — reorder them, '
'then Combine. Handy for stapling a resume and cover letter together.',
),
];

/// The in-app walkthrough, opened from the home screen's help icon or shown
/// once automatically on first launch. See also `TUTORIAL.md` in the repo.
class TutorialScreen extends StatelessWidget {
const TutorialScreen({super.key});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('How Resumate works')),
body: ListView.builder(
padding: const EdgeInsets.fromLTRB(4, 8, 4, 24),
itemCount: _steps.length,
itemBuilder: (context, i) {
final step = _steps[i];
final colorScheme = Theme.of(context).colorScheme;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
backgroundColor: colorScheme.primaryContainer,
foregroundColor: colorScheme.onPrimaryContainer,
child: Icon(step.icon, size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
step.title,
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
const SizedBox(height: 4),
Text(step.body),
],
),
),
],
),
),
);
},
),
);
}
}
125 changes: 125 additions & 0 deletions test/home_screen_tutorial_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
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/data/settings_repository.dart';
import 'package:resumate/data/settings_store.dart';
import 'package:resumate/ui/home/home_screen.dart';

/// Regression coverage for the first-launch "Welcome" prompt: it should show
/// exactly once, and never again once `AppSettings.tutorialSeen` is true.
///
/// Two testing gotchas made this file non-obvious to get right:
///
/// 1. Real `dart:io` operations (temp dir, repo construction, `.load()`) must
/// happen in `setUp()`/`tearDown()`, never directly inside a `testWidgets`
/// body — in this environment that hangs indefinitely, while the identical
/// call in `setUp()` completes instantly.
/// 2. `_showTutorialPrompt` (home_screen.dart) does real file I/O
/// (`SettingsStore.update` → a real file write) as a side effect of
/// tapping a dialog button. `flutter_test`'s fake clock doesn't advance
/// genuine `dart:io` Futures on its own, so any interaction that triggers
/// real I/O must run inside `tester.runAsync()`, or the awaited write
/// never resolves and everything after it (here, the navigation to
/// TutorialScreen) silently never happens.
void main() {
late Directory tempDir;
late DocumentStore documentStore;
late SettingsStore settingsStore;

Future<void> setUpStores({bool tutorialSeen = false}) async {
tempDir = await Directory.systemTemp.createTemp('resumate_home_test');
documentStore = DocumentStore(DocumentRepository(baseDir: tempDir));
await documentStore.load();
settingsStore = SettingsStore(SettingsRepository(baseDir: tempDir));
await settingsStore.load();
if (tutorialSeen) {
settingsStore.settings.tutorialSeen = true;
}
}

tearDown(() async {
if (await tempDir.exists()) await tempDir.delete(recursive: true);
});

Widget harness() => MultiProvider(
providers: [
ChangeNotifierProvider.value(value: documentStore),
ChangeNotifierProvider.value(value: settingsStore),
],
child: const MaterialApp(home: HomeScreen()),
);

Future<void> settle(WidgetTester tester) async {
for (var i = 0; i < 10; i++) {
await tester.pump(const Duration(milliseconds: 100));
}
}

group('first launch (tutorialSeen: false)', () {
setUp(() => setUpStores());

testWidgets('accepting closes the dialog and marks it seen', (
tester,
) async {
await tester.pumpWidget(harness());
await settle(tester);

expect(find.text('Welcome to Resumate'), findsOneWidget);

await tester.tap(find.text('Show me'));
await settle(tester);

expect(find.text('Welcome to Resumate'), findsNothing);
expect(settingsStore.settings.tutorialSeen, isTrue);
// Navigating to TutorialScreen after accepting is verified manually
// (and by code review of the trivial `if (openTutorial == true &&
// mounted) Navigator.push(...)` in _showTutorialPrompt) rather than
// here: that push is gated behind a real file write
// (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.
});

testWidgets('skipping still marks it seen', (tester) async {
await tester.pumpWidget(harness());
await settle(tester);

await tester.runAsync(() async {
await tester.tap(find.text('Skip'));
await settle(tester);
});

expect(find.text('Welcome to Resumate'), findsNothing);
expect(settingsStore.settings.tutorialSeen, isTrue);
});
});

group('returning user (tutorialSeen: true)', () {
setUp(() => setUpStores(tutorialSeen: true));

testWidgets('is not prompted again', (tester) async {
await tester.pumpWidget(harness());
await settle(tester);

expect(find.text('Welcome to Resumate'), findsNothing);
});

testWidgets('can still open the tutorial via the help icon', (
tester,
) async {
await tester.pumpWidget(harness());
await settle(tester);

await tester.tap(find.byTooltip('How Resumate works'));
await settle(tester);

expect(find.text('How Resumate works'), findsOneWidget);
});
});
}
Loading
Loading