diff --git a/iap/lib/m3_lightmeter_iap.dart b/iap/lib/m3_lightmeter_iap.dart index 43e69aa..003b0da 100644 --- a/iap/lib/m3_lightmeter_iap.dart +++ b/iap/lib/m3_lightmeter_iap.dart @@ -5,5 +5,6 @@ import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; export 'src/data/models/iap_product.dart'; export 'src/providers/iap_products_provider.dart'; export 'src/data/iap_storage_service.dart'; +export 'src/data/films_storage_service.dart'; const List films = []; diff --git a/iap/lib/src/data/films_storage_service.dart b/iap/lib/src/data/films_storage_service.dart new file mode 100644 index 0000000..9cab6b0 --- /dev/null +++ b/iap/lib/src/data/films_storage_service.dart @@ -0,0 +1,32 @@ +import 'package:flutter/foundation.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +typedef SelectableFilm = ({T film, bool isUsed}); + +class FilmsStorageService { + FilmsStorageService(); + + Future init() async {} + + @visibleForTesting + Future createTable(dynamic _) async {} + + String get selectedFilmId => ''; + set selectedFilmId(String id) {} + + Future addFilm(FilmExponential _, {bool isUsed = true}) async {} + + Future updateFilm(FilmExponential _) async {} + + Future toggleFilm(Film _, bool __) async {} + + Future deleteFilm(FilmExponential _) async {} + + Future>> getPredefinedFilms() async { + return const {}; + } + + Future>> getCustomFilms() async { + return const {}; + } +} diff --git a/iap/lib/src/data/iap_storage_service.dart b/iap/lib/src/data/iap_storage_service.dart index f62f622..14eaa4b 100644 --- a/iap/lib/src/data/iap_storage_service.dart +++ b/iap/lib/src/data/iap_storage_service.dart @@ -8,10 +8,4 @@ class IAPStorageService { List get equipmentProfiles => []; set equipmentProfiles(List profiles) {} - - Film get selectedFilm => const Film.other(); - set selectedFilm(Film value) {} - - List get filmsInUse => []; - set filmsInUse(List profiles) {} } diff --git a/iap/pubspec.yaml b/iap/pubspec.yaml index 39352cf..56bc8e1 100644 --- a/iap/pubspec.yaml +++ b/iap/pubspec.yaml @@ -1,11 +1,10 @@ name: m3_lightmeter_iap description: IAP stubs for the M3 Lightmeter app. -version: 0.2.0 +version: 1.0.0 publish_to: 'none' environment: - sdk: '>=2.19.2 <3.0.0' - flutter: ">=1.17.0" + sdk: ">=3.0.0 <4.0.0" dependencies: flutter: @@ -13,7 +12,7 @@ dependencies: m3_lightmeter_resources: git: url: "https://github.com/vodemn/m3_lightmeter_resources" - ref: v1.4.0 + ref: v2.0.0 shared_preferences: 2.2.0 dev_dependencies: diff --git a/integration_test/e2e_test.dart b/integration_test/e2e_test.dart index a5b43c3..88edb94 100644 --- a/integration_test/e2e_test.dart +++ b/integration_test/e2e_test.dart @@ -47,7 +47,11 @@ void testE2E(String description) { testWidgets( description, (tester) async { - await tester.pumpApplication(equipmentProfiles: [], filmsInUse: []); + await tester.pumpApplication( + equipmentProfiles: [], + predefinedFilms: mockFilms.toFilmsMap(isUsed: true), + customFilms: {}, + ); /// Create Praktica + Zenitar profile from scratch await tester.openSettings(); @@ -76,11 +80,6 @@ void testE2E(String description) { expect(find.text('f/3.5 - f/22'), findsOneWidget); expect(find.text('1/1000 - B'), findsNWidgets(2)); await tester.navigatorPop(); - - /// Select some films - await tester.tap(find.text(S.current.filmsInUse)); - await tester.pumpAndSettle(); - await tester.setDialogFilterValues([mockFilms[0], mockFilms[1]], deselectAll: false); await tester.navigatorPop(); /// Select some initial settings according to the selected gear and film diff --git a/integration_test/metering_screen_layout_test.dart b/integration_test/metering_screen_layout_test.dart index ec2ce7b..1d29f73 100644 --- a/integration_test/metering_screen_layout_test.dart +++ b/integration_test/metering_screen_layout_test.dart @@ -135,7 +135,7 @@ void testToggleLayoutFeatures(String description) { testWidgets( 'Film picker', (tester) async { - await tester.pumpApplication(selectedFilm: mockFilms.first); + await tester.pumpApplication(selectedFilmId: mockFilms.first.id); await tester.takePhoto(); expectPickerTitle(mockFilms.first.name); expectExtremeExposurePairs('f/1.0 - 1/320', 'f/45 - 12"'); diff --git a/integration_test/mocks/paid_features_mock.dart b/integration_test/mocks/paid_features_mock.dart index 34e8fb0..3748c96 100644 --- a/integration_test/mocks/paid_features_mock.dart +++ b/integration_test/mocks/paid_features_mock.dart @@ -7,24 +7,27 @@ import 'package:mocktail/mocktail.dart'; class _MockIAPStorageService extends Mock implements IAPStorageService {} +class _MockFilmsStorageService extends Mock implements FilmsStorageService {} + class MockIAPProviders extends StatefulWidget { final List? equipmentProfiles; final String selectedEquipmentProfileId; - final List availableFilms; - final List filmsInUse; - final Film selectedFilm; + final Map> predefinedFilms; + final Map> customFilms; + final String selectedFilmId; final Widget child; - const MockIAPProviders({ + MockIAPProviders({ this.equipmentProfiles = const [], this.selectedEquipmentProfileId = '', - List? availableFilms, - List? filmsInUse, - this.selectedFilm = const Film.other(), + Map>? predefinedFilms, + Map>? customFilms, + String? selectedFilmId, required this.child, super.key, - }) : availableFilms = availableFilms ?? mockFilms, - filmsInUse = filmsInUse ?? mockFilms; + }) : predefinedFilms = predefinedFilms ?? mockFilms.toFilmsMap(), + customFilms = customFilms ?? mockFilms.toFilmsMap(), + selectedFilmId = selectedFilmId ?? const FilmStub().id; @override State createState() => _MockIAPProvidersState(); @@ -32,6 +35,7 @@ class MockIAPProviders extends StatefulWidget { class _MockIAPProvidersState extends State { late final _MockIAPStorageService mockIAPStorageService; + late final _MockFilmsStorageService mockFilmsStorageService; @override void initState() { @@ -39,8 +43,12 @@ class _MockIAPProvidersState extends State { mockIAPStorageService = _MockIAPStorageService(); when(() => mockIAPStorageService.equipmentProfiles).thenReturn(widget.equipmentProfiles ?? mockEquipmentProfiles); when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn(widget.selectedEquipmentProfileId); - when(() => mockIAPStorageService.filmsInUse).thenReturn(widget.filmsInUse); - when(() => mockIAPStorageService.selectedFilm).thenReturn(widget.selectedFilm); + + mockFilmsStorageService = _MockFilmsStorageService(); + when(() => mockFilmsStorageService.init()).thenAnswer((_) async {}); + when(() => mockFilmsStorageService.getPredefinedFilms()).thenAnswer((_) => Future.value(widget.predefinedFilms)); + when(() => mockFilmsStorageService.getCustomFilms()).thenAnswer((_) => Future.value(widget.customFilms)); + when(() => mockFilmsStorageService.selectedFilmId).thenReturn(widget.selectedFilmId); } @override @@ -48,8 +56,7 @@ class _MockIAPProvidersState extends State { return EquipmentProfileProvider( storageService: mockIAPStorageService, child: FilmsProvider( - storageService: mockIAPStorageService, - availableFilms: widget.availableFilms, + filmsStorageService: mockFilmsStorageService, child: widget.child, ), ); @@ -128,13 +135,52 @@ final mockEquipmentProfiles = [ ), ]; -const mockFilms = [_MockFilm(100, 2), _MockFilm(400, 2), _MockFilm(3, 800), _MockFilm(400, 1.5)]; +const mockFilms = [ + _FilmMultiplying(id: '1', name: 'Mock film 1', iso: 100, reciprocityMultiplier: 2), + _FilmMultiplying(id: '2', name: 'Mock film 2', iso: 400, reciprocityMultiplier: 2), + _FilmMultiplying(id: '3', name: 'Mock film 3', iso: 800, reciprocityMultiplier: 3), + _FilmMultiplying(id: '4', name: 'Mock film 4', iso: 1200, reciprocityMultiplier: 1.5), +]; -class _MockFilm extends Film { +extension FilmMapper on List { + Map toFilmsMap({bool isUsed = true}) => + Map.fromEntries(map((e) => MapEntry(e.id, (film: e as T, isUsed: isUsed)))); +} + +class _FilmMultiplying extends FilmExponential { final double reciprocityMultiplier; - const _MockFilm(int iso, this.reciprocityMultiplier) : super('Mock film $iso x$reciprocityMultiplier', iso); + const _FilmMultiplying({ + String? id, + required String name, + required super.iso, + required this.reciprocityMultiplier, + }) : super(id: id ?? name, name: 'Mock film $iso x$reciprocityMultiplier', exponent: 1); @override - double reciprocityFormula(double t) => t * reciprocityMultiplier; + ShutterSpeedValue reciprocityFailure(ShutterSpeedValue shutterSpeed) { + if (shutterSpeed.isFraction) { + return shutterSpeed; + } else { + return ShutterSpeedValue( + shutterSpeed.rawValue * reciprocityMultiplier, + shutterSpeed.isFraction, + shutterSpeed.stopType, + ); + } + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + return other is _FilmMultiplying && + other.id == id && + other.name == name && + other.iso == iso && + other.reciprocityMultiplier == reciprocityMultiplier; + } + + @override + int get hashCode => Object.hash(id, name, iso, reciprocityMultiplier, runtimeType); } diff --git a/integration_test/utils/widget_tester_actions.dart b/integration_test/utils/widget_tester_actions.dart index 8515c1d..8ff2bb4 100644 --- a/integration_test/utils/widget_tester_actions.dart +++ b/integration_test/utils/widget_tester_actions.dart @@ -22,9 +22,9 @@ extension WidgetTesterCommonActions on WidgetTester { IAPProductStatus productStatus = IAPProductStatus.purchased, List? equipmentProfiles, String selectedEquipmentProfileId = '', - List? availableFilms, - List? filmsInUse, - Film selectedFilm = const Film.other(), + Map>? predefinedFilms, + Map>? customFilms, + String selectedFilmId = '', }) async { await pumpWidget( MockIAPProductsProvider( @@ -34,9 +34,9 @@ extension WidgetTesterCommonActions on WidgetTester { child: MockIAPProviders( equipmentProfiles: equipmentProfiles, selectedEquipmentProfileId: selectedEquipmentProfileId, - availableFilms: availableFilms, - filmsInUse: filmsInUse, - selectedFilm: selectedFilm, + predefinedFilms: predefinedFilms, + customFilms: customFilms, + selectedFilmId: selectedFilmId, child: const Application(), ), ), diff --git a/lib/application.dart b/lib/application.dart index b8939e4..87cdfe3 100644 --- a/lib/application.dart +++ b/lib/application.dart @@ -3,8 +3,12 @@ import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:lightmeter/data/models/supported_locale.dart'; import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/navigation/modal_route_args_parser.dart'; +import 'package:lightmeter/navigation/routes.dart'; import 'package:lightmeter/platform_config.dart'; import 'package:lightmeter/providers/user_preferences_provider.dart'; +import 'package:lightmeter/screens/film_edit/flow_film_edit.dart'; +import 'package:lightmeter/screens/films/screen_films.dart'; import 'package:lightmeter/screens/lightmeter_pro/screen_lightmeter_pro.dart'; import 'package:lightmeter/screens/metering/flow_metering.dart'; import 'package:lightmeter/screens/settings/flow_settings.dart'; @@ -41,12 +45,15 @@ class Application extends StatelessWidget { data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), child: child!, ), - initialRoute: "metering", + initialRoute: NavigationRoutes.meteringScreen.name, routes: { - "metering": (_) => const ReleaseNotesFlow(child: MeteringFlow()), - "settings": (_) => const SettingsFlow(), - "lightmeterPro": (_) => LightmeterProScreen(), - "timer": (context) => TimerFlow(args: ModalRoute.of(context)!.settings.arguments! as TimerFlowArgs), + NavigationRoutes.meteringScreen.name: (_) => const ReleaseNotesFlow(child: MeteringFlow()), + NavigationRoutes.settingsScreen.name: (_) => const SettingsFlow(), + NavigationRoutes.filmsListScreen.name: (_) => const FilmsScreen(), + NavigationRoutes.filmAddScreen.name: (_) => const FilmEditFlow(args: FilmEditArgs()), + NavigationRoutes.filmEditScreen.name: (context) => FilmEditFlow(args: context.routeArgs()), + NavigationRoutes.proFeaturesScreen.name: (_) => LightmeterProScreen(), + NavigationRoutes.timerScreen.name: (context) => TimerFlow(args: context.routeArgs()), }, ), ); diff --git a/lib/application_wrapper.dart b/lib/application_wrapper.dart index 9f824d4..ed83604 100644 --- a/lib/application_wrapper.dart +++ b/lib/application_wrapper.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:lightmeter/data/analytics/analytics.dart'; import 'package:lightmeter/data/analytics/api/analytics_firebase.dart'; import 'package:lightmeter/data/caffeine_service.dart'; @@ -18,59 +19,88 @@ 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 { +class ApplicationWrapper extends StatefulWidget { final Environment env; final Widget child; const ApplicationWrapper(this.env, {required this.child, super.key}); + @override + State createState() => _ApplicationWrapperState(); +} + +class _ApplicationWrapperState extends State { + late final remoteConfigService = widget.env.buildType != BuildType.dev + ? const RemoteConfigService(LightmeterAnalytics(api: LightmeterAnalyticsFirebase())) + : const MockRemoteConfigService(); + + late final IAPStorageService iapStorageService; + late final UserPreferencesService userPreferencesService; + late final bool hasLightSensor; + + final filmsStorageService = FilmsStorageService(); + final filmsProviderKey = GlobalKey(); + + late final Future _initFuture; + + @override + void initState() { + super.initState(); + _initFuture = _initialize(); + } + @override Widget build(BuildContext context) { - final remoteConfigService = env.buildType != BuildType.dev - ? const RemoteConfigService(LightmeterAnalytics(api: LightmeterAnalyticsFirebase())) - : const MockRemoteConfigService(); - return FutureBuilder( - future: Future.wait([ - SharedPreferences.getInstance(), - const LightSensorService(LocalPlatform()).hasSensor(), - remoteConfigService.activeAndFetchFeatures(), - ]), - builder: (_, snapshot) { - if (snapshot.data != null) { - final iapService = IAPStorageService(snapshot.data![0] as SharedPreferences); - final userPreferencesService = UserPreferencesService(snapshot.data![0] as SharedPreferences); - final hasLightSensor = snapshot.data![1] as bool; - return ServicesProvider( - analytics: const LightmeterAnalytics(api: LightmeterAnalyticsFirebase()), - caffeineService: const CaffeineService(), - environment: env.copyWith(hasLightSensor: hasLightSensor), - hapticsService: const HapticsService(), - lightSensorService: const LightSensorService(LocalPlatform()), - permissionsService: const PermissionsService(), - userPreferencesService: userPreferencesService, - volumeEventsService: const VolumeEventsService(LocalPlatform()), - child: RemoteConfigProvider( - remoteConfigService: remoteConfigService, - child: EquipmentProfileProvider( - storageService: iapService, - child: FilmsProvider( - storageService: iapService, + return FilmsProvider( + key: filmsProviderKey, + filmsStorageService: filmsStorageService, + child: FutureBuilder( + future: _initFuture, + builder: (context, snapshot) { + if (snapshot.error != null) { + return Center(child: Text(snapshot.error!.toString())); + } else if (snapshot.connectionState == ConnectionState.done) { + return ServicesProvider( + analytics: const LightmeterAnalytics(api: LightmeterAnalyticsFirebase()), + caffeineService: const CaffeineService(), + environment: widget.env.copyWith(hasLightSensor: hasLightSensor), + hapticsService: const HapticsService(), + lightSensorService: const LightSensorService(LocalPlatform()), + permissionsService: const PermissionsService(), + userPreferencesService: userPreferencesService, + volumeEventsService: const VolumeEventsService(LocalPlatform()), + child: RemoteConfigProvider( + remoteConfigService: remoteConfigService, + child: EquipmentProfileProvider( + storageService: iapStorageService, child: UserPreferencesProvider( hasLightSensor: hasLightSensor, userPreferencesService: userPreferencesService, - child: child, + child: widget.child, ), ), ), - ), - ); - } else if (snapshot.error != null) { - return Center(child: Text(snapshot.error!.toString())); - } + ); + } - // TODO(@vodemn): maybe user splashscreen instead - return const SizedBox(); - }, + return const SizedBox(); + }, + ), ); } + + Future _initialize() async { + await Future.wait([ + SharedPreferences.getInstance(), + const LightSensorService(LocalPlatform()).hasSensor(), + remoteConfigService.activeAndFetchFeatures(), + filmsStorageService.init().then((_) => filmsProviderKey.currentState?.init()), + ]).then((value) { + final sharedPrefs = (value[0] as SharedPreferences?)!; + iapStorageService = IAPStorageService(sharedPrefs); + userPreferencesService = UserPreferencesService(sharedPrefs); + hasLightSensor = value[1] as bool? ?? false; + FlutterNativeSplash.remove(); + }); + } } diff --git a/lib/data/models/app_feature.dart b/lib/data/models/app_feature.dart index f9be63a..d6d916d 100644 --- a/lib/data/models/app_feature.dart +++ b/lib/data/models/app_feature.dart @@ -9,6 +9,7 @@ enum AppFeature { spotMetering, histogram, listOfFilms, + customFilms, equipmentProfiles, timer, mainScreenCustomization; @@ -33,6 +34,8 @@ enum AppFeature { return S.of(context).featureHistogram; case AppFeature.listOfFilms: return S.of(context).featureListOfFilms; + case AppFeature.customFilms: + return S.of(context).featureCustomFilms; case AppFeature.equipmentProfiles: return S.of(context).featureEquipmentProfiles; case AppFeature.timer: diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 14f3950..f4404c1 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -67,8 +67,6 @@ "equipmentProfile": "Equipment profile", "equipmentProfiles": "Equipment profiles", "tapToAdd": "Tap to add", - "filmsInUse": "Films in use", - "filmsInUseDescription": "Select films which you use.", "general": "General", "keepScreenOn": "Keep screen on", "haptics": "Haptics", @@ -116,6 +114,7 @@ "featureSpotMetering": "Spot metering", "featureHistogram": "Histogram", "featureListOfFilms": "List of 20+ films with reciprocity formulas", + "featureCustomFilms": "Ability to create custom films", "featureEquipmentProfiles": "Equipment profiles", "featureTimer": "Built-in timer for long exposure", "featureMeteringScreenLayout": "Customizable main screen", @@ -150,5 +149,16 @@ } } }, - "close": "Close" + "close": "Close", + "films": "Films", + "filmsInUse": "Films in use", + "filmsInUseDescription": "Select films which you use.", + "filmsCustom": "Custom films", + "addFilmTitle": "Add film", + "editFilmTitle": "Edit film", + "filmFormula": "Formula", + "filmFormulaExponential": "T=t^Rf", + "filmFormulaExponentialRf": "Rf", + "filmFormulaExponentialRfPlaceholder": "1.3", + "name": "Name" } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index e002a0c..efa160d 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -117,6 +117,7 @@ "featureSpotMetering": "Mesure spot", "featureHistogram": "Histogramme", "featureListOfFilms": "Liste de plus de 20 films avec des formules de correction", + "featureCustomFilms": "Possibilité de créer des films personnalisés", "featureEquipmentProfiles": "Profils de l'équipement", "featureTimer": "Minuteur intégré pour longues expositions", "featureMeteringScreenLayout": "Écran principal personnalisable", diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 2e60b8d..ee4559c 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -116,6 +116,7 @@ "featureSpotMetering": "Точечный замер", "featureHistogram": "Гистограмма", "featureListOfFilms": "Список из 20+ плёнок с формулами коррекции", + "featureCustomFilms": "Возможность создания собственных плёнок", "featureEquipmentProfiles": "Профили оборудования", "featureTimer": "Встроенный таймер для длинных выдержек", "featureMeteringScreenLayout": "Настраиваемый главный экран", diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb index 76df1fb..d8d2732 100644 --- a/lib/l10n/intl_zh.arb +++ b/lib/l10n/intl_zh.arb @@ -115,6 +115,7 @@ "featureSpotMetering": "点测光", "featureHistogram": "直方图", "featureListOfFilms": "20多部电影的修正公式列表", + "featureCustomFilms": "创建自定义胶片的能力", "featureEquipmentProfiles": "设备配置文件", "featureTimer": "内置长曝光计时器", "featureMeteringScreenLayout": "可自定义的主屏幕", diff --git a/lib/navigation/modal_route_args_parser.dart b/lib/navigation/modal_route_args_parser.dart new file mode 100644 index 0000000..4064a9d --- /dev/null +++ b/lib/navigation/modal_route_args_parser.dart @@ -0,0 +1,7 @@ +import 'package:flutter/widgets.dart'; + +extension ModalRouteArgsParser on BuildContext { + T routeArgs() { + return ModalRoute.of(this)!.settings.arguments! as T; + } +} diff --git a/lib/navigation/routes.dart b/lib/navigation/routes.dart new file mode 100644 index 0000000..4b496f0 --- /dev/null +++ b/lib/navigation/routes.dart @@ -0,0 +1,9 @@ +enum NavigationRoutes { + meteringScreen, + settingsScreen, + filmsListScreen, + filmAddScreen, + filmEditScreen, + proFeaturesScreen, + timerScreen, +} diff --git a/lib/providers/films_provider.dart b/lib/providers/films_provider.dart index 3e9d02d..6fa8f33 100644 --- a/lib/providers/films_provider.dart +++ b/lib/providers/films_provider.dart @@ -1,17 +1,14 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/utils/context_utils.dart'; -import 'package:lightmeter/utils/selectable_provider.dart'; import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class FilmsProvider extends StatefulWidget { - final IAPStorageService storageService; - final List? availableFilms; + final FilmsStorageService filmsStorageService; final Widget child; const FilmsProvider({ - required this.storageService, - this.availableFilms, + required this.filmsStorageService, required this.child, super.key, }); @@ -25,82 +22,144 @@ class FilmsProvider extends StatefulWidget { } class FilmsProviderState extends State { - late List _filmsInUse; - late Film _selected; + final Map> predefinedFilms = {}; + final Map> customFilms = {}; + String _selectedId = ''; - @override - void initState() { - super.initState(); - _filmsInUse = widget.storageService.filmsInUse; - _selected = widget.storageService.selectedFilm; - _discardSelectedIfNotIncluded(); - } + Film get _selectedFilm => customFilms[_selectedId]?.film ?? predefinedFilms[_selectedId]?.film ?? const FilmStub(); @override Widget build(BuildContext context) { return Films( - values: [ - const Film.other(), - ...widget.availableFilms ?? films, - ], - filmsInUse: [ - const Film.other(), - if (context.isPro) ..._filmsInUse, - ], - selected: context.isPro ? _selected : const Film.other(), + predefinedFilms: context.isPro ? predefinedFilms : {}, + customFilms: context.isPro ? customFilms : {}, + selected: context.isPro ? _selectedFilm : const FilmStub(), child: widget.child, ); } - void setFilm(Film film) { - if (_selected != film) { - _selected = film; - widget.storageService.selectedFilm = film; + Future init() async { + _selectedId = widget.filmsStorageService.selectedFilmId; + predefinedFilms.addAll(await widget.filmsStorageService.getPredefinedFilms()); + customFilms.addAll(await widget.filmsStorageService.getCustomFilms()); + _discardSelectedIfNotIncluded(); + if (mounted) setState(() {}); + } + + /* Both type of films **/ + + Future toggleFilm(Film film, bool enabled) async { + if (predefinedFilms.containsKey(film.id)) { + predefinedFilms[film.id] = (film: film, isUsed: enabled); + } else if (customFilms.containsKey(film.id)) { + customFilms[film.id] = (film: film as FilmExponential, isUsed: enabled); + } else { + return; + } + await widget.filmsStorageService.toggleFilm(film, enabled); + _discardSelectedIfNotIncluded(); + setState(() {}); + } + + void selectFilm(Film film) { + if (_selectedFilm != film) { + _selectedId = film.id; + widget.filmsStorageService.selectedFilmId = _selectedId; setState(() {}); } } - void saveFilms(List films) { - _filmsInUse = films; - widget.storageService.filmsInUse = films; + /* Custom films **/ + + Future addCustomFilm(FilmExponential film) async { + // ignore: avoid_redundant_argument_values + await widget.filmsStorageService.addFilm(film, isUsed: true); + customFilms[film.id] = (film: film, isUsed: true); + setState(() {}); + } + + Future updateCustomFilm(FilmExponential film) async { + await widget.filmsStorageService.updateFilm(film); + customFilms[film.id] = (film: film, isUsed: customFilms[film.id]!.isUsed); + setState(() {}); + } + + Future deleteCustomFilm(FilmExponential film) async { + await widget.filmsStorageService.deleteFilm(film); + customFilms.remove(film.id); _discardSelectedIfNotIncluded(); setState(() {}); } void _discardSelectedIfNotIncluded() { - if (_selected != const Film.other() && !_filmsInUse.contains(_selected)) { - _selected = const Film.other(); - widget.storageService.selectedFilm = const Film.other(); + if (_selectedId == const FilmStub().id) { + return; + } + final isSelectedUsed = predefinedFilms[_selectedId]?.isUsed ?? customFilms[_selectedId]?.isUsed ?? false; + if (!isSelectedUsed) { + _selectedId = const FilmStub().id; + widget.filmsStorageService.selectedFilmId = _selectedId; } } } -class Films extends SelectableInheritedModel { - final List filmsInUse; +enum _FilmsModelAspect { + customFilmsList, + predefinedFilmsList, + filmsInUse, + selected, +} + +class Films extends InheritedModel<_FilmsModelAspect> { + final Map> predefinedFilms; + + @protected + final Map> customFilms; + final Film selected; const Films({ - super.key, - required super.values, - required this.filmsInUse, - required super.selected, + required this.predefinedFilms, + required this.customFilms, + required this.selected, required super.child, }); - /// [Film.other()] + all the custom fields with actual reciprocity formulas - static List of(BuildContext context) { - return InheritedModel.inheritFrom(context)!.values; + static List predefinedFilmsOf(BuildContext context) { + return InheritedModel.inheritFrom(context, aspect: _FilmsModelAspect.predefinedFilmsList)! + .predefinedFilms + .values + .map((value) => value.film) + .toList(); } - /// [Film.other()] + films in use selected by user + static List customFilmsOf(BuildContext context) { + return InheritedModel.inheritFrom(context, aspect: _FilmsModelAspect.customFilmsList)! + .customFilms + .values + .map((value) => value.film) + .toList(); + } + + /// [FilmStub()] + films in use selected by user static List inUseOf(BuildContext context) { - return InheritedModel.inheritFrom( - context, - aspect: SelectableAspect.list, - )! - .filmsInUse; + final model = InheritedModel.inheritFrom(context, aspect: _FilmsModelAspect.filmsInUse)!; + return [ + const FilmStub(), + ...model.customFilms.values.where((e) => e.isUsed).map((e) => e.film), + ...model.predefinedFilms.values.where((e) => e.isUsed).map((e) => e.film), + ]; } static Film selectedOf(BuildContext context) { - return InheritedModel.inheritFrom(context, aspect: SelectableAspect.selected)!.selected; + return InheritedModel.inheritFrom(context, aspect: _FilmsModelAspect.selected)!.selected; + } + + @override + bool updateShouldNotify(Films _) => true; + + @override + bool updateShouldNotifyDependent(Films oldWidget, Set<_FilmsModelAspect> dependencies) { + // TODO: reduce unnecessary notifications + return true; } } diff --git a/lib/res/theme.dart b/lib/res/theme.dart index 2ae0e13..9f9d392 100644 --- a/lib/res/theme.dart +++ b/lib/res/theme.dart @@ -67,7 +67,8 @@ ThemeData themeFrom(Color primaryColor, Brightness brightness) { style: ListTileStyle.list, iconColor: scheme.onSurface, textColor: scheme.onSurface, - subtitleTextStyle: theme.textTheme.bodyMedium!.copyWith(color: scheme.onSurfaceVariant), + subtitleTextStyle: theme.textTheme.bodyMedium, + leadingAndTrailingTextStyle: theme.textTheme.bodyMedium, ), ); } diff --git a/lib/runner.dart b/lib/runner.dart index b43d7bf..9e695a0 100644 --- a/lib/runner.dart +++ b/lib/runner.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:lightmeter/application.dart'; import 'package:lightmeter/application_wrapper.dart'; import 'package:lightmeter/constants.dart'; @@ -16,7 +17,8 @@ const _errorsLogger = LightmeterAnalytics(api: LightmeterAnalyticsFirebase()); Future runLightmeterApp(Environment env) async { runZonedGuarded( () async { - WidgetsFlutterBinding.ensureInitialized(); + final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); + FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); if (env.buildType == BuildType.prod) { await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); } @@ -28,6 +30,7 @@ Future runLightmeterApp(Environment env) async { products: [ IAPProduct( storeId: IAPProductType.paidFeatures.storeId, + status: IAPProductStatus.purchased, price: '0.0\$', ), ], diff --git a/lib/screens/film_edit/bloc_film_edit.dart b/lib/screens/film_edit/bloc_film_edit.dart new file mode 100644 index 0000000..fb41886 --- /dev/null +++ b/lib/screens/film_edit/bloc_film_edit.dart @@ -0,0 +1,132 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/providers/films_provider.dart'; +import 'package:lightmeter/screens/film_edit/event_film_edit.dart'; +import 'package:lightmeter/screens/film_edit/state_film_edit.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:uuid/uuid.dart'; + +class FilmEditBloc extends Bloc { + static const _defaultFilm = FilmExponential(name: '', iso: 100, exponent: 1.3); + + final FilmsProviderState filmsProvider; + final FilmExponential _originalFilm; + FilmExponential _newFilm; + final bool _isEdit; + + factory FilmEditBloc( + FilmsProviderState filmsProvider, { + required FilmExponential? film, + required bool isEdit, + }) => + film != null + ? FilmEditBloc._( + filmsProvider, + film, + isEdit, + ) + : FilmEditBloc._( + filmsProvider, + _defaultFilm, + isEdit, + ); + + FilmEditBloc._( + this.filmsProvider, + FilmExponential film, + this._isEdit, + ) : _originalFilm = film, + _newFilm = film, + super( + FilmEditState( + name: film.name, + isoValue: IsoValue.values.firstWhere((element) => element.value == film.iso), + exponent: film.exponent, + canSave: false, + ), + ) { + on( + (event, emit) async { + switch (event) { + case final FilmEditNameChangedEvent e: + await _onNameChanged(e, emit); + case final FilmEditIsoChangedEvent e: + await _onIsoChanged(e, emit); + case final FilmEditExpChangedEvent e: + await _onExpChanged(e, emit); + case FilmEditSaveEvent(): + await _onSave(event, emit); + case FilmEditDeleteEvent(): + await _onDelete(event, emit); + } + }, + ); + } + + Future _onNameChanged(FilmEditNameChangedEvent event, Emitter emit) async { + _newFilm = _newFilm.copyWith(name: event.name); + emit( + state.copyWith( + name: event.name, + canSave: _canSave(event.name, state.exponent), + ), + ); + } + + Future _onIsoChanged(FilmEditIsoChangedEvent event, Emitter emit) async { + _newFilm = _newFilm.copyWith(iso: event.iso.value); + emit( + state.copyWith( + isoValue: event.iso, + canSave: _canSave(state.name, state.exponent), + ), + ); + } + + Future _onExpChanged(FilmEditExpChangedEvent event, Emitter emit) async { + if (event.exponent != null) { + _newFilm = _newFilm.copyWith(exponent: event.exponent); + } + emit( + FilmEditState( + name: state.name, + isoValue: state.isoValue, + exponent: event.exponent, + canSave: _canSave(state.name, event.exponent), + ), + ); + } + + Future _onSave(FilmEditSaveEvent _, Emitter emit) async { + emit(state.copyWith(isLoading: true)); + if (_isEdit) { + await filmsProvider.updateCustomFilm( + FilmExponential( + id: _originalFilm.id, + name: state.name, + iso: state.isoValue.value, + exponent: state.exponent!, + ), + ); + } else { + await filmsProvider.addCustomFilm( + FilmExponential( + id: const Uuid().v1(), + name: state.name, + iso: state.isoValue.value, + exponent: state.exponent!, + ), + ); + } + emit(state.copyWith(isLoading: false)); + } + + Future _onDelete(FilmEditDeleteEvent _, Emitter emit) async { + emit(state.copyWith(isLoading: true)); + await filmsProvider.deleteCustomFilm(_originalFilm); + emit(state.copyWith(isLoading: false)); + } + + bool _canSave(String name, double? exponent) { + return name.isNotEmpty && exponent != null && _newFilm != _originalFilm; + } +} diff --git a/lib/screens/film_edit/components/exponential_formula_input/widget_input_exponential_formula_film_edit.dart b/lib/screens/film_edit/components/exponential_formula_input/widget_input_exponential_formula_film_edit.dart new file mode 100644 index 0000000..a06dfed --- /dev/null +++ b/lib/screens/film_edit/components/exponential_formula_input/widget_input_exponential_formula_film_edit.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/shared/text_field/widget_text_field.dart'; + +class FilmEditExponentialFormulaInput extends StatefulWidget { + final double? value; + final ValueChanged onChanged; + + const FilmEditExponentialFormulaInput({ + super.key, + required this.value, + required this.onChanged, + }); + + @override + State createState() => _FilmEditExponentialFormulaInputState(); +} + +class _FilmEditExponentialFormulaInputState extends State { + TextStyle get style => + Theme.of(context).textTheme.bodyMedium!.copyWith(color: Theme.of(context).listTileTheme.textColor); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ListTile( + leading: const Icon(Icons.show_chart), + title: Text(S.of(context).filmFormula), + trailing: Text(S.of(context).filmFormulaExponential), + ), + ListTile( + leading: const SizedBox(), + title: Text( + S.of(context).filmFormulaExponentialRf, + style: Theme.of(context).listTileTheme.titleTextStyle, + ), + trailing: SizedBox( + width: _textInputWidth(context), + child: LightmeterTextField( + initialValue: widget.value?.toString() ?? '', + inputFormatters: [FilteringTextInputFormatter.allow(RegExp("[0-9.]"))], + onChanged: (value) { + widget.onChanged(double.tryParse(value)); + }, + textAlign: TextAlign.end, + style: style, + ), + ), + ), + ], + ); + } + + double _textInputWidth(BuildContext context) { + final textPainter = TextPainter( + text: TextSpan(text: widget.value.toString(), style: style), + maxLines: 1, + textDirection: TextDirection.ltr, + )..layout(); + return textPainter.maxIntrinsicWidth + Dimens.grid4; + } +} diff --git a/lib/screens/film_edit/components/iso_picker/widget_picker_iso_film_edit.dart b/lib/screens/film_edit/components/iso_picker/widget_picker_iso_film_edit.dart new file mode 100644 index 0000000..9a37c6e --- /dev/null +++ b/lib/screens/film_edit/components/iso_picker/widget_picker_iso_film_edit.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/generated/l10n.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_resources/m3_lightmeter_resources.dart'; + +class FilmEditIsoPicker extends StatelessWidget { + final IsoValue selected; + final ValueChanged onChanged; + + const FilmEditIsoPicker({ + required this.selected, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: const Icon(Icons.iso), + title: Text(S.of(context).iso), + trailing: Text(selected.value.toString()), + onTap: () { + showDialog( + context: context, + builder: (_) => Dialog( + child: DialogPicker( + icon: Icons.iso, + title: S.of(context).iso, + subtitle: S.of(context).filmSpeed, + values: IsoValue.values, + initialValue: selected, + itemTitleBuilder: (_, value) => Text(value.value.toString()), + onSelect: (value) { + onChanged(value); + Navigator.of(context).pop(); + }, + onCancel: () { + Navigator.of(context).pop(); + }, + ), + ), + ); + }, + ); + } +} diff --git a/lib/screens/film_edit/components/name_field/widget_field_name_film_edit.dart b/lib/screens/film_edit/components/name_field/widget_field_name_film_edit.dart new file mode 100644 index 0000000..84dd7ef --- /dev/null +++ b/lib/screens/film_edit/components/name_field/widget_field_name_film_edit.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/shared/text_field/widget_text_field.dart'; + +class FilmEditNameField extends StatelessWidget { + final String name; + final ValueChanged onChanged; + + const FilmEditNameField({ + required this.name, + required this.onChanged, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only( + left: Dimens.paddingM, + top: Dimens.paddingS / 2, + right: Dimens.paddingL, + bottom: Dimens.paddingS / 2, + ), + child: LightmeterTextField( + initialValue: name, + autofocus: true, + maxLength: 48, + hintText: S.of(context).name, + onChanged: onChanged, + style: Theme.of(context).listTileTheme.titleTextStyle, + leading: const Icon(Icons.edit_outlined), + ), + ); + } +} diff --git a/lib/screens/film_edit/event_film_edit.dart b/lib/screens/film_edit/event_film_edit.dart new file mode 100644 index 0000000..405168c --- /dev/null +++ b/lib/screens/film_edit/event_film_edit.dart @@ -0,0 +1,31 @@ +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +sealed class FilmEditEvent { + const FilmEditEvent(); +} + +class FilmEditNameChangedEvent extends FilmEditEvent { + final String name; + + const FilmEditNameChangedEvent(this.name); +} + +class FilmEditIsoChangedEvent extends FilmEditEvent { + final IsoValue iso; + + const FilmEditIsoChangedEvent(this.iso); +} + +class FilmEditExpChangedEvent extends FilmEditEvent { + final double? exponent; + + const FilmEditExpChangedEvent(this.exponent); +} + +class FilmEditSaveEvent extends FilmEditEvent { + const FilmEditSaveEvent(); +} + +class FilmEditDeleteEvent extends FilmEditEvent { + const FilmEditDeleteEvent(); +} diff --git a/lib/screens/film_edit/flow_film_edit.dart b/lib/screens/film_edit/flow_film_edit.dart new file mode 100644 index 0000000..556e770 --- /dev/null +++ b/lib/screens/film_edit/flow_film_edit.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/providers/films_provider.dart'; +import 'package:lightmeter/screens/film_edit/bloc_film_edit.dart'; +import 'package:lightmeter/screens/film_edit/screen_film_edit.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +class FilmEditArgs { + final FilmExponential? film; + + const FilmEditArgs({this.film}); +} + +class FilmEditFlow extends StatelessWidget { + final FilmEditArgs args; + + const FilmEditFlow({required this.args, super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => FilmEditBloc( + FilmsProvider.of(context), + film: args.film, + isEdit: args.film != null, + ), + child: FilmEditScreen(isEdit: args.film != null), + ); + } +} diff --git a/lib/screens/film_edit/screen_film_edit.dart b/lib/screens/film_edit/screen_film_edit.dart new file mode 100644 index 0000000..dbd6b80 --- /dev/null +++ b/lib/screens/film_edit/screen_film_edit.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/film_edit/bloc_film_edit.dart'; +import 'package:lightmeter/screens/film_edit/components/exponential_formula_input/widget_input_exponential_formula_film_edit.dart'; +import 'package:lightmeter/screens/film_edit/components/iso_picker/widget_picker_iso_film_edit.dart'; +import 'package:lightmeter/screens/film_edit/components/name_field/widget_field_name_film_edit.dart'; +import 'package:lightmeter/screens/film_edit/event_film_edit.dart'; +import 'package:lightmeter/screens/film_edit/state_film_edit.dart'; +import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart'; + +class FilmEditScreen extends StatefulWidget { + final bool isEdit; + + const FilmEditScreen({ + required this.isEdit, + super.key, + }); + + @override + State createState() => _FilmEditScreenState(); +} + +class _FilmEditScreenState extends State { + @override + Widget build(BuildContext context) { + return BlocConsumer( + listenWhen: (previous, current) => previous.isLoading != current.isLoading, + listener: (context, state) { + if (state.isLoading) { + FocusScope.of(context).unfocus(); + } else { + Navigator.of(context).pop(); + } + }, + buildWhen: (previous, current) => previous.isLoading != current.isLoading, + builder: (context, state) => IgnorePointer( + ignoring: state.isLoading, + child: SliverScreen( + title: Text(widget.isEdit ? S.of(context).editFilmTitle : S.of(context).addFilmTitle), + appBarActions: [ + BlocBuilder( + buildWhen: (previous, current) => previous.canSave != current.canSave, + builder: (context, state) => IconButton( + onPressed: state.canSave + ? () { + context.read().add(const FilmEditSaveEvent()); + } + : null, + icon: const Icon(Icons.save), + ), + ), + if (widget.isEdit) + IconButton( + onPressed: () { + context.read().add(const FilmEditDeleteEvent()); + }, + icon: const Icon(Icons.delete), + ), + ], + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + Dimens.paddingM, + 0, + Dimens.paddingM, + Dimens.paddingM, + ), + child: Card( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Dimens.paddingM), + child: Opacity( + opacity: state.isLoading ? Dimens.disabledOpacity : Dimens.enabledOpacity, + child: Column( + children: [ + BlocBuilder( + buildWhen: (previous, current) => previous.name != current.name, + builder: (context, state) => FilmEditNameField( + name: state.name, + onChanged: (value) { + context.read().add(FilmEditNameChangedEvent(value)); + }, + ), + ), + BlocBuilder( + buildWhen: (previous, current) => previous.isoValue != current.isoValue, + builder: (context, state) => FilmEditIsoPicker( + selected: state.isoValue, + onChanged: (value) { + context.read().add(FilmEditIsoChangedEvent(value)); + }, + ), + ), + BlocBuilder( + buildWhen: (previous, current) => previous.exponent != current.exponent, + builder: (context, state) => FilmEditExponentialFormulaInput( + value: state.exponent, + onChanged: (value) { + context.read().add(FilmEditExpChangedEvent(value)); + }, + ), + ), + ], + ), + ), + ), + ), + ), + ), + SliverToBoxAdapter(child: SizedBox(height: MediaQuery.paddingOf(context).bottom)), + ], + ), + ), + ); + } +} diff --git a/lib/screens/film_edit/state_film_edit.dart b/lib/screens/film_edit/state_film_edit.dart new file mode 100644 index 0000000..1572931 --- /dev/null +++ b/lib/screens/film_edit/state_film_edit.dart @@ -0,0 +1,32 @@ +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +class FilmEditState { + final String name; + final IsoValue isoValue; + final double? exponent; + final bool canSave; + final bool isLoading; + + const FilmEditState({ + required this.name, + required this.isoValue, + required this.exponent, + required this.canSave, + this.isLoading = false, + }); + + FilmEditState copyWith({ + String? name, + IsoValue? isoValue, + double? exponent, + bool? canSave, + bool? isLoading, + }) => + FilmEditState( + name: name ?? this.name, + isoValue: isoValue ?? this.isoValue, + exponent: exponent ?? this.exponent, + canSave: canSave ?? this.canSave, + isLoading: isLoading ?? this.isLoading, + ); +} diff --git a/lib/screens/films/screen_films.dart b/lib/screens/films/screen_films.dart new file mode 100644 index 0000000..5a11c2f --- /dev/null +++ b/lib/screens/films/screen_films.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/navigation/routes.dart'; +import 'package:lightmeter/providers/films_provider.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/film_edit/flow_film_edit.dart'; +import 'package:lightmeter/screens/shared/sliver_placeholder/widget_sliver_placeholder.dart'; +import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +class FilmsScreen extends StatefulWidget { + const FilmsScreen({super.key}); + + @override + State createState() => _FilmsScreenState(); +} + +class _FilmsScreenState extends State with SingleTickerProviderStateMixin { + late final tabController = TabController(length: 2, vsync: this); + + @override + void initState() { + super.initState(); + tabController.addListener(() { + setState(() {}); + }); + } + + @override + void dispose() { + tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SliverScreen( + title: Text(S.of(context).films), + bottom: TabBar( + controller: tabController, + tabs: [ + Tab(text: S.of(context).filmsInUse), + Tab(text: S.of(context).filmsCustom), + ], + ), + appBarActions: [ + AnimatedBuilder( + animation: tabController, + builder: (context, _) => AnimatedSwitcher( + duration: Dimens.switchDuration, + child: tabController.index == 0 + ? const SizedBox.shrink() + : IconButton( + onPressed: _addFilm, + icon: const Icon(Icons.add_outlined), + tooltip: S.of(context).tooltipAdd, + ), + ), + ), + ], + slivers: [ + if (tabController.index == 0) + _FilmsListBuilder( + films: Films.predefinedFilmsOf(context).toList(), + onFilmSelected: FilmsProvider.of(context).toggleFilm, + ) + else if (tabController.index == 1 && Films.customFilmsOf(context).isNotEmpty) + _FilmsListBuilder( + films: Films.customFilmsOf(context).toList(), + onFilmSelected: FilmsProvider.of(context).toggleFilm, + onFilmEdit: _editFilm, + ) + else + SliverPlaceholder(onTap: _addFilm), + ], + ); + } + + void _addFilm() { + Navigator.of(context).pushNamed( + NavigationRoutes.filmAddScreen.name, + arguments: const FilmEditArgs(), + ); + } + + void _editFilm(FilmExponential film) { + Navigator.of(context).pushNamed( + NavigationRoutes.filmEditScreen.name, + arguments: FilmEditArgs(film: film), + ); + } +} + +class _FilmsListBuilder extends StatelessWidget { + final List films; + final void Function(T film, bool value) onFilmSelected; + final void Function(T film)? onFilmEdit; + + const _FilmsListBuilder({ + required this.films, + required this.onFilmSelected, + this.onFilmEdit, + }); + + @override + Widget build(BuildContext context) { + return SliverList.builder( + itemCount: films.length, + itemBuilder: (_, index) => Padding( + padding: EdgeInsets.fromLTRB( + Dimens.paddingM, + index == 0 ? Dimens.paddingM : 0, + Dimens.paddingM, + index == films.length - 1 ? Dimens.paddingM + MediaQuery.paddingOf(context).bottom : 0.0, + ), + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: index == 0 ? const Radius.circular(Dimens.borderRadiusL) : Radius.zero, + bottom: index == films.length - 1 ? const Radius.circular(Dimens.borderRadiusL) : Radius.zero, + ), + ), + child: Padding( + padding: EdgeInsets.only( + top: index == 0 ? Dimens.paddingM : 0.0, + bottom: index == films.length - 1 ? Dimens.paddingM : 0.0, + ), + child: CheckboxListTile( + controlAffinity: ListTileControlAffinity.leading, + value: Films.inUseOf(context).contains(films[index]), + title: Text(films[index].name), + onChanged: (value) { + onFilmSelected(films[index], value ?? false); + }, + secondary: onFilmEdit != null + ? IconButton( + onPressed: () => onFilmEdit!(films[index]), + icon: const Icon(Icons.edit), + ) + : null, + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/lightmeter_pro/screen_lightmeter_pro.dart b/lib/screens/lightmeter_pro/screen_lightmeter_pro.dart index 35bdf83..c527f76 100644 --- a/lib/screens/lightmeter_pro/screen_lightmeter_pro.dart +++ b/lib/screens/lightmeter_pro/screen_lightmeter_pro.dart @@ -21,7 +21,7 @@ class LightmeterProScreen extends StatelessWidget { children: [ Expanded( child: SliverScreen( - title: S.of(context).proFeaturesTitle, + title: Text(S.of(context).proFeaturesTitle), slivers: [ SliverToBoxAdapter( child: Padding( diff --git a/lib/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart b/lib/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart index 6c6ccf1..e46893f 100644 --- a/lib/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart +++ b/lib/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart @@ -19,7 +19,7 @@ class FilmPicker extends StatelessWidget { selectedValue: Films.selectedOf(context), values: Films.inUseOf(context), itemTitleBuilder: (_, value) => Text(value.name.isEmpty ? S.of(context).none : value.name), - onChanged: FilmsProvider.of(context).setFilm, + onChanged: FilmsProvider.of(context).selectFilm, closedChild: ReadingValueContainer.singleValue( value: ReadingValue( label: _label(context), @@ -30,7 +30,7 @@ class FilmPicker extends StatelessWidget { } String _label(BuildContext context) { - if (Films.selectedOf(context) == const Film.other() || Films.selectedOf(context).iso == selectedIso.value) { + if (Films.selectedOf(context) == const FilmStub() || Films.selectedOf(context).iso == selectedIso.value) { return S.of(context).film; } diff --git a/lib/screens/metering/components/shared/readings_container/components/lightmeter_pro/widget_lightmeter_pro.dart b/lib/screens/metering/components/shared/readings_container/components/lightmeter_pro/widget_lightmeter_pro.dart index 245bab3..f1b77ff 100644 --- a/lib/screens/metering/components/shared/readings_container/components/lightmeter_pro/widget_lightmeter_pro.dart +++ b/lib/screens/metering/components/shared/readings_container/components/lightmeter_pro/widget_lightmeter_pro.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/navigation/routes.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart'; class LightmeterProAnimatedDialog extends StatelessWidget { @@ -9,7 +10,7 @@ class LightmeterProAnimatedDialog extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( onTap: () { - Navigator.of(context).pushNamed("lightmeterPro"); + Navigator.of(context).pushNamed(NavigationRoutes.proFeaturesScreen.name); }, child: ReadingValueContainer( color: Theme.of(context).colorScheme.secondary, diff --git a/lib/screens/metering/screen_metering.dart b/lib/screens/metering/screen_metering.dart index 12f910d..7771bf2 100644 --- a/lib/screens/metering/screen_metering.dart +++ b/lib/screens/metering/screen_metering.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lightmeter/data/models/ev_source_type.dart'; import 'package:lightmeter/data/models/exposure_pair.dart'; +import 'package:lightmeter/navigation/routes.dart'; import 'package:lightmeter/providers/equipment_profile_provider.dart'; import 'package:lightmeter/providers/services_provider.dart'; import 'package:lightmeter/providers/user_preferences_provider.dart'; @@ -36,7 +37,7 @@ class MeteringScreen extends StatelessWidget { onNdChanged: (value) => context.read().add(NdChangedEvent(value)), onExposurePairTap: (value) => pushNamed( context, - 'timer', + NavigationRoutes.timerScreen.name, arguments: TimerFlowArgs( exposurePair: value, isoValue: state.iso, @@ -55,7 +56,10 @@ class MeteringScreen extends StatelessWidget { ? UserPreferencesProvider.of(context).toggleEvSourceType : null, onMeasure: () => context.read().add(const MeasureEvent()), - onSettings: () => pushNamed(context, 'settings'), + onSettings: () => pushNamed( + context, + NavigationRoutes.settingsScreen.name, + ), ), ), ], diff --git a/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart b/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart index 3ab7ae5..93c39e6 100644 --- a/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart +++ b/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/navigation/routes.dart'; import 'package:lightmeter/res/dimens.dart'; import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; @@ -15,7 +16,7 @@ class BuyProListTile extends StatelessWidget { title: Text(S.of(context).getPro), onTap: !isPending ? () { - Navigator.of(context).pushNamed("lightmeterPro"); + Navigator.of(context).pushNamed(NavigationRoutes.proFeaturesScreen.name); } : null, trailing: isPending diff --git a/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart b/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart index b07b767..f13d4ff 100644 --- a/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart +++ b/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart @@ -4,7 +4,7 @@ import 'package:lightmeter/providers/equipment_profile_provider.dart'; import 'package:lightmeter/res/dimens.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/components/equipment_profile_name_dialog/widget_dialog_equipment_profile_name.dart'; -import 'package:lightmeter/screens/shared/icon_placeholder/widget_icon_placeholder.dart'; +import 'package:lightmeter/screens/shared/sliver_placeholder/widget_sliver_placeholder.dart'; import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; @@ -28,7 +28,7 @@ class _EquipmentProfilesScreenState extends State { @override Widget build(BuildContext context) { return SliverScreen( - title: S.of(context).equipmentProfiles, + title: Text(S.of(context).equipmentProfiles), appBarActions: [ IconButton( onPressed: _addProfile, @@ -37,12 +37,7 @@ class _EquipmentProfilesScreenState extends State { ), ], slivers: profilesCount == 1 - ? [ - SliverFillRemaining( - hasScrollBody: false, - child: _EquipmentProfilesListPlaceholder(onTap: _addProfile), - ), - ] + ? [SliverPlaceholder(onTap: _addProfile)] : [ SliverList( delegate: SliverChildBuilderDelegate( @@ -131,32 +126,3 @@ class _EquipmentProfilesScreenState extends State { } } } - -class _EquipmentProfilesListPlaceholder extends StatelessWidget { - final VoidCallback onTap; - - const _EquipmentProfilesListPlaceholder({required this.onTap}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: Dimens.sliverAppBarExpandedHeight), - child: FractionallySizedBox( - widthFactor: 1 / 1.618, - child: Center( - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(Dimens.paddingL), - child: IconPlaceholder( - icon: Icons.add_outlined, - text: S.of(context).tapToAdd, - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/screens/settings/components/metering/components/films/widget_list_tile_films.dart b/lib/screens/settings/components/metering/components/films/widget_list_tile_films.dart index 4eaf017..0cc1733 100644 --- a/lib/screens/settings/components/metering/components/films/widget_list_tile_films.dart +++ b/lib/screens/settings/components/metering/components/films/widget_list_tile_films.dart @@ -1,9 +1,7 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; -import 'package:lightmeter/providers/films_provider.dart'; -import 'package:lightmeter/screens/settings/components/shared/dialog_filter/widget_dialog_filter.dart'; +import 'package:lightmeter/navigation/routes.dart'; import 'package:lightmeter/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart'; -import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class FilmsListTile extends StatelessWidget { const FilmsListTile({super.key}); @@ -12,24 +10,8 @@ class FilmsListTile extends StatelessWidget { Widget build(BuildContext context) { return IAPListTile( leading: const Icon(Icons.camera_roll_outlined), - title: Text(S.of(context).filmsInUse), - onTap: () { - showDialog>( - context: context, - builder: (_) => DialogFilter( - icon: const Icon(Icons.camera_roll_outlined), - title: S.of(context).filmsInUse, - description: S.of(context).filmsInUseDescription, - values: Films.of(context).sublist(1), - selectedValues: Films.inUseOf(context), - titleAdapter: (_, value) => value.name, - ), - ).then((values) { - if (values != null) { - FilmsProvider.of(context).saveFilms(values); - } - }); - }, + title: Text(S.of(context).films), + onTap: () => Navigator.of(context).pushNamed(NavigationRoutes.filmsListScreen.name), ); } } diff --git a/lib/screens/settings/components/metering/components/metering_screen_layout/widget_list_tile_metering_screen_layout.dart b/lib/screens/settings/components/metering/components/metering_screen_layout/widget_list_tile_metering_screen_layout.dart index 9dfb026..f7c412b 100644 --- a/lib/screens/settings/components/metering/components/metering_screen_layout/widget_list_tile_metering_screen_layout.dart +++ b/lib/screens/settings/components/metering/components/metering_screen_layout/widget_list_tile_metering_screen_layout.dart @@ -39,7 +39,7 @@ class MeteringScreenLayoutListTile extends StatelessWidget { EquipmentProfileProvider.of(context).setProfile(EquipmentProfiles.of(context).first); } if (!value[MeteringScreenLayoutFeature.filmPicker]!) { - FilmsProvider.of(context).setFilm(const Film.other()); + FilmsProvider.of(context).selectFilm(const FilmStub()); } UserPreferencesProvider.of(context).setMeteringScreenLayout(value); }, diff --git a/lib/screens/settings/components/shared/expandable_section_list/components/dialog_section_name/widget_dialog_section_name.dart b/lib/screens/settings/components/shared/expandable_section_list/components/dialog_section_name/widget_dialog_section_name.dart new file mode 100644 index 0000000..4fa2edc --- /dev/null +++ b/lib/screens/settings/components/shared/expandable_section_list/components/dialog_section_name/widget_dialog_section_name.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; + +class ExpandableSectionNameDialog extends StatefulWidget { + final String title; + final String hint; + final String initialValue; + + const ExpandableSectionNameDialog({ + this.initialValue = '', + required this.title, + required this.hint, + super.key, + }); + + @override + State createState() => _ExpandableSectionNameDialogState(); +} + +class _ExpandableSectionNameDialogState extends State { + late final _nameController = TextEditingController(text: widget.initialValue); + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + icon: const Icon(Icons.edit_outlined), + titlePadding: Dimens.dialogIconTitlePadding, + title: Text(widget.title), + content: TextField( + autofocus: true, + controller: _nameController, + decoration: InputDecoration(hintText: widget.hint), + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text(S.of(context).cancel), + ), + ValueListenableBuilder( + valueListenable: _nameController, + builder: (_, value, __) => TextButton( + onPressed: value.text.isNotEmpty ? () => Navigator.of(context).pop(value.text) : null, + child: Text(S.of(context).save), + ), + ), + ], + ); + } +} diff --git a/lib/screens/settings/components/shared/expandable_section_list/components/expandable_section_list_item/widget_expandable_section_list_item.dart b/lib/screens/settings/components/shared/expandable_section_list/components/expandable_section_list_item/widget_expandable_section_list_item.dart new file mode 100644 index 0000000..28e1926 --- /dev/null +++ b/lib/screens/settings/components/shared/expandable_section_list/components/expandable_section_list_item/widget_expandable_section_list_item.dart @@ -0,0 +1,184 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; + +class ExpandableSectionListItem extends StatefulWidget { + final String title; + final VoidCallback onTitleTap; + final VoidCallback onExpand; + final List actions; + final List children; + + const ExpandableSectionListItem({ + required this.title, + required this.onTitleTap, + required this.onExpand, + required this.actions, + required this.children, + super.key, + }); + + static ExpandableSectionListItemState of(BuildContext context) { + return context.findAncestorStateOfType()!; + } + + @override + State createState() => ExpandableSectionListItemState(); +} + +class ExpandableSectionListItemState extends State with TickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + duration: Dimens.durationM, + vsync: this, + ); + bool get _expanded => _controller.isCompleted; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Dimens.paddingM), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM), + title: Row( + children: [ + _AnimatedNameLeading(controller: _controller), + const SizedBox(width: Dimens.grid8), + Flexible( + child: Text( + widget.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + trailing: _AnimatedArrowButton( + controller: _controller, + onPressed: () => _expanded ? collapse() : expand(), + ), + onTap: () => _expanded ? widget.onTitleTap() : expand(), + ), + _AnimatedContent( + controller: _controller, + actions: widget.actions, + children: widget.children, + ), + ], + ), + ), + ); + } + + void expand() { + widget.onExpand(); + _controller.forward(); + SchedulerBinding.instance.addPostFrameCallback((_) { + Future.delayed(_controller.duration!).then((_) { + Scrollable.ensureVisible( + context, + alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, + duration: _controller.duration!, + ); + }); + }); + } + + void collapse() { + _controller.reverse(); + } +} + +class _AnimatedNameLeading extends AnimatedWidget { + const _AnimatedNameLeading({required AnimationController controller}) : super(listenable: controller); + + Animation get _progress => listenable as Animation; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(right: _progress.value * Dimens.grid8), + child: Icon( + Icons.edit_outlined, + size: _progress.value * Dimens.grid24, + ), + ); + } +} + +class _AnimatedArrowButton extends AnimatedWidget { + final VoidCallback onPressed; + + const _AnimatedArrowButton({ + required AnimationController controller, + required this.onPressed, + }) : super(listenable: controller); + + Animation get _progress => listenable as Animation; + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onPressed, + icon: Transform.rotate( + angle: _progress.value * pi, + child: const Icon(Icons.keyboard_arrow_down_outlined), + ), + tooltip: _progress.value == 0 ? S.of(context).tooltipExpand : S.of(context).tooltipCollapse, + ); + } +} + +class _AnimatedContent extends AnimatedWidget { + final List actions; + final List children; + + const _AnimatedContent({ + required AnimationController controller, + required this.actions, + required this.children, + }) : super(listenable: controller); + + Animation get _progress => listenable as Animation; + + @override + Widget build(BuildContext context) { + return SizedOverflowBox( + alignment: Alignment.topCenter, + size: Size( + double.maxFinite, + _progress.value * Dimens.grid56 * (children.length + 1), + ), + // https://github.com/gskinnerTeam/flutter-folio/pull/62 + child: Opacity( + opacity: _progress.value, + child: Column( + children: [ + ...children, + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM), + trailing: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: actions, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/settings/components/shared/expandable_section_list/widget_expandable_section_list.dart b/lib/screens/settings/components/shared/expandable_section_list/widget_expandable_section_list.dart new file mode 100644 index 0000000..0fc5de2 --- /dev/null +++ b/lib/screens/settings/components/shared/expandable_section_list/widget_expandable_section_list.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/settings/components/shared/expandable_section_list/components/expandable_section_list_item/widget_expandable_section_list_item.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +typedef _WidgetBuilder = W Function(BuildContext context, T value); + +class ExpandableSectionList extends StatefulWidget { + final List values; + final VoidCallback onSectionTitleTap; + final _WidgetBuilder, T> contentBuilder; + final _WidgetBuilder, T> actionsBuilder; + + const ExpandableSectionList({ + required this.values, + required this.onSectionTitleTap, + required this.contentBuilder, + required this.actionsBuilder, + super.key, + }); + + @override + State createState() => _ExpandableSectionListState(); +} + +class _ExpandableSectionListState extends State> { + final Map> keysMap = {}; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _updateProfilesKeys(); + } + + @override + Widget build(BuildContext context) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final item = widget.values[index]; + return Padding( + padding: EdgeInsets.fromLTRB( + Dimens.paddingM, + index == 0 ? Dimens.paddingM : 0, + Dimens.paddingM, + Dimens.paddingM, + ), + child: ExpandableSectionListItem( + key: keysMap[item.id], + title: item.name, + onTitleTap: widget.onSectionTitleTap, + onExpand: () => _keepExpandedAt(index), + actions: widget.actionsBuilder(context, item), + children: widget.contentBuilder(context, item), + ), + ); + }, + childCount: widget.values.length, + ), + ); + } + + void _keepExpandedAt(int index) { + keysMap.values.toList().getRange(0, index).forEach((element) { + element.currentState?.collapse(); + }); + keysMap.values.toList().getRange(index + 1, keysMap.length).forEach((element) { + element.currentState?.collapse(); + }); + } + + void _updateProfilesKeys() { + if (widget.values.length > keysMap.length) { + // item added + final List idsToAdd = []; + for (final item in widget.values) { + if (!keysMap.keys.contains(item.id)) idsToAdd.add(item.id); + } + for (final id in idsToAdd) { + keysMap[id] = GlobalKey(debugLabel: id); + } + idsToAdd.clear(); + } else if (widget.values.length < keysMap.length) { + // item deleted + final List idsToDelete = []; + for (final id in keysMap.keys) { + if (!widget.values.any((p) => p.id == id)) idsToDelete.add(id); + } + idsToDelete.forEach(keysMap.remove); + idsToDelete.clear(); + } else { + // item updated, no need to updated keys + } + } +} diff --git a/lib/screens/settings/screen_settings.dart b/lib/screens/settings/screen_settings.dart index 295e01c..b153428 100644 --- a/lib/screens/settings/screen_settings.dart +++ b/lib/screens/settings/screen_settings.dart @@ -33,7 +33,7 @@ class _SettingsScreenState extends State { Widget build(BuildContext context) { return ScaffoldMessenger( child: SliverScreen( - title: S.of(context).settings, + title: Text(S.of(context).settings), slivers: [ SliverList( delegate: SliverChildListDelegate( diff --git a/lib/screens/shared/sliver_placeholder/widget_sliver_placeholder.dart b/lib/screens/shared/sliver_placeholder/widget_sliver_placeholder.dart new file mode 100644 index 0000000..a966b51 --- /dev/null +++ b/lib/screens/shared/sliver_placeholder/widget_sliver_placeholder.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/shared/icon_placeholder/widget_icon_placeholder.dart'; +import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart'; + +class SliverPlaceholder extends StatelessWidget { + final VoidCallback onTap; + + const SliverPlaceholder({required this.onTap}); + + @override + Widget build(BuildContext context) { + final sliverScreenBottomHeight = + context.findAncestorWidgetOfExactType()?.bottom?.preferredSize.height ?? 0.0; + return SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: EdgeInsets.only(bottom: Dimens.sliverAppBarExpandedHeight - sliverScreenBottomHeight), + child: FractionallySizedBox( + widthFactor: 1 / 1.618, + child: Center( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(Dimens.paddingL), + child: IconPlaceholder( + icon: Icons.add_outlined, + text: S.of(context).tapToAdd, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/shared/sliver_screen/screen_sliver.dart b/lib/screens/shared/sliver_screen/screen_sliver.dart index 4c69457..fe52771 100644 --- a/lib/screens/shared/sliver_screen/screen_sliver.dart +++ b/lib/screens/shared/sliver_screen/screen_sliver.dart @@ -1,15 +1,18 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/utils/text_height.dart'; class SliverScreen extends StatelessWidget { - final String title; + final Widget title; final List appBarActions; + final PreferredSizeWidget? bottom; final List slivers; const SliverScreen({ required this.title, this.appBarActions = const [], + this.bottom, required this.slivers, super.key, }); @@ -22,30 +25,10 @@ class SliverScreen extends StatelessWidget { bottom: false, child: CustomScrollView( slivers: [ - SliverAppBar( - pinned: true, - automaticallyImplyLeading: false, - expandedHeight: Dimens.sliverAppBarExpandedHeight, - flexibleSpace: FlexibleSpaceBar( - centerTitle: false, - titlePadding: const EdgeInsets.all(Dimens.paddingM), - title: Text( - title, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: Dimens.grid24, - ), - ), - ), - actions: [ - ...appBarActions, - if (Navigator.of(context).canPop()) - IconButton( - onPressed: Navigator.of(context).pop, - icon: const Icon(Icons.close_outlined), - tooltip: S.of(context).tooltipClose, - ), - ], + _AppBar( + title: title, + appBarActions: appBarActions, + bottom: bottom, ), ...slivers, ], @@ -54,3 +37,85 @@ class SliverScreen extends StatelessWidget { ); } } + +class _AppBar extends StatelessWidget { + final Widget title; + final List appBarActions; + final PreferredSizeWidget? bottom; + + const _AppBar({ + required this.title, + this.appBarActions = const [], + this.bottom, + }); + + @override + Widget build(BuildContext context) { + return SliverAppBar.large( + automaticallyImplyLeading: false, + expandedHeight: Dimens.sliverAppBarExpandedHeight + (bottom?.preferredSize.height ?? 0.0), + flexibleSpace: FlexibleSpaceBar( + centerTitle: false, + titlePadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM), + title: DefaultTextStyle( + style: Theme.of(context).textTheme.headlineSmall!.copyWith(color: Theme.of(context).colorScheme.onSurface), + maxLines: 2, + overflow: TextOverflow.ellipsis, + child: _Title( + actionsCount: appBarActions.length + (Navigator.of(context).canPop() ? 1 : 0), + bottomSize: bottom?.preferredSize.height ?? 0.0, + child: title, + ), + ), + ), + bottom: bottom, + actions: [ + ...appBarActions, + if (Navigator.of(context).canPop()) + IconButton( + onPressed: Navigator.of(context).pop, + icon: const Icon(Icons.close_outlined), + tooltip: S.of(context).tooltipClose, + ), + ], + ); + } +} + +class _Title extends StatelessWidget { + final Widget child; + final int actionsCount; + final double bottomSize; + final double actionsPadding; + + const _Title({ + required this.actionsCount, + required this.bottomSize, + required this.child, + }) : actionsPadding = Dimens.grid48 * actionsCount - Dimens.paddingM; + + @override + Widget build(BuildContext context) { + final settings = context.dependOnInheritedWidgetOfExactType()!; + final extentScale = + ((settings.maxExtent - settings.currentExtent) / (settings.maxExtent - settings.minExtent)).clamp(0.0, 1.0); + final titleScale = Tween(begin: 1.5, end: 1.0).transform(extentScale); + final maxFromTextToAppbar = settings.maxExtent - settings.minExtent - Dimens.paddingM; + final currentFromTextToAppbar = settings.currentExtent - settings.minExtent - Dimens.paddingM; + final actionsPaddingScale = (1 - currentFromTextToAppbar / maxFromTextToAppbar).clamp(0.0, 1.0); + + return LayoutBuilder( + builder: (context, constraints) => Padding( + padding: EdgeInsets.only(bottom: (Dimens.paddingM * (1 - actionsPaddingScale) + bottomSize) / titleScale), + child: SizedBox( + height: DefaultTextStyle.of(context).style.lineHeight * 2, + width: constraints.maxWidth - (actionsPadding * actionsPaddingScale) / titleScale, + child: Align( + alignment: FractionalOffset(0.0, 0.5 + 0.5 * (1 - extentScale)), + child: child, + ), + ), + ), + ); + } +} diff --git a/lib/screens/shared/text_field/widget_text_field.dart b/lib/screens/shared/text_field/widget_text_field.dart new file mode 100644 index 0000000..5ffae94 --- /dev/null +++ b/lib/screens/shared/text_field/widget_text_field.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class LightmeterTextField extends TextFormField { + LightmeterTextField({ + super.controller, + super.autofocus, + super.initialValue, + super.inputFormatters, + super.maxLength, + super.onChanged, + super.style, + super.textAlign, + Widget? leading, + String? hintText, + }) : super( + autovalidateMode: AutovalidateMode.onUserInteraction, + maxLines: 1, + decoration: InputDecoration( + counter: const SizedBox(), + contentPadding: EdgeInsets.zero, + errorStyle: const TextStyle(fontSize: 0), + icon: leading, + hintText: hintText, + ), + validator: (value) { + if (value == null || value.isEmpty) { + return ''; + } else { + return null; + } + }, + ); +} diff --git a/lib/utils/selectable_provider.dart b/lib/utils/selectable_provider.dart index c18f998..c7a8aa8 100644 --- a/lib/utils/selectable_provider.dart +++ b/lib/utils/selectable_provider.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; enum SelectableAspect { list, selected } + + class SelectableInheritedModel extends InheritedModel { const SelectableInheritedModel({ super.key, diff --git a/lib/utils/text_height.dart b/lib/utils/text_height.dart index 6c3afa4..3c0faee 100644 --- a/lib/utils/text_height.dart +++ b/lib/utils/text_height.dart @@ -34,3 +34,7 @@ Size textSize( )..layout(maxWidth: maxWidth); return titlePainter.size; } + +extension TextLineHeight on TextStyle { + double get lineHeight => fontSize! * height!; +} diff --git a/pubspec.yaml b/pubspec.yaml index ae67d95..ba32852 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,17 +24,18 @@ dependencies: flutter_bloc: 8.1.3 flutter_localizations: sdk: flutter + flutter_native_splash: 2.3.5 intl: 0.18.1 intl_utils: 2.8.2 light_sensor: 3.0.0 m3_lightmeter_iap: git: url: "https://github.com/vodemn/m3_lightmeter_iap" - ref: v0.11.2 + ref: v1.1.0 m3_lightmeter_resources: git: url: "https://github.com/vodemn/m3_lightmeter_resources" - ref: v1.4.0 + ref: v2.0.0 material_color_utilities: 0.5.0 package_info_plus: 4.2.0 permission_handler: 10.4.3 @@ -48,7 +49,6 @@ dev_dependencies: args: 2.5.0 bloc_test: 9.1.3 build_runner: 2.4.6 - flutter_native_splash: 2.3.5 flutter_test: sdk: flutter golden_toolkit: 0.15.0 @@ -63,10 +63,6 @@ dev_dependencies: test: 1.24.3 dependency_overrides: - m3_lightmeter_resources: - git: - url: "https://github.com/vodemn/m3_lightmeter_resources" - ref: v1.4.0 material_color_utilities: 0.11.1 flutter: diff --git a/screenshots/generate_screenshots.dart b/screenshots/generate_screenshots.dart index 13aa98d..77ad568 100644 --- a/screenshots/generate_screenshots.dart +++ b/screenshots/generate_screenshots.dart @@ -32,7 +32,7 @@ import 'models/screenshot_args.dart'; //https://stackoverflow.com/a/67186625/13167574 -const _mockFilm = Film('Ilford HP5+', 400); +const _mockFilm = FilmExponential(id: '1', name: 'Ilford HP5+', iso: 400, exponent: 1.34); final Color _lightThemeColor = primaryColorsList[5]; final Color _darkThemeColor = primaryColorsList[3]; final ThemeData _themeLight = themeFrom(_lightThemeColor, Brightness.light); @@ -92,9 +92,9 @@ void main() { testWidgets('Generate light theme screenshots', (tester) async { await mockSharedPrefs(theme: ThemeType.light, color: _lightThemeColor); await tester.pumpApplication( - availableFilms: [_mockFilm], - filmsInUse: [_mockFilm], - selectedFilm: _mockFilm, + predefinedFilms: [_mockFilm].toFilmsMap(), + customFilms: {}, + selectedFilmId: _mockFilm.id, ); await tester.takePhoto(); @@ -132,9 +132,9 @@ void main() { (tester) async { await mockSharedPrefs(theme: ThemeType.dark, color: _darkThemeColor); await tester.pumpApplication( - availableFilms: [_mockFilm], - filmsInUse: [_mockFilm], - selectedFilm: _mockFilm, + predefinedFilms: [_mockFilm].toFilmsMap(), + customFilms: {}, + selectedFilmId: _mockFilm.id, ); await tester.takePhoto(); @@ -157,9 +157,9 @@ void main() { color: _lightThemeColor, ); await tester.pumpApplication( - availableFilms: [_mockFilm], - filmsInUse: [_mockFilm], - selectedFilm: _mockFilm, + predefinedFilms: [_mockFilm].toFilmsMap(), + customFilms: {}, + selectedFilmId: _mockFilm.id, ); await tester.takePhoto(); diff --git a/test/application_mock.dart b/test/application_mock.dart index a7882cc..ff9a9ef 100644 --- a/test/application_mock.dart +++ b/test/application_mock.dart @@ -2,13 +2,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:light_sensor/light_sensor.dart'; -import 'package:lightmeter/application_wrapper.dart'; +import 'package:lightmeter/data/analytics/analytics.dart'; +import 'package:lightmeter/data/analytics/api/analytics_firebase.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/remote_config_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/remote_config_provider.dart'; +import 'package:lightmeter/providers/services_provider.dart'; import 'package:lightmeter/providers/user_preferences_provider.dart'; import 'package:lightmeter/res/theme.dart'; import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:platform/platform.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../integration_test/mocks/paid_features_mock.dart'; import '../integration_test/utils/platform_channel_mock.dart'; @@ -94,12 +106,11 @@ class _GoldenTestApplicationMockState extends State { price: '0.0\$', ), ], - child: ApplicationWrapper( - const Environment.dev(), + child: _MockApplicationWrapper( child: MockIAPProviders( equipmentProfiles: mockEquipmentProfiles, selectedEquipmentProfileId: mockEquipmentProfiles.first.id, - selectedFilm: mockFilms.first, + selectedFilmId: mockFilms.first.id, child: Builder( builder: (context) { return MaterialApp( @@ -126,3 +137,40 @@ class _GoldenTestApplicationMockState extends State { ); } } + +class _MockApplicationWrapper extends StatelessWidget { + final Widget child; + + const _MockApplicationWrapper({required this.child}); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: SharedPreferences.getInstance(), + builder: (_, snapshot) { + if (snapshot.data != null) { + final userPreferencesService = UserPreferencesService(snapshot.data!); + return ServicesProvider( + analytics: const LightmeterAnalytics(api: LightmeterAnalyticsFirebase()), + caffeineService: const CaffeineService(), + environment: const Environment.dev().copyWith(hasLightSensor: true), + hapticsService: const HapticsService(), + lightSensorService: const LightSensorService(LocalPlatform()), + permissionsService: const PermissionsService(), + userPreferencesService: userPreferencesService, + volumeEventsService: const VolumeEventsService(LocalPlatform()), + child: RemoteConfigProvider( + remoteConfigService: const MockRemoteConfigService(), + child: UserPreferencesProvider( + hasLightSensor: true, + userPreferencesService: userPreferencesService, + child: child, + ), + ), + ); + } + return const SizedBox(); + }, + ); + } +} diff --git a/test/providers/films_provider_test.dart b/test/providers/films_provider_test.dart index f898579..9f659fd 100644 --- a/test/providers/films_provider_test.dart +++ b/test/providers/films_provider_test.dart @@ -5,18 +5,29 @@ import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; import 'package:mocktail/mocktail.dart'; -class _MockIAPStorageService extends Mock implements IAPStorageService {} +class _MockFilmsStorageService extends Mock implements FilmsStorageService {} void main() { TestWidgetsFlutterBinding.ensureInitialized(); - late _MockIAPStorageService mockIAPStorageService; + late _MockFilmsStorageService mockFilmsStorageService; setUpAll(() { - mockIAPStorageService = _MockIAPStorageService(); + mockFilmsStorageService = _MockFilmsStorageService(); + }); + + setUp(() { + registerFallbackValue(mockCustomFilms.first); + when(() => mockFilmsStorageService.toggleFilm(any(), any())).thenAnswer((_) async {}); + when(() => mockFilmsStorageService.addFilm(any())).thenAnswer((_) async {}); + when(() => mockFilmsStorageService.updateFilm(any())).thenAnswer((_) async {}); + when(() => mockFilmsStorageService.deleteFilm(any())).thenAnswer((_) async {}); + when(() => mockFilmsStorageService.getPredefinedFilms()) + .thenAnswer((_) => Future.value(mockPredefinedFilms.toFilmsMap())); + when(() => mockFilmsStorageService.getCustomFilms()).thenAnswer((_) => Future.value(mockCustomFilms.toFilmsMap())); }); tearDown(() { - reset(mockIAPStorageService); + reset(mockFilmsStorageService); }); Future pumpTestWidget(WidgetTester tester, IAPProductStatus productStatus) async { @@ -30,16 +41,20 @@ void main() { ), ], child: FilmsProvider( - storageService: mockIAPStorageService, - availableFilms: mockFilms, + filmsStorageService: mockFilmsStorageService, child: const _Application(), ), ), ); + await tester.pumpAndSettle(); } - void expectFilmsCount(int count) { - expect(find.text('Films count: $count'), findsOneWidget); + void expectPredefinedFilmsCount(int count) { + expect(find.text('Predefined films count: $count'), findsOneWidget); + } + + void expectCustomFilmsCount(int count) { + expect(find.text('Custom films count: $count'), findsOneWidget); } void expectFilmsInUseCount(int count) { @@ -54,17 +69,21 @@ void main() { 'FilmsProvider dependency on IAPProductStatus', () { setUp(() { - when(() => mockIAPStorageService.selectedFilm).thenReturn(mockFilms.first); - when(() => mockIAPStorageService.filmsInUse).thenReturn(mockFilms); + when(() => mockFilmsStorageService.selectedFilmId).thenReturn(mockPredefinedFilms.first.id); + when(() => mockFilmsStorageService.getPredefinedFilms()) + .thenAnswer((_) => Future.value(mockPredefinedFilms.toFilmsMap())); + when(() => mockFilmsStorageService.getCustomFilms()) + .thenAnswer((_) => Future.value(mockCustomFilms.toFilmsMap())); }); testWidgets( 'IAPProductStatus.purchased - show all saved films', (tester) async { await pumpTestWidget(tester, IAPProductStatus.purchased); - expectFilmsCount(mockFilms.length + 1); - expectFilmsInUseCount(mockFilms.length + 1); - expectSelectedFilmName(mockFilms.first.name); + expectPredefinedFilmsCount(mockPredefinedFilms.length); + expectCustomFilmsCount(mockCustomFilms.length); + expectFilmsInUseCount(mockPredefinedFilms.length + mockCustomFilms.length + 1); + expectSelectedFilmName(mockPredefinedFilms.first.name); }, ); @@ -72,7 +91,8 @@ void main() { 'IAPProductStatus.purchasable - show only default', (tester) async { await pumpTestWidget(tester, IAPProductStatus.purchasable); - expectFilmsCount(mockFilms.length + 1); + expectPredefinedFilmsCount(0); + expectCustomFilmsCount(0); expectFilmsInUseCount(1); expectSelectedFilmName(''); }, @@ -82,7 +102,8 @@ void main() { 'IAPProductStatus.pending - show only default', (tester) async { await pumpTestWidget(tester, IAPProductStatus.pending); - expectFilmsCount(mockFilms.length + 1); + expectPredefinedFilmsCount(0); + expectCustomFilmsCount(0); expectFilmsInUseCount(1); expectSelectedFilmName(''); }, @@ -91,126 +112,126 @@ void main() { ); group( - 'FilmsProvider CRUD', + 'toggleFilm', () { testWidgets( - 'Select films in use', + 'toggle predefined film', (tester) async { - when(() => mockIAPStorageService.selectedFilm).thenReturn(const Film.other()); - when(() => mockIAPStorageService.filmsInUse).thenReturn([]); - - /// Init + when(() => mockFilmsStorageService.selectedFilmId).thenReturn(mockPredefinedFilms.first.id); await pumpTestWidget(tester, IAPProductStatus.purchased); - expectFilmsCount(mockFilms.length + 1); - expectFilmsInUseCount(1); + expectPredefinedFilmsCount(mockPredefinedFilms.length); + expectCustomFilmsCount(mockCustomFilms.length); + expectFilmsInUseCount(mockPredefinedFilms.length + mockCustomFilms.length + 1); + expectSelectedFilmName(mockPredefinedFilms.first.name); + + await tester.filmsProvider.toggleFilm(mockPredefinedFilms.first, false); + await tester.pump(); + expectPredefinedFilmsCount(mockPredefinedFilms.length); + expectCustomFilmsCount(mockCustomFilms.length); + expectFilmsInUseCount(mockPredefinedFilms.length - 1 + mockCustomFilms.length + 1); expectSelectedFilmName(''); - /// Select all filmsInUse - await tester.tap(find.byKey(_Application.saveFilmsButtonKey(0))); - await tester.pumpAndSettle(); - expectFilmsCount(mockFilms.length + 1); - expectFilmsInUseCount(mockFilms.length + 1); - expectSelectedFilmName(''); - - verify(() => mockIAPStorageService.filmsInUse = mockFilms.skip(0).toList()).called(1); - verifyNever(() => mockIAPStorageService.selectedFilm = const Film.other()); + verify(() => mockFilmsStorageService.toggleFilm(mockPredefinedFilms.first, false)).called(1); + verify(() => mockFilmsStorageService.selectedFilmId = '').called(1); }, ); testWidgets( - 'Select film', + 'toggle custom film', (tester) async { - when(() => mockIAPStorageService.selectedFilm).thenReturn(const Film.other()); - when(() => mockIAPStorageService.filmsInUse).thenReturn(mockFilms); - - /// Init + when(() => mockFilmsStorageService.selectedFilmId).thenReturn(mockCustomFilms.first.id); await pumpTestWidget(tester, IAPProductStatus.purchased); - expectFilmsCount(mockFilms.length + 1); - expectFilmsInUseCount(mockFilms.length + 1); + expectPredefinedFilmsCount(mockPredefinedFilms.length); + expectCustomFilmsCount(mockCustomFilms.length); + expectFilmsInUseCount(mockPredefinedFilms.length + mockCustomFilms.length + 1); + expectSelectedFilmName(mockCustomFilms.first.name); + + await tester.filmsProvider.toggleFilm(mockCustomFilms.first, false); + await tester.pump(); + expectPredefinedFilmsCount(mockPredefinedFilms.length); + expectCustomFilmsCount(mockCustomFilms.length); + expectFilmsInUseCount(mockPredefinedFilms.length - 1 + mockCustomFilms.length + 1); expectSelectedFilmName(''); - /// Select all filmsInUse - await tester.tap(find.byKey(_Application.setFilmButtonKey(0))); - await tester.pumpAndSettle(); - expectFilmsCount(mockFilms.length + 1); - expectFilmsInUseCount(mockFilms.length + 1); - expectSelectedFilmName(mockFilms.first.name); - - verifyNever(() => mockIAPStorageService.filmsInUse = any>()); - verify(() => mockIAPStorageService.selectedFilm = mockFilms.first).called(1); - }, - ); - - group( - 'Coming from free app', - () { - testWidgets( - 'Has selected film', - (tester) async { - when(() => mockIAPStorageService.selectedFilm).thenReturn(mockFilms[2]); - when(() => mockIAPStorageService.filmsInUse).thenReturn([]); - - /// Init - await pumpTestWidget(tester, IAPProductStatus.purchased); - expectFilmsInUseCount(1); - expectSelectedFilmName(''); - - verifyNever(() => mockIAPStorageService.filmsInUse = any>()); - verify(() => mockIAPStorageService.selectedFilm = const Film.other()).called(1); - }, - ); - - testWidgets( - 'None film selected', - (tester) async { - when(() => mockIAPStorageService.selectedFilm).thenReturn(const Film.other()); - when(() => mockIAPStorageService.filmsInUse).thenReturn([]); - - /// Init - await pumpTestWidget(tester, IAPProductStatus.purchased); - expectFilmsInUseCount(1); - expectSelectedFilmName(''); - - verifyNever(() => mockIAPStorageService.filmsInUse = any>()); - verifyNever(() => mockIAPStorageService.selectedFilm = const Film.other()); - }, - ); - }, - ); - - testWidgets( - 'Discard selected (by filmsInUse list update)', - (tester) async { - when(() => mockIAPStorageService.selectedFilm).thenReturn(mockFilms.first); - when(() => mockIAPStorageService.filmsInUse).thenReturn(mockFilms); - - /// Init - await pumpTestWidget(tester, IAPProductStatus.purchased); - expectFilmsCount(mockFilms.length + 1); - expectFilmsInUseCount(mockFilms.length + 1); - expectSelectedFilmName(mockFilms.first.name); - - /// Select all filmsInUse except the first one - await tester.tap(find.byKey(_Application.saveFilmsButtonKey(1))); - await tester.pumpAndSettle(); - expectFilmsCount(mockFilms.length + 1); - expectFilmsInUseCount((mockFilms.length - 1) + 1); - expectSelectedFilmName(''); - - verify(() => mockIAPStorageService.filmsInUse = mockFilms.skip(1).toList()).called(1); - verify(() => mockIAPStorageService.selectedFilm = const Film.other()).called(1); + verify(() => mockFilmsStorageService.toggleFilm(mockCustomFilms.first, false)).called(1); + verify(() => mockFilmsStorageService.selectedFilmId = '').called(1); }, ); }, ); + + testWidgets( + 'selectFilm', + (tester) async { + when(() => mockFilmsStorageService.selectedFilmId).thenReturn(''); + await pumpTestWidget(tester, IAPProductStatus.purchased); + expectSelectedFilmName(''); + + tester.filmsProvider.selectFilm(mockPredefinedFilms.first); + await tester.pump(); + expectSelectedFilmName(mockPredefinedFilms.first.name); + + tester.filmsProvider.selectFilm(mockCustomFilms.first); + await tester.pump(); + expectSelectedFilmName(mockCustomFilms.first.name); + + verify(() => mockFilmsStorageService.selectedFilmId = mockPredefinedFilms.first.id).called(1); + verify(() => mockFilmsStorageService.selectedFilmId = mockCustomFilms.first.id).called(1); + }, + ); + + testWidgets( + 'Custom film CRUD', + (tester) async { + when(() => mockFilmsStorageService.selectedFilmId).thenReturn(''); + when(() => mockFilmsStorageService.getCustomFilms()).thenAnswer((_) => Future.value({})); + await pumpTestWidget(tester, IAPProductStatus.purchased); + expectPredefinedFilmsCount(mockPredefinedFilms.length); + expectCustomFilmsCount(0); + expectFilmsInUseCount(mockPredefinedFilms.length + 0 + 1); + expectSelectedFilmName(''); + + await tester.filmsProvider.addCustomFilm(mockCustomFilms.first); + await tester.filmsProvider.toggleFilm(mockCustomFilms.first, true); + tester.filmsProvider.selectFilm(mockCustomFilms.first); + await tester.pump(); + expectPredefinedFilmsCount(mockPredefinedFilms.length); + expectCustomFilmsCount(1); + expectFilmsInUseCount(mockPredefinedFilms.length + 1 + 1); + expectSelectedFilmName(mockCustomFilms.first.name); + verify(() => mockFilmsStorageService.addFilm(mockCustomFilms.first)).called(1); + verify(() => mockFilmsStorageService.toggleFilm(mockCustomFilms.first, true)).called(1); + verify(() => mockFilmsStorageService.selectedFilmId = mockCustomFilms.first.id).called(1); + + const editedFilmName = 'Edited custom film 2x'; + final editedFilm = mockCustomFilms.first.copyWith(name: editedFilmName); + await tester.filmsProvider.updateCustomFilm(editedFilm); + await tester.pump(); + expectSelectedFilmName(editedFilm.name); + verify(() => mockFilmsStorageService.updateFilm(editedFilm)).called(1); + + await tester.filmsProvider.deleteCustomFilm(editedFilm); + await tester.pump(); + expectPredefinedFilmsCount(mockPredefinedFilms.length); + expectCustomFilmsCount(0); + expectFilmsInUseCount(mockPredefinedFilms.length + 0 + 1); + expectSelectedFilmName(''); + verify(() => mockFilmsStorageService.deleteFilm(editedFilm)).called(1); + verify(() => mockFilmsStorageService.selectedFilmId = '').called(1); + }, + ); +} + +extension on WidgetTester { + FilmsProviderState get filmsProvider { + final BuildContext context = element(find.byType(_Application)); + return FilmsProvider.of(context); + } } class _Application extends StatelessWidget { const _Application(); - static ValueKey saveFilmsButtonKey(int index) => ValueKey('saveFilmsButtonKey$index'); - static ValueKey setFilmButtonKey(int index) => ValueKey('setFilmButtonKey$index'); - @override Widget build(BuildContext context) { return MaterialApp( @@ -218,59 +239,30 @@ class _Application extends StatelessWidget { body: Center( child: Column( children: [ - Text("Films count: ${Films.of(context).length}"), + Text("Predefined films count: ${Films.predefinedFilmsOf(context).length}"), + Text("Custom films count: ${Films.customFilmsOf(context).length}"), Text("Films in use count: ${Films.inUseOf(context).length}"), Text("Selected film: ${Films.selectedOf(context).name}"), - _filmRow(context, 0), - _filmRow(context, 1), ], ), ), ), ); } - - Widget _filmRow(BuildContext context, int index) { - return Row( - children: [ - ElevatedButton( - key: saveFilmsButtonKey(index), - onPressed: () { - FilmsProvider.of(context).saveFilms(mockFilms.skip(index).toList()); - }, - child: const Text("Save filmsInUse"), - ), - ElevatedButton( - key: setFilmButtonKey(index), - onPressed: () { - FilmsProvider.of(context).setFilm(mockFilms[index]); - }, - child: const Text("Set film"), - ), - ], - ); - } } -const mockFilms = [_MockFilm2x(), _MockFilm3x(), _MockFilm4x()]; +const mockPredefinedFilms = [ + FilmExponential(id: '1', name: 'Mock film 2x', iso: 400, exponent: 1.34), + FilmExponential(id: '2', name: 'Mock film 3x', iso: 800, exponent: 1.34), + FilmExponential(id: '3', name: 'Mock film 4x', iso: 1200, exponent: 1.34), +]; -class _MockFilm2x extends Film { - const _MockFilm2x() : super('Mock film 2x', 400); +const mockCustomFilms = [ + FilmExponential(id: '1abc', name: 'Mock custom film 2x', iso: 400, exponent: 1.34), + FilmExponential(id: '2abc', name: 'Mock custom film 3x', iso: 800, exponent: 1.34), +]; - @override - double reciprocityFormula(double t) => t * 2; -} - -class _MockFilm3x extends Film { - const _MockFilm3x() : super('Mock film 3x', 800); - - @override - double reciprocityFormula(double t) => t * 3; -} - -class _MockFilm4x extends Film { - const _MockFilm4x() : super('Mock film 4x', 1600); - - @override - double reciprocityFormula(double t) => t * 4; +extension on List { + Map toFilmsMap() => + Map.fromEntries(map((e) => MapEntry(e.id, (film: e as T, isUsed: true)))); } diff --git a/test/screens/lightmeter_pro/goldens/lightmeter_pro_screen.png b/test/screens/lightmeter_pro/goldens/lightmeter_pro_screen.png index 7db028a..5ad139a 100644 Binary files a/test/screens/lightmeter_pro/goldens/lightmeter_pro_screen.png and b/test/screens/lightmeter_pro/goldens/lightmeter_pro_screen.png differ diff --git a/test/screens/metering/components/shared/readings_container/extreme_exposure_pairs_container_test.dart b/test/screens/metering/components/shared/readings_container/extreme_exposure_pairs_container_test.dart index ef95e1d..62ae5d0 100644 --- a/test/screens/metering/components/shared/readings_container/extreme_exposure_pairs_container_test.dart +++ b/test/screens/metering/components/shared/readings_container/extreme_exposure_pairs_container_test.dart @@ -50,9 +50,9 @@ extension WidgetTesterActions on WidgetTester { }) async { await pumpWidget( Films( - values: const [Film.other()], - filmsInUse: const [Film.other()], - selected: const Film.other(), + predefinedFilms: const {}, + customFilms: const {}, + selected: const FilmStub(), child: WidgetTestApplicationMock( child: Row( children: [ diff --git a/test/screens/metering/components/shared/readings_container/film_picker_test.dart b/test/screens/metering/components/shared/readings_container/film_picker_test.dart index 765ca51..a5bf3ee 100644 --- a/test/screens/metering/components/shared/readings_container/film_picker_test.dart +++ b/test/screens/metering/components/shared/readings_container/film_picker_test.dart @@ -10,14 +10,19 @@ import 'package:mocktail/mocktail.dart'; import '../../../../../application_mock.dart'; import 'utils.dart'; -class _MockIAPStorageService extends Mock implements IAPStorageService {} +class _MockFilmsStorageService extends Mock implements FilmsStorageService {} void main() { - late final _MockIAPStorageService mockIAPStorageService; + late final _MockFilmsStorageService mockFilmsStorageService; setUpAll(() { - mockIAPStorageService = _MockIAPStorageService(); - when(() => mockIAPStorageService.filmsInUse).thenReturn(_films); + mockFilmsStorageService = _MockFilmsStorageService(); + when(() => mockFilmsStorageService.getPredefinedFilms()).thenAnswer( + (_) => Future.value(Map.fromEntries(_films.map((e) => MapEntry(e.id, (film: e, isUsed: true))))), + ); + when(() => mockFilmsStorageService.getCustomFilms()).thenAnswer( + (_) => Future.value({}), + ); }); Future pumpApplication(WidgetTester tester) async { @@ -31,7 +36,7 @@ void main() { ), ], child: FilmsProvider( - storageService: mockIAPStorageService, + filmsStorageService: mockFilmsStorageService, child: const WidgetTestApplicationMock( child: Row( children: [ @@ -49,9 +54,9 @@ void main() { group('Film push/pull label', () { testWidgets( - 'Film.other()', + 'FilmStub()', (tester) async { - when(() => mockIAPStorageService.selectedFilm).thenReturn(const Film.other()); + when(() => mockFilmsStorageService.selectedFilmId).thenReturn(const FilmStub().id); await pumpApplication(tester); expectReadingValueContainerText(S.current.film); expectReadingValueContainerText(S.current.none); @@ -61,7 +66,7 @@ void main() { testWidgets( 'Film with the same ISO', (tester) async { - when(() => mockIAPStorageService.selectedFilm).thenReturn(_films[1]); + when(() => mockFilmsStorageService.selectedFilmId).thenReturn(_films[1].id); await pumpApplication(tester); expectReadingValueContainerText(S.current.film); expectReadingValueContainerText(_films[1].name); @@ -71,7 +76,7 @@ void main() { testWidgets( 'Film with greater ISO', (tester) async { - when(() => mockIAPStorageService.selectedFilm).thenReturn(_films[2]); + when(() => mockFilmsStorageService.selectedFilmId).thenReturn(_films[2].id); await pumpApplication(tester); expectReadingValueContainerText(S.current.filmPull); expectReadingValueContainerText(_films[2].name); @@ -81,7 +86,7 @@ void main() { testWidgets( 'Film with lower ISO', (tester) async { - when(() => mockIAPStorageService.selectedFilm).thenReturn(_films[0]); + when(() => mockFilmsStorageService.selectedFilmId).thenReturn(_films[0].id); await pumpApplication(tester); expectReadingValueContainerText(S.current.filmPush); expectReadingValueContainerText(_films[0].name); @@ -92,7 +97,7 @@ void main() { testWidgets( 'Film picker shows only films in use', (tester) async { - when(() => mockIAPStorageService.selectedFilm).thenReturn(_films[0]); + when(() => mockFilmsStorageService.selectedFilmId).thenReturn(_films[0].id); await pumpApplication(tester); await tester.openAnimatedPicker(); expectRadioListTile(S.current.none, isSelected: true); @@ -104,8 +109,8 @@ void main() { } const _films = [ - Film('ISO 100 Film', 100), - Film('ISO 400 Film', 400), - Film('ISO 800 Film', 800), - Film('ISO 1600 Film', 1600), + FilmExponential(id: '1', name: 'ISO 100 Film', iso: 100, exponent: 1.34), + FilmExponential(id: '2', name: 'ISO 400 Film', iso: 400, exponent: 1.34), + FilmExponential(id: '3', name: 'ISO 800 Film', iso: 800, exponent: 1.34), + FilmExponential(id: '4', name: 'ISO 1600 Film', iso: 1600, exponent: 1.34), ]; diff --git a/test/screens/settings/goldens/settings_screen.png b/test/screens/settings/goldens/settings_screen.png index e54b4bd..ffe5fe6 100644 Binary files a/test/screens/settings/goldens/settings_screen.png and b/test/screens/settings/goldens/settings_screen.png differ