Ad and purchases enabled (still need actual testing)

This commit is contained in:
Courtney Arnold 2026-08-16 18:27:56 -05:00
parent 2fcc6dcab7
commit faf319322a
35 changed files with 1676 additions and 34 deletions

View file

@ -0,0 +1,88 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
import 'package:fuel_tax_tracker/services/database_service.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
/// Covers the "pay to remove ads for a year" entitlement outside of the
/// merge SQL itself (see db_merge_test.dart for that): [DatabaseService]'s
/// storage round-trip, and [AppState.adsCurrentlyDisabled]'s pure
/// expiry-comparison logic.
void main() {
group('DatabaseService ad-free entitlement storage', () {
late Directory tempDir;
late DatabaseService database;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('ad_free_entitlement_test_');
PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path);
database = DatabaseService();
await database.init();
});
tearDown(() async {
await database.rawDb.close();
await tempDir.delete(recursive: true);
});
test('getAdFreeUntil is null before any purchase is recorded', () async {
expect(await database.getAdFreeUntil(), isNull);
});
test('setAdFreeUntil then getAdFreeUntil round-trips the value', () async {
final until = DateTime.utc(2027, 8, 13);
final now = DateTime.utc(2026, 8, 13);
await database.setAdFreeUntil(until, now);
expect(await database.getAdFreeUntil(), until);
});
test('setAdFreeUntil overwrites a previous value rather than adding a row', () async {
await database.setAdFreeUntil(DateTime.utc(2027, 1, 1), DateTime.utc(2026, 1, 1));
await database.setAdFreeUntil(DateTime.utc(2028, 1, 1), DateTime.utc(2027, 1, 1));
final rows = await database.rawDb.query('ad_free_entitlement');
expect(rows, hasLength(1));
expect(await database.getAdFreeUntil(), DateTime.utc(2028, 1, 1));
});
});
group('AppState.adsCurrentlyDisabled', () {
test('false when no entitlement has ever been recorded', () {
final appState = AppState();
expect(appState.adsCurrentlyDisabled, isFalse);
});
test('true when the entitlement date is in the future', () {
final appState = AppState();
appState.adFreeUntil = DateTime.now().toUtc().add(const Duration(days: 30));
expect(appState.adsCurrentlyDisabled, isTrue);
});
test('false once the entitlement date is in the past (the year lapsed)', () {
final appState = AppState();
appState.adFreeUntil = DateTime.now().toUtc().subtract(const Duration(days: 1));
expect(appState.adsCurrentlyDisabled, isFalse);
});
});
}
class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin {
final String tempPath;
_FakePathProviderPlatform(this.tempPath);
@override
Future<String?> getApplicationDocumentsPath() async => tempPath;
@override
Future<String?> getTemporaryPath() async => tempPath;
}

View file

