diff --git a/.vscode/settings.json b/.vscode/settings.json index 97cf9fc..0ddf5c0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,10 +7,9 @@ "files.watcherExclude": { "**/.fvm": true }, - "dart.lineLength": 100, + "dart.lineLength": 120, "[dart]": { "editor.rulers": [ - 100, 120, ], "editor.selectionHighlight": true, diff --git a/integration_test/metering_screen_test.dart b/integration_test/metering_screen_test.dart new file mode 100644 index 0000000..95456ef --- /dev/null +++ b/integration_test/metering_screen_test.dart @@ -0,0 +1,267 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:lightmeter/application.dart'; +import 'package:lightmeter/data/caffeine_service.dart'; +import 'package:lightmeter/data/haptics_service.dart'; +import 'package:lightmeter/data/light_sensor_service.dart'; +import 'package:lightmeter/data/models/ev_source_type.dart'; +import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; +import 'package:lightmeter/data/models/supported_locale.dart'; +import 'package:lightmeter/data/models/theme_type.dart'; +import 'package:lightmeter/data/models/volume_action.dart'; +import 'package:lightmeter/data/permissions_service.dart'; +import 'package:lightmeter/data/shared_prefs_service.dart'; +import 'package:lightmeter/data/volume_events_service.dart'; +import 'package:lightmeter/environment.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/providers/services_provider.dart'; +import 'package:lightmeter/providers/user_preferences_provider.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/res/theme.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/dialog_picker/widget_picker_dialog.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import 'mocks/paid_features_mock.dart'; +import 'utils/expectations.dart'; + +class _MockUserPreferencesService extends Mock implements UserPreferencesService {} + +class _MockCaffeineService extends Mock implements CaffeineService {} + +class _MockHapticsService extends Mock implements HapticsService {} + +class _MockPermissionsService extends Mock implements PermissionsService {} + +class _MockLightSensorService extends Mock implements LightSensorService {} + +class _MockVolumeEventsService extends Mock implements VolumeEventsService {} + +const _defaultIsoValue = IsoValue(400, StopType.full); + +//https://stackoverflow.com/a/67186625/13167574 +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + late _MockUserPreferencesService mockUserPreferencesService; + late _MockCaffeineService mockCaffeineService; + late _MockHapticsService mockHapticsService; + late _MockPermissionsService mockPermissionsService; + late _MockLightSensorService mockLightSensorService; + late _MockVolumeEventsService mockVolumeEventsService; + + setUpAll(() { + mockUserPreferencesService = _MockUserPreferencesService(); + when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.camera); + when(() => mockUserPreferencesService.stopType).thenReturn(StopType.third); + when(() => mockUserPreferencesService.locale).thenReturn(SupportedLocale.en); + when(() => mockUserPreferencesService.caffeine).thenReturn(true); + when(() => mockUserPreferencesService.volumeAction).thenReturn(VolumeAction.shutter); + when(() => mockUserPreferencesService.cameraEvCalibration).thenReturn(0.0); + when(() => mockUserPreferencesService.lightSensorEvCalibration).thenReturn(0.0); + when(() => mockUserPreferencesService.iso).thenReturn(_defaultIsoValue); + when(() => mockUserPreferencesService.ndFilter).thenReturn(NdValue.values.first); + when(() => mockUserPreferencesService.haptics).thenReturn(true); + when(() => mockUserPreferencesService.meteringScreenLayout).thenReturn({ + MeteringScreenLayoutFeature.equipmentProfiles: true, + MeteringScreenLayoutFeature.extremeExposurePairs: true, + MeteringScreenLayoutFeature.filmPicker: true, + MeteringScreenLayoutFeature.histogram: false, + }); + when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.light); + when(() => mockUserPreferencesService.primaryColor).thenReturn(primaryColorsList[5]); + when(() => mockUserPreferencesService.dynamicColor).thenReturn(false); + + mockCaffeineService = _MockCaffeineService(); + when(() => mockCaffeineService.isKeepScreenOn()).thenAnswer((_) async => false); + when(() => mockCaffeineService.keepScreenOn(true)).thenAnswer((_) async => true); + when(() => mockCaffeineService.keepScreenOn(false)).thenAnswer((_) async => false); + + mockHapticsService = _MockHapticsService(); + when(() => mockHapticsService.quickVibration()).thenAnswer((_) async {}); + when(() => mockHapticsService.responseVibration()).thenAnswer((_) async {}); + when(() => mockHapticsService.errorVibration()).thenAnswer((_) async {}); + + mockPermissionsService = _MockPermissionsService(); + when(() => mockPermissionsService.requestCameraPermission()).thenAnswer((_) async => PermissionStatus.granted); + when(() => mockPermissionsService.checkCameraPermission()).thenAnswer((_) async => PermissionStatus.granted); + + mockLightSensorService = _MockLightSensorService(); + when(() => mockLightSensorService.hasSensor()).thenAnswer((_) async => true); + when(() => mockLightSensorService.luxStream()).thenAnswer((_) => Stream.fromIterable([100])); + + mockVolumeEventsService = _MockVolumeEventsService(); + when(() => mockVolumeEventsService.setVolumeHandling(true)).thenAnswer((_) async => true); + when(() => mockVolumeEventsService.setVolumeHandling(false)).thenAnswer((_) async => false); + when(() => mockVolumeEventsService.volumeButtonsEventStream()).thenAnswer((_) => const Stream.empty()); + + when(() => mockHapticsService.quickVibration()).thenAnswer((_) async {}); + when(() => mockHapticsService.responseVibration()).thenAnswer((_) async {}); + }); + + Future pumpApplication(WidgetTester tester, IAPProductStatus purchaseStatus) async { + await tester.pumpWidget( + MockIAPProviders( + purchaseStatus: purchaseStatus, + child: ServicesProvider( + environment: const Environment.prod().copyWith(hasLightSensor: true), + userPreferencesService: mockUserPreferencesService, + caffeineService: mockCaffeineService, + hapticsService: mockHapticsService, + permissionsService: mockPermissionsService, + lightSensorService: mockLightSensorService, + volumeEventsService: mockVolumeEventsService, + child: const UserPreferencesProvider( + child: Application(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + group( + 'AnimatedDialog picker standalone tests', + () { + setUpAll(() { + when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.sensor); + when(() => mockUserPreferencesService.iso).thenReturn(_defaultIsoValue); + when(() => mockUserPreferencesService.iso = _defaultIsoValue).thenReturn(_defaultIsoValue); + }); + + testWidgets( + 'Open & close with select', + (tester) async { + await pumpApplication(tester, IAPProductStatus.purchasable); + await tester.openAnimatedPicker(); + expect(find.byType(DialogPicker), findsOneWidget); + await tester.tapSelectButton(); + expect(find.byType(DialogPicker), findsNothing); + }, + ); + + testWidgets( + 'Open & close with cancel', + (tester) async { + await pumpApplication(tester, IAPProductStatus.purchasable); + await tester.openAnimatedPicker(); + expect(find.byType(DialogPicker), findsOneWidget); + await tester.tapCancelButton(); + expect(find.byType(DialogPicker), findsNothing); + }, + ); + + testWidgets( + 'Open & close with tap outside', + (tester) async { + await pumpApplication(tester, IAPProductStatus.purchasable); + await tester.openAnimatedPicker(); + expect(find.byType(DialogPicker), findsOneWidget); + + /// tester taps the center of the found widget, + /// which results in tap on the dialog instead of the underlying barrier + /// therefore just tap at offset outside the dialog + await tester.longPressAt(const Offset(16, 16)); + await tester.pumpAndSettle(Dimens.durationML); + expect(find.byType(DialogPicker), findsNothing); + }, + ); + + testWidgets( + 'Open & close with back gesture', + (tester) async { + await pumpApplication(tester, IAPProductStatus.purchasable); + await tester.openAnimatedPicker(); + expect(find.byType(DialogPicker), findsOneWidget); + + //// https://github.com/flutter/flutter/blob/master/packages/flutter/test/widgets/router_test.dart#L970-L971 + //// final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute')); + //// await tester.binding.defaultBinaryMessenger.handlePlatformMessage('flutter/navigation', message, (_) {}); + /// https://github.com/flutter/packages/blob/main/packages/animations/test/open_container_test.dart#L234 + (tester.state(find.byType(Navigator)) as NavigatorState).pop(); + await tester.pumpAndSettle(Dimens.durationML); + expect(find.byType(DialogPicker), findsNothing); + }, + ); + }, + ); + + group( + 'Free version', + () { + testWidgets( + 'Initial state', + (tester) async { + await pumpApplication(tester, IAPProductStatus.purchasable); + expectAnimatedPicker(S.current.equipmentProfile, S.current.none); + expectAnimatedPicker(S.current.film, S.current.none); + expectAnimatedPicker(S.current.iso, '400'); + expectAnimatedPicker(S.current.nd, S.current.none); + + await tester.openAnimatedPicker(); + expect(find.byType(DialogPicker), findsOneWidget); + // expect None selected and no other profiles present + await tester.tapCancelButton(); + expect(find.byType(DialogPicker), findsNothing); + + await tester.openAnimatedPicker(); + await tester.openAnimatedPicker(); + await tester.openAnimatedPicker(); + }, + ); + }, + skip: true, + ); + + group( + 'Pro version', + () { + testWidgets( + 'Initial state', + (tester) async { + await pumpApplication(tester, IAPProductStatus.purchased); + }, + ); + + testWidgets( + 'Film (push/pull)', + (tester) async { + await pumpApplication(tester, IAPProductStatus.purchased); + }, + ); + }, + skip: true, + ); +} + +extension WidgetTesterActions on WidgetTester { + Future openAnimatedPicker() async { + await tap(find.byType(T)); + await pumpAndSettle(Dimens.durationL); + } + + Future tapSelectButton() async { + final cancelButton = find.byWidgetPredicate( + (widget) => widget is TextButton && widget.child is Text && (widget.child as Text?)?.data == S.current.select, + ); + expect(cancelButton, findsOneWidget); + await tap(cancelButton); + await pumpAndSettle(); + } + + Future tapCancelButton() async { + final cancelButton = find.byWidgetPredicate( + (widget) => widget is TextButton && widget.child is Text && (widget.child as Text?)?.data == S.current.cancel, + ); + expect(cancelButton, findsOneWidget); + await tap(cancelButton); + await pumpAndSettle(); + } +} diff --git a/integration_test/mocks/paid_features_mock.dart b/integration_test/mocks/paid_features_mock.dart new file mode 100644 index 0000000..433216b --- /dev/null +++ b/integration_test/mocks/paid_features_mock.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MockSharedPreferences extends Mock implements SharedPreferences {} + +class MockIAPProviders extends StatelessWidget { + final IAPProductStatus purchaseStatus; + final Widget child; + + const MockIAPProviders({ + required this.purchaseStatus, + required this.child, + super.key, + }); + + const MockIAPProviders.purchasable({ + required this.child, + super.key, + }) : purchaseStatus = IAPProductStatus.purchasable; + + const MockIAPProviders.purchased({ + required this.child, + super.key, + }) : purchaseStatus = IAPProductStatus.purchased; + + @override + Widget build(BuildContext context) { + if (purchaseStatus == IAPProductStatus.purchased) { + return IAPProviders( + sharedPreferences: _MockSharedPreferences(), + child: EquipmentProfiles( + selected: _mockEquipmentProfiles[0], + values: _mockEquipmentProfiles, + child: Films( + selected: const Film('Ilford HP5+', 400), + values: const [Film.other(), Film('Ilford HP5+', 400)], + filmsInUse: const [Film.other(), Film('Ilford HP5+', 400)], + child: child, + ), + ), + ); + } + return IAPProviders( + sharedPreferences: _MockSharedPreferences(), + child: EquipmentProfiles( + selected: _defaultEquipmentProfile, + values: const [_defaultEquipmentProfile], + child: Films( + selected: const Film.other(), + values: const [Film.other()], + filmsInUse: const [Film.other()], + child: child, + ), + ), + ); + } +} + +const _defaultEquipmentProfile = EquipmentProfile( + id: '', + name: '', + apertureValues: ApertureValue.values, + ndValues: NdValue.values, + shutterSpeedValues: ShutterSpeedValue.values, + isoValues: IsoValue.values, +); + +final _mockEquipmentProfiles = [ + _defaultEquipmentProfile, + EquipmentProfile( + id: '1', + name: 'Praktica + Zenitar', + apertureValues: ApertureValue.values.sublist( + ApertureValue.values.indexOf(const ApertureValue(1.7, StopType.half)), + ApertureValue.values.indexOf(const ApertureValue(16, StopType.full)) + 1, + ), + ndValues: NdValue.values.sublist(0, 3), + shutterSpeedValues: ShutterSpeedValue.values.sublist( + ShutterSpeedValue.values.indexOf(const ShutterSpeedValue(1000, true, StopType.full)), + ShutterSpeedValue.values.indexOf(const ShutterSpeedValue(16, false, StopType.full)) + 1, + ), + isoValues: const [ + IsoValue(50, StopType.full), + IsoValue(100, StopType.full), + IsoValue(200, StopType.full), + IsoValue(250, StopType.third), + IsoValue(400, StopType.full), + IsoValue(500, StopType.third), + IsoValue(800, StopType.full), + IsoValue(1600, StopType.full), + IsoValue(3200, StopType.full), + ], + ), + const EquipmentProfile( + id: '2', + name: 'Praktica + Jupiter', + apertureValues: ApertureValue.values, + ndValues: NdValue.values, + shutterSpeedValues: ShutterSpeedValue.values, + isoValues: IsoValue.values, + ), +]; diff --git a/integration_test/utils/expectations.dart b/integration_test/utils/expectations.dart new file mode 100644 index 0000000..db5eb18 --- /dev/null +++ b/integration_test/utils/expectations.dart @@ -0,0 +1,14 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/screens/settings/components/shared/dialog_picker/widget_dialog_picker.dart'; + + +void expectAnimatedPicker(String title, String value) { + final pickerFinder = find.byType(T); + expect(find.descendant(of: pickerFinder, matching: find.text(title)), findsOneWidget); + expect(find.descendant(of: pickerFinder, matching: find.text(value)), findsOneWidget); +} + +/// Finds exactly one dialog picker of the provided value type +void expectDialogPicker() { + expect(find.byType(DialogPicker), findsOneWidget); +}