diff --git a/.gitignore b/.gitignore index 3fd9832..703a8b6 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,5 @@ ios/firebase_app_id_file.json ios/Runner/GoogleService-Info.plist /lib/firebase_options.dart -coverage/ \ No newline at end of file +coverage/ +screenshots/ \ No newline at end of file diff --git a/assets/camera_stub_image.jpg b/assets/camera_stub_image.jpg new file mode 100644 index 0000000..70b289f Binary files /dev/null and b/assets/camera_stub_image.jpg differ diff --git a/iap/lib/src/data/models/iap_product.dart b/iap/lib/src/data/models/iap_product.dart index ba24c17..b075ff6 100644 --- a/iap/lib/src/data/models/iap_product.dart +++ b/iap/lib/src/data/models/iap_product.dart @@ -6,8 +6,26 @@ enum IAPProductStatus { enum IAPProductType { paidFeatures } -abstract class IAPProduct { - const IAPProduct._(); +class IAPProduct { + final String storeId; + final IAPProductStatus status; - IAPProductStatus get status => IAPProductStatus.purchasable; + const IAPProduct({ + required this.storeId, + this.status = IAPProductStatus.purchasable, + }); + + IAPProduct copyWith({IAPProductStatus? status}) => IAPProduct( + storeId: storeId, + status: status ?? this.status, + ); +} + +extension IAPProductTypeExtension on IAPProductType { + String get storeId { + switch (this) { + case IAPProductType.paidFeatures: + return ""; + } + } } diff --git a/iap/lib/src/providers/iap_products_provider.dart b/iap/lib/src/providers/iap_products_provider.dart index 3014cd9..4895fdf 100644 --- a/iap/lib/src/providers/iap_products_provider.dart +++ b/iap/lib/src/providers/iap_products_provider.dart @@ -18,7 +18,12 @@ class IAPProductsProviderState extends State { @override Widget build(BuildContext context) { return IAPProducts( - products: const [], + products: [ + IAPProduct( + storeId: IAPProductType.paidFeatures.storeId, + status: IAPProductStatus.purchased, + ) + ], child: widget.child, ); } @@ -35,13 +40,28 @@ class IAPProducts extends InheritedModel { super.key, }); - static IAPProduct? productOf(BuildContext context, IAPProductType type) => null; + static IAPProduct? productOf(BuildContext context, IAPProductType type) { + final IAPProducts? result = InheritedModel.inheritFrom(context, aspect: type); + return result!._findProduct(type); + } - static bool isPurchased(BuildContext context, IAPProductType type) => false; + static bool isPurchased(BuildContext context, IAPProductType type) { + final IAPProducts? result = InheritedModel.inheritFrom(context, aspect: type); + return result!._findProduct(type)?.status == IAPProductStatus.purchased; + } @override bool updateShouldNotify(IAPProducts oldWidget) => false; @override - bool updateShouldNotifyDependent(covariant IAPProducts oldWidget, Set dependencies) => false; + bool updateShouldNotifyDependent(IAPProducts oldWidget, Set dependencies) => + false; + + IAPProduct? _findProduct(IAPProductType type) { + try { + return products.firstWhere((element) => element.storeId == type.storeId); + } catch (_) { + return null; + } + } } diff --git a/integration_test/generate_screenshots.dart b/integration_test/generate_screenshots.dart new file mode 100644 index 0000000..510c7fb --- /dev/null +++ b/integration_test/generate_screenshots.dart @@ -0,0 +1,294 @@ +import 'dart:io'; + +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/bottom_controls/components/measure_button/widget_button_measure.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/shared/animated_dialog_picker/components/dialog_picker/widget_picker_dialog.dart'; +import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_container/widget_container_equipment_profile.dart'; +import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart'; +import 'package:lightmeter/screens/settings/screen_settings.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 'package:shared_preferences/shared_preferences.dart'; + +class _MockSharedPreferences extends Mock implements SharedPreferences {} + +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 {} + +//https://stackoverflow.com/a/67186625/13167574 +void main() { + late _MockUserPreferencesService mockUserPreferencesService; + late _MockCaffeineService mockCaffeineService; + late _MockHapticsService mockHapticsService; + late _MockPermissionsService mockPermissionsService; + late _MockLightSensorService mockLightSensorService; + late _MockVolumeEventsService mockVolumeEventsService; + + final binding = IntegrationTestWidgetsFlutterBinding(); + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + 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(const IsoValue(400, StopType.full)); + 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.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) async { + await tester.pumpWidget( + IAPProviders( + sharedPreferences: _MockSharedPreferences(), + child: EquipmentProfiles( + selected: _mockEquipmentProfiles[1], + 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: 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(); + } + + /// Generates several screenshots with the light theme + /// and the additionally the first one with the dark theme + void generateScreenshots(Color color) { + testWidgets('${color.value}_light', (tester) async { + when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.light); + when(() => mockUserPreferencesService.primaryColor).thenReturn(color); + await pumpApplication(tester); + + await tester.takePhoto(); + await tester.takeScreenshot(binding, '${color.value}_metering_reflected'); + + await tester.tap(find.byTooltip(S.current.tooltipUseLightSensor)); + await tester.pumpAndSettle(); + await tester.tap(find.byType(MeteringMeasureButton)); + await tester.tap(find.byType(MeteringMeasureButton)); + await tester.takeScreenshot(binding, '${color.value}_metering_incident'); + + expect(find.byType(IsoValuePicker), findsOneWidget); + await tester.tap(find.byType(IsoValuePicker)); + await tester.pumpAndSettle(Dimens.durationL); + expect(find.byType(DialogPicker), findsOneWidget); + await tester.takeScreenshot(binding, '${color.value}_metering_iso_picker'); + + await tester.tapCancelButton(); + expect(find.byType(DialogPicker), findsNothing); + expect(find.byTooltip(S.current.tooltipOpenSettings), findsOneWidget); + await tester.tap(find.byTooltip(S.current.tooltipOpenSettings)); + await tester.pumpAndSettle(); + expect(find.byType(SettingsScreen), findsOneWidget); + await tester.takeScreenshot(binding, '${color.value}_settings'); + + await tester.tapListTile(S.current.meteringScreenLayout); + await tester.takeScreenshot(binding, '${color.value}_settings_metering_screen_layout'); + + await tester.tapCancelButton(); + await tester.tapListTile(S.current.equipmentProfiles); + expect(find.byType(EquipmentProfilesScreen), findsOneWidget); + await tester.tap(find.byType(EquipmentProfileContainer).first); + await tester.pumpAndSettle(); + await tester.takeScreenshot(binding, '${color.value}-equipment_profiles'); + + await tester.tap(find.byIcon(Icons.iso).first); + await tester.pumpAndSettle(); + await tester.takeScreenshot(binding, '${color.value}_equipment_profiles_iso_picker'); + }); + + testWidgets( + '${color.value}_dark', + (tester) async { + when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.dark); + when(() => mockUserPreferencesService.primaryColor).thenReturn(color); + await pumpApplication(tester); + + await tester.takePhoto(); + await tester.takeScreenshot(binding, '${color.value}_metering_reflected_dark'); + + await tester.tap(find.byTooltip(S.current.tooltipUseLightSensor)); + await tester.pumpAndSettle(); + await tester.tap(find.byType(MeteringMeasureButton)); + await tester.tap(find.byType(MeteringMeasureButton)); + await tester.takeScreenshot(binding, '${color.value}_metering_incident_dark'); + }, + ); + } + + generateScreenshots(primaryColorsList[5]); + generateScreenshots(primaryColorsList[3]); + generateScreenshots(primaryColorsList[9]); +} + +extension on WidgetTester { + Future takeScreenshot(IntegrationTestWidgetsFlutterBinding binding, String name) async { + if (Platform.isAndroid) { + await binding.convertFlutterSurfaceToImage(); + await pumpAndSettle(); + } + await binding.takeScreenshot(name); + await pumpAndSettle(); + } + + Future takePhoto() async { + await tap(find.byType(MeteringMeasureButton)); + await pump(const Duration(seconds: 2)); // wait for circular progress indicator + await pump(const Duration(seconds: 1)); // wait for circular progress indicator + 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(); + } + + Future tapListTile(String title) async { + final listTile = find.byWidgetPredicate( + (widget) => + widget is ListTile && widget.title is Text && (widget.title as Text?)?.data == title, + ); + expect(listTile, findsOneWidget); + await tap(listTile); + await pumpAndSettle(); + } +} + +final _mockEquipmentProfiles = [ + const EquipmentProfile( + id: '', + name: '', + apertureValues: ApertureValue.values, + ndValues: NdValue.values, + shutterSpeedValues: ShutterSpeedValue.values, + isoValues: IsoValue.values, + ), + 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)), + ), + 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)), + ), + 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/generate_screenshots.sh b/integration_test/generate_screenshots.sh new file mode 100644 index 0000000..8296e96 --- /dev/null +++ b/integration_test/generate_screenshots.sh @@ -0,0 +1,10 @@ +flutter drive \ + --dart-define="cameraPreviewAspectRatio=240/320" \ + --dart-define="cameraStubImage=assets/camera_stub_image.jpg" \ + --driver=test_driver/screenshot_driver.dart \ + --target=integration_test/generate_screenshots.dart \ + --profile \ + --flavor=dev \ + --no-dds \ + --endless-trace-buffer \ + --purge-persistent-cache diff --git a/lib/application.dart b/lib/application.dart index 38ef0bf..68dd44a 100644 --- a/lib/application.dart +++ b/lib/application.dart @@ -1,97 +1,48 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.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/supported_locale.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/screens/metering/flow_metering.dart'; import 'package:lightmeter/screens/settings/flow_settings.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; -import 'package:platform/platform.dart'; -import 'package:shared_preferences/shared_preferences.dart'; class Application extends StatelessWidget { - final Environment env; - - const Application(this.env, {super.key}); + const Application({super.key}); @override Widget build(BuildContext context) { - return FutureBuilder( - future: Future.wait([ - SharedPreferences.getInstance(), - const LightSensorService(LocalPlatform()).hasSensor(), - ]), - builder: (_, snapshot) { - if (snapshot.data != null) { - return IAPProviders( - sharedPreferences: snapshot.data![0] as SharedPreferences, - child: ServicesProvider( - caffeineService: const CaffeineService(), - environment: env.copyWith(hasLightSensor: snapshot.data![1] as bool), - hapticsService: const HapticsService(), - lightSensorService: const LightSensorService(LocalPlatform()), - permissionsService: const PermissionsService(), - userPreferencesService: - UserPreferencesService(snapshot.data![0] as SharedPreferences), - volumeEventsService: const VolumeEventsService(LocalPlatform()), - child: UserPreferencesProvider( - child: Builder( - builder: (context) { - final theme = UserPreferencesProvider.themeOf(context); - final systemIconsBrightness = - ThemeData.estimateBrightnessForColor(theme.colorScheme.onSurface); - return AnnotatedRegion( - value: SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarBrightness: systemIconsBrightness == Brightness.light - ? Brightness.dark - : Brightness.light, - statusBarIconBrightness: systemIconsBrightness, - systemNavigationBarColor: Colors.transparent, - systemNavigationBarIconBrightness: systemIconsBrightness, - ), - child: MaterialApp( - theme: theme, - locale: Locale(UserPreferencesProvider.localeOf(context).intlName), - localizationsDelegates: const [ - S.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: S.delegate.supportedLocales, - builder: (context, child) => MediaQuery( - data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), - child: child!, - ), - initialRoute: "metering", - routes: { - "metering": (context) => const MeteringFlow(), - "settings": (context) => const SettingsFlow(), - }, - ), - ); - }, - ), - ), - ), - ); - } else if (snapshot.error != null) { - return Center(child: Text(snapshot.error!.toString())); - } - - // TODO(@vodemn): maybe user splashscreen instead - return const SizedBox(); - }, + final theme = UserPreferencesProvider.themeOf(context); + final systemIconsBrightness = ThemeData.estimateBrightnessForColor(theme.colorScheme.onSurface); + return AnnotatedRegion( + value: SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarBrightness: + systemIconsBrightness == Brightness.light ? Brightness.dark : Brightness.light, + statusBarIconBrightness: systemIconsBrightness, + systemNavigationBarColor: Colors.transparent, + systemNavigationBarIconBrightness: systemIconsBrightness, + ), + child: MaterialApp( + theme: theme, + locale: Locale(UserPreferencesProvider.localeOf(context).intlName), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), + child: child!, + ), + initialRoute: "metering", + routes: { + "metering": (context) => const MeteringFlow(), + "settings": (context) => const SettingsFlow(), + }, + ), ); } } diff --git a/lib/application_wrapper.dart b/lib/application_wrapper.dart new file mode 100644 index 0000000..f8627f9 --- /dev/null +++ b/lib/application_wrapper.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.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/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/providers/services_provider.dart'; +import 'package:lightmeter/providers/user_preferences_provider.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:platform/platform.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ApplicationWrapper extends StatelessWidget { + final Environment env; + final Widget child; + + const ApplicationWrapper(this.env, {required this.child, super.key}); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: Future.wait([ + SharedPreferences.getInstance(), + const LightSensorService(LocalPlatform()).hasSensor(), + ]), + builder: (_, snapshot) { + if (snapshot.data != null) { + return IAPProviders( + sharedPreferences: snapshot.data![0] as SharedPreferences, + child: ServicesProvider( + caffeineService: const CaffeineService(), + environment: env.copyWith(hasLightSensor: snapshot.data![1] as bool), + hapticsService: const HapticsService(), + lightSensorService: const LightSensorService(LocalPlatform()), + permissionsService: const PermissionsService(), + userPreferencesService: + UserPreferencesService(snapshot.data![0] as SharedPreferences), + volumeEventsService: const VolumeEventsService(LocalPlatform()), + child: UserPreferencesProvider( + child: child, + ), + ), + ); + } else if (snapshot.error != null) { + return Center(child: Text(snapshot.error!.toString())); + } + + // TODO(@vodemn): maybe user splashscreen instead + return const SizedBox(); + }, + ); + } +} diff --git a/lib/main_dev.dart b/lib/main_dev.dart index fd6ed56..9ef3faf 100644 --- a/lib/main_dev.dart +++ b/lib/main_dev.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/application.dart'; +import 'package:lightmeter/application_wrapper.dart'; import 'package:lightmeter/environment.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - runApp(const Application(Environment.dev())); + runApp(const ApplicationWrapper(Environment.dev(), child: Application())); } diff --git a/lib/main_prod.dart b/lib/main_prod.dart index 2ccc397..b75513e 100644 --- a/lib/main_prod.dart +++ b/lib/main_prod.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/application.dart'; +import 'package:lightmeter/application_wrapper.dart'; import 'package:lightmeter/environment.dart'; import 'package:lightmeter/firebase.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await initializeFirebase(handleErrors: true); - runApp(const Application(Environment.prod())); + runApp(const ApplicationWrapper(Environment.prod(), child: Application())); } diff --git a/lib/main_release.dart b/lib/main_release.dart index b87dff8..bb6384a 100644 --- a/lib/main_release.dart +++ b/lib/main_release.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/application.dart'; +import 'package:lightmeter/application_wrapper.dart'; import 'package:lightmeter/environment.dart'; import 'package:lightmeter/firebase.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await initializeFirebase(handleErrors: false); - runApp(const Application(Environment.prod())); + runApp(const ApplicationWrapper(Environment.prod(), child: Application())); } diff --git a/lib/platform_config.dart b/lib/platform_config.dart index 7d98a4b..def5a80 100644 --- a/lib/platform_config.dart +++ b/lib/platform_config.dart @@ -5,4 +5,6 @@ class PlatformConfig { final rational = const String.fromEnvironment('cameraPreviewAspectRatio').split('/'); return int.parse(rational[0]) / int.parse(rational[1]); } + + static String get cameraStubImage => const String.fromEnvironment('cameraStubImage'); } diff --git a/lib/screens/metering/components/camera_container/bloc_container_camera.dart b/lib/screens/metering/components/camera_container/bloc_container_camera.dart index 0313391..1d7d4b5 100644 --- a/lib/screens/metering/components/camera_container/bloc_container_camera.dart +++ b/lib/screens/metering/components/camera_container/bloc_container_camera.dart @@ -2,13 +2,14 @@ import 'dart:async'; import 'dart:developer'; import 'dart:io'; import 'dart:math' as math; -import 'dart:typed_data'; import 'package:camera/camera.dart'; import 'package:exif/exif.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lightmeter/interactors/metering_interactor.dart'; +import 'package:lightmeter/platform_config.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' as communication_event; @@ -32,7 +33,7 @@ class CameraContainerBloc extends EvSourceBlocBase !(_cameraController == null || - !_cameraController!.value.isInitialized || - _cameraController!.value.isTakingPicture); + bool get _canTakePhoto => + PlatformConfig.cameraStubImage.isNotEmpty || + !(_cameraController == null || + !_cameraController!.value.isInitialized || + _cameraController!.value.isTakingPicture); Future _takePhoto() async { try { // https://github.com/flutter/flutter/issues/84957#issuecomment-1661155095 - await _cameraController!.setFocusMode(FocusMode.locked); - await _cameraController!.setExposureMode(ExposureMode.locked); - final file = await _cameraController!.takePicture(); - await _cameraController!.setFocusMode(FocusMode.auto); - await _cameraController!.setExposureMode(ExposureMode.auto); - final Uint8List bytes = await file.readAsBytes(); - Directory(file.path).deleteSync(recursive: true); + late final Uint8List bytes; + if (PlatformConfig.cameraStubImage.isNotEmpty) { + bytes = (await rootBundle.load(PlatformConfig.cameraStubImage)).buffer.asUint8List(); + } else { + await _cameraController!.setFocusMode(FocusMode.locked); + await _cameraController!.setExposureMode(ExposureMode.locked); + final file = await _cameraController!.takePicture(); + await _cameraController!.setFocusMode(FocusMode.auto); + await _cameraController!.setExposureMode(ExposureMode.auto); + bytes = await file.readAsBytes(); + Directory(file.path).deleteSync(recursive: true); + } final tags = await readExifFromBytes(bytes); final iso = double.tryParse("${tags["EXIF ISOSpeedRatings"]}"); diff --git a/lib/screens/metering/components/camera_container/components/camera_preview/widget_camera_preview.dart b/lib/screens/metering/components/camera_container/components/camera_preview/widget_camera_preview.dart index 1ae0ca9..3e9538f 100644 --- a/lib/screens/metering/components/camera_container/components/camera_preview/widget_camera_preview.dart +++ b/lib/screens/metering/components/camera_container/components/camera_preview/widget_camera_preview.dart @@ -69,6 +69,9 @@ class _CameraPreviewBuilderState extends State<_CameraPreviewBuilder> { @override Widget build(BuildContext context) { + if (PlatformConfig.cameraStubImage.isNotEmpty) { + return Image.asset(PlatformConfig.cameraStubImage); + } return ValueListenableBuilder( valueListenable: _initializedNotifier, builder: (context, value, child) => value diff --git a/lib/screens/settings/components/shared/dialog_filter/widget_dialog_filter.dart b/lib/screens/settings/components/shared/dialog_filter/widget_dialog_filter.dart index af2b35d..76cd729 100644 --- a/lib/screens/settings/components/shared/dialog_filter/widget_dialog_filter.dart +++ b/lib/screens/settings/components/shared/dialog_filter/widget_dialog_filter.dart @@ -34,6 +34,26 @@ class _DialogFilterState extends State> { bool get _hasAnySelected => checkboxValues.contains(true); bool get _hasAnyUnselected => checkboxValues.contains(false); + late final ScrollController _scrollController; + + @override + void initState() { + super.initState(); + int i = 0; + for (; i < checkboxValues.length; i++) { + if (checkboxValues[i]) { + break; + } + } + _scrollController = ScrollController(initialScrollOffset: Dimens.grid56 * i); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return AlertDialog( @@ -50,6 +70,7 @@ class _DialogFilterState extends State> { const Divider(), Expanded( child: SingleChildScrollView( + controller: _scrollController, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, diff --git a/pubspec.yaml b/pubspec.yaml index 5454e81..090b8ce 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,12 +48,16 @@ dev_dependencies: flutter_test: sdk: flutter google_fonts: 3.0.1 + integration_test: + sdk: flutter lint: 2.1.2 mocktail: 0.3.0 test: 1.24.1 flutter: uses-material-design: true + assets: + - assets/camera_stub_image.jpg flutter_intl: enabled: true diff --git a/test_driver/screenshot_driver.dart b/test_driver/screenshot_driver.dart new file mode 100644 index 0000000..3b11ee3 --- /dev/null +++ b/test_driver/screenshot_driver.dart @@ -0,0 +1,42 @@ +import 'dart:developer'; +import 'dart:io'; +import 'package:integration_test/integration_test_driver_extended.dart'; + +Future main() async { + try { + final bool adbExists = Process.runSync('which', ['adb']).exitCode == 0; + if (!adbExists) { + log(r'This test needs ADB to exist on the $PATH. Skipping...'); + exit(0); + } + final deviceId = await Process.run('adb', ["-s", 'shell', 'devices']).then((value) { + if (value.stdout is String) { + return RegExp(r"(?:List of devices attached\n)([A-Z0-9]*)(?:\sdevice\n)") + .firstMatch(value.stdout as String)! + .group(1); + } + }); + if (deviceId == null) { + log('This test needs at least one device connected'); + exit(0); + } + await Process.run('adb', [ + "-s", + deviceId, // https://github.com/flutter/flutter/issues/86295#issuecomment-1192766368 + 'shell', + 'pm', + 'grant', + 'com.vodemn.lightmeter.dev', + 'android.permission.CAMERA' + ]); + await integrationDriver( + onScreenshot: (name, bytes, [args]) async { + final File image = await File('screenshots/$name.png').create(recursive: true); + image.writeAsBytesSync(bytes); + return true; + }, + ); + } catch (e) { + log('Error occured: $e'); + } +}