@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:fuel_tax_tracker/screens/data_settings_screen.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart';
import 'package:fuel_tax_tracker/services/cloud_sync_service.dart';
import 'package:fuel_tax_tracker/services/database_service.dart';
import 'package:fuel_tax_tracker/widgets/backup_reminder.dart';
/// Covers the "your data isn't backed up" nudge shown after saving a fuel
/// entry with no cloud backup configured — see
/// `ConfirmFuelEntryScreen._save`. The dialog and the spotlight it can lead
/// to (`showBackupReminderDialog`/`openCloudBackupSetup`) are tested here
/// as standalone functions rather than through a full save, matching
/// settings_purge_test.dart's convention of not exercising actions that
/// need an initialized database.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
group('showBackupReminderDialog', () {
Future<bool?> openDialog(WidgetTester tester) async {
bool? result;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () async => result = await showBackupReminderDialog(context),
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
return result;
}
testWidgets('shows the warning with both actions', (tester) async {
await openDialog(tester);
expect(find.text("Your Data Isn't Backed Up"), findsOneWidget);
expect(find.text('Not Now'), findsOneWidget);
expect(find.text('Set Up Backup'), findsOneWidget);
});
testWidgets('Not Now resolves false and closes the dialog', (tester) async {
bool? result;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () async => result = await showBackupReminderDialog(context),
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await tester.tap(find.text('Not Now'));
await tester.pumpAndSettle();
expect(result, isFalse);
expect(find.text("Your Data Isn't Backed Up"), findsNothing);
});
testWidgets('Set Up Backup resolves true and closes the dialog', (tester) async {
bool? result;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () async => result = await showBackupReminderDialog(context),
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await tester.tap(find.text('Set Up Backup'));
await tester.pumpAndSettle();
expect(result, isTrue);
expect(find.text("Your Data Isn't Backed Up"), findsNothing);
});
});
group('openCloudBackupSetup', () {
testWidgets('pushes Settings > Data with the cloud storage card spotlighted',
(tester) async {
final appState = AppState()..isLoading = false;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: MaterialApp(
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () => openCloudBackupSetup(context),
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.byType(DataSettingsScreen), findsOneWidget);
expect(find.text('Back Up to the Cloud'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Got It'), findsOneWidget);
});
testWidgets('dismissing the spotlight leaves the Data screen up', (tester) async {
final appState = AppState()..isLoading = false;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: MaterialApp(
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () => openCloudBackupSetup(context),
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Got It'));
await tester.pumpAndSettle();
expect(find.text('Back Up to the Cloud'), findsNothing, reason: 'spotlight dismissed');
expect(find.byType(DataSettingsScreen), findsOneWidget, reason: 'still on the Data screen');
});
});
group('AppState.hasCloudBackupConfigured', () {
test('false with no cloud provider connected at all', () {
final appState = AppState();
expect(appState.hasCloudBackupConfigured, isFalse);
});
test('false when a provider is connected but no folder has been chosen', () {
final appState = AppState()
..cloudSync = CloudSyncService(provider: _FakeProvider(), databaseService: DatabaseService());
expect(appState.hasCloudBackupConfigured, isFalse);
});
test('true once a backup folder has been configured', () {
final appState = AppState()
..cloudSync =
(CloudSyncService(provider: _FakeProvider(), databaseService: DatabaseService())
..configure('folder-id'));
expect(appState.hasCloudBackupConfigured, isTrue);
});
});
}
class _FakeProvider implements CloudStorageProvider {
@override
CloudProviderId get id => CloudProviderId.googleDrive;
@override
String get displayName => 'Fake Provider';
@override
bool get isSignedIn => true;
@override
String? get accountLabel => 'tester@example.com';
@override
Future<bool> attemptSilentSignIn() async => true;
@override
Future<String> signIn() async => accountLabel!;
@override
Future<void> signOut() async {}
@override
CloudStorageSession beginSession() => throw UnimplementedError();
}

View file

@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart';
import 'package:fuel_tax_tracker/services/cloud_sync_service.dart';
import 'package:fuel_tax_tracker/services/database_service.dart';
import 'package:fuel_tax_tracker/services/db_schema.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
@ -114,6 +115,41 @@ void main() {
reason: 'should not nest another app folder inside a folder already named that');
});
test(
'selectAppFolder moves an already-configured app folder to a new parent instead of '
'creating a fresh one there', () async {
final session = _FakeSession();
final provider = _FakeProvider(session);
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
// Establishes an app folder under the first parent — e.g. the root
// default a fresh connection assumes (see AppState.connectProvider) —
// before the user picks somewhere else.
await syncService.selectAppFolder('root');
expect(syncService.isConfigured, isTrue);
final newFolderId = await syncService.selectAppFolder('new-parent-id');
expect(session.moveFolderCalls, ['root/$appFolderName->new-parent-id']);
expect(newFolderId, 'new-parent-id/moved');
expect(session.findOrCreateFolderCalls, ['root/$appFolderName'],
reason: 'only the very first (root) selection created a folder; the second moved it');
});
test(
'selectAppFolder adopts a same-named folder already at the destination instead of '
'moving onto/alongside it', () async {
final session = _FakeSession()
..foldersByParent['new-parent-id'] = [CloudFolder(id: 'shared-folder-id', name: appFolderName)];
final provider = _FakeProvider(session);
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
await syncService.selectAppFolder('root');
final folderId = await syncService.selectAppFolder('new-parent-id');
expect(folderId, 'shared-folder-id');
expect(session.moveFolderCalls, isEmpty, reason: 'adopted the existing one, did not move onto it');
});
test('syncNow is a no-op when not configured with a folder yet', () async {
final session = _FakeSession();
final provider = _FakeProvider(session);
@ -124,6 +160,89 @@ void main() {
expect(result.ranSync, isFalse);
expect(session.createdLockNames, isEmpty);
});
group('beforeUpload hook', () {
test('is awaited after pulling/merging remote changes but before anything is uploaded',
() async {
final calls = <String>[];
// A real copy of the (correctly schema'd, currently empty) local
// database — CloudSyncService ATTACHes and merges against whatever
// downloadFileBytes returns, so it has to be an actual valid SQLite
// database, not arbitrary bytes.
final remoteDbBytes = await File(databaseService.databasePath).readAsBytes();
final session = _FakeSession();
session.existingDataFile = CloudFileInfo(id: 'remote-db-id', versionTag: 'remote-v1');
session.remoteDbBytes = remoteDbBytes;
session.onDownload = () => calls.add('download');
session.onUpload = (name) => calls.add('upload:$name');
final provider = _FakeProvider(session);
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
syncService.configure('app-folder-id');
final result = await syncService.syncNow(
beforeUpload: () async => calls.add('beforeUpload'),
);
expect(result.ranSync, isTrue);
expect(calls.first, 'download', reason: 'pull/merge happens first');
expect(calls[1], 'beforeUpload');
// Everything after beforeUpload is an upload (the data-file push,
// possibly preceded by pending receipt uploads — none pending here).
expect(calls.skip(2), everyElement(startsWith('upload:')));
expect(calls, contains('upload:$dataFileName'));
});
test('is awaited even when there is nothing to pull (no existing remote data file yet)',
() async {
final calls = <String>[];
final session = _FakeSession()..onUpload = (name) => calls.add('upload:$name');
final provider = _FakeProvider(session);
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
syncService.configure('app-folder-id');
await syncService.syncNow(beforeUpload: () async => calls.add('beforeUpload'));
expect(calls.first, 'beforeUpload');
expect(calls, contains('upload:$dataFileName'));
});
test('omitting it changes nothing — every other syncNow call site keeps working', () async {
final session = _FakeSession();
final provider = _FakeProvider(session);
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
syncService.configure('app-folder-id');
final result = await syncService.syncNow();
expect(result.ranSync, isTrue);
});
});
test(
'pull/merge does not throw against a remote snapshot from before the '
'ad_free_entitlement table existed', () async {
// Simulates a real user's pre-existing cloud folder: a DB file uploaded
// by an older app version, with only the schema that existed then (no
// ad_free_entitlement table) — CloudSyncService must skip merging that
// table rather than throw "no such table: remote_db.ad_free_entitlement".
final oldSchemaDbPath = p.join(tempDir.path, 'old_schema_remote.db');
final oldSchemaDb = await databaseFactory.openDatabase(oldSchemaDbPath);
await oldSchemaDb.execute(createVehiclesTableSql);
await oldSchemaDb.execute(createFuelEntriesTableSql);
await oldSchemaDb.execute(createFuelEntriesIndexSql);
await oldSchemaDb.close();
final session = _FakeSession();
session.existingDataFile = CloudFileInfo(id: 'remote-db-id', versionTag: 'remote-v1');
session.remoteDbBytes = await File(oldSchemaDbPath).readAsBytes();
final provider = _FakeProvider(session);
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
syncService.configure('app-folder-id');
final result = await syncService.syncNow();
expect(result.ranSync, isTrue);
});
}
class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin {
@ -144,12 +263,29 @@ class _FakeSession implements CloudStorageSession {
final Map<String, String> uploadedFileFolders = {};
final List<String> findOrCreateFolderCalls = [];
CloudFileInfo? existingDataFile;
void Function()? onDownload;
void Function(String name)? onUpload;
/// Bytes [downloadFileBytes] returns — needs to be a real, attachable
/// SQLite database (with the vehicles/fuel_entries schema) for
/// [CloudSyncService]'s ATTACH-based merge to succeed against it, not
/// just arbitrary bytes. Tests that exercise the pull/merge path set this
/// to a copy of their own [DatabaseService]'s (already correctly
/// schema'd) database file.
List<int> remoteDbBytes = const [];
@override
bool get supportsSharedWithMe => false;
/// Folders [listFolders] returns, keyed by the `parentId` they'd be
/// listed under — empty (nothing at any destination) unless a test sets
/// this up, e.g. to simulate a same-named app folder someone else
/// already created at the destination.
final Map<String, List<CloudFolder>> foldersByParent = {};
@override
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async => [];
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async =>
foldersByParent[parentId] ?? [];
@override
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
@ -157,13 +293,24 @@ class _FakeSession implements CloudStorageSession {
return '$parentId/$name';
}
final List<String> moveFolderCalls = [];
@override
Future<String> moveFolder({required String folderId, required String newParentId}) async {
moveFolderCalls.add('$folderId->$newParentId');
return '$newParentId/moved';
}
@override
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
return name == dataFileName ? existingDataFile : null;
}
@override
Future<List<int>> downloadFileBytes(String fileId) async => <int>[];
Future<List<int>> downloadFileBytes(String fileId) async {
onDownload?.call();
return remoteDbBytes;
}
@override
Future<CloudFileInfo> uploadFile({
@ -175,6 +322,7 @@ class _FakeSession implements CloudStorageSession {
}) async {
uploadedFileNames.add(name);
uploadedFileFolders[name] = folderId;
onUpload?.call(name);
return CloudFileInfo(id: 'uploaded-$name', versionTag: 'v1');
}

View file

@ -36,6 +36,7 @@ void main() {
try {
await local.execute(mergeVehiclesSql);
await local.execute(mergeFuelEntriesSql);
await local.execute(mergeAdFreeEntitlementSql);
} finally {
await local.execute('DETACH DATABASE remote_db');
}
@ -153,6 +154,59 @@ void main() {
expect(rows.first['receipt_image_path'], isNull);
});
});
group('ad_free_entitlement merge', () {
test('pulls in the entitlement when only remote has one', () async {
await remote.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 5000, updatedAt: 1000));
await merge();
final rows = await local.query('ad_free_entitlement');
expect(rows, hasLength(1));
expect(rows.first['id'], 1);
expect(rows.first['ad_free_until'], 5000);
});
test('remote wins when strictly newer than local', () async {
await local.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 1000, updatedAt: 1000));
await remote.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 9000, updatedAt: 2000));
await merge();
final rows = await local.query('ad_free_entitlement');
expect(rows, hasLength(1));
expect(rows.first['ad_free_until'], 9000);
});
test('local wins on a tie or when strictly newer', () async {
await local.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 9000, updatedAt: 2000));
await remote.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 1000, updatedAt: 1000));
await merge();
final rows = await local.query('ad_free_entitlement');
expect(rows, hasLength(1));
expect(rows.first['ad_free_until'], 9000);
});
test('a null ad_free_until merges in fine (e.g. purchase consumed/lapsed remotely)', () async {
await remote.insert(
'ad_free_entitlement', _entitlementRow(adFreeUntil: null, updatedAt: 1000));
await merge();
final rows = await local.query('ad_free_entitlement');
expect(rows, hasLength(1));
expect(rows.first['ad_free_until'], isNull);
});
test('no-op when neither side has a row yet', () async {
await merge();
final rows = await local.query('ad_free_entitlement');
expect(rows, isEmpty);
});
});
}
Future<Database> _openFreshDb(String path) async {
@ -160,6 +214,7 @@ Future<Database> _openFreshDb(String path) async {
await db.execute(createVehiclesTableSql);
await db.execute(createFuelEntriesTableSql);
await db.execute(createFuelEntriesIndexSql);
await db.execute(createAdFreeEntitlementTableSql);
return db;
}
@ -180,6 +235,12 @@ Map<String, Object?> _vehicleRow({
'dirty': dirty,
};
Map<String, Object?> _entitlementRow({int? adFreeUntil, int updatedAt = 0}) => {
'id': 1,
'ad_free_until': adFreeUntil,
'updated_at': updatedAt,
};
Map<String, Object?> _fuelEntryRow({
required String id,
required String vehicleId,

View file

@ -158,6 +158,9 @@ class _FakeSession implements CloudStorageSession {
@override
Future<String> findOrCreateFolder({required String parentId, required String name}) async => '';
@override
Future<String> moveFolder({required String folderId, required String newParentId}) async =>
folderId;
@override
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async => null;
@override
Future<CloudFileInfo> uploadFile({

View file

@ -241,6 +241,8 @@ void main() {
expect(find.byType(DataSettingsScreen), findsNothing, reason: 'tour-pushed screen popped');
expect(find.text('Back Up to the Cloud'), findsNothing, reason: 'overlay gone');
expect(appState.hasSeenOnboardingTour, isTrue);
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget,
reason: 'returned to Receipts, not left on the Settings tab the tour ended on');
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool('has_seen_onboarding_tour'), isTrue,
@ -256,14 +258,30 @@ void main() {
expect(find.text('What This App Does'), findsNothing);
expect(appState.hasSeenOnboardingTour, isTrue);
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget,
reason: 'stayed on the tab it started on');
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
});
testWidgets('Skip Tour from the last step still pops the tour-pushed Data screen',
testWidgets('Skip Tour from a later step still returns to Receipts, not the tab it was on',
(tester) async {
await pumpShellWithFreshTour(tester);
// Two steps in, the tour has already jumped to the Vehicles tab.
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, 'Vehicles'), findsOneWidget);
await tester.tap(find.widgetWithText(TextButton, 'Skip Tour'));
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
});
testWidgets('Skip Tour from the last step pops the tour-pushed Data screen and returns to '
'Receipts', (tester) async {
await pumpShellWithFreshTour(tester);
for (var i = 0; i < 5; i++) {
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
await tester.pumpAndSettle();
@ -274,6 +292,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(DataSettingsScreen), findsNothing);
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
});
});
}

View file

@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:fuel_tax_tracker/main.dart';
import 'package:fuel_tax_tracker/screens/main_shell.dart';
import 'package:fuel_tax_tracker/screens/user_agreement_screen.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
/// Covers [UserAgreementScreen] — the liability gate `main.dart`'s
/// `AppRoot` shows before [MainShell] until [AppState.hasAcceptedUserAgreement]
/// is true, so a fresh install sees it before anything else, including the
/// onboarding tour.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Future<AppState> pumpScreen(WidgetTester tester) async {
// AppState.hasAcceptedUserAgreement defaults to true precisely so that
// ad-hoc AppState() construction in every other screen's tests doesn't
// trigger this gate — these tests explicitly flip it to false to
// reproduce a real fresh install's state.
final appState = AppState()
..isLoading = false
..hasAcceptedUserAgreement = false;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: UserAgreementScreen()),
),
);
return appState;
}
testWidgets('shows the data-loss liability section and both actions', (tester) async {
await pumpScreen(tester);
expect(find.text('We Are Not Responsible for Your Data or Any Data Loss'), findsOneWidget);
expect(find.textContaining('developer is not responsible for your data'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'I Agree'), findsOneWidget);
expect(find.widgetWithText(OutlinedButton, 'Decline'), findsOneWidget);
});
testWidgets('I Agree marks the agreement accepted and persists it', (tester) async {
final appState = await pumpScreen(tester);
await tester.tap(find.widgetWithText(FilledButton, 'I Agree'));
await tester.pumpAndSettle();
expect(appState.hasAcceptedUserAgreement, isTrue);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool('has_accepted_user_agreement'), isTrue,
reason: 'persisted so the agreement never shows again on this device');
});
testWidgets('Decline asks the platform to close the app, without accepting', (tester) async {
final appState = await pumpScreen(tester);
final calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
calls.add(call);
return null;
});
addTearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null);
});
await tester.tap(find.widgetWithText(OutlinedButton, 'Decline'));
await tester.pumpAndSettle();
expect(calls.map((c) => c.method), contains('SystemNavigator.pop'));
expect(appState.hasAcceptedUserAgreement, isFalse);
});
group('AppRoot gating', () {
Future<AppState> pumpRoot(WidgetTester tester, {required bool hasAcceptedUserAgreement}) async {
final appState = AppState()
..isLoading = false
..hasAcceptedUserAgreement = hasAcceptedUserAgreement;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: AppRoot()),
),
);
return appState;
}
testWidgets('shows UserAgreementScreen instead of MainShell before it is accepted',
(tester) async {
await pumpRoot(tester, hasAcceptedUserAgreement: false);
expect(find.byType(UserAgreementScreen), findsOneWidget);
expect(find.byType(MainShell), findsNothing);
});
testWidgets('shows MainShell once the agreement is accepted', (tester) async {
final appState = await pumpRoot(tester, hasAcceptedUserAgreement: false);
await tester.tap(find.widgetWithText(FilledButton, 'I Agree'));
await tester.pumpAndSettle();
expect(appState.hasAcceptedUserAgreement, isTrue);
expect(find.byType(UserAgreementScreen), findsNothing);
expect(find.byType(MainShell), findsOneWidget);
});
testWidgets('shows MainShell directly when already accepted (returning user)', (tester) async {
await pumpRoot(tester, hasAcceptedUserAgreement: true);
expect(find.byType(UserAgreementScreen), findsNothing);
expect(find.byType(MainShell), findsOneWidget);
});
});
}