diff --git a/iap/lib/m3_lightmeter_iap.dart b/iap/lib/m3_lightmeter_iap.dart index 003b0da..7814253 100644 --- a/iap/lib/m3_lightmeter_iap.dart +++ b/iap/lib/m3_lightmeter_iap.dart @@ -4,7 +4,8 @@ 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/equipment_profile_service.dart'; export 'src/data/films_storage_service.dart'; +export 'src/data/iap_storage_service.dart'; const List films = []; diff --git a/iap/lib/src/data/equipment_profile_service.dart b/iap/lib/src/data/equipment_profile_service.dart new file mode 100644 index 0000000..f6d8d4f --- /dev/null +++ b/iap/lib/src/data/equipment_profile_service.dart @@ -0,0 +1,29 @@ +import 'dart:async'; + +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +class EquipmentProfilesStorageService { + EquipmentProfilesStorageService(); + + Future init() async {} + + String get selectedEquipmentProfileId => ''; + set selectedEquipmentProfileId(String id) {} + + Future addProfile(EquipmentProfile profile) async {} + + Future updateProfile({ + required String id, + String? name, + List? isoValues, + List? ndValues, + List? apertureValues, + List? shutterSpeedValues, + double? lensZoom, + bool? isUsed, + }) async {} + + Future deleteProfile(String id) async {} + + Future> getProfiles() async => {}; +} diff --git a/iap/lib/src/data/films_storage_service.dart b/iap/lib/src/data/films_storage_service.dart index 9cab6b0..105afd6 100644 --- a/iap/lib/src/data/films_storage_service.dart +++ b/iap/lib/src/data/films_storage_service.dart @@ -1,16 +1,10 @@ -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) {} @@ -22,11 +16,11 @@ class FilmsStorageService { Future deleteFilm(FilmExponential _) async {} - Future>> getPredefinedFilms() async { + Future> getPredefinedFilms() async { return const {}; } - Future>> getCustomFilms() async { + Future> getCustomFilms() async { return const {}; } } diff --git a/iap/pubspec.yaml b/iap/pubspec.yaml index 56bc8e1..f6f17ab 100644 --- a/iap/pubspec.yaml +++ b/iap/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: m3_lightmeter_resources: git: url: "https://github.com/vodemn/m3_lightmeter_resources" - ref: v2.0.0 + ref: v2.1.0 shared_preferences: 2.2.0 dev_dependencies: diff --git a/integration_test/e2e_test.dart b/integration_test/e2e_test.dart index 88edb94..37f6838 100644 --- a/integration_test/e2e_test.dart +++ b/integration_test/e2e_test.dart @@ -48,8 +48,8 @@ void testE2E(String description) { description, (tester) async { await tester.pumpApplication( - equipmentProfiles: [], - predefinedFilms: mockFilms.toFilmsMap(isUsed: true), + equipmentProfiles: {}, + predefinedFilms: mockFilms.toTogglableMap(), customFilms: {}, ); @@ -58,27 +58,33 @@ void testE2E(String description) { await tester.tapDescendantTextOf(S.current.equipmentProfiles); await tester.tap(find.byIcon(Icons.add_outlined).first); await tester.pumpAndSettle(); - await tester.setProfileName(mockEquipmentProfiles[0].name); - await tester.expandEquipmentProfileContainer(mockEquipmentProfiles[0].name); - await tester.setIsoValues(0, mockEquipmentProfiles[0].isoValues); - await tester.setNdValues(0, mockEquipmentProfiles[0].ndValues); - await tester.setApertureValues(0, mockEquipmentProfiles[0].apertureValues); - await tester.setShutterSpeedValues(0, mockEquipmentProfiles[0].shutterSpeedValues); - await tester.setZoomValue(0, mockEquipmentProfiles[0].lensZoom); + await tester.enterProfileName(mockEquipmentProfiles[0].name); + await tester.setIsoValues(mockEquipmentProfiles[0].isoValues); + await tester.setNdValues(mockEquipmentProfiles[0].ndValues); + await tester.setApertureValues(mockEquipmentProfiles[0].apertureValues); + await tester.setShutterSpeedValues(mockEquipmentProfiles[0].shutterSpeedValues); + await tester.setZoomValue(mockEquipmentProfiles[0].lensZoom); expect(find.text('x1.91'), findsOneWidget); expect(find.text('f/1.7 - f/16'), findsOneWidget); expect(find.text('1/1000 - B'), findsOneWidget); + await tester.saveEdits(); /// Create Praktica + Jupiter profile from Zenitar profile + await tester.tap(find.byIcon(Icons.edit_outlined)); + await tester.pumpAndSettle(); await tester.tap(find.byIcon(Icons.copy_outlined).first); await tester.pumpAndSettle(); - await tester.setProfileName(mockEquipmentProfiles[1].name); - await tester.expandEquipmentProfileContainer(mockEquipmentProfiles[1].name); - await tester.setApertureValues(1, mockEquipmentProfiles[1].apertureValues); - await tester.setZoomValue(1, mockEquipmentProfiles[1].lensZoom); + await tester.enterProfileName(mockEquipmentProfiles[1].name); + await tester.setApertureValues(mockEquipmentProfiles[1].apertureValues); + await tester.setZoomValue(mockEquipmentProfiles[1].lensZoom); expect(find.text('x5.02'), findsOneWidget); expect(find.text('f/3.5 - f/22'), findsOneWidget); - expect(find.text('1/1000 - B'), findsNWidgets(2)); + expect(find.text('1/1000 - B'), findsNWidgets(1)); + await tester.saveEdits(); + + /// Verify that both profiles were added and leave to the main screen + expect(find.text(mockEquipmentProfiles[0].name), findsOneWidget); + expect(find.text(mockEquipmentProfiles[1].name), findsOneWidget); await tester.navigatorPop(); await tester.navigatorPop(); @@ -142,45 +148,45 @@ void testE2E(String description) { nd: 'None', ev: mockPhotoEv100 + 1, ); + + /// Delete profile + await tester.openSettings(); + await tester.tapDescendantTextOf(S.current.equipmentProfiles); + await tester.tap(find.byIcon(Icons.edit_outlined).first); + await tester.pumpAndSettle(); + await tester.deleteEdits(); + expect(find.text(mockEquipmentProfiles[0].name), findsNothing); + expect(find.text(mockEquipmentProfiles[1].name), findsOneWidget); }, ); } extension EquipmentProfileActions on WidgetTester { - Future expandEquipmentProfileContainer(String name) async { - await tap(find.text(name)); - await pump(Dimens.durationM); - } - - Future setProfileName(String name) async { + Future enterProfileName(String name) async { await enterText(find.byType(TextField), name); await pump(); - await tapSaveButton(); } - Future setIsoValues(int profileIndex, List values) => - _openAndSetDialogFilterValues(profileIndex, S.current.isoValues, values); - Future setNdValues(int profileIndex, List values) => - _openAndSetDialogFilterValues(profileIndex, S.current.ndFilters, values); + Future setIsoValues(List values) => + _openAndSetDialogFilterValues(S.current.isoValues, values); + Future setNdValues(List values) => _openAndSetDialogFilterValues(S.current.ndFilters, values); Future _openAndSetDialogFilterValues( - int profileIndex, String listTileTitle, List valuesToSelect, { bool deselectAll = true, }) async { - await tap(find.text(listTileTitle).at(profileIndex)); + await tap(find.text(listTileTitle)); await pumpAndSettle(); await setDialogFilterValues(valuesToSelect, deselectAll: deselectAll); } - Future setApertureValues(int profileIndex, List values) => - _setDialogRangePickerValues(profileIndex, S.current.apertureValues, values); + Future setApertureValues(List values) => + _setDialogRangePickerValues(S.current.apertureValues, values); - Future setShutterSpeedValues(int profileIndex, List values) => - _setDialogRangePickerValues(profileIndex, S.current.shutterSpeedValues, values); + Future setShutterSpeedValues(List values) => + _setDialogRangePickerValues(S.current.shutterSpeedValues, values); - Future setZoomValue(int profileIndex, double value) => - _setDialogSliderPickerValue(profileIndex, S.current.lensZoom, value); + Future setZoomValue(double value) => _setDialogSliderPickerValue(S.current.lensZoom, value); } extension on WidgetTester { @@ -190,6 +196,16 @@ extension on WidgetTester { await tapSelectButton(); } + Future saveEdits() async { + await tap(find.byIcon(Icons.save_outlined)); + await pumpAndSettle(Dimens.durationML); + } + + Future deleteEdits() async { + await tap(find.byIcon(Icons.delete_outlined)); + await pumpAndSettle(Dimens.durationML); + } + Future setDialogFilterValues( List valuesToSelect, { bool deselectAll = true, @@ -212,11 +228,10 @@ extension on WidgetTester { } Future _setDialogRangePickerValues( - int profileIndex, String listTileTitle, List valuesToSelect, ) async { - await tap(find.text(listTileTitle).at(profileIndex)); + await tap(find.text(listTileTitle)); await pumpAndSettle(); final dialog = widget>(find.byType(DialogRangePicker)); @@ -247,11 +262,10 @@ extension on WidgetTester { } Future _setDialogSliderPickerValue( - int profileIndex, String listTileTitle, double value, ) async { - await tap(find.text(listTileTitle).at(profileIndex)); + await tap(find.text(listTileTitle)); await pumpAndSettle(); final sliderFinder = find.byType(Slider); diff --git a/integration_test/mocks/paid_features_mock.dart b/integration_test/mocks/paid_features_mock.dart index 3748c96..b139ef1 100644 --- a/integration_test/mocks/paid_features_mock.dart +++ b/integration_test/mocks/paid_features_mock.dart @@ -5,28 +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 _MockEquipmentProfilesStorageService extends Mock implements EquipmentProfilesStorageService {} class _MockFilmsStorageService extends Mock implements FilmsStorageService {} class MockIAPProviders extends StatefulWidget { - final List? equipmentProfiles; + final TogglableMap equipmentProfiles; final String selectedEquipmentProfileId; - final Map> predefinedFilms; - final Map> customFilms; + final TogglableMap predefinedFilms; + final TogglableMap customFilms; final String selectedFilmId; final Widget child; MockIAPProviders({ - this.equipmentProfiles = const [], + TogglableMap? equipmentProfiles, this.selectedEquipmentProfileId = '', - Map>? predefinedFilms, - Map>? customFilms, + TogglableMap? predefinedFilms, + TogglableMap? customFilms, String? selectedFilmId, required this.child, super.key, - }) : predefinedFilms = predefinedFilms ?? mockFilms.toFilmsMap(), - customFilms = customFilms ?? mockFilms.toFilmsMap(), + }) : equipmentProfiles = equipmentProfiles ?? mockEquipmentProfiles.toTogglableMap(), + predefinedFilms = predefinedFilms ?? mockFilms.toTogglableMap(), + customFilms = customFilms ?? mockFilms.toTogglableMap(), selectedFilmId = selectedFilmId ?? const FilmStub().id; @override @@ -34,15 +35,27 @@ class MockIAPProviders extends StatefulWidget { } class _MockIAPProvidersState extends State { - late final _MockIAPStorageService mockIAPStorageService; + late final _MockEquipmentProfilesStorageService mockEquipmentProfilesStorageService; late final _MockFilmsStorageService mockFilmsStorageService; @override void initState() { super.initState(); - mockIAPStorageService = _MockIAPStorageService(); - when(() => mockIAPStorageService.equipmentProfiles).thenReturn(widget.equipmentProfiles ?? mockEquipmentProfiles); - when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn(widget.selectedEquipmentProfileId); + registerFallbackValue(defaultEquipmentProfile); + mockEquipmentProfilesStorageService = _MockEquipmentProfilesStorageService(); + when(() => mockEquipmentProfilesStorageService.init()).thenAnswer((_) async {}); + when(() => mockEquipmentProfilesStorageService.getProfiles()) + .thenAnswer((_) => Future.value(widget.equipmentProfiles)); + when(() => mockEquipmentProfilesStorageService.selectedEquipmentProfileId) + .thenReturn(widget.selectedEquipmentProfileId); + when(() => mockEquipmentProfilesStorageService.addProfile(any())).thenAnswer((_) async {}); + when( + () => mockEquipmentProfilesStorageService.updateProfile( + id: any(named: 'id'), + name: any(named: 'name'), + ), + ).thenAnswer((_) async {}); + when(() => mockEquipmentProfilesStorageService.deleteProfile(any())).thenAnswer((_) async {}); mockFilmsStorageService = _MockFilmsStorageService(); when(() => mockFilmsStorageService.init()).thenAnswer((_) async {}); @@ -53,10 +66,10 @@ class _MockIAPProvidersState extends State { @override Widget build(BuildContext context) { - return EquipmentProfileProvider( - storageService: mockIAPStorageService, + return EquipmentProfilesProvider( + storageService: mockEquipmentProfilesStorageService, child: FilmsProvider( - filmsStorageService: mockFilmsStorageService, + storageService: mockFilmsStorageService, child: widget.child, ), ); @@ -142,11 +155,6 @@ const mockFilms = [ _FilmMultiplying(id: '4', name: 'Mock film 4', iso: 1200, reciprocityMultiplier: 1.5), ]; -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; diff --git a/integration_test/utils/widget_tester_actions.dart b/integration_test/utils/widget_tester_actions.dart index 8ff2bb4..72c0e55 100644 --- a/integration_test/utils/widget_tester_actions.dart +++ b/integration_test/utils/widget_tester_actions.dart @@ -20,10 +20,10 @@ const mockPhotoEv100 = 8.3; extension WidgetTesterCommonActions on WidgetTester { Future pumpApplication({ IAPProductStatus productStatus = IAPProductStatus.purchased, - List? equipmentProfiles, + TogglableMap? equipmentProfiles, String selectedEquipmentProfileId = '', - Map>? predefinedFilms, - Map>? customFilms, + TogglableMap? predefinedFilms, + TogglableMap? customFilms, String selectedFilmId = '', }) async { await pumpWidget( diff --git a/lib/application.dart b/lib/application.dart index 87cdfe3..a58de01 100644 --- a/lib/application.dart +++ b/lib/application.dart @@ -7,6 +7,8 @@ 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/equipment_profile_edit/flow_equipment_profile_edit.dart'; +import 'package:lightmeter/screens/equipment_profiles/screen_equipment_profiles.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'; @@ -49,8 +51,10 @@ class Application extends StatelessWidget { routes: { NavigationRoutes.meteringScreen.name: (_) => const ReleaseNotesFlow(child: MeteringFlow()), NavigationRoutes.settingsScreen.name: (_) => const SettingsFlow(), + NavigationRoutes.equipmentProfilesListScreen.name: (_) => const EquipmentProfilesScreen(), + NavigationRoutes.equipmentProfileEditScreen.name: (context) => + EquipmentProfileEditFlow(args: context.routeArgs()), 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 7722226..611e1f7 100644 --- a/lib/application_wrapper.dart +++ b/lib/application_wrapper.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:lightmeter/data/analytics/analytics.dart'; @@ -34,11 +36,14 @@ class _ApplicationWrapperState extends State { ? const RemoteConfigService(LightmeterAnalytics(api: LightmeterAnalyticsFirebase())) : const MockRemoteConfigService(); - late final IAPStorageService iapStorageService; late final UserPreferencesService userPreferencesService; late final bool hasLightSensor; + final equipmentProfilesStorageService = EquipmentProfilesStorageService(); + final equipmentProfilesStorageServiceCompleter = Completer(); + final filmsStorageService = FilmsStorageService(); + final filmsStorageServiceCompleter = Completer(); late final Future _initFuture; @@ -46,6 +51,7 @@ class _ApplicationWrapperState extends State { void initState() { super.initState(); _initFuture = _initialize(); + _removeSplashscreen(); } @override @@ -67,11 +73,12 @@ class _ApplicationWrapperState extends State { volumeEventsService: const VolumeEventsService(LocalPlatform()), child: RemoteConfigProvider( remoteConfigService: remoteConfigService, - child: EquipmentProfileProvider( - storageService: iapStorageService, + child: EquipmentProfilesProvider( + storageService: equipmentProfilesStorageService, + onInitialized: equipmentProfilesStorageServiceCompleter.complete, child: FilmsProvider( - filmsStorageService: filmsStorageService, - onInitialized: _onFilmsProviderInitialized, + storageService: filmsStorageService, + onInitialized: filmsStorageServiceCompleter.complete, child: UserPreferencesProvider( hasLightSensor: hasLightSensor, userPreferencesService: userPreferencesService, @@ -93,16 +100,20 @@ class _ApplicationWrapperState extends State { SharedPreferences.getInstance(), const LightSensorService(LocalPlatform()).hasSensor(), remoteConfigService.activeAndFetchFeatures(), + equipmentProfilesStorageService.init(), filmsStorageService.init(), ]).then((value) { - final sharedPrefs = (value[0] as SharedPreferences?)!; - iapStorageService = IAPStorageService(sharedPrefs); - userPreferencesService = UserPreferencesService(sharedPrefs); + userPreferencesService = UserPreferencesService((value[0] as SharedPreferences?)!); hasLightSensor = value[1] as bool? ?? false; }); } - void _onFilmsProviderInitialized() { - FlutterNativeSplash.remove(); + void _removeSplashscreen() { + Future.wait([ + equipmentProfilesStorageServiceCompleter.future, + filmsStorageServiceCompleter.future, + ]).then((_) { + FlutterNativeSplash.remove(); + }); } } diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index f4404c1..28d5242 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -152,7 +152,6 @@ "close": "Close", "films": "Films", "filmsInUse": "Films in use", - "filmsInUseDescription": "Select films which you use.", "filmsCustom": "Custom films", "addFilmTitle": "Add film", "editFilmTitle": "Edit film", @@ -160,5 +159,7 @@ "filmFormulaExponential": "T=t^Rf", "filmFormulaExponentialRf": "Rf", "filmFormulaExponentialRfPlaceholder": "1.3", - "name": "Name" + "name": "Name", + "addEquipmentProfileTitle": "Add equipment", + "editEquipmentProfileTitle": "Edit equipment" } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index efa160d..f1226fe 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -67,8 +67,6 @@ "equipmentProfile": "Profil de l'équipement", "equipmentProfiles": "Profils de l'équipement", "tapToAdd": "Appuie pour ajouter", - "filmsInUse": "Films en usage", - "filmsInUseDescription": "Sélectionnez les films que vous utilisez.", "general": "Général", "keepScreenOn": "Garder l'écran allumé", "haptics": "Haptiques", @@ -142,5 +140,17 @@ "tooltipUseLightSensor": "Utiliser un capteur de lumière", "tooltipUseCamera": "Utiliser la caméra", "tooltipOpenSettings": "Ouvrir les paramètres", - "exposurePair": "Paire d'exposition" + "exposurePair": "Paire d'exposition", + "films": "Films", + "filmsInUse": "Films en usage", + "filmsCustom": "Films personnalisés", + "addFilmTitle": "Ajouter un film", + "editFilmTitle": "Editer le film", + "filmFormula": "Formule", + "filmFormulaExponential": "T=t^Rf", + "filmFormulaExponentialRf": "Rf", + "filmFormulaExponentialRfPlaceholder": "1.3", + "name": "Titre", + "addEquipmentProfileTitle": "Ajouter un profil", + "editEquipmentProfileTitle": "Editer le profil" } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index ee4559c..d9a813b 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -67,8 +67,6 @@ "equipmentProfile": "Оборудование", "equipmentProfiles": "Профили оборудования", "tapToAdd": "Нажмите, чтобы добавить", - "filmsInUse": "Используемые плёнки", - "filmsInUseDescription": "Выберите плёнки, которыми вы пользуетесь.", "general": "Общие", "keepScreenOn": "Запрет блокировки", "haptics": "Вибрация", @@ -141,5 +139,17 @@ "tooltipUseLightSensor": "Использовать датчик освещенности", "tooltipUseCamera": "Использовать камеру", "tooltipOpenSettings": "Открыть настройки", - "exposurePair": "Пара экспозиции" + "exposurePair": "Пара экспозиции", + "films": "Плёнки", + "filmsInUse": "Используемые плёнки", + "filmsCustom": "Пользовательские плёнки", + "addFilmTitle": "Добавить плёнку", + "editFilmTitle": "Редактировать плёнку", + "filmFormula": "Формула", + "filmFormulaExponential": "T=t^Rf", + "filmFormulaExponentialRf": "Rf", + "filmFormulaExponentialRfPlaceholder": "1.3", + "name": "Название", + "addEquipmentProfileTitle": "Добавить профиль", + "editEquipmentProfileTitle": "Редактировать профиль" } \ No newline at end of file diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb index d8d2732..d82d7ce 100644 --- a/lib/l10n/intl_zh.arb +++ b/lib/l10n/intl_zh.arb @@ -67,8 +67,6 @@ "equipmentProfile": "设备配置", "equipmentProfiles": "设备配置", "tapToAdd": "点击添加", - "filmsInUse": "使用的胶片", - "filmsInUseDescription": "选择你使用的胶片", "general": "通用", "keepScreenOn": "保持屏幕常亮", "haptics": "震动", @@ -140,5 +138,16 @@ "tooltipUseLightSensor": "使用光线传感器", "tooltipUseCamera": "使用摄像头", "tooltipOpenSettings": "打开设置", - "exposurePair": "曝光对" + "exposurePair": "曝光对", + "films": "Films", + "filmsInUse": "使用的胶片", + "filmsCustom": "定制胶片", + "addFilmTitle": "添加胶片", + "editFilmTitle": "编辑胶片", + "filmFormula": "计算公式", + "filmFormulaExponential": "T=t^Rf", + "filmFormulaExponentialRf": "Rf", + "filmFormulaExponentialRfPlaceholder": "1.3", + "addEquipmentProfileTitle": "添加简介", + "editEquipmentProfileTitle": "编辑个人资料" } \ No newline at end of file diff --git a/lib/navigation/routes.dart b/lib/navigation/routes.dart index 4b496f0..420d90f 100644 --- a/lib/navigation/routes.dart +++ b/lib/navigation/routes.dart @@ -1,8 +1,9 @@ enum NavigationRoutes { meteringScreen, settingsScreen, + equipmentProfilesListScreen, + equipmentProfileEditScreen, filmsListScreen, - filmAddScreen, filmEditScreen, proFeaturesScreen, timerScreen, diff --git a/lib/providers/equipment_profile_provider.dart b/lib/providers/equipment_profile_provider.dart index 74397a5..5ebcd8c 100644 --- a/lib/providers/equipment_profile_provider.dart +++ b/lib/providers/equipment_profile_provider.dart @@ -1,30 +1,11 @@ +import 'package:collection/collection.dart'; 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'; -import 'package:uuid/uuid.dart'; -class EquipmentProfileProvider extends StatefulWidget { - final IAPStorageService storageService; - final Widget child; - - const EquipmentProfileProvider({ - required this.storageService, - required this.child, - super.key, - }); - - static EquipmentProfileProviderState of(BuildContext context) { - return context.findAncestorStateOfType()!; - } - - @override - State createState() => EquipmentProfileProviderState(); -} - -class EquipmentProfileProviderState extends State { - static const EquipmentProfile _defaultProfile = EquipmentProfile( +class EquipmentProfilesProvider extends StatefulWidget { + static const EquipmentProfile defaultProfile = EquipmentProfile( id: '', name: '', apertureValues: ApertureValue.values, @@ -33,34 +14,89 @@ class EquipmentProfileProviderState extends State { isoValues: IsoValue.values, ); - List _customProfiles = []; + final EquipmentProfilesStorageService storageService; + final VoidCallback? onInitialized; + final Widget child; + + const EquipmentProfilesProvider({ + required this.storageService, + this.onInitialized, + required this.child, + super.key, + }); + + static EquipmentProfilesProviderState of(BuildContext context) { + return context.findAncestorStateOfType()!; + } + + @override + State createState() => EquipmentProfilesProviderState(); +} + +class EquipmentProfilesProviderState extends State { + final TogglableMap _customProfiles = {}; String _selectedId = ''; - EquipmentProfile get _selectedProfile => _customProfiles.firstWhere( - (e) => e.id == _selectedId, - orElse: () => _defaultProfile, - ); + EquipmentProfile get _selectedProfile => + _customProfiles[_selectedId]?.value ?? EquipmentProfilesProvider.defaultProfile; @override void initState() { super.initState(); - _selectedId = widget.storageService.selectedEquipmentProfileId; - _customProfiles = widget.storageService.equipmentProfiles; + _init(); } @override Widget build(BuildContext context) { return EquipmentProfiles( - values: [ - _defaultProfile, - if (context.isPro) ..._customProfiles, - ], - selected: context.isPro ? _selectedProfile : _defaultProfile, + profiles: context.isPro ? _customProfiles : {}, + selected: context.isPro ? _selectedProfile : EquipmentProfilesProvider.defaultProfile, child: widget.child, ); } - void setProfile(EquipmentProfile data) { + Future _init() async { + _selectedId = widget.storageService.selectedEquipmentProfileId; + _customProfiles.addAll(await widget.storageService.getProfiles()); + _discardSelectedIfNotIncluded(); + if (mounted) setState(() {}); + widget.onInitialized?.call(); + } + + Future addProfile(EquipmentProfile profile) async { + await widget.storageService.addProfile(profile); + _customProfiles[profile.id] = (value: profile, isUsed: true); + setState(() {}); + } + + Future updateProfile(EquipmentProfile profile) async { + final oldProfile = _customProfiles[profile.id]!.value; + await widget.storageService.updateProfile( + id: profile.id, + name: oldProfile.name != profile.name ? profile.name : null, + apertureValues: oldProfile.apertureValues != profile.apertureValues ? profile.apertureValues : null, + shutterSpeedValues: + oldProfile.shutterSpeedValues != profile.shutterSpeedValues ? profile.shutterSpeedValues : null, + isoValues: oldProfile.isoValues != profile.isoValues ? profile.isoValues : null, + ndValues: oldProfile.ndValues != profile.ndValues ? profile.ndValues : null, + lensZoom: oldProfile.lensZoom != profile.lensZoom ? profile.lensZoom : null, + ); + _customProfiles[profile.id] = (value: profile, isUsed: _customProfiles[profile.id]!.isUsed); + setState(() {}); + } + + Future deleteProfile(EquipmentProfile profile) async { + await widget.storageService.deleteProfile(profile.id); + if (profile.id == _selectedId) { + _selectedId = EquipmentProfilesProvider.defaultProfile.id; + widget.storageService.selectedEquipmentProfileId = EquipmentProfilesProvider.defaultProfile.id; + } + _customProfiles.remove(profile.id); + _discardSelectedIfNotIncluded(); + setState(() {}); + } + + void selectProfile(EquipmentProfile data) { if (_selectedId != data.id) { setState(() { _selectedId = data.id; @@ -69,62 +105,81 @@ class EquipmentProfileProviderState extends State { } } - /// Creates a default equipment profile - void addProfile(String name, [EquipmentProfile? copyFrom]) { - _customProfiles.add( - EquipmentProfile( - id: const Uuid().v1(), - name: name, - apertureValues: copyFrom?.apertureValues ?? ApertureValue.values, - ndValues: copyFrom?.ndValues ?? NdValue.values, - shutterSpeedValues: copyFrom?.shutterSpeedValues ?? ShutterSpeedValue.values, - isoValues: copyFrom?.isoValues ?? IsoValue.values, - ), - ); - _refreshSavedProfiles(); - } - - void updateProfile(EquipmentProfile data) { - final indexToUpdate = _customProfiles.indexWhere((element) => element.id == data.id); - if (indexToUpdate >= 0) { - _customProfiles[indexToUpdate] = data; - _refreshSavedProfiles(); + Future toggleProfile(EquipmentProfile profile, bool enabled) async { + if (_customProfiles.containsKey(profile.id)) { + _customProfiles[profile.id] = (value: profile, isUsed: enabled); + } else { + return; } - } - - void deleteProfile(EquipmentProfile data) { - if (data.id == _selectedId) { - _selectedId = _defaultProfile.id; - widget.storageService.selectedEquipmentProfileId = _defaultProfile.id; - } - _customProfiles.remove(data); - _refreshSavedProfiles(); - } - - void _refreshSavedProfiles() { - widget.storageService.equipmentProfiles = _customProfiles; + await widget.storageService.updateProfile(id: profile.id, isUsed: enabled); + _discardSelectedIfNotIncluded(); setState(() {}); } + + void _discardSelectedIfNotIncluded() { + if (_selectedId == EquipmentProfilesProvider.defaultProfile.id) { + return; + } + final isSelectedUsed = _customProfiles[_selectedId]?.isUsed ?? false; + if (!isSelectedUsed) { + _selectedId = EquipmentProfilesProvider.defaultProfile.id; + widget.storageService.selectedEquipmentProfileId = _selectedId; + } + } } -class EquipmentProfiles extends SelectableInheritedModel { +enum _EquipmentProfilesModelAspect { + profiles, + profilesInUse, + selected, +} + +class EquipmentProfiles extends InheritedModel<_EquipmentProfilesModelAspect> { + final TogglableMap profiles; + final EquipmentProfile selected; + const EquipmentProfiles({ - super.key, - required super.values, - required super.selected, + required this.profiles, + required this.selected, required super.child, }); - /// [_defaultProfile] + profiles created by the user + /// _default + profiles create by the user static List of(BuildContext context) { - return InheritedModel.inheritFrom(context, aspect: SelectableAspect.list)!.values; + final model = + InheritedModel.inheritFrom(context, aspect: _EquipmentProfilesModelAspect.profiles)!; + return List.from( + [ + EquipmentProfilesProvider.defaultProfile, + ...model.profiles.values.map((p) => p.value), + ], + ); + } + + static List inUseOf(BuildContext context) { + final model = + InheritedModel.inheritFrom(context, aspect: _EquipmentProfilesModelAspect.profilesInUse)!; + return List.from( + [ + EquipmentProfilesProvider.defaultProfile, + ...model.profiles.values.where((p) => p.isUsed).map((p) => p.value), + ], + ); } static EquipmentProfile selectedOf(BuildContext context) { - return InheritedModel.inheritFrom( - context, - aspect: SelectableAspect.selected, - )! + return InheritedModel.inheritFrom(context, aspect: _EquipmentProfilesModelAspect.selected)! .selected; } + + @override + bool updateShouldNotify(EquipmentProfiles _) => true; + + @override + bool updateShouldNotifyDependent(EquipmentProfiles oldWidget, Set<_EquipmentProfilesModelAspect> dependencies) { + return (dependencies.contains(_EquipmentProfilesModelAspect.selected) && oldWidget.selected != selected) || + ((dependencies.contains(_EquipmentProfilesModelAspect.profiles) || + dependencies.contains(_EquipmentProfilesModelAspect.profilesInUse)) && + const DeepCollectionEquality().equals(oldWidget.profiles, profiles)); + } } diff --git a/lib/providers/films_provider.dart b/lib/providers/films_provider.dart index 428fb8e..6f804ed 100644 --- a/lib/providers/films_provider.dart +++ b/lib/providers/films_provider.dart @@ -5,12 +5,12 @@ import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class FilmsProvider extends StatefulWidget { - final FilmsStorageService filmsStorageService; + final FilmsStorageService storageService; final VoidCallback? onInitialized; final Widget child; const FilmsProvider({ - required this.filmsStorageService, + required this.storageService, this.onInitialized, required this.child, super.key, @@ -25,11 +25,11 @@ class FilmsProvider extends StatefulWidget { } class FilmsProviderState extends State { - final Map> predefinedFilms = {}; - final Map> customFilms = {}; + final TogglableMap predefinedFilms = {}; + final TogglableMap customFilms = {}; String _selectedId = ''; - Film get _selectedFilm => customFilms[_selectedId]?.film ?? predefinedFilms[_selectedId]?.film ?? const FilmStub(); + Film get _selectedFilm => customFilms[_selectedId]?.value ?? predefinedFilms[_selectedId]?.value ?? const FilmStub(); @override void initState() { @@ -48,9 +48,9 @@ class FilmsProviderState extends State { } Future _init() async { - _selectedId = widget.filmsStorageService.selectedFilmId; - predefinedFilms.addAll(await widget.filmsStorageService.getPredefinedFilms()); - customFilms.addAll(await widget.filmsStorageService.getCustomFilms()); + _selectedId = widget.storageService.selectedFilmId; + predefinedFilms.addAll(await widget.storageService.getPredefinedFilms()); + customFilms.addAll(await widget.storageService.getCustomFilms()); _discardSelectedIfNotIncluded(); if (mounted) setState(() {}); widget.onInitialized?.call(); @@ -60,13 +60,13 @@ class FilmsProviderState extends State { Future toggleFilm(Film film, bool enabled) async { if (predefinedFilms.containsKey(film.id)) { - predefinedFilms[film.id] = (film: film, isUsed: enabled); + predefinedFilms[film.id] = (value: film, isUsed: enabled); } else if (customFilms.containsKey(film.id)) { - customFilms[film.id] = (film: film as FilmExponential, isUsed: enabled); + customFilms[film.id] = (value: film as FilmExponential, isUsed: enabled); } else { return; } - await widget.filmsStorageService.toggleFilm(film, enabled); + await widget.storageService.toggleFilm(film, enabled); _discardSelectedIfNotIncluded(); setState(() {}); } @@ -74,7 +74,7 @@ class FilmsProviderState extends State { void selectFilm(Film film) { if (_selectedFilm != film) { _selectedId = film.id; - widget.filmsStorageService.selectedFilmId = _selectedId; + widget.storageService.selectedFilmId = _selectedId; setState(() {}); } } @@ -83,19 +83,19 @@ class FilmsProviderState extends State { Future addCustomFilm(FilmExponential film) async { // ignore: avoid_redundant_argument_values - await widget.filmsStorageService.addFilm(film, isUsed: true); - customFilms[film.id] = (film: film, isUsed: true); + await widget.storageService.addFilm(film, isUsed: true); + customFilms[film.id] = (value: film, isUsed: true); setState(() {}); } Future updateCustomFilm(FilmExponential film) async { - await widget.filmsStorageService.updateFilm(film); - customFilms[film.id] = (film: film, isUsed: customFilms[film.id]!.isUsed); + await widget.storageService.updateFilm(film); + customFilms[film.id] = (value: film, isUsed: customFilms[film.id]!.isUsed); setState(() {}); } Future deleteCustomFilm(FilmExponential film) async { - await widget.filmsStorageService.deleteFilm(film); + await widget.storageService.deleteFilm(film); customFilms.remove(film.id); _discardSelectedIfNotIncluded(); setState(() {}); @@ -108,7 +108,7 @@ class FilmsProviderState extends State { final isSelectedUsed = predefinedFilms[_selectedId]?.isUsed ?? customFilms[_selectedId]?.isUsed ?? false; if (!isSelectedUsed) { _selectedId = const FilmStub().id; - widget.filmsStorageService.selectedFilmId = _selectedId; + widget.storageService.selectedFilmId = _selectedId; } } } @@ -121,10 +121,10 @@ enum _FilmsModelAspect { } class Films extends InheritedModel<_FilmsModelAspect> { - final Map> predefinedFilms; + final TogglableMap predefinedFilms; @protected - final Map> customFilms; + final TogglableMap customFilms; final Film selected; const Films({ @@ -138,7 +138,7 @@ class Films extends InheritedModel<_FilmsModelAspect> { return InheritedModel.inheritFrom(context, aspect: _FilmsModelAspect.predefinedFilms)! .predefinedFilms .values - .map((value) => value.film) + .map((value) => value.value) .toList(); } @@ -146,7 +146,7 @@ class Films extends InheritedModel<_FilmsModelAspect> { return InheritedModel.inheritFrom(context, aspect: _FilmsModelAspect.customFilms)! .customFilms .values - .map((value) => value.film) + .map((value) => value.value) .toList(); } @@ -155,8 +155,8 @@ class Films extends InheritedModel<_FilmsModelAspect> { 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), + ...model.customFilms.values.where((e) => e.isUsed).map((e) => e.value), + ...model.predefinedFilms.values.where((e) => e.isUsed).map((e) => e.value), ]; } diff --git a/lib/screens/equipment_profile_edit/bloc_equipment_profile_edit.dart b/lib/screens/equipment_profile_edit/bloc_equipment_profile_edit.dart new file mode 100644 index 0000000..83ed0dd --- /dev/null +++ b/lib/screens/equipment_profile_edit/bloc_equipment_profile_edit.dart @@ -0,0 +1,158 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/providers/equipment_profile_provider.dart'; +import 'package:lightmeter/screens/equipment_profile_edit/event_equipment_profile_edit.dart'; +import 'package:lightmeter/screens/equipment_profile_edit/state_equipment_profile_edit.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:uuid/uuid.dart'; + +class EquipmentProfileEditBloc extends Bloc { + final EquipmentProfilesProviderState profilesProvider; + final EquipmentProfile _originalEquipmentProfile; + EquipmentProfile _newEquipmentProfile; + final bool _isEdit; + + factory EquipmentProfileEditBloc( + EquipmentProfilesProviderState profilesProvider, { + required EquipmentProfile? profile, + required bool isEdit, + }) => + profile != null + ? EquipmentProfileEditBloc._( + profilesProvider, + profile, + isEdit, + ) + : EquipmentProfileEditBloc._( + profilesProvider, + EquipmentProfilesProvider.defaultProfile, + isEdit, + ); + + EquipmentProfileEditBloc._( + this.profilesProvider, + EquipmentProfile profile, + this._isEdit, + ) : _originalEquipmentProfile = profile, + _newEquipmentProfile = profile, + super( + EquipmentProfileEditState( + name: profile.name, + apertureValues: profile.apertureValues, + shutterSpeedValues: profile.shutterSpeedValues, + isoValues: profile.isoValues, + ndValues: profile.ndValues, + lensZoom: profile.lensZoom, + canSave: false, + ), + ) { + on( + (event, emit) async { + switch (event) { + case final EquipmentProfileNameChangedEvent e: + await _onNameChanged(e, emit); + case final EquipmentProfileApertureValuesChangedEvent e: + await _onApertureValuesChanged(e, emit); + case final EquipmentProfileShutterSpeedValuesChangedEvent e: + await _onShutterSpeedValuesChanged(e, emit); + case final EquipmentProfileIsoValuesChangedEvent e: + await _onIsoValuesChanged(e, emit); + case final EquipmentProfileNdValuesChangedEvent e: + await _onNdValuesChanged(e, emit); + case final EquipmentProfileLensZoomChangedEvent e: + await _onLensZoomChanged(e, emit); + case EquipmentProfileSaveEvent(): + await _onSave(event, emit); + case EquipmentProfileCopyEvent(): + await _onCopy(event, emit); + case EquipmentProfileDeleteEvent(): + await _onDelete(event, emit); + } + }, + ); + } + + Future _onNameChanged(EquipmentProfileNameChangedEvent event, Emitter emit) async { + _newEquipmentProfile = _newEquipmentProfile.copyWith(name: event.name); + emit( + state.copyWith( + name: event.name, + canSave: _canSave(event.name, state.lensZoom), + ), + ); + } + + Future _onApertureValuesChanged(EquipmentProfileApertureValuesChangedEvent event, Emitter emit) async { + _newEquipmentProfile = _newEquipmentProfile.copyWith(apertureValues: event.apertureValues); + emit(state.copyWith(apertureValues: event.apertureValues)); + } + + Future _onShutterSpeedValuesChanged(EquipmentProfileShutterSpeedValuesChangedEvent event, Emitter emit) async { + _newEquipmentProfile = _newEquipmentProfile.copyWith(shutterSpeedValues: event.shutterSpeedValues); + emit(state.copyWith(shutterSpeedValues: event.shutterSpeedValues)); + } + + Future _onIsoValuesChanged(EquipmentProfileIsoValuesChangedEvent event, Emitter emit) async { + _newEquipmentProfile = _newEquipmentProfile.copyWith(isoValues: event.isoValues); + emit(state.copyWith(isoValues: event.isoValues)); + } + + Future _onNdValuesChanged(EquipmentProfileNdValuesChangedEvent event, Emitter emit) async { + _newEquipmentProfile = _newEquipmentProfile.copyWith(ndValues: event.ndValues); + emit(state.copyWith(ndValues: event.ndValues)); + } + + Future _onLensZoomChanged(EquipmentProfileLensZoomChangedEvent event, Emitter emit) async { + _newEquipmentProfile = _newEquipmentProfile.copyWith(lensZoom: event.lensZoom); + emit( + state.copyWith( + lensZoom: event.lensZoom, + canSave: _canSave(state.name, event.lensZoom), + ), + ); + } + + Future _onSave(EquipmentProfileSaveEvent _, Emitter emit) async { + emit(state.copyWith(isLoading: true)); + if (_isEdit) { + await profilesProvider.updateProfile( + EquipmentProfile( + id: _originalEquipmentProfile.id, + name: state.name, + apertureValues: state.apertureValues, + ndValues: state.ndValues, + shutterSpeedValues: state.shutterSpeedValues, + isoValues: state.isoValues, + lensZoom: state.lensZoom, + ), + ); + } else { + await profilesProvider.addProfile( + EquipmentProfile( + id: const Uuid().v1(), + name: state.name, + apertureValues: state.apertureValues, + ndValues: state.ndValues, + shutterSpeedValues: state.shutterSpeedValues, + isoValues: state.isoValues, + lensZoom: state.lensZoom, + ), + ); + } + emit(state.copyWith(isLoading: false)); + } + + Future _onCopy(EquipmentProfileCopyEvent _, Emitter emit) async { + emit(state.copyWith(isLoading: true)); + emit(state.copyWith(isLoading: false, profileToCopy: _originalEquipmentProfile)); + } + + Future _onDelete(EquipmentProfileDeleteEvent _, Emitter emit) async { + emit(state.copyWith(isLoading: true)); + await profilesProvider.deleteProfile(_newEquipmentProfile); + emit(state.copyWith(isLoading: false)); + } + + bool _canSave(String name, double? lensZoom) { + return name.isNotEmpty && lensZoom != null && _newEquipmentProfile != _originalEquipmentProfile; + } +} diff --git a/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_container/components/filter_list_tile/widget_list_tile_filter.dart b/lib/screens/equipment_profile_edit/components/filter_list_tile/widget_list_tile_filter.dart similarity index 100% rename from lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_container/components/filter_list_tile/widget_list_tile_filter.dart rename to lib/screens/equipment_profile_edit/components/filter_list_tile/widget_list_tile_filter.dart diff --git a/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_container/components/range_picker_list_tile/widget_list_tile_range_picker.dart b/lib/screens/equipment_profile_edit/components/range_picker_list_tile/widget_list_tile_range_picker.dart similarity index 100% rename from lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_container/components/range_picker_list_tile/widget_list_tile_range_picker.dart rename to lib/screens/equipment_profile_edit/components/range_picker_list_tile/widget_list_tile_range_picker.dart diff --git a/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_container/components/slider_picker_list_tile/widget_list_tile_slider_picker.dart b/lib/screens/equipment_profile_edit/components/slider_picker_list_tile/widget_list_tile_slider_picker.dart similarity index 100% rename from lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_container/components/slider_picker_list_tile/widget_list_tile_slider_picker.dart rename to lib/screens/equipment_profile_edit/components/slider_picker_list_tile/widget_list_tile_slider_picker.dart diff --git a/lib/screens/equipment_profile_edit/event_equipment_profile_edit.dart b/lib/screens/equipment_profile_edit/event_equipment_profile_edit.dart new file mode 100644 index 0000000..a441ef7 --- /dev/null +++ b/lib/screens/equipment_profile_edit/event_equipment_profile_edit.dart @@ -0,0 +1,53 @@ +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +sealed class EquipmentProfileEditEvent { + const EquipmentProfileEditEvent(); +} + +class EquipmentProfileNameChangedEvent extends EquipmentProfileEditEvent { + final String name; + + const EquipmentProfileNameChangedEvent(this.name); +} + +class EquipmentProfileIsoValuesChangedEvent extends EquipmentProfileEditEvent { + final List isoValues; + + const EquipmentProfileIsoValuesChangedEvent(this.isoValues); +} + +class EquipmentProfileNdValuesChangedEvent extends EquipmentProfileEditEvent { + final List ndValues; + + const EquipmentProfileNdValuesChangedEvent(this.ndValues); +} + +class EquipmentProfileApertureValuesChangedEvent extends EquipmentProfileEditEvent { + final List apertureValues; + + const EquipmentProfileApertureValuesChangedEvent(this.apertureValues); +} + +class EquipmentProfileShutterSpeedValuesChangedEvent extends EquipmentProfileEditEvent { + final List shutterSpeedValues; + + const EquipmentProfileShutterSpeedValuesChangedEvent(this.shutterSpeedValues); +} + +class EquipmentProfileLensZoomChangedEvent extends EquipmentProfileEditEvent { + final double lensZoom; + + const EquipmentProfileLensZoomChangedEvent(this.lensZoom); +} + +class EquipmentProfileSaveEvent extends EquipmentProfileEditEvent { + const EquipmentProfileSaveEvent(); +} + +class EquipmentProfileCopyEvent extends EquipmentProfileEditEvent { + const EquipmentProfileCopyEvent(); +} + +class EquipmentProfileDeleteEvent extends EquipmentProfileEditEvent { + const EquipmentProfileDeleteEvent(); +} diff --git a/lib/screens/equipment_profile_edit/flow_equipment_profile_edit.dart b/lib/screens/equipment_profile_edit/flow_equipment_profile_edit.dart new file mode 100644 index 0000000..94db81a --- /dev/null +++ b/lib/screens/equipment_profile_edit/flow_equipment_profile_edit.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/providers/equipment_profile_provider.dart'; +import 'package:lightmeter/screens/equipment_profile_edit/bloc_equipment_profile_edit.dart'; +import 'package:lightmeter/screens/equipment_profile_edit/screen_equipment_profile_edit.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +enum EquipmentProfileEditType { add, edit } + +class EquipmentProfileEditArgs { + final EquipmentProfileEditType editType; + final EquipmentProfile? profile; + + const EquipmentProfileEditArgs({required this.editType, this.profile}); +} + +class EquipmentProfileEditFlow extends StatelessWidget { + final EquipmentProfileEditArgs args; + final bool _isEdit; + + EquipmentProfileEditFlow({ + required this.args, + super.key, + }) : _isEdit = args.editType == EquipmentProfileEditType.edit; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => EquipmentProfileEditBloc( + EquipmentProfilesProvider.of(context), + profile: args.profile, + isEdit: _isEdit, + ), + child: EquipmentProfileEditScreen(isEdit: _isEdit), + ); + } +} diff --git a/lib/screens/equipment_profile_edit/screen_equipment_profile_edit.dart b/lib/screens/equipment_profile_edit/screen_equipment_profile_edit.dart new file mode 100644 index 0000000..6d6ce2b --- /dev/null +++ b/lib/screens/equipment_profile_edit/screen_equipment_profile_edit.dart @@ -0,0 +1,257 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/navigation/routes.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/equipment_profile_edit/bloc_equipment_profile_edit.dart'; +import 'package:lightmeter/screens/equipment_profile_edit/components/filter_list_tile/widget_list_tile_filter.dart'; +import 'package:lightmeter/screens/equipment_profile_edit/components/range_picker_list_tile/widget_list_tile_range_picker.dart'; +import 'package:lightmeter/screens/equipment_profile_edit/components/slider_picker_list_tile/widget_list_tile_slider_picker.dart'; +import 'package:lightmeter/screens/equipment_profile_edit/event_equipment_profile_edit.dart'; +import 'package:lightmeter/screens/equipment_profile_edit/flow_equipment_profile_edit.dart'; +import 'package:lightmeter/screens/equipment_profile_edit/state_equipment_profile_edit.dart'; +import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart'; +import 'package:lightmeter/screens/shared/text_field/widget_text_field.dart'; +import 'package:lightmeter/utils/double_to_zoom.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +class EquipmentProfileEditScreen extends StatefulWidget { + final bool isEdit; + + const EquipmentProfileEditScreen({ + required this.isEdit, + super.key, + }); + + @override + State createState() => _EquipmentProfileEditScreenState(); +} + +class _EquipmentProfileEditScreenState 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 { + if (state.profileToCopy != null) { + Navigator.of(context).pushReplacementNamed( + NavigationRoutes.equipmentProfileEditScreen.name, + arguments: EquipmentProfileEditArgs( + editType: EquipmentProfileEditType.add, + profile: state.profileToCopy, + ), + ); + } 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).editEquipmentProfileTitle : S.of(context).addEquipmentProfileTitle), + appBarActions: [ + BlocBuilder( + buildWhen: (previous, current) => previous.canSave != current.canSave, + builder: (context, state) => IconButton( + onPressed: state.canSave + ? () { + context.read().add(const EquipmentProfileSaveEvent()); + } + : null, + icon: const Icon(Icons.save_outlined), + ), + ), + if (widget.isEdit) + BlocBuilder( + buildWhen: (previous, current) => previous.canSave != current.canSave, + builder: (context, state) => IconButton( + onPressed: state.canSave + ? null + : () { + context.read().add(const EquipmentProfileCopyEvent()); + }, + icon: const Icon(Icons.copy_outlined), + ), + ), + if (widget.isEdit) + IconButton( + onPressed: () { + context.read().add(const EquipmentProfileDeleteEvent()); + }, + icon: const Icon(Icons.delete_outlined), + ), + ], + 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: const Column( + children: [ + _NameFieldBuilder(), + _IsoValuesListTileBuilder(), + _NdValuesListTileBuilder(), + _ApertureValuesListTileBuilder(), + _ShutterSpeedValuesListTileBuilder(), + _LensZoomListTileBuilder(), + ], + ), + ), + ), + ), + ), + ), + SliverToBoxAdapter(child: SizedBox(height: MediaQuery.paddingOf(context).bottom)), + ], + ), + ), + ); + } +} + +class _NameFieldBuilder extends StatelessWidget { + const _NameFieldBuilder(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => Padding( + padding: const EdgeInsets.only( + left: Dimens.paddingM, + top: Dimens.paddingS / 2, + right: Dimens.paddingL, + bottom: Dimens.paddingS / 2, + ), + child: LightmeterTextField( + initialValue: state.name, + autofocus: true, + maxLength: 48, + hintText: S.of(context).name, + style: Theme.of(context).listTileTheme.titleTextStyle, + leading: const Icon(Icons.edit_outlined), + onChanged: (value) { + context.read().add(EquipmentProfileNameChangedEvent(value)); + }, + ), + ), + ); + } +} + +class _IsoValuesListTileBuilder extends StatelessWidget { + const _IsoValuesListTileBuilder(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => FilterListTile( + icon: Icons.iso_outlined, + title: S.of(context).isoValues, + description: S.of(context).isoValuesFilterDescription, + values: IsoValue.values, + selectedValues: state.isoValues, + onChanged: (value) { + context.read().add(EquipmentProfileIsoValuesChangedEvent(value)); + }, + ), + ); + } +} + +class _NdValuesListTileBuilder extends StatelessWidget { + const _NdValuesListTileBuilder(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => FilterListTile( + icon: Icons.filter_b_and_w_outlined, + title: S.of(context).ndFilters, + description: S.of(context).ndFiltersFilterDescription, + values: NdValue.values, + selectedValues: state.ndValues, + onChanged: (value) { + context.read().add(EquipmentProfileNdValuesChangedEvent(value)); + }, + ), + ); + } +} + +class _ApertureValuesListTileBuilder extends StatelessWidget { + const _ApertureValuesListTileBuilder(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => RangePickerListTile( + icon: Icons.camera_outlined, + title: S.of(context).apertureValues, + description: S.of(context).apertureValuesFilterDescription, + values: ApertureValue.values, + selectedValues: state.apertureValues, + onChanged: (value) { + context.read().add(EquipmentProfileApertureValuesChangedEvent(value)); + }, + ), + ); + } +} + +class _ShutterSpeedValuesListTileBuilder extends StatelessWidget { + const _ShutterSpeedValuesListTileBuilder(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => RangePickerListTile( + icon: Icons.shutter_speed_outlined, + title: S.of(context).shutterSpeedValues, + description: S.of(context).shutterSpeedValuesFilterDescription, + values: ShutterSpeedValue.values, + selectedValues: state.shutterSpeedValues, + trailingAdapter: (context, value) => + value.value == 1 ? S.of(context).shutterSpeedManualShort : value.toString(), + dialogValueAdapter: (context, value) => value.value == 1 ? S.of(context).shutterSpeedManual : value.toString(), + onChanged: (value) { + context.read().add(EquipmentProfileShutterSpeedValuesChangedEvent(value)); + }, + ), + ); + } +} + +class _LensZoomListTileBuilder extends StatelessWidget { + const _LensZoomListTileBuilder(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => SliderPickerListTile( + icon: Icons.zoom_in_outlined, + title: S.of(context).lensZoom, + description: S.of(context).lensZoomDescription, + value: state.lensZoom, + range: const RangeValues(1, 7), + valueAdapter: (_, value) => value.toZoom(), + onChanged: (value) { + context.read().add(EquipmentProfileLensZoomChangedEvent(value)); + }, + ), + ); + } +} diff --git a/lib/screens/equipment_profile_edit/state_equipment_profile_edit.dart b/lib/screens/equipment_profile_edit/state_equipment_profile_edit.dart new file mode 100644 index 0000000..5002a84 --- /dev/null +++ b/lib/screens/equipment_profile_edit/state_equipment_profile_edit.dart @@ -0,0 +1,48 @@ +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +class EquipmentProfileEditState { + final String name; + final List apertureValues; + final List ndValues; + final List shutterSpeedValues; + final List isoValues; + final double lensZoom; + final bool canSave; + final bool isLoading; + final EquipmentProfile? profileToCopy; + + const EquipmentProfileEditState({ + required this.name, + required this.apertureValues, + required this.ndValues, + required this.shutterSpeedValues, + required this.isoValues, + required this.lensZoom, + required this.canSave, + this.isLoading = false, + this.profileToCopy, + }); + + EquipmentProfileEditState copyWith({ + String? name, + List? apertureValues, + List? ndValues, + List? shutterSpeedValues, + List? isoValues, + double? lensZoom, + bool? canSave, + bool? isLoading, + EquipmentProfile? profileToCopy, + }) => + EquipmentProfileEditState( + name: name ?? this.name, + apertureValues: apertureValues ?? this.apertureValues, + ndValues: ndValues ?? this.ndValues, + shutterSpeedValues: shutterSpeedValues ?? this.shutterSpeedValues, + isoValues: isoValues ?? this.isoValues, + lensZoom: lensZoom ?? this.lensZoom, + canSave: canSave ?? this.canSave, + isLoading: isLoading ?? this.isLoading, + profileToCopy: profileToCopy ?? this.profileToCopy, + ); +} diff --git a/lib/screens/equipment_profiles/screen_equipment_profiles.dart b/lib/screens/equipment_profiles/screen_equipment_profiles.dart new file mode 100644 index 0000000..fc46b4f --- /dev/null +++ b/lib/screens/equipment_profiles/screen_equipment_profiles.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/navigation/routes.dart'; +import 'package:lightmeter/providers/equipment_profile_provider.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/equipment_profile_edit/flow_equipment_profile_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 EquipmentProfilesScreen extends StatefulWidget { + const EquipmentProfilesScreen({super.key}); + + @override + State createState() => _EquipmentProfilesScreenState(); +} + +class _EquipmentProfilesScreenState extends State with SingleTickerProviderStateMixin { + @override + Widget build(BuildContext context) { + return SliverScreen( + title: Text(S.of(context).equipmentProfiles), + appBarActions: [ + IconButton( + onPressed: _addProfile, + icon: const Icon(Icons.add_outlined), + tooltip: S.of(context).tooltipAdd, + ), + ], + slivers: [ + if (EquipmentProfiles.of(context).length > 1) + _EquipmentProfilesListBuilder( + values: EquipmentProfiles.of(context).skip(1).toList(), + onEdit: _editProfile, + onCheckbox: EquipmentProfilesProvider.of(context).toggleProfile, + ) + else + SliverPlaceholder(onTap: _addProfile), + ], + ); + } + + void _addProfile() { + Navigator.of(context).pushNamed( + NavigationRoutes.equipmentProfileEditScreen.name, + arguments: const EquipmentProfileEditArgs(editType: EquipmentProfileEditType.add), + ); + } + + void _editProfile(EquipmentProfile profile) { + Navigator.of(context).pushNamed( + NavigationRoutes.equipmentProfileEditScreen.name, + arguments: EquipmentProfileEditArgs( + editType: EquipmentProfileEditType.edit, + profile: profile, + ), + ); + } +} + +class _EquipmentProfilesListBuilder extends StatelessWidget { + final List values; + final void Function(EquipmentProfile film) onEdit; + final void Function(EquipmentProfile film, bool value) onCheckbox; + + const _EquipmentProfilesListBuilder({ + required this.values, + required this.onEdit, + required this.onCheckbox, + }); + + @override + Widget build(BuildContext context) { + return SliverList.builder( + itemCount: values.length, + itemBuilder: (_, index) => Padding( + padding: EdgeInsets.fromLTRB( + Dimens.paddingM, + index == 0 ? Dimens.paddingM : 0, + Dimens.paddingM, + index == values.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 == values.length - 1 ? const Radius.circular(Dimens.borderRadiusL) : Radius.zero, + ), + ), + child: Padding( + padding: EdgeInsets.only( + top: index == 0 ? Dimens.paddingM : 0.0, + bottom: index == values.length - 1 ? Dimens.paddingM : 0.0, + ), + child: CheckboxListTile( + title: Text(values[index].name), + controlAffinity: ListTileControlAffinity.leading, + value: EquipmentProfiles.inUseOf(context).contains(values[index]), + onChanged: (value) => onCheckbox(values[index], value ?? false), + secondary: IconButton( + onPressed: () => onEdit(values[index]), + icon: const Icon(Icons.edit_outlined), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/film_edit/screen_film_edit.dart b/lib/screens/film_edit/screen_film_edit.dart index dbd6b80..2c7fe7a 100644 --- a/lib/screens/film_edit/screen_film_edit.dart +++ b/lib/screens/film_edit/screen_film_edit.dart @@ -48,7 +48,7 @@ class _FilmEditScreenState extends State { context.read().add(const FilmEditSaveEvent()); } : null, - icon: const Icon(Icons.save), + icon: const Icon(Icons.save_outlined), ), ), if (widget.isEdit) @@ -56,7 +56,7 @@ class _FilmEditScreenState extends State { onPressed: () { context.read().add(const FilmEditDeleteEvent()); }, - icon: const Icon(Icons.delete), + icon: const Icon(Icons.delete_outlined), ), ], slivers: [ diff --git a/lib/screens/films/screen_films.dart b/lib/screens/films/screen_films.dart index 5a11c2f..f553456 100644 --- a/lib/screens/films/screen_films.dart +++ b/lib/screens/films/screen_films.dart @@ -78,7 +78,7 @@ class _FilmsScreenState extends State with SingleTickerProviderStat void _addFilm() { Navigator.of(context).pushNamed( - NavigationRoutes.filmAddScreen.name, + NavigationRoutes.filmEditScreen.name, arguments: const FilmEditArgs(), ); } @@ -135,7 +135,7 @@ class _FilmsListBuilder extends StatelessWidget { secondary: onFilmEdit != null ? IconButton( onPressed: () => onFilmEdit!(films[index]), - icon: const Icon(Icons.edit), + icon: const Icon(Icons.edit_outlined), ) : null, ), diff --git a/lib/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart b/lib/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart index d82cdf4..4930c46 100644 --- a/lib/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart +++ b/lib/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart @@ -14,9 +14,9 @@ class EquipmentProfilePicker extends StatelessWidget { icon: Icons.camera_outlined, title: S.of(context).equipmentProfile, selectedValue: EquipmentProfiles.selectedOf(context), - values: EquipmentProfiles.of(context), + values: EquipmentProfiles.inUseOf(context), itemTitleBuilder: (_, value) => Text(value.id.isEmpty ? S.of(context).none : value.name), - onChanged: EquipmentProfileProvider.of(context).setProfile, + onChanged: EquipmentProfilesProvider.of(context).selectProfile, closedChild: ReadingValueContainer.singleValue( value: ReadingValue( label: S.of(context).equipmentProfile, diff --git a/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_container/widget_container_equipment_profile.dart b/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_container/widget_container_equipment_profile.dart deleted file mode 100644 index 72ba238..0000000 --- a/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_container/widget_container_equipment_profile.dart +++ /dev/null @@ -1,310 +0,0 @@ -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'; -import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_container/components/filter_list_tile/widget_list_tile_filter.dart'; -import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_container/components/range_picker_list_tile/widget_list_tile_range_picker.dart'; -import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_container/components/slider_picker_list_tile/widget_list_tile_slider_picker.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/utils/double_to_zoom.dart'; -import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; - -class EquipmentProfileContainer extends StatefulWidget { - final EquipmentProfile data; - final ValueChanged onUpdate; - final VoidCallback onCopy; - final VoidCallback onDelete; - final VoidCallback onExpand; - - const EquipmentProfileContainer({ - required this.data, - required this.onUpdate, - required this.onCopy, - required this.onDelete, - required this.onExpand, - super.key, - }); - - @override - State createState() => EquipmentProfileContainerState(); -} - -class EquipmentProfileContainerState extends State with TickerProviderStateMixin { - late EquipmentProfile _equipmentData = EquipmentProfile( - id: widget.data.id, - name: widget.data.name, - apertureValues: widget.data.apertureValues, - ndValues: widget.data.ndValues, - shutterSpeedValues: widget.data.shutterSpeedValues, - isoValues: widget.data.isoValues, - lensZoom: widget.data.lensZoom, - ); - - late final AnimationController _controller = AnimationController( - duration: Dimens.durationM, - vsync: this, - ); - bool get _expanded => _controller.isCompleted; - - @override - void didUpdateWidget(EquipmentProfileContainer oldWidget) { - super.didUpdateWidget(oldWidget); - _equipmentData = EquipmentProfile( - id: widget.data.id, - name: widget.data.name, - apertureValues: widget.data.apertureValues, - ndValues: widget.data.ndValues, - shutterSpeedValues: widget.data.shutterSpeedValues, - isoValues: widget.data.isoValues, - lensZoom: widget.data.lensZoom, - ); - } - - @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( - _equipmentData.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - trailing: _AnimatedArrowButton( - controller: _controller, - onPressed: () => _expanded ? collapse() : expand(), - ), - onTap: () => _expanded ? _showNameDialog() : expand(), - ), - _AnimatedEquipmentListTiles( - controller: _controller, - equipmentData: _equipmentData, - onApertureValuesSelected: (value) { - _equipmentData = _equipmentData.copyWith(apertureValues: value); - widget.onUpdate(_equipmentData); - }, - onIsoValuesSelecred: (value) { - _equipmentData = _equipmentData.copyWith(isoValues: value); - widget.onUpdate(_equipmentData); - }, - onNdValuesSelected: (value) { - _equipmentData = _equipmentData.copyWith(ndValues: value); - widget.onUpdate(_equipmentData); - }, - onShutterSpeedValuesSelected: (value) { - _equipmentData = _equipmentData.copyWith(shutterSpeedValues: value); - widget.onUpdate(_equipmentData); - }, - onLensZoomChanged: (value) { - _equipmentData = _equipmentData.copyWith(lensZoom: value); - widget.onUpdate(_equipmentData); - }, - onCopy: widget.onCopy, - onDelete: widget.onDelete, - ), - ], - ), - ), - ); - } - - void _showNameDialog() { - showDialog( - context: context, - builder: (_) => EquipmentProfileNameDialog(initialValue: _equipmentData.name), - ).then((value) { - if (value != null) { - _equipmentData = _equipmentData.copyWith(name: value); - widget.onUpdate(_equipmentData); - } - }); - } - - 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 _AnimatedEquipmentListTiles extends AnimatedWidget { - final EquipmentProfile equipmentData; - final ValueChanged> onApertureValuesSelected; - final ValueChanged> onIsoValuesSelecred; - final ValueChanged> onNdValuesSelected; - final ValueChanged> onShutterSpeedValuesSelected; - final ValueChanged onLensZoomChanged; - final VoidCallback onCopy; - final VoidCallback onDelete; - - const _AnimatedEquipmentListTiles({ - required AnimationController controller, - required this.equipmentData, - required this.onApertureValuesSelected, - required this.onIsoValuesSelecred, - required this.onNdValuesSelected, - required this.onShutterSpeedValuesSelected, - required this.onLensZoomChanged, - required this.onCopy, - required this.onDelete, - }) : 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 * 6, - ), - // https://github.com/gskinnerTeam/flutter-folio/pull/62 - child: Opacity( - opacity: _progress.value, - child: Column( - children: [ - FilterListTile( - icon: Icons.iso_outlined, - title: S.of(context).isoValues, - description: S.of(context).isoValuesFilterDescription, - values: IsoValue.values, - selectedValues: equipmentData.isoValues, - onChanged: onIsoValuesSelecred, - ), - FilterListTile( - icon: Icons.filter_b_and_w_outlined, - title: S.of(context).ndFilters, - description: S.of(context).ndFiltersFilterDescription, - values: NdValue.values, - selectedValues: equipmentData.ndValues, - onChanged: onNdValuesSelected, - ), - RangePickerListTile( - icon: Icons.camera_outlined, - title: S.of(context).apertureValues, - description: S.of(context).apertureValuesFilterDescription, - values: ApertureValue.values, - selectedValues: equipmentData.apertureValues, - onChanged: onApertureValuesSelected, - ), - RangePickerListTile( - icon: Icons.shutter_speed_outlined, - title: S.of(context).shutterSpeedValues, - description: S.of(context).shutterSpeedValuesFilterDescription, - values: ShutterSpeedValue.values, - selectedValues: equipmentData.shutterSpeedValues, - onChanged: onShutterSpeedValuesSelected, - trailingAdapter: (context, value) => - value.value == 1 ? S.of(context).shutterSpeedManualShort : value.toString(), - dialogValueAdapter: (context, value) => - value.value == 1 ? S.of(context).shutterSpeedManual : value.toString(), - ), - SliderPickerListTile( - icon: Icons.zoom_in_outlined, - title: S.of(context).lensZoom, - description: S.of(context).lensZoomDescription, - value: equipmentData.lensZoom, - range: const RangeValues(1, 7), - onChanged: onLensZoomChanged, - valueAdapter: (_, value) => value.toZoom(), - ), - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM), - trailing: Row( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: onCopy, - icon: const Icon(Icons.copy_outlined), - tooltip: S.of(context).tooltipCopy, - ), - IconButton( - onPressed: onDelete, - icon: const Icon(Icons.delete_outlined), - tooltip: S.of(context).tooltipDelete, - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_name_dialog/widget_dialog_equipment_profile_name.dart b/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_name_dialog/widget_dialog_equipment_profile_name.dart deleted file mode 100644 index fa6556b..0000000 --- a/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_name_dialog/widget_dialog_equipment_profile_name.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:lightmeter/generated/l10n.dart'; -import 'package:lightmeter/res/dimens.dart'; - -class EquipmentProfileNameDialog extends StatefulWidget { - final String initialValue; - - const EquipmentProfileNameDialog({this.initialValue = '', super.key}); - - @override - State createState() => _EquipmentProfileNameDialogState(); -} - -class _EquipmentProfileNameDialogState 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(S.of(context).equipmentProfileName), - content: TextField( - autofocus: true, - controller: _nameController, - decoration: InputDecoration(hintText: S.of(context).equipmentProfileNameHint), - ), - 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/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 deleted file mode 100644 index f13d4ff..0000000 --- a/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:lightmeter/generated/l10n.dart'; -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/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 EquipmentProfilesScreen extends StatefulWidget { - const EquipmentProfilesScreen({super.key}); - - @override - State createState() => _EquipmentProfilesScreenState(); -} - -class _EquipmentProfilesScreenState extends State { - final Map> keysMap = {}; - int get profilesCount => keysMap.length; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _updateProfilesKeys(); - } - - @override - Widget build(BuildContext context) { - return SliverScreen( - title: Text(S.of(context).equipmentProfiles), - appBarActions: [ - IconButton( - onPressed: _addProfile, - icon: const Icon(Icons.add_outlined), - tooltip: S.of(context).tooltipAdd, - ), - ], - slivers: profilesCount == 1 - ? [SliverPlaceholder(onTap: _addProfile)] - : [ - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (index == 0) { - // skip default profile - return const SizedBox.shrink(); - } - - final profile = EquipmentProfiles.of(context)[index]; - return Padding( - padding: EdgeInsets.fromLTRB( - Dimens.paddingM, - index == 0 ? Dimens.paddingM : 0, - Dimens.paddingM, - Dimens.paddingM, - ), - child: EquipmentProfileContainer( - key: keysMap[profile.id], - data: profile, - onExpand: () => _keepExpandedAt(index), - onUpdate: _updateProfileAt, - onCopy: () => _addProfile(profile), - onDelete: () => _removeProfileAt(profile), - ), - ); - }, - childCount: EquipmentProfiles.of(context).length, - ), - ), - SliverToBoxAdapter(child: SizedBox(height: MediaQuery.paddingOf(context).bottom)), - ], - ); - } - - void _addProfile([EquipmentProfile? copyFrom]) { - showDialog( - context: context, - builder: (_) => const EquipmentProfileNameDialog(), - ).then((name) { - if (name != null) { - EquipmentProfileProvider.of(context).addProfile(name, copyFrom); - } - }); - } - - void _updateProfileAt(EquipmentProfile data) { - EquipmentProfileProvider.of(context).updateProfile(data); - } - - void _removeProfileAt(EquipmentProfile data) { - EquipmentProfileProvider.of(context).deleteProfile(data); - } - - void _keepExpandedAt(int index) { - keysMap.values.toList().getRange(0, index).forEach((element) { - element.currentState?.collapse(); - }); - keysMap.values.toList().getRange(index + 1, profilesCount).forEach((element) { - element.currentState?.collapse(); - }); - } - - void _updateProfilesKeys() { - final profiles = EquipmentProfiles.of(context); - if (profiles.length > keysMap.length) { - // profile added - final List idsToAdd = []; - for (final profile in profiles) { - if (!keysMap.keys.contains(profile.id)) idsToAdd.add(profile.id); - } - for (final id in idsToAdd) { - keysMap[id] = GlobalKey(debugLabel: id); - } - idsToAdd.clear(); - } else if (profiles.length < keysMap.length) { - // profile deleted - final List idsToDelete = []; - for (final id in keysMap.keys) { - if (!profiles.any((p) => p.id == id)) idsToDelete.add(id); - } - idsToDelete.forEach(keysMap.remove); - idsToDelete.clear(); - } else { - // profile updated, no need to updated keys - } - } -} diff --git a/lib/screens/settings/components/metering/components/equipment_profiles/widget_list_tile_equipment_profiles.dart b/lib/screens/settings/components/metering/components/equipment_profiles/widget_list_tile_equipment_profiles.dart index 610dd59..1c8c6db 100644 --- a/lib/screens/settings/components/metering/components/equipment_profiles/widget_list_tile_equipment_profiles.dart +++ b/lib/screens/settings/components/metering/components/equipment_profiles/widget_list_tile_equipment_profiles.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; -import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.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 EquipmentProfilesListTile extends StatelessWidget { const EquipmentProfilesListTile({super.key}); @@ -13,9 +12,7 @@ class EquipmentProfilesListTile extends StatelessWidget { leading: const Icon(Icons.camera_outlined), title: Text(S.of(context).equipmentProfiles), onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const EquipmentProfilesScreen()), - ); + Navigator.of(context).pushNamed(NavigationRoutes.equipmentProfilesListScreen.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 f7c412b..14bf0fb 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 @@ -36,7 +36,7 @@ class MeteringScreenLayoutListTile extends StatelessWidget { }, onSave: (value) { if (!value[MeteringScreenLayoutFeature.equipmentProfiles]!) { - EquipmentProfileProvider.of(context).setProfile(EquipmentProfiles.of(context).first); + EquipmentProfilesProvider.of(context).selectProfile(EquipmentProfiles.of(context).first); } if (!value[MeteringScreenLayoutFeature.filmPicker]!) { FilmsProvider.of(context).selectFilm(const FilmStub()); diff --git a/pubspec.yaml b/pubspec.yaml index 3a09662..3d203bb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,11 +32,11 @@ dependencies: m3_lightmeter_iap: git: url: "https://github.com/vodemn/m3_lightmeter_iap" - ref: v1.1.1 + ref: v2.1.0 m3_lightmeter_resources: git: url: "https://github.com/vodemn/m3_lightmeter_resources" - ref: v2.0.0 + ref: v2.1.0 material_color_utilities: 0.5.0 package_info_plus: 4.2.0 permission_handler: 10.4.3 diff --git a/screenshots/generate_screenshots.dart b/screenshots/generate_screenshots.dart index 77ad568..a47b9e9 100644 --- a/screenshots/generate_screenshots.dart +++ b/screenshots/generate_screenshots.dart @@ -15,10 +15,10 @@ import 'package:lightmeter/data/shared_prefs_service.dart'; import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/res/dimens.dart'; import 'package:lightmeter/res/theme.dart'; +import 'package:lightmeter/screens/equipment_profiles/screen_equipment_profiles.dart'; import 'package:lightmeter/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart'; import 'package:lightmeter/screens/metering/screen_metering.dart'; -import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart'; import 'package:lightmeter/screens/settings/screen_settings.dart'; import 'package:lightmeter/screens/shared/animated_circular_button/widget_button_circular_animated.dart'; import 'package:lightmeter/screens/timer/screen_timer.dart'; @@ -92,7 +92,7 @@ void main() { testWidgets('Generate light theme screenshots', (tester) async { await mockSharedPrefs(theme: ThemeType.light, color: _lightThemeColor); await tester.pumpApplication( - predefinedFilms: [_mockFilm].toFilmsMap(), + predefinedFilms: [_mockFilm].toTogglableMap(), customFilms: {}, selectedFilmId: _mockFilm.id, ); @@ -132,7 +132,7 @@ void main() { (tester) async { await mockSharedPrefs(theme: ThemeType.dark, color: _darkThemeColor); await tester.pumpApplication( - predefinedFilms: [_mockFilm].toFilmsMap(), + predefinedFilms: [_mockFilm].toTogglableMap(), customFilms: {}, selectedFilmId: _mockFilm.id, ); @@ -157,7 +157,7 @@ void main() { color: _lightThemeColor, ); await tester.pumpApplication( - predefinedFilms: [_mockFilm].toFilmsMap(), + predefinedFilms: [_mockFilm].toTogglableMap(), customFilms: {}, selectedFilmId: _mockFilm.id, ); diff --git a/test/application_mock.dart b/test/application_mock.dart index ff9a9ef..8475389 100644 --- a/test/application_mock.dart +++ b/test/application_mock.dart @@ -108,7 +108,6 @@ class _GoldenTestApplicationMockState extends State { ], child: _MockApplicationWrapper( child: MockIAPProviders( - equipmentProfiles: mockEquipmentProfiles, selectedEquipmentProfileId: mockEquipmentProfiles.first.id, selectedFilmId: mockFilms.first.id, child: Builder( diff --git a/test/providers/equipment_profile_provider_test.dart b/test/providers/equipment_profile_provider_test.dart index 15472dc..1b57171 100644 --- a/test/providers/equipment_profile_provider_test.dart +++ b/test/providers/equipment_profile_provider_test.dart @@ -5,14 +5,28 @@ 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 _MockEquipmentProfilesStorageService extends Mock implements EquipmentProfilesStorageService {} void main() { TestWidgetsFlutterBinding.ensureInitialized(); - late _MockIAPStorageService storageService; + late _MockEquipmentProfilesStorageService storageService; setUpAll(() { - storageService = _MockIAPStorageService(); + storageService = _MockEquipmentProfilesStorageService(); + }); + + setUp(() { + registerFallbackValue(_customProfiles.first); + when(() => storageService.addProfile(any())).thenAnswer((_) async {}); + when( + () => storageService.updateProfile( + id: any(named: 'id'), + name: any(named: 'name'), + isUsed: any(named: 'isUsed'), + ), + ).thenAnswer((_) async {}); + when(() => storageService.deleteProfile(any())).thenAnswer((_) async {}); + when(() => storageService.getProfiles()).thenAnswer((_) => Future.value(_customProfiles.toTogglableMap())); }); tearDown(() { @@ -29,35 +43,40 @@ void main() { price: '0.0\$', ), ], - child: EquipmentProfileProvider( + child: EquipmentProfilesProvider( storageService: storageService, child: const _Application(), ), ), ); + await tester.pumpAndSettle(); } void expectEquipmentProfilesCount(int count) { - expect(find.text('Equipment profiles count: $count'), findsOneWidget); + expect(find.text(_EquipmentProfilesCount.text(count)), findsOneWidget); + } + + void expectEquipmentProfilesInUseCount(int count) { + expect(find.text(_EquipmentProfilesInUseCount.text(count)), findsOneWidget); } void expectSelectedEquipmentProfileName(String name) { - expect(find.text('Selected equipment profile: $name'), findsOneWidget); + expect(find.text(_SelectedEquipmentProfile.text(name)), findsOneWidget); } group( - 'EquipmentProfileProvider dependency on IAPProductStatus', + 'EquipmentProfilesProvider dependency on IAPProductStatus', () { setUp(() { when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles.first.id); - when(() => storageService.equipmentProfiles).thenReturn(_customProfiles); + when(() => storageService.getProfiles()).thenAnswer((_) => Future.value(_customProfiles.toTogglableMap())); }); testWidgets( 'IAPProductStatus.purchased - show all saved profiles', (tester) async { await pumpTestWidget(tester, IAPProductStatus.purchased); - expectEquipmentProfilesCount(3); + expectEquipmentProfilesCount(_customProfiles.length + 1); expectSelectedEquipmentProfileName(_customProfiles.first.name); }, ); @@ -82,203 +101,137 @@ void main() { }, ); - group('EquipmentProfileProvider CRUD', () { - testWidgets( - 'Add', - (tester) async { - when(() => storageService.equipmentProfiles).thenReturn([]); - when(() => storageService.selectedEquipmentProfileId).thenReturn(''); + testWidgets( + 'toggleProfile', + (tester) async { + when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles.first.id); + await pumpTestWidget(tester, IAPProductStatus.purchased); + expectEquipmentProfilesCount(_customProfiles.length + 1); + expectEquipmentProfilesInUseCount(_customProfiles.length + 1); + expectSelectedEquipmentProfileName(_customProfiles.first.name); - await pumpTestWidget(tester, IAPProductStatus.purchased); - expectEquipmentProfilesCount(1); - expectSelectedEquipmentProfileName(''); + await tester.equipmentProfilesProvider.toggleProfile(_customProfiles.first, false); + await tester.pump(); + expectEquipmentProfilesCount(_customProfiles.length + 1); + expectEquipmentProfilesInUseCount(_customProfiles.length + 1 - 1); + expectSelectedEquipmentProfileName(''); - await tester.tap(find.byKey(_Application.addProfileButtonKey)); - await tester.pump(); - expectEquipmentProfilesCount(2); - expectSelectedEquipmentProfileName(''); + verify(() => storageService.updateProfile(id: _customProfiles.first.id, isUsed: false)).called(1); + verify(() => storageService.selectedEquipmentProfileId = '').called(1); + }, + ); - verifyNever(() => storageService.selectedEquipmentProfileId = ''); - verify(() => storageService.equipmentProfiles = any>()).called(1); - }, - ); + testWidgets( + 'EquipmentProfilesProvider CRUD', + (tester) async { + when(() => storageService.getProfiles()).thenAnswer((_) async => {}); + when(() => storageService.selectedEquipmentProfileId).thenReturn(''); - testWidgets( - 'Add from', - (tester) async { - when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles)); - when(() => storageService.selectedEquipmentProfileId).thenReturn(''); + await pumpTestWidget(tester, IAPProductStatus.purchased); + expectEquipmentProfilesCount(1); + expectSelectedEquipmentProfileName(''); - await pumpTestWidget(tester, IAPProductStatus.purchased); - expectEquipmentProfilesCount(3); - expectSelectedEquipmentProfileName(''); + /// Add first profile and verify + await tester.equipmentProfilesProvider.addProfile(_customProfiles.first); + await tester.pump(); + expectEquipmentProfilesCount(2); + expectSelectedEquipmentProfileName(''); + verify(() => storageService.addProfile(any())).called(1); - await tester.tap(find.byKey(_Application.addFromProfileButtonKey(_customProfiles[0].id))); - await tester.pump(); - expectEquipmentProfilesCount(4); - expectSelectedEquipmentProfileName(''); + /// Add the other profiles and select the 1st one + for (final profile in _customProfiles.skip(1)) { + await tester.equipmentProfilesProvider.addProfile(profile); + } + tester.equipmentProfilesProvider.selectProfile(_customProfiles.first); + await tester.pump(); + expectEquipmentProfilesCount(1 + _customProfiles.length); + expectSelectedEquipmentProfileName(_customProfiles.first.name); - verifyNever(() => storageService.selectedEquipmentProfileId = ''); - verify(() => storageService.equipmentProfiles = any>()).called(1); - }, - ); + /// Edit the selected profile + final updatedName = "${_customProfiles.first} updated"; + await tester.equipmentProfilesProvider.updateProfile(_customProfiles.first.copyWith(name: updatedName)); + await tester.pump(); + expectEquipmentProfilesCount(1 + _customProfiles.length); + expectSelectedEquipmentProfileName(updatedName); + verify(() => storageService.updateProfile(id: _customProfiles.first.id, name: updatedName)).called(1); - testWidgets( - 'Edit selected', - (tester) async { - when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles)); - when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id); + /// Delete a non-selected profile + await tester.equipmentProfilesProvider.deleteProfile(_customProfiles.last); + await tester.pump(); + expectEquipmentProfilesCount(1 + _customProfiles.length - 1); + expectSelectedEquipmentProfileName(updatedName); + verifyNever(() => storageService.selectedEquipmentProfileId = ''); + verify(() => storageService.deleteProfile(_customProfiles.last.id)).called(1); - await pumpTestWidget(tester, IAPProductStatus.purchased); + /// Delete the selected profile + await tester.equipmentProfilesProvider.deleteProfile(_customProfiles.first); + await tester.pump(); + expectEquipmentProfilesCount(1 + _customProfiles.length - 2); + expectSelectedEquipmentProfileName(''); + verify(() => storageService.selectedEquipmentProfileId = '').called(1); + verify(() => storageService.deleteProfile(_customProfiles.first.id)).called(1); + }, + ); +} - /// Change the name & limit ISO values of the both added profiles - await tester.tap(find.byKey(_Application.updateProfileButtonKey(_customProfiles[0].id))); - await tester.pumpAndSettle(); - expectEquipmentProfilesCount(3); - expectSelectedEquipmentProfileName("${_customProfiles[0].name} updated"); - - verifyNever(() => storageService.selectedEquipmentProfileId = _customProfiles[0].id); - verify(() => storageService.equipmentProfiles = any>()).called(1); - }, - ); - - testWidgets( - 'Delete selected', - (tester) async { - when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles)); - when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id); - - await pumpTestWidget(tester, IAPProductStatus.purchased); - expectEquipmentProfilesCount(3); - expectSelectedEquipmentProfileName(_customProfiles[0].name); - - /// Delete the selected profile - await tester.tap(find.byKey(_Application.deleteProfileButtonKey(_customProfiles[0].id))); - await tester.pumpAndSettle(); - expectEquipmentProfilesCount(2); - expectSelectedEquipmentProfileName(''); - - verify(() => storageService.selectedEquipmentProfileId = '').called(1); - verify(() => storageService.equipmentProfiles = any>()).called(1); - }, - ); - - testWidgets( - 'Delete not selected', - (tester) async { - when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles)); - when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id); - - await pumpTestWidget(tester, IAPProductStatus.purchased); - expectEquipmentProfilesCount(3); - expectSelectedEquipmentProfileName(_customProfiles[0].name); - - /// Delete the not selected profile - await tester.tap(find.byKey(_Application.deleteProfileButtonKey(_customProfiles[1].id))); - await tester.pumpAndSettle(); - expectEquipmentProfilesCount(2); - expectSelectedEquipmentProfileName(_customProfiles[0].name); - - verifyNever(() => storageService.selectedEquipmentProfileId = ''); - verify(() => storageService.equipmentProfiles = any>()).called(1); - }, - ); - - testWidgets( - 'Select', - (tester) async { - when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles)); - when(() => storageService.selectedEquipmentProfileId).thenReturn(''); - - await pumpTestWidget(tester, IAPProductStatus.purchased); - expectEquipmentProfilesCount(3); - expectSelectedEquipmentProfileName(''); - - /// Select the 1st custom profile - await tester.tap(find.byKey(_Application.setProfileButtonKey(_customProfiles[0].id))); - await tester.pumpAndSettle(); - expectEquipmentProfilesCount(3); - expectSelectedEquipmentProfileName(_customProfiles[0].name); - - verify(() => storageService.selectedEquipmentProfileId = _customProfiles[0].id).called(1); - verifyNever(() => storageService.equipmentProfiles = any>()); - }, - ); - }); +extension on WidgetTester { + EquipmentProfilesProviderState get equipmentProfilesProvider { + final BuildContext context = element(find.byType(_Application)); + return EquipmentProfilesProvider.of(context); + } } class _Application extends StatelessWidget { const _Application(); - static ValueKey get addProfileButtonKey => const ValueKey('addProfileButtonKey'); - static ValueKey addFromProfileButtonKey(String id) => ValueKey('addFromProfileButtonKey$id'); - static ValueKey setProfileButtonKey(String id) => ValueKey('setProfileButtonKey$id'); - static ValueKey updateProfileButtonKey(String id) => ValueKey('updateProfileButtonKey$id'); - static ValueKey deleteProfileButtonKey(String id) => ValueKey('deleteProfileButtonKey$id'); - @override Widget build(BuildContext context) { - return MaterialApp( + return const MaterialApp( home: Scaffold( - appBar: AppBar(title: const Text('IAPProviders test')), body: Center( child: Column( children: [ - Text("Equipment profiles count: ${EquipmentProfiles.of(context).length}"), - Text("Selected equipment profile: ${EquipmentProfiles.selectedOf(context).name}"), - ElevatedButton( - key: addProfileButtonKey, - onPressed: () { - EquipmentProfileProvider.of(context).addProfile('Test added'); - }, - child: const Text("Add"), - ), - ...EquipmentProfiles.of(context).map((e) => _equipmentProfilesCrudRow(context, e)), + _EquipmentProfilesCount(), + _EquipmentProfilesInUseCount(), + _SelectedEquipmentProfile(), ], ), ), ), ); } +} - Widget _equipmentProfilesCrudRow(BuildContext context, EquipmentProfile profile) { - return Row( - children: [ - ElevatedButton( - key: setProfileButtonKey(profile.id), - onPressed: () { - EquipmentProfileProvider.of(context).setProfile(profile); - }, - child: const Text("Set"), - ), - ElevatedButton( - key: addFromProfileButtonKey(profile.id), - onPressed: () { - EquipmentProfileProvider.of(context).addProfile('Test from ${profile.name}', profile); - }, - child: const Text("Add from"), - ), - ElevatedButton( - key: updateProfileButtonKey(profile.id), - onPressed: () { - EquipmentProfileProvider.of(context).updateProfile( - profile.copyWith( - name: '${profile.name} updated', - isoValues: _customProfiles.first.isoValues, - ), - ); - }, - child: const Text("Update"), - ), - ElevatedButton( - key: deleteProfileButtonKey(profile.id), - onPressed: () { - EquipmentProfileProvider.of(context).deleteProfile(profile); - }, - child: const Text("Delete"), - ), - ], - ); +class _EquipmentProfilesCount extends StatelessWidget { + static String text(int count) => "Profiles count: $count"; + + const _EquipmentProfilesCount(); + + @override + Widget build(BuildContext context) { + return Text(text(EquipmentProfiles.of(context).length)); + } +} + +class _EquipmentProfilesInUseCount extends StatelessWidget { + static String text(int count) => "Profiles in use count: $count"; + + const _EquipmentProfilesInUseCount(); + + @override + Widget build(BuildContext context) { + return Text(text(EquipmentProfiles.inUseOf(context).length)); + } +} + +class _SelectedEquipmentProfile extends StatelessWidget { + static String text(String name) => "Selected profile: $name}"; + + const _SelectedEquipmentProfile(); + + @override + Widget build(BuildContext context) { + return Text(text(EquipmentProfiles.selectedOf(context).name)); } } diff --git a/test/providers/films_provider_test.dart b/test/providers/films_provider_test.dart index d9de6a3..531a9b8 100644 --- a/test/providers/films_provider_test.dart +++ b/test/providers/films_provider_test.dart @@ -9,25 +9,24 @@ class _MockFilmsStorageService extends Mock implements FilmsStorageService {} void main() { TestWidgetsFlutterBinding.ensureInitialized(); - late _MockFilmsStorageService mockFilmsStorageService; + late _MockFilmsStorageService storageService; setUpAll(() { - mockFilmsStorageService = _MockFilmsStorageService(); + storageService = _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())); + when(() => storageService.toggleFilm(any(), any())).thenAnswer((_) async {}); + when(() => storageService.addFilm(any())).thenAnswer((_) async {}); + when(() => storageService.updateFilm(any())).thenAnswer((_) async {}); + when(() => storageService.deleteFilm(any())).thenAnswer((_) async {}); + when(() => storageService.getPredefinedFilms()).thenAnswer((_) => Future.value(mockPredefinedFilms.toFilmsMap())); + when(() => storageService.getCustomFilms()).thenAnswer((_) => Future.value(mockCustomFilms.toFilmsMap())); }); tearDown(() { - reset(mockFilmsStorageService); + reset(storageService); }); Future pumpTestWidget(WidgetTester tester, IAPProductStatus productStatus) async { @@ -41,7 +40,7 @@ void main() { ), ], child: FilmsProvider( - filmsStorageService: mockFilmsStorageService, + storageService: storageService, child: const _Application(), ), ), @@ -69,11 +68,10 @@ void main() { 'FilmsProvider dependency on IAPProductStatus', () { setUp(() { - when(() => mockFilmsStorageService.selectedFilmId).thenReturn(mockPredefinedFilms.first.id); - when(() => mockFilmsStorageService.getPredefinedFilms()) + when(() => storageService.selectedFilmId).thenReturn(mockPredefinedFilms.first.id); + when(() => storageService.getPredefinedFilms()) .thenAnswer((_) => Future.value(mockPredefinedFilms.toFilmsMap())); - when(() => mockFilmsStorageService.getCustomFilms()) - .thenAnswer((_) => Future.value(mockCustomFilms.toFilmsMap())); + when(() => storageService.getCustomFilms()).thenAnswer((_) => Future.value(mockCustomFilms.toFilmsMap())); }); testWidgets( @@ -117,7 +115,7 @@ void main() { testWidgets( 'toggle predefined film', (tester) async { - when(() => mockFilmsStorageService.selectedFilmId).thenReturn(mockPredefinedFilms.first.id); + when(() => storageService.selectedFilmId).thenReturn(mockPredefinedFilms.first.id); await pumpTestWidget(tester, IAPProductStatus.purchased); expectPredefinedFilmsCount(mockPredefinedFilms.length); expectCustomFilmsCount(mockCustomFilms.length); @@ -131,15 +129,15 @@ void main() { expectFilmsInUseCount(mockPredefinedFilms.length - 1 + mockCustomFilms.length + 1); expectSelectedFilmName(''); - verify(() => mockFilmsStorageService.toggleFilm(mockPredefinedFilms.first, false)).called(1); - verify(() => mockFilmsStorageService.selectedFilmId = '').called(1); + verify(() => storageService.toggleFilm(mockPredefinedFilms.first, false)).called(1); + verify(() => storageService.selectedFilmId = '').called(1); }, ); testWidgets( 'toggle custom film', (tester) async { - when(() => mockFilmsStorageService.selectedFilmId).thenReturn(mockCustomFilms.first.id); + when(() => storageService.selectedFilmId).thenReturn(mockCustomFilms.first.id); await pumpTestWidget(tester, IAPProductStatus.purchased); expectPredefinedFilmsCount(mockPredefinedFilms.length); expectCustomFilmsCount(mockCustomFilms.length); @@ -153,8 +151,8 @@ void main() { expectFilmsInUseCount(mockPredefinedFilms.length - 1 + mockCustomFilms.length + 1); expectSelectedFilmName(''); - verify(() => mockFilmsStorageService.toggleFilm(mockCustomFilms.first, false)).called(1); - verify(() => mockFilmsStorageService.selectedFilmId = '').called(1); + verify(() => storageService.toggleFilm(mockCustomFilms.first, false)).called(1); + verify(() => storageService.selectedFilmId = '').called(1); }, ); }, @@ -163,7 +161,7 @@ void main() { testWidgets( 'selectFilm', (tester) async { - when(() => mockFilmsStorageService.selectedFilmId).thenReturn(''); + when(() => storageService.selectedFilmId).thenReturn(''); await pumpTestWidget(tester, IAPProductStatus.purchased); expectSelectedFilmName(''); @@ -175,16 +173,16 @@ void main() { await tester.pump(); expectSelectedFilmName(mockCustomFilms.first.name); - verify(() => mockFilmsStorageService.selectedFilmId = mockPredefinedFilms.first.id).called(1); - verify(() => mockFilmsStorageService.selectedFilmId = mockCustomFilms.first.id).called(1); + verify(() => storageService.selectedFilmId = mockPredefinedFilms.first.id).called(1); + verify(() => storageService.selectedFilmId = mockCustomFilms.first.id).called(1); }, ); testWidgets( 'Custom film CRUD', (tester) async { - when(() => mockFilmsStorageService.selectedFilmId).thenReturn(''); - when(() => mockFilmsStorageService.getCustomFilms()).thenAnswer((_) => Future.value({})); + when(() => storageService.selectedFilmId).thenReturn(''); + when(() => storageService.getCustomFilms()).thenAnswer((_) => Future.value({})); await pumpTestWidget(tester, IAPProductStatus.purchased); expectPredefinedFilmsCount(mockPredefinedFilms.length); expectCustomFilmsCount(0); @@ -199,16 +197,16 @@ void main() { 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); + verify(() => storageService.addFilm(mockCustomFilms.first)).called(1); + verify(() => storageService.toggleFilm(mockCustomFilms.first, true)).called(1); + verify(() => storageService.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); + verify(() => storageService.updateFilm(editedFilm)).called(1); await tester.filmsProvider.deleteCustomFilm(editedFilm); await tester.pump(); @@ -216,8 +214,8 @@ void main() { expectCustomFilmsCount(0); expectFilmsInUseCount(mockPredefinedFilms.length + 0 + 1); expectSelectedFilmName(''); - verify(() => mockFilmsStorageService.deleteFilm(editedFilm)).called(1); - verify(() => mockFilmsStorageService.selectedFilmId = '').called(1); + verify(() => storageService.deleteFilm(editedFilm)).called(1); + verify(() => storageService.selectedFilmId = '').called(1); }, ); } @@ -307,6 +305,6 @@ const mockCustomFilms = [ ]; extension on List { - Map toFilmsMap() => - Map.fromEntries(map((e) => MapEntry(e.id, (film: e as T, isUsed: true)))); + Map toFilmsMap() => + Map.fromEntries(map((e) => MapEntry(e.id, (value: e as T, isUsed: true)))); } diff --git a/test/screens/metering/components/shared/readings_container/equipment_profile_picker_test.dart b/test/screens/metering/components/shared/readings_container/equipment_profile_picker_test.dart index 8c7d96b..bf97fea 100644 --- a/test/screens/metering/components/shared/readings_container/equipment_profile_picker_test.dart +++ b/test/screens/metering/components/shared/readings_container/equipment_profile_picker_test.dart @@ -10,15 +10,15 @@ import 'package:mocktail/mocktail.dart'; import '../../../../../application_mock.dart'; import 'utils.dart'; -class _MockIAPStorageService extends Mock implements IAPStorageService {} +class _MockEquipmentProfilesStorageService extends Mock implements EquipmentProfilesStorageService {} void main() { - late final _MockIAPStorageService mockIAPStorageService; + late final _MockEquipmentProfilesStorageService storageService; setUpAll(() { - mockIAPStorageService = _MockIAPStorageService(); - when(() => mockIAPStorageService.equipmentProfiles).thenReturn(_mockEquipmentProfiles); - when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn(''); + storageService = _MockEquipmentProfilesStorageService(); + when(() => storageService.getProfiles()).thenAnswer((_) async => _mockEquipmentProfiles.toTogglableMap()); + when(() => storageService.selectedEquipmentProfileId).thenReturn(''); }); Future pumpApplication(WidgetTester tester) async { @@ -31,8 +31,8 @@ void main() { price: '0.0\$', ), ], - child: EquipmentProfileProvider( - storageService: mockIAPStorageService, + child: EquipmentProfilesProvider( + storageService: storageService, child: const WidgetTestApplicationMock( child: Row(children: [Expanded(child: EquipmentProfilePicker())]), ), @@ -59,7 +59,7 @@ void main() { testWidgets( 'None', (tester) async { - when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn(''); + when(() => storageService.selectedEquipmentProfileId).thenReturn(''); await pumpApplication(tester); expectReadingValueContainerText(S.current.none); await tester.openAnimatedPicker(); @@ -70,7 +70,7 @@ void main() { testWidgets( 'Praktica + Zenitar', (tester) async { - when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn(_mockEquipmentProfiles.first.id); + when(() => storageService.selectedEquipmentProfileId).thenReturn(_mockEquipmentProfiles.first.id); await pumpApplication(tester); expectReadingValueContainerText(_mockEquipmentProfiles.first.name); await tester.openAnimatedPicker(); @@ -79,6 +79,18 @@ void main() { ); }, ); + + testWidgets( + 'Equipment profile picker shows only profiles in use', + (tester) async { + when(() => storageService.getProfiles()) + .thenAnswer((_) async => _mockEquipmentProfiles.skip(1).toList().toTogglableMap()); + await pumpApplication(tester); + await tester.openAnimatedPicker(); + expectRadioListTile(S.current.none, isSelected: true); + expectRadioListTile(_mockEquipmentProfiles[1].name); + }, + ); } final _mockEquipmentProfiles = [ 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 a5bf3ee..d42aba4 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 @@ -18,7 +18,7 @@ void main() { setUpAll(() { mockFilmsStorageService = _MockFilmsStorageService(); when(() => mockFilmsStorageService.getPredefinedFilms()).thenAnswer( - (_) => Future.value(Map.fromEntries(_films.map((e) => MapEntry(e.id, (film: e, isUsed: true))))), + (_) => Future.value(Map.fromEntries(_films.map((e) => MapEntry(e.id, (value: e, isUsed: true))))), ); when(() => mockFilmsStorageService.getCustomFilms()).thenAnswer( (_) => Future.value({}), @@ -36,7 +36,7 @@ void main() { ), ], child: FilmsProvider( - filmsStorageService: mockFilmsStorageService, + storageService: mockFilmsStorageService, child: const WidgetTestApplicationMock( child: Row( children: [ diff --git a/test/screens/metering/utils/listener_equipment_profiles_test.dart b/test/screens/metering/utils/listener_equipment_profiles_test.dart index 38e2a14..488eb0d 100644 --- a/test/screens/metering/utils/listener_equipment_profiles_test.dart +++ b/test/screens/metering/utils/listener_equipment_profiles_test.dart @@ -8,14 +8,26 @@ import 'package:mocktail/mocktail.dart'; import '../../../function_mock.dart'; -class _MockIAPStorageService extends Mock implements IAPStorageService {} +class _MockEquipmentProfilesStorageService extends Mock implements EquipmentProfilesStorageService {} void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final storageService = _MockIAPStorageService(); - final equipmentProfileProviderKey = GlobalKey(); + final storageService = _MockEquipmentProfilesStorageService(); final onDidChangeDependencies = MockValueChanged(); + setUp(() { + registerFallbackValue(_customProfiles.first); + when(() => storageService.addProfile(any())).thenAnswer((_) async {}); + when( + () => storageService.updateProfile( + id: any(named: 'id'), + name: any(named: 'name'), + ), + ).thenAnswer((_) async {}); + when(() => storageService.deleteProfile(any())).thenAnswer((_) async {}); + when(() => storageService.getProfiles()).thenAnswer((_) => Future.value(_customProfiles.toTogglableMap())); + }); + tearDown(() { reset(onDidChangeDependencies); reset(storageService); @@ -31,8 +43,7 @@ void main() { price: '0.0\$', ), ], - child: EquipmentProfileProvider( - key: equipmentProfileProviderKey, + child: EquipmentProfilesProvider( storageService: storageService, child: MaterialApp( home: EquipmentProfileListener( @@ -48,11 +59,10 @@ void main() { testWidgets( 'Trigger `onDidChangeDependencies` by selecting a new profile', (tester) async { - when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles)); when(() => storageService.selectedEquipmentProfileId).thenReturn(''); await pumpTestWidget(tester); - equipmentProfileProviderKey.currentState!.setProfile(_customProfiles[0]); + tester.equipmentProfilesProvider.selectProfile(_customProfiles[0]); await tester.pump(); verify(() => onDidChangeDependencies.onChanged(_customProfiles[0])).called(1); }, @@ -61,18 +71,17 @@ void main() { testWidgets( 'Trigger `onDidChangeDependencies` by updating the selected profile', (tester) async { - when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles)); when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id); await pumpTestWidget(tester); final updatedProfile1 = _customProfiles[0].copyWith(name: 'Test 1 updated'); - equipmentProfileProviderKey.currentState!.updateProfile(updatedProfile1); + await tester.equipmentProfilesProvider.updateProfile(updatedProfile1); await tester.pump(); verify(() => onDidChangeDependencies.onChanged(updatedProfile1)).called(1); /// Verify that updating the not selected profile doesn't trigger the callback final updatedProfile2 = _customProfiles[1].copyWith(name: 'Test 2 updated'); - equipmentProfileProviderKey.currentState!.updateProfile(updatedProfile2); + await tester.equipmentProfilesProvider.updateProfile(updatedProfile2); await tester.pump(); verifyNever(() => onDidChangeDependencies.onChanged(updatedProfile2)); }, @@ -81,18 +90,24 @@ void main() { testWidgets( "Don't trigger `onDidChangeDependencies` by updating the unselected profile", (tester) async { - when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles)); when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id); await pumpTestWidget(tester); final updatedProfile2 = _customProfiles[1].copyWith(name: 'Test 2 updated'); - equipmentProfileProviderKey.currentState!.updateProfile(updatedProfile2); + await tester.equipmentProfilesProvider.updateProfile(updatedProfile2); await tester.pump(); verifyNever(() => onDidChangeDependencies.onChanged(updatedProfile2)); }, ); } +extension on WidgetTester { + EquipmentProfilesProviderState get equipmentProfilesProvider { + final BuildContext context = element(find.byType(MaterialApp)); + return EquipmentProfilesProvider.of(context); + } +} + final List _customProfiles = [ const EquipmentProfile( id: '1',