Compare commits

..

No commits in common. "bfe3dc0ef42b81324a9726f6300971f7a507c045" and "c66381f813dc9bf13b5df7ba307bc78de7479b55" have entirely different histories.

43 changed files with 952 additions and 1247 deletions

View file

@ -27,14 +27,6 @@ on:
required: true required: true
type: boolean type: boolean
default: true default: true
release-track:
description: "Release track"
type: choice
required: true
options:
- production
- beta
default: production
env: env:
RELEASE_NOTES_ARTIFACT_NAME: release_notes_en_${{ inputs.version }} RELEASE_NOTES_ARTIFACT_NAME: release_notes_en_${{ inputs.version }}
@ -136,7 +128,6 @@ jobs:
with: with:
artifacts: "m3_lightmeter.apk" artifacts: "m3_lightmeter.apk"
skipIfReleaseExists: true skipIfReleaseExists: true
prerelease: ${{ inputs.release-track == 'beta' }}
tag: "v${{ github.event.inputs.version }}" tag: "v${{ github.event.inputs.version }}"
bodyFile: "${{ env.RELEASE_NOTES_PATH }}/${{ env.RELEASE_NOTES_FILE }}" bodyFile: "${{ env.RELEASE_NOTES_PATH }}/${{ env.RELEASE_NOTES_FILE }}"
@ -187,7 +178,7 @@ jobs:
packageName: com.vodemn.lightmeter packageName: com.vodemn.lightmeter
releaseFiles: app-prod-release.aab releaseFiles: app-prod-release.aab
releaseName: ${{ env.release_name }} releaseName: ${{ env.release_name }}
track: ${{ inputs.release-track }} track: production
status: completed status: completed
debugSymbols: merged_native_libs.zip debugSymbols: merged_native_libs.zip
whatsNewDirectory: whatsnew whatsNewDirectory: whatsnew

View file

@ -4,8 +4,7 @@ import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
export 'src/data/models/iap_product.dart'; export 'src/data/models/iap_product.dart';
export 'src/providers/iap_products_provider.dart'; export 'src/providers/iap_products_provider.dart';
export 'src/data/equipment_profile_service.dart';
export 'src/data/films_storage_service.dart';
export 'src/data/iap_storage_service.dart'; export 'src/data/iap_storage_service.dart';
export 'src/data/films_storage_service.dart';
const List<Film> films = []; const List<Film> films = [];

View file

@ -1,29 +0,0 @@
import 'dart:async';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class EquipmentProfilesStorageService {
EquipmentProfilesStorageService();
Future<void> init() async {}
String get selectedEquipmentProfileId => '';
set selectedEquipmentProfileId(String id) {}
Future<void> addProfile(EquipmentProfile profile) async {}
Future<void> updateProfile({
required String id,
String? name,
List<IsoValue>? isoValues,
List<NdValue>? ndValues,
List<ApertureValue>? apertureValues,
List<ShutterSpeedValue>? shutterSpeedValues,
double? lensZoom,
bool? isUsed,
}) async {}
Future<void> deleteProfile(String id) async {}
Future<TogglableMap<EquipmentProfile>> getProfiles() async => {};
}

View file

@ -1,10 +1,16 @@
import 'package:flutter/foundation.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
typedef SelectableFilm<T extends Film> = ({T film, bool isUsed});
class FilmsStorageService { class FilmsStorageService {
FilmsStorageService(); FilmsStorageService();
Future<void> init() async {} Future<void> init() async {}
@visibleForTesting
Future<void> createTable(dynamic _) async {}
String get selectedFilmId => ''; String get selectedFilmId => '';
set selectedFilmId(String id) {} set selectedFilmId(String id) {}
@ -16,11 +22,11 @@ class FilmsStorageService {
Future<void> deleteFilm(FilmExponential _) async {} Future<void> deleteFilm(FilmExponential _) async {}
Future<TogglableMap<Film>> getPredefinedFilms() async { Future<Map<String, SelectableFilm<Film>>> getPredefinedFilms() async {
return const {}; return const {};
} }
Future<TogglableMap<FilmExponential>> getCustomFilms() async { Future<Map<String, SelectableFilm<FilmExponential>>> getCustomFilms() async {
return const {}; return const {};
} }
} }

View file

@ -12,7 +12,7 @@ dependencies:
m3_lightmeter_resources: m3_lightmeter_resources:
git: git:
url: "https://github.com/vodemn/m3_lightmeter_resources" url: "https://github.com/vodemn/m3_lightmeter_resources"
ref: v2.1.0 ref: v2.0.0
shared_preferences: 2.2.0 shared_preferences: 2.2.0
dev_dependencies: dev_dependencies:

View file

@ -48,8 +48,8 @@ void testE2E(String description) {
description, description,
(tester) async { (tester) async {
await tester.pumpApplication( await tester.pumpApplication(
equipmentProfiles: {}, equipmentProfiles: [],
predefinedFilms: mockFilms.toTogglableMap(), predefinedFilms: mockFilms.toFilmsMap(isUsed: true),
customFilms: {}, customFilms: {},
); );
@ -58,33 +58,27 @@ void testE2E(String description) {
await tester.tapDescendantTextOf<SettingsScreen>(S.current.equipmentProfiles); await tester.tapDescendantTextOf<SettingsScreen>(S.current.equipmentProfiles);
await tester.tap(find.byIcon(Icons.add_outlined).first); await tester.tap(find.byIcon(Icons.add_outlined).first);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.enterProfileName(mockEquipmentProfiles[0].name); await tester.setProfileName(mockEquipmentProfiles[0].name);
await tester.setIsoValues(mockEquipmentProfiles[0].isoValues); await tester.expandEquipmentProfileContainer(mockEquipmentProfiles[0].name);
await tester.setNdValues(mockEquipmentProfiles[0].ndValues); await tester.setIsoValues(0, mockEquipmentProfiles[0].isoValues);
await tester.setApertureValues(mockEquipmentProfiles[0].apertureValues); await tester.setNdValues(0, mockEquipmentProfiles[0].ndValues);
await tester.setShutterSpeedValues(mockEquipmentProfiles[0].shutterSpeedValues); await tester.setApertureValues(0, mockEquipmentProfiles[0].apertureValues);
await tester.setZoomValue(mockEquipmentProfiles[0].lensZoom); await tester.setShutterSpeedValues(0, mockEquipmentProfiles[0].shutterSpeedValues);
await tester.setZoomValue(0, mockEquipmentProfiles[0].lensZoom);
expect(find.text('x1.91'), findsOneWidget); expect(find.text('x1.91'), findsOneWidget);
expect(find.text('f/1.7 - f/16'), findsOneWidget); expect(find.text('f/1.7 - f/16'), findsOneWidget);
expect(find.text('1/1000 - B'), findsOneWidget); expect(find.text('1/1000 - B'), findsOneWidget);
await tester.saveEdits();
/// Create Praktica + Jupiter profile from Zenitar profile /// 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.tap(find.byIcon(Icons.copy_outlined).first);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.enterProfileName(mockEquipmentProfiles[1].name); await tester.setProfileName(mockEquipmentProfiles[1].name);
await tester.setApertureValues(mockEquipmentProfiles[1].apertureValues); await tester.expandEquipmentProfileContainer(mockEquipmentProfiles[1].name);
await tester.setZoomValue(mockEquipmentProfiles[1].lensZoom); await tester.setApertureValues(1, mockEquipmentProfiles[1].apertureValues);
await tester.setZoomValue(1, mockEquipmentProfiles[1].lensZoom);
expect(find.text('x5.02'), findsOneWidget); expect(find.text('x5.02'), findsOneWidget);
expect(find.text('f/3.5 - f/22'), findsOneWidget); expect(find.text('f/3.5 - f/22'), findsOneWidget);
expect(find.text('1/1000 - B'), findsNWidgets(1)); expect(find.text('1/1000 - B'), findsNWidgets(2));
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();
await tester.navigatorPop(); await tester.navigatorPop();
@ -148,45 +142,45 @@ void testE2E(String description) {
nd: 'None', nd: 'None',
ev: mockPhotoEv100 + 1, ev: mockPhotoEv100 + 1,
); );
/// Delete profile
await tester.openSettings();
await tester.tapDescendantTextOf<SettingsScreen>(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 { extension EquipmentProfileActions on WidgetTester {
Future<void> enterProfileName(String name) async { Future<void> expandEquipmentProfileContainer(String name) async {
await enterText(find.byType(TextField), name); await tap(find.text(name));
await pump(); await pump(Dimens.durationM);
} }
Future<void> setIsoValues(List<IsoValue> values) => Future<void> setProfileName(String name) async {
_openAndSetDialogFilterValues<IsoValue>(S.current.isoValues, values); await enterText(find.byType(TextField), name);
Future<void> setNdValues(List<NdValue> values) => _openAndSetDialogFilterValues<NdValue>(S.current.ndFilters, values); await pump();
await tapSaveButton();
}
Future<void> setIsoValues(int profileIndex, List<IsoValue> values) =>
_openAndSetDialogFilterValues<IsoValue>(profileIndex, S.current.isoValues, values);
Future<void> setNdValues(int profileIndex, List<NdValue> values) =>
_openAndSetDialogFilterValues<NdValue>(profileIndex, S.current.ndFilters, values);
Future<void> _openAndSetDialogFilterValues<T extends PhotographyValue>( Future<void> _openAndSetDialogFilterValues<T extends PhotographyValue>(
int profileIndex,
String listTileTitle, String listTileTitle,
List<T> valuesToSelect, { List<T> valuesToSelect, {
bool deselectAll = true, bool deselectAll = true,
}) async { }) async {
await tap(find.text(listTileTitle)); await tap(find.text(listTileTitle).at(profileIndex));
await pumpAndSettle(); await pumpAndSettle();
await setDialogFilterValues(valuesToSelect, deselectAll: deselectAll); await setDialogFilterValues(valuesToSelect, deselectAll: deselectAll);
} }
Future<void> setApertureValues(List<ApertureValue> values) => Future<void> setApertureValues(int profileIndex, List<ApertureValue> values) =>
_setDialogRangePickerValues<ApertureValue>(S.current.apertureValues, values); _setDialogRangePickerValues<ApertureValue>(profileIndex, S.current.apertureValues, values);
Future<void> setShutterSpeedValues(List<ShutterSpeedValue> values) => Future<void> setShutterSpeedValues(int profileIndex, List<ShutterSpeedValue> values) =>
_setDialogRangePickerValues<ShutterSpeedValue>(S.current.shutterSpeedValues, values); _setDialogRangePickerValues<ShutterSpeedValue>(profileIndex, S.current.shutterSpeedValues, values);
Future<void> setZoomValue(double value) => _setDialogSliderPickerValue(S.current.lensZoom, value); Future<void> setZoomValue(int profileIndex, double value) =>
_setDialogSliderPickerValue(profileIndex, S.current.lensZoom, value);
} }
extension on WidgetTester { extension on WidgetTester {
@ -196,16 +190,6 @@ extension on WidgetTester {
await tapSelectButton(); await tapSelectButton();
} }
Future<void> saveEdits() async {
await tap(find.byIcon(Icons.save_outlined));
await pumpAndSettle(Dimens.durationML);
}
Future<void> deleteEdits() async {
await tap(find.byIcon(Icons.delete_outlined));
await pumpAndSettle(Dimens.durationML);
}
Future<void> setDialogFilterValues<T>( Future<void> setDialogFilterValues<T>(
List<T> valuesToSelect, { List<T> valuesToSelect, {
bool deselectAll = true, bool deselectAll = true,
@ -228,10 +212,11 @@ extension on WidgetTester {
} }
Future<void> _setDialogRangePickerValues<T extends PhotographyValue>( Future<void> _setDialogRangePickerValues<T extends PhotographyValue>(
int profileIndex,
String listTileTitle, String listTileTitle,
List<T> valuesToSelect, List<T> valuesToSelect,
) async { ) async {
await tap(find.text(listTileTitle)); await tap(find.text(listTileTitle).at(profileIndex));
await pumpAndSettle(); await pumpAndSettle();
final dialog = widget<DialogRangePicker<T>>(find.byType(DialogRangePicker<T>)); final dialog = widget<DialogRangePicker<T>>(find.byType(DialogRangePicker<T>));
@ -262,10 +247,11 @@ extension on WidgetTester {
} }
Future<void> _setDialogSliderPickerValue( Future<void> _setDialogSliderPickerValue(
int profileIndex,
String listTileTitle, String listTileTitle,
double value, double value,
) async { ) async {
await tap(find.text(listTileTitle)); await tap(find.text(listTileTitle).at(profileIndex));
await pumpAndSettle(); await pumpAndSettle();
final sliderFinder = find.byType(Slider); final sliderFinder = find.byType(Slider);

View file

@ -5,29 +5,28 @@ import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
class _MockEquipmentProfilesStorageService extends Mock implements EquipmentProfilesStorageService {} class _MockIAPStorageService extends Mock implements IAPStorageService {}
class _MockFilmsStorageService extends Mock implements FilmsStorageService {} class _MockFilmsStorageService extends Mock implements FilmsStorageService {}
class MockIAPProviders extends StatefulWidget { class MockIAPProviders extends StatefulWidget {
final TogglableMap<EquipmentProfile> equipmentProfiles; final List<EquipmentProfile>? equipmentProfiles;
final String selectedEquipmentProfileId; final String selectedEquipmentProfileId;
final TogglableMap<Film> predefinedFilms; final Map<String, SelectableFilm<Film>> predefinedFilms;
final TogglableMap<FilmExponential> customFilms; final Map<String, SelectableFilm<FilmExponential>> customFilms;
final String selectedFilmId; final String selectedFilmId;
final Widget child; final Widget child;
MockIAPProviders({ MockIAPProviders({
TogglableMap<EquipmentProfile>? equipmentProfiles, this.equipmentProfiles = const [],
this.selectedEquipmentProfileId = '', this.selectedEquipmentProfileId = '',
TogglableMap<Film>? predefinedFilms, Map<String, SelectableFilm<Film>>? predefinedFilms,
TogglableMap<FilmExponential>? customFilms, Map<String, SelectableFilm<FilmExponential>>? customFilms,
String? selectedFilmId, String? selectedFilmId,
required this.child, required this.child,
super.key, super.key,
}) : equipmentProfiles = equipmentProfiles ?? mockEquipmentProfiles.toTogglableMap(), }) : predefinedFilms = predefinedFilms ?? mockFilms.toFilmsMap(),
predefinedFilms = predefinedFilms ?? mockFilms.toTogglableMap(), customFilms = customFilms ?? mockFilms.toFilmsMap(),
customFilms = customFilms ?? mockFilms.toTogglableMap(),
selectedFilmId = selectedFilmId ?? const FilmStub().id; selectedFilmId = selectedFilmId ?? const FilmStub().id;
@override @override
@ -35,27 +34,15 @@ class MockIAPProviders extends StatefulWidget {
} }
class _MockIAPProvidersState extends State<MockIAPProviders> { class _MockIAPProvidersState extends State<MockIAPProviders> {
late final _MockEquipmentProfilesStorageService mockEquipmentProfilesStorageService; late final _MockIAPStorageService mockIAPStorageService;
late final _MockFilmsStorageService mockFilmsStorageService; late final _MockFilmsStorageService mockFilmsStorageService;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
registerFallbackValue(defaultEquipmentProfile); mockIAPStorageService = _MockIAPStorageService();
mockEquipmentProfilesStorageService = _MockEquipmentProfilesStorageService(); when(() => mockIAPStorageService.equipmentProfiles).thenReturn(widget.equipmentProfiles ?? mockEquipmentProfiles);
when(() => mockEquipmentProfilesStorageService.init()).thenAnswer((_) async {}); when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn(widget.selectedEquipmentProfileId);
when(() => mockEquipmentProfilesStorageService.getProfiles())
.thenAnswer((_) => Future.value(widget.equipmentProfiles));
when(() => mockEquipmentProfilesStorageService.selectedEquipmentProfileId)
.thenReturn(widget.selectedEquipmentProfileId);
when(() => mockEquipmentProfilesStorageService.addProfile(any<EquipmentProfile>())).thenAnswer((_) async {});
when(
() => mockEquipmentProfilesStorageService.updateProfile(
id: any<String>(named: 'id'),
name: any<String>(named: 'name'),
),
).thenAnswer((_) async {});
when(() => mockEquipmentProfilesStorageService.deleteProfile(any<String>())).thenAnswer((_) async {});
mockFilmsStorageService = _MockFilmsStorageService(); mockFilmsStorageService = _MockFilmsStorageService();
when(() => mockFilmsStorageService.init()).thenAnswer((_) async {}); when(() => mockFilmsStorageService.init()).thenAnswer((_) async {});
@ -66,10 +53,10 @@ class _MockIAPProvidersState extends State<MockIAPProviders> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return EquipmentProfilesProvider( return EquipmentProfileProvider(
storageService: mockEquipmentProfilesStorageService, storageService: mockIAPStorageService,
child: FilmsProvider( child: FilmsProvider(
storageService: mockFilmsStorageService, filmsStorageService: mockFilmsStorageService,
child: widget.child, child: widget.child,
), ),
); );
@ -155,6 +142,11 @@ const mockFilms = [
_FilmMultiplying(id: '4', name: 'Mock film 4', iso: 1200, reciprocityMultiplier: 1.5), _FilmMultiplying(id: '4', name: 'Mock film 4', iso: 1200, reciprocityMultiplier: 1.5),
]; ];
extension FilmMapper on List<Film> {
Map<String, ({T film, bool isUsed})> toFilmsMap<T extends Film>({bool isUsed = true}) =>
Map.fromEntries(map((e) => MapEntry(e.id, (film: e as T, isUsed: isUsed))));
}
class _FilmMultiplying extends FilmExponential { class _FilmMultiplying extends FilmExponential {
final double reciprocityMultiplier; final double reciprocityMultiplier;

View file

@ -20,10 +20,10 @@ const mockPhotoEv100 = 8.3;
extension WidgetTesterCommonActions on WidgetTester { extension WidgetTesterCommonActions on WidgetTester {
Future<void> pumpApplication({ Future<void> pumpApplication({
IAPProductStatus productStatus = IAPProductStatus.purchased, IAPProductStatus productStatus = IAPProductStatus.purchased,
TogglableMap<EquipmentProfile>? equipmentProfiles, List<EquipmentProfile>? equipmentProfiles,
String selectedEquipmentProfileId = '', String selectedEquipmentProfileId = '',
TogglableMap<Film>? predefinedFilms, Map<String, SelectableFilm<Film>>? predefinedFilms,
TogglableMap<FilmExponential>? customFilms, Map<String, SelectableFilm<FilmExponential>>? customFilms,
String selectedFilmId = '', String selectedFilmId = '',
}) async { }) async {
await pumpWidget( await pumpWidget(

View file

@ -7,8 +7,6 @@ import 'package:lightmeter/navigation/modal_route_args_parser.dart';
import 'package:lightmeter/navigation/routes.dart'; import 'package:lightmeter/navigation/routes.dart';
import 'package:lightmeter/platform_config.dart'; import 'package:lightmeter/platform_config.dart';
import 'package:lightmeter/providers/user_preferences_provider.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/film_edit/flow_film_edit.dart';
import 'package:lightmeter/screens/films/screen_films.dart'; import 'package:lightmeter/screens/films/screen_films.dart';
import 'package:lightmeter/screens/lightmeter_pro/screen_lightmeter_pro.dart'; import 'package:lightmeter/screens/lightmeter_pro/screen_lightmeter_pro.dart';
@ -51,10 +49,8 @@ class Application extends StatelessWidget {
routes: { routes: {
NavigationRoutes.meteringScreen.name: (_) => const ReleaseNotesFlow(child: MeteringFlow()), NavigationRoutes.meteringScreen.name: (_) => const ReleaseNotesFlow(child: MeteringFlow()),
NavigationRoutes.settingsScreen.name: (_) => const SettingsFlow(), NavigationRoutes.settingsScreen.name: (_) => const SettingsFlow(),
NavigationRoutes.equipmentProfilesListScreen.name: (_) => const EquipmentProfilesScreen(),
NavigationRoutes.equipmentProfileEditScreen.name: (context) =>
EquipmentProfileEditFlow(args: context.routeArgs<EquipmentProfileEditArgs>()),
NavigationRoutes.filmsListScreen.name: (_) => const FilmsScreen(), NavigationRoutes.filmsListScreen.name: (_) => const FilmsScreen(),
NavigationRoutes.filmAddScreen.name: (_) => const FilmEditFlow(args: FilmEditArgs()),
NavigationRoutes.filmEditScreen.name: (context) => FilmEditFlow(args: context.routeArgs<FilmEditArgs>()), NavigationRoutes.filmEditScreen.name: (context) => FilmEditFlow(args: context.routeArgs<FilmEditArgs>()),
NavigationRoutes.proFeaturesScreen.name: (_) => LightmeterProScreen(), NavigationRoutes.proFeaturesScreen.name: (_) => LightmeterProScreen(),
NavigationRoutes.timerScreen.name: (context) => TimerFlow(args: context.routeArgs<TimerFlowArgs>()), NavigationRoutes.timerScreen.name: (context) => TimerFlow(args: context.routeArgs<TimerFlowArgs>()),

View file

@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:lightmeter/data/analytics/analytics.dart'; import 'package:lightmeter/data/analytics/analytics.dart';
@ -36,14 +34,11 @@ class _ApplicationWrapperState extends State<ApplicationWrapper> {
? const RemoteConfigService(LightmeterAnalytics(api: LightmeterAnalyticsFirebase())) ? const RemoteConfigService(LightmeterAnalytics(api: LightmeterAnalyticsFirebase()))
: const MockRemoteConfigService(); : const MockRemoteConfigService();
late final IAPStorageService iapStorageService;
late final UserPreferencesService userPreferencesService; late final UserPreferencesService userPreferencesService;
late final bool hasLightSensor; late final bool hasLightSensor;
final equipmentProfilesStorageService = EquipmentProfilesStorageService();
final equipmentProfilesStorageServiceCompleter = Completer<void>();
final filmsStorageService = FilmsStorageService(); final filmsStorageService = FilmsStorageService();
final filmsStorageServiceCompleter = Completer<void>();
late final Future<void> _initFuture; late final Future<void> _initFuture;
@ -51,7 +46,6 @@ class _ApplicationWrapperState extends State<ApplicationWrapper> {
void initState() { void initState() {
super.initState(); super.initState();
_initFuture = _initialize(); _initFuture = _initialize();
_removeSplashscreen();
} }
@override @override
@ -73,12 +67,11 @@ class _ApplicationWrapperState extends State<ApplicationWrapper> {
volumeEventsService: const VolumeEventsService(LocalPlatform()), volumeEventsService: const VolumeEventsService(LocalPlatform()),
child: RemoteConfigProvider( child: RemoteConfigProvider(
remoteConfigService: remoteConfigService, remoteConfigService: remoteConfigService,
child: EquipmentProfilesProvider( child: EquipmentProfileProvider(
storageService: equipmentProfilesStorageService, storageService: iapStorageService,
onInitialized: equipmentProfilesStorageServiceCompleter.complete,
child: FilmsProvider( child: FilmsProvider(
storageService: filmsStorageService, filmsStorageService: filmsStorageService,
onInitialized: filmsStorageServiceCompleter.complete, onInitialized: _onFilmsProviderInitialized,
child: UserPreferencesProvider( child: UserPreferencesProvider(
hasLightSensor: hasLightSensor, hasLightSensor: hasLightSensor,
userPreferencesService: userPreferencesService, userPreferencesService: userPreferencesService,
@ -100,20 +93,16 @@ class _ApplicationWrapperState extends State<ApplicationWrapper> {
SharedPreferences.getInstance(), SharedPreferences.getInstance(),
const LightSensorService(LocalPlatform()).hasSensor(), const LightSensorService(LocalPlatform()).hasSensor(),
remoteConfigService.activeAndFetchFeatures(), remoteConfigService.activeAndFetchFeatures(),
equipmentProfilesStorageService.init(),
filmsStorageService.init(), filmsStorageService.init(),
]).then((value) { ]).then((value) {
userPreferencesService = UserPreferencesService((value[0] as SharedPreferences?)!); final sharedPrefs = (value[0] as SharedPreferences?)!;
iapStorageService = IAPStorageService(sharedPrefs);
userPreferencesService = UserPreferencesService(sharedPrefs);
hasLightSensor = value[1] as bool? ?? false; hasLightSensor = value[1] as bool? ?? false;
}); });
} }
void _removeSplashscreen() { void _onFilmsProviderInitialized() {
Future.wait([ FlutterNativeSplash.remove();
equipmentProfilesStorageServiceCompleter.future,
filmsStorageServiceCompleter.future,
]).then((_) {
FlutterNativeSplash.remove();
});
} }
} }

View file

@ -152,6 +152,7 @@
"close": "Close", "close": "Close",
"films": "Films", "films": "Films",
"filmsInUse": "Films in use", "filmsInUse": "Films in use",
"filmsInUseDescription": "Select films which you use.",
"filmsCustom": "Custom films", "filmsCustom": "Custom films",
"addFilmTitle": "Add film", "addFilmTitle": "Add film",
"editFilmTitle": "Edit film", "editFilmTitle": "Edit film",
@ -159,7 +160,5 @@
"filmFormulaExponential": "T=t^Rf", "filmFormulaExponential": "T=t^Rf",
"filmFormulaExponentialRf": "Rf", "filmFormulaExponentialRf": "Rf",
"filmFormulaExponentialRfPlaceholder": "1.3", "filmFormulaExponentialRfPlaceholder": "1.3",
"name": "Name", "name": "Name"
"addEquipmentProfileTitle": "Add equipment",
"editEquipmentProfileTitle": "Edit equipment"
} }

View file

@ -67,6 +67,8 @@
"equipmentProfile": "Profil de l'équipement", "equipmentProfile": "Profil de l'équipement",
"equipmentProfiles": "Profils de l'équipement", "equipmentProfiles": "Profils de l'équipement",
"tapToAdd": "Appuie pour ajouter", "tapToAdd": "Appuie pour ajouter",
"filmsInUse": "Films en usage",
"filmsInUseDescription": "Sélectionnez les films que vous utilisez.",
"general": "Général", "general": "Général",
"keepScreenOn": "Garder l'écran allumé", "keepScreenOn": "Garder l'écran allumé",
"haptics": "Haptiques", "haptics": "Haptiques",
@ -140,17 +142,5 @@
"tooltipUseLightSensor": "Utiliser un capteur de lumière", "tooltipUseLightSensor": "Utiliser un capteur de lumière",
"tooltipUseCamera": "Utiliser la caméra", "tooltipUseCamera": "Utiliser la caméra",
"tooltipOpenSettings": "Ouvrir les paramètres", "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"
} }

View file

@ -67,6 +67,8 @@
"equipmentProfile": "Оборудование", "equipmentProfile": "Оборудование",
"equipmentProfiles": "Профили оборудования", "equipmentProfiles": "Профили оборудования",
"tapToAdd": "Нажмите, чтобы добавить", "tapToAdd": "Нажмите, чтобы добавить",
"filmsInUse": "Используемые плёнки",
"filmsInUseDescription": "Выберите плёнки, которыми вы пользуетесь.",
"general": "Общие", "general": "Общие",
"keepScreenOn": "Запрет блокировки", "keepScreenOn": "Запрет блокировки",
"haptics": "Вибрация", "haptics": "Вибрация",
@ -139,17 +141,5 @@
"tooltipUseLightSensor": "Использовать датчик освещенности", "tooltipUseLightSensor": "Использовать датчик освещенности",
"tooltipUseCamera": "Использовать камеру", "tooltipUseCamera": "Использовать камеру",
"tooltipOpenSettings": "Открыть настройки", "tooltipOpenSettings": "Открыть настройки",
"exposurePair": "Пара экспозиции", "exposurePair": "Пара экспозиции"
"films": "Плёнки",
"filmsInUse": "Используемые плёнки",
"filmsCustom": "Пользовательские плёнки",
"addFilmTitle": "Добавить плёнку",
"editFilmTitle": "Редактировать плёнку",
"filmFormula": "Формула",
"filmFormulaExponential": "T=t^Rf",
"filmFormulaExponentialRf": "Rf",
"filmFormulaExponentialRfPlaceholder": "1.3",
"name": "Название",
"addEquipmentProfileTitle": "Добавить профиль",
"editEquipmentProfileTitle": "Редактировать профиль"
} }

View file

@ -67,6 +67,8 @@
"equipmentProfile": "设备配置", "equipmentProfile": "设备配置",
"equipmentProfiles": "设备配置", "equipmentProfiles": "设备配置",
"tapToAdd": "点击添加", "tapToAdd": "点击添加",
"filmsInUse": "使用的胶片",
"filmsInUseDescription": "选择你使用的胶片",
"general": "通用", "general": "通用",
"keepScreenOn": "保持屏幕常亮", "keepScreenOn": "保持屏幕常亮",
"haptics": "震动", "haptics": "震动",
@ -138,16 +140,5 @@
"tooltipUseLightSensor": "使用光线传感器", "tooltipUseLightSensor": "使用光线传感器",
"tooltipUseCamera": "使用摄像头", "tooltipUseCamera": "使用摄像头",
"tooltipOpenSettings": "打开设置", "tooltipOpenSettings": "打开设置",
"exposurePair": "曝光对", "exposurePair": "曝光对"
"films": "Films",
"filmsInUse": "使用的胶片",
"filmsCustom": "定制胶片",
"addFilmTitle": "添加胶片",
"editFilmTitle": "编辑胶片",
"filmFormula": "计算公式",
"filmFormulaExponential": "T=t^Rf",
"filmFormulaExponentialRf": "Rf",
"filmFormulaExponentialRfPlaceholder": "1.3",
"addEquipmentProfileTitle": "添加简介",
"editEquipmentProfileTitle": "编辑个人资料"
} }

View file

@ -1,9 +1,8 @@
enum NavigationRoutes { enum NavigationRoutes {
meteringScreen, meteringScreen,
settingsScreen, settingsScreen,
equipmentProfilesListScreen,
equipmentProfileEditScreen,
filmsListScreen, filmsListScreen,
filmAddScreen,
filmEditScreen, filmEditScreen,
proFeaturesScreen, proFeaturesScreen,
timerScreen, timerScreen,

View file

@ -1,11 +1,30 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lightmeter/utils/context_utils.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_iap/m3_lightmeter_iap.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
import 'package:uuid/uuid.dart';
class EquipmentProfilesProvider extends StatefulWidget { class EquipmentProfileProvider extends StatefulWidget {
static const EquipmentProfile defaultProfile = EquipmentProfile( final IAPStorageService storageService;
final Widget child;
const EquipmentProfileProvider({
required this.storageService,
required this.child,
super.key,
});
static EquipmentProfileProviderState of(BuildContext context) {
return context.findAncestorStateOfType<EquipmentProfileProviderState>()!;
}
@override
State<EquipmentProfileProvider> createState() => EquipmentProfileProviderState();
}
class EquipmentProfileProviderState extends State<EquipmentProfileProvider> {
static const EquipmentProfile _defaultProfile = EquipmentProfile(
id: '', id: '',
name: '', name: '',
apertureValues: ApertureValue.values, apertureValues: ApertureValue.values,
@ -14,89 +33,34 @@ class EquipmentProfilesProvider extends StatefulWidget {
isoValues: IsoValue.values, isoValues: IsoValue.values,
); );
final EquipmentProfilesStorageService storageService; List<EquipmentProfile> _customProfiles = [];
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<EquipmentProfilesProviderState>()!;
}
@override
State<EquipmentProfilesProvider> createState() => EquipmentProfilesProviderState();
}
class EquipmentProfilesProviderState extends State<EquipmentProfilesProvider> {
final TogglableMap<EquipmentProfile> _customProfiles = {};
String _selectedId = ''; String _selectedId = '';
EquipmentProfile get _selectedProfile => EquipmentProfile get _selectedProfile => _customProfiles.firstWhere(
_customProfiles[_selectedId]?.value ?? EquipmentProfilesProvider.defaultProfile; (e) => e.id == _selectedId,
orElse: () => _defaultProfile,
);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_init(); _selectedId = widget.storageService.selectedEquipmentProfileId;
_customProfiles = widget.storageService.equipmentProfiles;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return EquipmentProfiles( return EquipmentProfiles(
profiles: context.isPro ? _customProfiles : {}, values: [
selected: context.isPro ? _selectedProfile : EquipmentProfilesProvider.defaultProfile, _defaultProfile,
if (context.isPro) ..._customProfiles,
],
selected: context.isPro ? _selectedProfile : _defaultProfile,
child: widget.child, child: widget.child,
); );
} }
Future<void> _init() async { void setProfile(EquipmentProfile data) {
_selectedId = widget.storageService.selectedEquipmentProfileId;
_customProfiles.addAll(await widget.storageService.getProfiles());
_discardSelectedIfNotIncluded();
if (mounted) setState(() {});
widget.onInitialized?.call();
}
Future<void> addProfile(EquipmentProfile profile) async {
await widget.storageService.addProfile(profile);
_customProfiles[profile.id] = (value: profile, isUsed: true);
setState(() {});
}
Future<void> 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<void> 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) { if (_selectedId != data.id) {
setState(() { setState(() {
_selectedId = data.id; _selectedId = data.id;
@ -105,81 +69,62 @@ class EquipmentProfilesProviderState extends State<EquipmentProfilesProvider> {
} }
} }
Future<void> toggleProfile(EquipmentProfile profile, bool enabled) async { /// Creates a default equipment profile
if (_customProfiles.containsKey(profile.id)) { void addProfile(String name, [EquipmentProfile? copyFrom]) {
_customProfiles[profile.id] = (value: profile, isUsed: enabled); _customProfiles.add(
} else { EquipmentProfile(
return; 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();
} }
await widget.storageService.updateProfile(id: profile.id, isUsed: enabled); }
_discardSelectedIfNotIncluded();
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;
setState(() {}); 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;
}
}
} }
enum _EquipmentProfilesModelAspect { class EquipmentProfiles extends SelectableInheritedModel<EquipmentProfile> {
profiles,
profilesInUse,
selected,
}
class EquipmentProfiles extends InheritedModel<_EquipmentProfilesModelAspect> {
final TogglableMap<EquipmentProfile> profiles;
final EquipmentProfile selected;
const EquipmentProfiles({ const EquipmentProfiles({
required this.profiles, super.key,
required this.selected, required super.values,
required super.selected,
required super.child, required super.child,
}); });
/// _default + profiles create by the user /// [_defaultProfile] + profiles created by the user
static List<EquipmentProfile> of(BuildContext context) { static List<EquipmentProfile> of(BuildContext context) {
final model = return InheritedModel.inheritFrom<EquipmentProfiles>(context, aspect: SelectableAspect.list)!.values;
InheritedModel.inheritFrom<EquipmentProfiles>(context, aspect: _EquipmentProfilesModelAspect.profiles)!;
return List<EquipmentProfile>.from(
[
EquipmentProfilesProvider.defaultProfile,
...model.profiles.values.map((p) => p.value),
],
);
}
static List<EquipmentProfile> inUseOf(BuildContext context) {
final model =
InheritedModel.inheritFrom<EquipmentProfiles>(context, aspect: _EquipmentProfilesModelAspect.profilesInUse)!;
return List<EquipmentProfile>.from(
[
EquipmentProfilesProvider.defaultProfile,
...model.profiles.values.where((p) => p.isUsed).map((p) => p.value),
],
);
} }
static EquipmentProfile selectedOf(BuildContext context) { static EquipmentProfile selectedOf(BuildContext context) {
return InheritedModel.inheritFrom<EquipmentProfiles>(context, aspect: _EquipmentProfilesModelAspect.selected)! return InheritedModel.inheritFrom<EquipmentProfiles>(
context,
aspect: SelectableAspect.selected,
)!
.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));
}
} }

View file

@ -5,12 +5,12 @@ import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class FilmsProvider extends StatefulWidget { class FilmsProvider extends StatefulWidget {
final FilmsStorageService storageService; final FilmsStorageService filmsStorageService;
final VoidCallback? onInitialized; final VoidCallback? onInitialized;
final Widget child; final Widget child;
const FilmsProvider({ const FilmsProvider({
required this.storageService, required this.filmsStorageService,
this.onInitialized, this.onInitialized,
required this.child, required this.child,
super.key, super.key,
@ -25,11 +25,11 @@ class FilmsProvider extends StatefulWidget {
} }
class FilmsProviderState extends State<FilmsProvider> { class FilmsProviderState extends State<FilmsProvider> {
final TogglableMap<Film> predefinedFilms = {}; final Map<String, SelectableFilm<Film>> predefinedFilms = {};
final TogglableMap<FilmExponential> customFilms = {}; final Map<String, SelectableFilm<FilmExponential>> customFilms = {};
String _selectedId = ''; String _selectedId = '';
Film get _selectedFilm => customFilms[_selectedId]?.value ?? predefinedFilms[_selectedId]?.value ?? const FilmStub(); Film get _selectedFilm => customFilms[_selectedId]?.film ?? predefinedFilms[_selectedId]?.film ?? const FilmStub();
@override @override
void initState() { void initState() {
@ -48,9 +48,9 @@ class FilmsProviderState extends State<FilmsProvider> {
} }
Future<void> _init() async { Future<void> _init() async {
_selectedId = widget.storageService.selectedFilmId; _selectedId = widget.filmsStorageService.selectedFilmId;
predefinedFilms.addAll(await widget.storageService.getPredefinedFilms()); predefinedFilms.addAll(await widget.filmsStorageService.getPredefinedFilms());
customFilms.addAll(await widget.storageService.getCustomFilms()); customFilms.addAll(await widget.filmsStorageService.getCustomFilms());
_discardSelectedIfNotIncluded(); _discardSelectedIfNotIncluded();
if (mounted) setState(() {}); if (mounted) setState(() {});
widget.onInitialized?.call(); widget.onInitialized?.call();
@ -60,13 +60,13 @@ class FilmsProviderState extends State<FilmsProvider> {
Future<void> toggleFilm(Film film, bool enabled) async { Future<void> toggleFilm(Film film, bool enabled) async {
if (predefinedFilms.containsKey(film.id)) { if (predefinedFilms.containsKey(film.id)) {
predefinedFilms[film.id] = (value: film, isUsed: enabled); predefinedFilms[film.id] = (film: film, isUsed: enabled);
} else if (customFilms.containsKey(film.id)) { } else if (customFilms.containsKey(film.id)) {
customFilms[film.id] = (value: film as FilmExponential, isUsed: enabled); customFilms[film.id] = (film: film as FilmExponential, isUsed: enabled);
} else { } else {
return; return;
} }
await widget.storageService.toggleFilm(film, enabled); await widget.filmsStorageService.toggleFilm(film, enabled);
_discardSelectedIfNotIncluded(); _discardSelectedIfNotIncluded();
setState(() {}); setState(() {});
} }
@ -74,7 +74,7 @@ class FilmsProviderState extends State<FilmsProvider> {
void selectFilm(Film film) { void selectFilm(Film film) {
if (_selectedFilm != film) { if (_selectedFilm != film) {
_selectedId = film.id; _selectedId = film.id;
widget.storageService.selectedFilmId = _selectedId; widget.filmsStorageService.selectedFilmId = _selectedId;
setState(() {}); setState(() {});
} }
} }
@ -83,19 +83,19 @@ class FilmsProviderState extends State<FilmsProvider> {
Future<void> addCustomFilm(FilmExponential film) async { Future<void> addCustomFilm(FilmExponential film) async {
// ignore: avoid_redundant_argument_values // ignore: avoid_redundant_argument_values
await widget.storageService.addFilm(film, isUsed: true); await widget.filmsStorageService.addFilm(film, isUsed: true);
customFilms[film.id] = (value: film, isUsed: true); customFilms[film.id] = (film: film, isUsed: true);
setState(() {}); setState(() {});
} }
Future<void> updateCustomFilm(FilmExponential film) async { Future<void> updateCustomFilm(FilmExponential film) async {
await widget.storageService.updateFilm(film); await widget.filmsStorageService.updateFilm(film);
customFilms[film.id] = (value: film, isUsed: customFilms[film.id]!.isUsed); customFilms[film.id] = (film: film, isUsed: customFilms[film.id]!.isUsed);
setState(() {}); setState(() {});
} }
Future<void> deleteCustomFilm(FilmExponential film) async { Future<void> deleteCustomFilm(FilmExponential film) async {
await widget.storageService.deleteFilm(film); await widget.filmsStorageService.deleteFilm(film);
customFilms.remove(film.id); customFilms.remove(film.id);
_discardSelectedIfNotIncluded(); _discardSelectedIfNotIncluded();
setState(() {}); setState(() {});
@ -108,7 +108,7 @@ class FilmsProviderState extends State<FilmsProvider> {
final isSelectedUsed = predefinedFilms[_selectedId]?.isUsed ?? customFilms[_selectedId]?.isUsed ?? false; final isSelectedUsed = predefinedFilms[_selectedId]?.isUsed ?? customFilms[_selectedId]?.isUsed ?? false;
if (!isSelectedUsed) { if (!isSelectedUsed) {
_selectedId = const FilmStub().id; _selectedId = const FilmStub().id;
widget.storageService.selectedFilmId = _selectedId; widget.filmsStorageService.selectedFilmId = _selectedId;
} }
} }
} }
@ -121,10 +121,10 @@ enum _FilmsModelAspect {
} }
class Films extends InheritedModel<_FilmsModelAspect> { class Films extends InheritedModel<_FilmsModelAspect> {
final TogglableMap<Film> predefinedFilms; final Map<String, SelectableFilm<Film>> predefinedFilms;
@protected @protected
final TogglableMap<FilmExponential> customFilms; final Map<String, SelectableFilm<FilmExponential>> customFilms;
final Film selected; final Film selected;
const Films({ const Films({
@ -138,7 +138,7 @@ class Films extends InheritedModel<_FilmsModelAspect> {
return InheritedModel.inheritFrom<Films>(context, aspect: _FilmsModelAspect.predefinedFilms)! return InheritedModel.inheritFrom<Films>(context, aspect: _FilmsModelAspect.predefinedFilms)!
.predefinedFilms .predefinedFilms
.values .values
.map((value) => value.value) .map((value) => value.film)
.toList(); .toList();
} }
@ -146,7 +146,7 @@ class Films extends InheritedModel<_FilmsModelAspect> {
return InheritedModel.inheritFrom<Films>(context, aspect: _FilmsModelAspect.customFilms)! return InheritedModel.inheritFrom<Films>(context, aspect: _FilmsModelAspect.customFilms)!
.customFilms .customFilms
.values .values
.map((value) => value.value) .map((value) => value.film)
.toList(); .toList();
} }
@ -155,8 +155,8 @@ class Films extends InheritedModel<_FilmsModelAspect> {
final model = InheritedModel.inheritFrom<Films>(context, aspect: _FilmsModelAspect.filmsInUse)!; final model = InheritedModel.inheritFrom<Films>(context, aspect: _FilmsModelAspect.filmsInUse)!;
return [ return [
const FilmStub(), const FilmStub(),
...model.customFilms.values.where((e) => e.isUsed).map((e) => e.value), ...model.customFilms.values.where((e) => e.isUsed).map((e) => e.film),
...model.predefinedFilms.values.where((e) => e.isUsed).map((e) => e.value), ...model.predefinedFilms.values.where((e) => e.isUsed).map((e) => e.film),
]; ];
} }

View file

@ -1,158 +0,0 @@
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<EquipmentProfileEditEvent, EquipmentProfileEditState> {
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<EquipmentProfileEditEvent>(
(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<void> _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<void> _onApertureValuesChanged(EquipmentProfileApertureValuesChangedEvent event, Emitter emit) async {
_newEquipmentProfile = _newEquipmentProfile.copyWith(apertureValues: event.apertureValues);
emit(state.copyWith(apertureValues: event.apertureValues));
}
Future<void> _onShutterSpeedValuesChanged(EquipmentProfileShutterSpeedValuesChangedEvent event, Emitter emit) async {
_newEquipmentProfile = _newEquipmentProfile.copyWith(shutterSpeedValues: event.shutterSpeedValues);
emit(state.copyWith(shutterSpeedValues: event.shutterSpeedValues));
}
Future<void> _onIsoValuesChanged(EquipmentProfileIsoValuesChangedEvent event, Emitter emit) async {
_newEquipmentProfile = _newEquipmentProfile.copyWith(isoValues: event.isoValues);
emit(state.copyWith(isoValues: event.isoValues));
}
Future<void> _onNdValuesChanged(EquipmentProfileNdValuesChangedEvent event, Emitter emit) async {
_newEquipmentProfile = _newEquipmentProfile.copyWith(ndValues: event.ndValues);
emit(state.copyWith(ndValues: event.ndValues));
}
Future<void> _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<void> _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<void> _onCopy(EquipmentProfileCopyEvent _, Emitter emit) async {
emit(state.copyWith(isLoading: true));
emit(state.copyWith(isLoading: false, profileToCopy: _originalEquipmentProfile));
}
Future<void> _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;
}
}

View file

@ -1,53 +0,0 @@
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<IsoValue> isoValues;
const EquipmentProfileIsoValuesChangedEvent(this.isoValues);
}
class EquipmentProfileNdValuesChangedEvent extends EquipmentProfileEditEvent {
final List<NdValue> ndValues;
const EquipmentProfileNdValuesChangedEvent(this.ndValues);
}
class EquipmentProfileApertureValuesChangedEvent extends EquipmentProfileEditEvent {
final List<ApertureValue> apertureValues;
const EquipmentProfileApertureValuesChangedEvent(this.apertureValues);
}
class EquipmentProfileShutterSpeedValuesChangedEvent extends EquipmentProfileEditEvent {
final List<ShutterSpeedValue> 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();
}

View file

@ -1,37 +0,0 @@
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),
);
}
}

View file

@ -1,257 +0,0 @@
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<EquipmentProfileEditScreen> createState() => _EquipmentProfileEditScreenState();
}
class _EquipmentProfileEditScreenState extends State<EquipmentProfileEditScreen> {
@override
Widget build(BuildContext context) {
return BlocConsumer<EquipmentProfileEditBloc, EquipmentProfileEditState>(
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<EquipmentProfileEditBloc, EquipmentProfileEditState>(
buildWhen: (previous, current) => previous.canSave != current.canSave,
builder: (context, state) => IconButton(
onPressed: state.canSave
? () {
context.read<EquipmentProfileEditBloc>().add(const EquipmentProfileSaveEvent());
}
: null,
icon: const Icon(Icons.save_outlined),
),
),
if (widget.isEdit)
BlocBuilder<EquipmentProfileEditBloc, EquipmentProfileEditState>(
buildWhen: (previous, current) => previous.canSave != current.canSave,
builder: (context, state) => IconButton(
onPressed: state.canSave
? null
: () {
context.read<EquipmentProfileEditBloc>().add(const EquipmentProfileCopyEvent());
},
icon: const Icon(Icons.copy_outlined),
),
),
if (widget.isEdit)
IconButton(
onPressed: () {
context.read<EquipmentProfileEditBloc>().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<EquipmentProfileEditBloc, EquipmentProfileEditState>(
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<EquipmentProfileEditBloc>().add(EquipmentProfileNameChangedEvent(value));
},
),
),
);
}
}
class _IsoValuesListTileBuilder extends StatelessWidget {
const _IsoValuesListTileBuilder();
@override
Widget build(BuildContext context) {
return BlocBuilder<EquipmentProfileEditBloc, EquipmentProfileEditState>(
builder: (context, state) => FilterListTile<IsoValue>(
icon: Icons.iso_outlined,
title: S.of(context).isoValues,
description: S.of(context).isoValuesFilterDescription,
values: IsoValue.values,
selectedValues: state.isoValues,
onChanged: (value) {
context.read<EquipmentProfileEditBloc>().add(EquipmentProfileIsoValuesChangedEvent(value));
},
),
);
}
}
class _NdValuesListTileBuilder extends StatelessWidget {
const _NdValuesListTileBuilder();
@override
Widget build(BuildContext context) {
return BlocBuilder<EquipmentProfileEditBloc, EquipmentProfileEditState>(
builder: (context, state) => FilterListTile<NdValue>(
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<EquipmentProfileEditBloc>().add(EquipmentProfileNdValuesChangedEvent(value));
},
),
);
}
}
class _ApertureValuesListTileBuilder extends StatelessWidget {
const _ApertureValuesListTileBuilder();
@override
Widget build(BuildContext context) {
return BlocBuilder<EquipmentProfileEditBloc, EquipmentProfileEditState>(
builder: (context, state) => RangePickerListTile<ApertureValue>(
icon: Icons.camera_outlined,
title: S.of(context).apertureValues,
description: S.of(context).apertureValuesFilterDescription,
values: ApertureValue.values,
selectedValues: state.apertureValues,
onChanged: (value) {
context.read<EquipmentProfileEditBloc>().add(EquipmentProfileApertureValuesChangedEvent(value));
},
),
);
}
}
class _ShutterSpeedValuesListTileBuilder extends StatelessWidget {
const _ShutterSpeedValuesListTileBuilder();
@override
Widget build(BuildContext context) {
return BlocBuilder<EquipmentProfileEditBloc, EquipmentProfileEditState>(
builder: (context, state) => RangePickerListTile<ShutterSpeedValue>(
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<EquipmentProfileEditBloc>().add(EquipmentProfileShutterSpeedValuesChangedEvent(value));
},
),
);
}
}
class _LensZoomListTileBuilder extends StatelessWidget {
const _LensZoomListTileBuilder();
@override
Widget build(BuildContext context) {
return BlocBuilder<EquipmentProfileEditBloc, EquipmentProfileEditState>(
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<EquipmentProfileEditBloc>().add(EquipmentProfileLensZoomChangedEvent(value));
},
),
);
}
}

View file

@ -1,48 +0,0 @@
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class EquipmentProfileEditState {
final String name;
final List<ApertureValue> apertureValues;
final List<NdValue> ndValues;
final List<ShutterSpeedValue> shutterSpeedValues;
final List<IsoValue> 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<ApertureValue>? apertureValues,
List<NdValue>? ndValues,
List<ShutterSpeedValue>? shutterSpeedValues,
List<IsoValue>? 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,
);
}

View file

@ -1,110 +0,0 @@
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<EquipmentProfilesScreen> createState() => _EquipmentProfilesScreenState();
}
class _EquipmentProfilesScreenState extends State<EquipmentProfilesScreen> 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<EquipmentProfile> 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),
),
),
),
),
),
);
}
}

View file

@ -48,7 +48,7 @@ class _FilmEditScreenState extends State<FilmEditScreen> {
context.read<FilmEditBloc>().add(const FilmEditSaveEvent()); context.read<FilmEditBloc>().add(const FilmEditSaveEvent());
} }
: null, : null,
icon: const Icon(Icons.save_outlined), icon: const Icon(Icons.save),
), ),
), ),
if (widget.isEdit) if (widget.isEdit)
@ -56,7 +56,7 @@ class _FilmEditScreenState extends State<FilmEditScreen> {
onPressed: () { onPressed: () {
context.read<FilmEditBloc>().add(const FilmEditDeleteEvent()); context.read<FilmEditBloc>().add(const FilmEditDeleteEvent());
}, },
icon: const Icon(Icons.delete_outlined), icon: const Icon(Icons.delete),
), ),
], ],
slivers: [ slivers: [

View file

@ -78,7 +78,7 @@ class _FilmsScreenState extends State<FilmsScreen> with SingleTickerProviderStat
void _addFilm() { void _addFilm() {
Navigator.of(context).pushNamed( Navigator.of(context).pushNamed(
NavigationRoutes.filmEditScreen.name, NavigationRoutes.filmAddScreen.name,
arguments: const FilmEditArgs(), arguments: const FilmEditArgs(),
); );
} }
@ -135,7 +135,7 @@ class _FilmsListBuilder<T extends Film> extends StatelessWidget {
secondary: onFilmEdit != null secondary: onFilmEdit != null
? IconButton( ? IconButton(
onPressed: () => onFilmEdit!(films[index]), onPressed: () => onFilmEdit!(films[index]),
icon: const Icon(Icons.edit_outlined), icon: const Icon(Icons.edit),
) )
: null, : null,
), ),

View file

@ -14,9 +14,9 @@ class EquipmentProfilePicker extends StatelessWidget {
icon: Icons.camera_outlined, icon: Icons.camera_outlined,
title: S.of(context).equipmentProfile, title: S.of(context).equipmentProfile,
selectedValue: EquipmentProfiles.selectedOf(context), selectedValue: EquipmentProfiles.selectedOf(context),
values: EquipmentProfiles.inUseOf(context), values: EquipmentProfiles.of(context),
itemTitleBuilder: (_, value) => Text(value.id.isEmpty ? S.of(context).none : value.name), itemTitleBuilder: (_, value) => Text(value.id.isEmpty ? S.of(context).none : value.name),
onChanged: EquipmentProfilesProvider.of(context).selectProfile, onChanged: EquipmentProfileProvider.of(context).setProfile,
closedChild: ReadingValueContainer.singleValue( closedChild: ReadingValueContainer.singleValue(
value: ReadingValue( value: ReadingValue(
label: S.of(context).equipmentProfile, label: S.of(context).equipmentProfile,

View file

@ -0,0 +1,310 @@
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<EquipmentProfile> 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<EquipmentProfileContainer> createState() => EquipmentProfileContainerState();
}
class EquipmentProfileContainerState extends State<EquipmentProfileContainer> 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<String>(
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<double> get _progress => listenable as Animation<double>;
@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<double> get _progress => listenable as Animation<double>;
@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<List<ApertureValue>> onApertureValuesSelected;
final ValueChanged<List<IsoValue>> onIsoValuesSelecred;
final ValueChanged<List<NdValue>> onNdValuesSelected;
final ValueChanged<List<ShutterSpeedValue>> onShutterSpeedValuesSelected;
final ValueChanged<double> 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<double> get _progress => listenable as Animation<double>;
@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<IsoValue>(
icon: Icons.iso_outlined,
title: S.of(context).isoValues,
description: S.of(context).isoValuesFilterDescription,
values: IsoValue.values,
selectedValues: equipmentData.isoValues,
onChanged: onIsoValuesSelecred,
),
FilterListTile<NdValue>(
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<ApertureValue>(
icon: Icons.camera_outlined,
title: S.of(context).apertureValues,
description: S.of(context).apertureValuesFilterDescription,
values: ApertureValue.values,
selectedValues: equipmentData.apertureValues,
onChanged: onApertureValuesSelected,
),
RangePickerListTile<ShutterSpeedValue>(
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,
),
],
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,49 @@
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<EquipmentProfileNameDialog> createState() => _EquipmentProfileNameDialogState();
}
class _EquipmentProfileNameDialogState extends State<EquipmentProfileNameDialog> {
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),
),
),
],
);
}
}

View file

@ -0,0 +1,128 @@
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<EquipmentProfilesScreen> createState() => _EquipmentProfilesScreenState();
}
class _EquipmentProfilesScreenState extends State<EquipmentProfilesScreen> {
final Map<String, GlobalKey<EquipmentProfileContainerState>> 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<String>(
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<String> idsToAdd = [];
for (final profile in profiles) {
if (!keysMap.keys.contains(profile.id)) idsToAdd.add(profile.id);
}
for (final id in idsToAdd) {
keysMap[id] = GlobalKey<EquipmentProfileContainerState>(debugLabel: id);
}
idsToAdd.clear();
} else if (profiles.length < keysMap.length) {
// profile deleted
final List<String> 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
}
}
}

View file

@ -1,7 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/navigation/routes.dart'; import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart';
import 'package:lightmeter/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.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 { class EquipmentProfilesListTile extends StatelessWidget {
const EquipmentProfilesListTile({super.key}); const EquipmentProfilesListTile({super.key});
@ -12,7 +13,9 @@ class EquipmentProfilesListTile extends StatelessWidget {
leading: const Icon(Icons.camera_outlined), leading: const Icon(Icons.camera_outlined),
title: Text(S.of(context).equipmentProfiles), title: Text(S.of(context).equipmentProfiles),
onTap: () { onTap: () {
Navigator.of(context).pushNamed(NavigationRoutes.equipmentProfilesListScreen.name); Navigator.of(context).push<EquipmentProfile>(
MaterialPageRoute(builder: (_) => const EquipmentProfilesScreen()),
);
}, },
); );
} }

View file

@ -36,7 +36,7 @@ class MeteringScreenLayoutListTile extends StatelessWidget {
}, },
onSave: (value) { onSave: (value) {
if (!value[MeteringScreenLayoutFeature.equipmentProfiles]!) { if (!value[MeteringScreenLayoutFeature.equipmentProfiles]!) {
EquipmentProfilesProvider.of(context).selectProfile(EquipmentProfiles.of(context).first); EquipmentProfileProvider.of(context).setProfile(EquipmentProfiles.of(context).first);
} }
if (!value[MeteringScreenLayoutFeature.filmPicker]!) { if (!value[MeteringScreenLayoutFeature.filmPicker]!) {
FilmsProvider.of(context).selectFilm(const FilmStub()); FilmsProvider.of(context).selectFilm(const FilmStub());

View file

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/services.dart' show rootBundle;
import 'package:lightmeter/data/models/supported_locale.dart';
import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/providers/user_preferences_provider.dart';
import 'package:lightmeter/res/dimens.dart'; import 'package:lightmeter/res/dimens.dart';
class ReleaseNotesDialog extends StatelessWidget { class ReleaseNotesDialog extends StatelessWidget {
@ -38,8 +40,17 @@ class ReleaseNotesDialog extends StatelessWidget {
} }
Future<String> loadReleaseNotes(BuildContext context) async { Future<String> loadReleaseNotes(BuildContext context) async {
late final String localeName;
switch (UserPreferencesProvider.localeOf(context)) {
case SupportedLocale.ru:
localeName = SupportedLocale.ru.name;
default:
localeName = SupportedLocale.en.name;
}
try { try {
return rootBundle.loadString('assets/release_notes/release_notes_en_$version.md'); return rootBundle.loadString('assets/release_notes/release_notes_${localeName}_$version.md');
} catch (e) { } catch (e) {
return ''; return '';
} }

View file

@ -32,11 +32,11 @@ dependencies:
m3_lightmeter_iap: m3_lightmeter_iap:
git: git:
url: "https://github.com/vodemn/m3_lightmeter_iap" url: "https://github.com/vodemn/m3_lightmeter_iap"
ref: v2.1.0 ref: v1.1.1
m3_lightmeter_resources: m3_lightmeter_resources:
git: git:
url: "https://github.com/vodemn/m3_lightmeter_resources" url: "https://github.com/vodemn/m3_lightmeter_resources"
ref: v2.1.0 ref: v2.0.0
material_color_utilities: 0.5.0 material_color_utilities: 0.5.0
package_info_plus: 4.2.0 package_info_plus: 4.2.0
permission_handler: 10.4.3 permission_handler: 10.4.3

View file

@ -15,10 +15,10 @@ import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/res/dimens.dart'; import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/res/theme.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/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/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart';
import 'package:lightmeter/screens/metering/screen_metering.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/settings/screen_settings.dart';
import 'package:lightmeter/screens/shared/animated_circular_button/widget_button_circular_animated.dart'; import 'package:lightmeter/screens/shared/animated_circular_button/widget_button_circular_animated.dart';
import 'package:lightmeter/screens/timer/screen_timer.dart'; import 'package:lightmeter/screens/timer/screen_timer.dart';
@ -92,7 +92,7 @@ void main() {
testWidgets('Generate light theme screenshots', (tester) async { testWidgets('Generate light theme screenshots', (tester) async {
await mockSharedPrefs(theme: ThemeType.light, color: _lightThemeColor); await mockSharedPrefs(theme: ThemeType.light, color: _lightThemeColor);
await tester.pumpApplication( await tester.pumpApplication(
predefinedFilms: [_mockFilm].toTogglableMap(), predefinedFilms: [_mockFilm].toFilmsMap(),
customFilms: {}, customFilms: {},
selectedFilmId: _mockFilm.id, selectedFilmId: _mockFilm.id,
); );
@ -132,7 +132,7 @@ void main() {
(tester) async { (tester) async {
await mockSharedPrefs(theme: ThemeType.dark, color: _darkThemeColor); await mockSharedPrefs(theme: ThemeType.dark, color: _darkThemeColor);
await tester.pumpApplication( await tester.pumpApplication(
predefinedFilms: [_mockFilm].toTogglableMap(), predefinedFilms: [_mockFilm].toFilmsMap(),
customFilms: {}, customFilms: {},
selectedFilmId: _mockFilm.id, selectedFilmId: _mockFilm.id,
); );
@ -157,7 +157,7 @@ void main() {
color: _lightThemeColor, color: _lightThemeColor,
); );
await tester.pumpApplication( await tester.pumpApplication(
predefinedFilms: [_mockFilm].toTogglableMap(), predefinedFilms: [_mockFilm].toFilmsMap(),
customFilms: {}, customFilms: {},
selectedFilmId: _mockFilm.id, selectedFilmId: _mockFilm.id,
); );

View file

@ -108,6 +108,7 @@ class _GoldenTestApplicationMockState extends State<GoldenTestApplicationMock> {
], ],
child: _MockApplicationWrapper( child: _MockApplicationWrapper(
child: MockIAPProviders( child: MockIAPProviders(
equipmentProfiles: mockEquipmentProfiles,
selectedEquipmentProfileId: mockEquipmentProfiles.first.id, selectedEquipmentProfileId: mockEquipmentProfiles.first.id,
selectedFilmId: mockFilms.first.id, selectedFilmId: mockFilms.first.id,
child: Builder( child: Builder(

View file

@ -5,28 +5,14 @@ import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
class _MockEquipmentProfilesStorageService extends Mock implements EquipmentProfilesStorageService {} class _MockIAPStorageService extends Mock implements IAPStorageService {}
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
late _MockEquipmentProfilesStorageService storageService; late _MockIAPStorageService storageService;
setUpAll(() { setUpAll(() {
storageService = _MockEquipmentProfilesStorageService(); storageService = _MockIAPStorageService();
});
setUp(() {
registerFallbackValue(_customProfiles.first);
when(() => storageService.addProfile(any<EquipmentProfile>())).thenAnswer((_) async {});
when(
() => storageService.updateProfile(
id: any<String>(named: 'id'),
name: any<String>(named: 'name'),
isUsed: any<bool>(named: 'isUsed'),
),
).thenAnswer((_) async {});
when(() => storageService.deleteProfile(any<String>())).thenAnswer((_) async {});
when(() => storageService.getProfiles()).thenAnswer((_) => Future.value(_customProfiles.toTogglableMap()));
}); });
tearDown(() { tearDown(() {
@ -43,40 +29,35 @@ void main() {
price: '0.0\$', price: '0.0\$',
), ),
], ],
child: EquipmentProfilesProvider( child: EquipmentProfileProvider(
storageService: storageService, storageService: storageService,
child: const _Application(), child: const _Application(),
), ),
), ),
); );
await tester.pumpAndSettle();
} }
void expectEquipmentProfilesCount(int count) { void expectEquipmentProfilesCount(int count) {
expect(find.text(_EquipmentProfilesCount.text(count)), findsOneWidget); expect(find.text('Equipment profiles count: $count'), findsOneWidget);
}
void expectEquipmentProfilesInUseCount(int count) {
expect(find.text(_EquipmentProfilesInUseCount.text(count)), findsOneWidget);
} }
void expectSelectedEquipmentProfileName(String name) { void expectSelectedEquipmentProfileName(String name) {
expect(find.text(_SelectedEquipmentProfile.text(name)), findsOneWidget); expect(find.text('Selected equipment profile: $name'), findsOneWidget);
} }
group( group(
'EquipmentProfilesProvider dependency on IAPProductStatus', 'EquipmentProfileProvider dependency on IAPProductStatus',
() { () {
setUp(() { setUp(() {
when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles.first.id); when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles.first.id);
when(() => storageService.getProfiles()).thenAnswer((_) => Future.value(_customProfiles.toTogglableMap())); when(() => storageService.equipmentProfiles).thenReturn(_customProfiles);
}); });
testWidgets( testWidgets(
'IAPProductStatus.purchased - show all saved profiles', 'IAPProductStatus.purchased - show all saved profiles',
(tester) async { (tester) async {
await pumpTestWidget(tester, IAPProductStatus.purchased); await pumpTestWidget(tester, IAPProductStatus.purchased);
expectEquipmentProfilesCount(_customProfiles.length + 1); expectEquipmentProfilesCount(3);
expectSelectedEquipmentProfileName(_customProfiles.first.name); expectSelectedEquipmentProfileName(_customProfiles.first.name);
}, },
); );
@ -101,137 +82,203 @@ void main() {
}, },
); );
testWidgets( group('EquipmentProfileProvider CRUD', () {
'toggleProfile', testWidgets(
(tester) async { 'Add',
when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles.first.id); (tester) async {
await pumpTestWidget(tester, IAPProductStatus.purchased); when(() => storageService.equipmentProfiles).thenReturn([]);
expectEquipmentProfilesCount(_customProfiles.length + 1); when(() => storageService.selectedEquipmentProfileId).thenReturn('');
expectEquipmentProfilesInUseCount(_customProfiles.length + 1);
expectSelectedEquipmentProfileName(_customProfiles.first.name);
await tester.equipmentProfilesProvider.toggleProfile(_customProfiles.first, false); await pumpTestWidget(tester, IAPProductStatus.purchased);
await tester.pump(); expectEquipmentProfilesCount(1);
expectEquipmentProfilesCount(_customProfiles.length + 1); expectSelectedEquipmentProfileName('');
expectEquipmentProfilesInUseCount(_customProfiles.length + 1 - 1);
expectSelectedEquipmentProfileName('');
verify(() => storageService.updateProfile(id: _customProfiles.first.id, isUsed: false)).called(1); await tester.tap(find.byKey(_Application.addProfileButtonKey));
verify(() => storageService.selectedEquipmentProfileId = '').called(1); await tester.pump();
}, expectEquipmentProfilesCount(2);
); expectSelectedEquipmentProfileName('');
testWidgets( verifyNever(() => storageService.selectedEquipmentProfileId = '');
'EquipmentProfilesProvider CRUD', verify(() => storageService.equipmentProfiles = any<List<EquipmentProfile>>()).called(1);
(tester) async { },
when(() => storageService.getProfiles()).thenAnswer((_) async => {}); );
when(() => storageService.selectedEquipmentProfileId).thenReturn('');
await pumpTestWidget(tester, IAPProductStatus.purchased); testWidgets(
expectEquipmentProfilesCount(1); 'Add from',
expectSelectedEquipmentProfileName(''); (tester) async {
when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles));
when(() => storageService.selectedEquipmentProfileId).thenReturn('');
/// Add first profile and verify await pumpTestWidget(tester, IAPProductStatus.purchased);
await tester.equipmentProfilesProvider.addProfile(_customProfiles.first); expectEquipmentProfilesCount(3);
await tester.pump(); expectSelectedEquipmentProfileName('');
expectEquipmentProfilesCount(2);
expectSelectedEquipmentProfileName('');
verify(() => storageService.addProfile(any<EquipmentProfile>())).called(1);
/// Add the other profiles and select the 1st one await tester.tap(find.byKey(_Application.addFromProfileButtonKey(_customProfiles[0].id)));
for (final profile in _customProfiles.skip(1)) { await tester.pump();
await tester.equipmentProfilesProvider.addProfile(profile); expectEquipmentProfilesCount(4);
} expectSelectedEquipmentProfileName('');
tester.equipmentProfilesProvider.selectProfile(_customProfiles.first);
await tester.pump();
expectEquipmentProfilesCount(1 + _customProfiles.length);
expectSelectedEquipmentProfileName(_customProfiles.first.name);
/// Edit the selected profile verifyNever(() => storageService.selectedEquipmentProfileId = '');
final updatedName = "${_customProfiles.first} updated"; verify(() => storageService.equipmentProfiles = any<List<EquipmentProfile>>()).called(1);
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);
/// Delete a non-selected profile testWidgets(
await tester.equipmentProfilesProvider.deleteProfile(_customProfiles.last); 'Edit selected',
await tester.pump(); (tester) async {
expectEquipmentProfilesCount(1 + _customProfiles.length - 1); when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles));
expectSelectedEquipmentProfileName(updatedName); when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id);
verifyNever(() => storageService.selectedEquipmentProfileId = '');
verify(() => storageService.deleteProfile(_customProfiles.last.id)).called(1);
/// Delete the selected profile await pumpTestWidget(tester, IAPProductStatus.purchased);
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);
},
);
}
extension on WidgetTester { /// Change the name & limit ISO values of the both added profiles
EquipmentProfilesProviderState get equipmentProfilesProvider { await tester.tap(find.byKey(_Application.updateProfileButtonKey(_customProfiles[0].id)));
final BuildContext context = element(find.byType(_Application)); await tester.pumpAndSettle();
return EquipmentProfilesProvider.of(context); expectEquipmentProfilesCount(3);
} expectSelectedEquipmentProfileName("${_customProfiles[0].name} updated");
verifyNever(() => storageService.selectedEquipmentProfileId = _customProfiles[0].id);
verify(() => storageService.equipmentProfiles = any<List<EquipmentProfile>>()).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<List<EquipmentProfile>>()).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<List<EquipmentProfile>>()).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<List<EquipmentProfile>>());
},
);
});
} }
class _Application extends StatelessWidget { class _Application extends StatelessWidget {
const _Application(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const MaterialApp( return MaterialApp(
home: Scaffold( home: Scaffold(
appBar: AppBar(title: const Text('IAPProviders test')),
body: Center( body: Center(
child: Column( child: Column(
children: [ children: [
_EquipmentProfilesCount(), Text("Equipment profiles count: ${EquipmentProfiles.of(context).length}"),
_EquipmentProfilesInUseCount(), Text("Selected equipment profile: ${EquipmentProfiles.selectedOf(context).name}"),
_SelectedEquipmentProfile(), ElevatedButton(
key: addProfileButtonKey,
onPressed: () {
EquipmentProfileProvider.of(context).addProfile('Test added');
},
child: const Text("Add"),
),
...EquipmentProfiles.of(context).map((e) => _equipmentProfilesCrudRow(context, e)),
], ],
), ),
), ),
), ),
); );
} }
}
class _EquipmentProfilesCount extends StatelessWidget { Widget _equipmentProfilesCrudRow(BuildContext context, EquipmentProfile profile) {
static String text(int count) => "Profiles count: $count"; return Row(
children: [
const _EquipmentProfilesCount(); ElevatedButton(
key: setProfileButtonKey(profile.id),
@override onPressed: () {
Widget build(BuildContext context) { EquipmentProfileProvider.of(context).setProfile(profile);
return Text(text(EquipmentProfiles.of(context).length)); },
} child: const Text("Set"),
} ),
ElevatedButton(
class _EquipmentProfilesInUseCount extends StatelessWidget { key: addFromProfileButtonKey(profile.id),
static String text(int count) => "Profiles in use count: $count"; onPressed: () {
EquipmentProfileProvider.of(context).addProfile('Test from ${profile.name}', profile);
const _EquipmentProfilesInUseCount(); },
child: const Text("Add from"),
@override ),
Widget build(BuildContext context) { ElevatedButton(
return Text(text(EquipmentProfiles.inUseOf(context).length)); key: updateProfileButtonKey(profile.id),
} onPressed: () {
} EquipmentProfileProvider.of(context).updateProfile(
profile.copyWith(
class _SelectedEquipmentProfile extends StatelessWidget { name: '${profile.name} updated',
static String text(String name) => "Selected profile: $name}"; isoValues: _customProfiles.first.isoValues,
),
const _SelectedEquipmentProfile(); );
},
@override child: const Text("Update"),
Widget build(BuildContext context) { ),
return Text(text(EquipmentProfiles.selectedOf(context).name)); ElevatedButton(
key: deleteProfileButtonKey(profile.id),
onPressed: () {
EquipmentProfileProvider.of(context).deleteProfile(profile);
},
child: const Text("Delete"),
),
],
);
} }
} }

View file

@ -9,24 +9,25 @@ class _MockFilmsStorageService extends Mock implements FilmsStorageService {}
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
late _MockFilmsStorageService storageService; late _MockFilmsStorageService mockFilmsStorageService;
setUpAll(() { setUpAll(() {
storageService = _MockFilmsStorageService(); mockFilmsStorageService = _MockFilmsStorageService();
}); });
setUp(() { setUp(() {
registerFallbackValue(mockCustomFilms.first); registerFallbackValue(mockCustomFilms.first);
when(() => storageService.toggleFilm(any<Film>(), any<bool>())).thenAnswer((_) async {}); when(() => mockFilmsStorageService.toggleFilm(any<Film>(), any<bool>())).thenAnswer((_) async {});
when(() => storageService.addFilm(any<FilmExponential>())).thenAnswer((_) async {}); when(() => mockFilmsStorageService.addFilm(any<FilmExponential>())).thenAnswer((_) async {});
when(() => storageService.updateFilm(any<FilmExponential>())).thenAnswer((_) async {}); when(() => mockFilmsStorageService.updateFilm(any<FilmExponential>())).thenAnswer((_) async {});
when(() => storageService.deleteFilm(any<FilmExponential>())).thenAnswer((_) async {}); when(() => mockFilmsStorageService.deleteFilm(any<FilmExponential>())).thenAnswer((_) async {});
when(() => storageService.getPredefinedFilms()).thenAnswer((_) => Future.value(mockPredefinedFilms.toFilmsMap())); when(() => mockFilmsStorageService.getPredefinedFilms())
when(() => storageService.getCustomFilms()).thenAnswer((_) => Future.value(mockCustomFilms.toFilmsMap())); .thenAnswer((_) => Future.value(mockPredefinedFilms.toFilmsMap()));
when(() => mockFilmsStorageService.getCustomFilms()).thenAnswer((_) => Future.value(mockCustomFilms.toFilmsMap()));
}); });
tearDown(() { tearDown(() {
reset(storageService); reset(mockFilmsStorageService);
}); });
Future<void> pumpTestWidget(WidgetTester tester, IAPProductStatus productStatus) async { Future<void> pumpTestWidget(WidgetTester tester, IAPProductStatus productStatus) async {
@ -40,7 +41,7 @@ void main() {
), ),
], ],
child: FilmsProvider( child: FilmsProvider(
storageService: storageService, filmsStorageService: mockFilmsStorageService,
child: const _Application(), child: const _Application(),
), ),
), ),
@ -68,10 +69,11 @@ void main() {
'FilmsProvider dependency on IAPProductStatus', 'FilmsProvider dependency on IAPProductStatus',
() { () {
setUp(() { setUp(() {
when(() => storageService.selectedFilmId).thenReturn(mockPredefinedFilms.first.id); when(() => mockFilmsStorageService.selectedFilmId).thenReturn(mockPredefinedFilms.first.id);
when(() => storageService.getPredefinedFilms()) when(() => mockFilmsStorageService.getPredefinedFilms())
.thenAnswer((_) => Future.value(mockPredefinedFilms.toFilmsMap())); .thenAnswer((_) => Future.value(mockPredefinedFilms.toFilmsMap()));
when(() => storageService.getCustomFilms()).thenAnswer((_) => Future.value(mockCustomFilms.toFilmsMap())); when(() => mockFilmsStorageService.getCustomFilms())
.thenAnswer((_) => Future.value(mockCustomFilms.toFilmsMap()));
}); });
testWidgets( testWidgets(
@ -115,7 +117,7 @@ void main() {
testWidgets( testWidgets(
'toggle predefined film', 'toggle predefined film',
(tester) async { (tester) async {
when(() => storageService.selectedFilmId).thenReturn(mockPredefinedFilms.first.id); when(() => mockFilmsStorageService.selectedFilmId).thenReturn(mockPredefinedFilms.first.id);
await pumpTestWidget(tester, IAPProductStatus.purchased); await pumpTestWidget(tester, IAPProductStatus.purchased);
expectPredefinedFilmsCount(mockPredefinedFilms.length); expectPredefinedFilmsCount(mockPredefinedFilms.length);
expectCustomFilmsCount(mockCustomFilms.length); expectCustomFilmsCount(mockCustomFilms.length);
@ -129,15 +131,15 @@ void main() {
expectFilmsInUseCount(mockPredefinedFilms.length - 1 + mockCustomFilms.length + 1); expectFilmsInUseCount(mockPredefinedFilms.length - 1 + mockCustomFilms.length + 1);
expectSelectedFilmName(''); expectSelectedFilmName('');
verify(() => storageService.toggleFilm(mockPredefinedFilms.first, false)).called(1); verify(() => mockFilmsStorageService.toggleFilm(mockPredefinedFilms.first, false)).called(1);
verify(() => storageService.selectedFilmId = '').called(1); verify(() => mockFilmsStorageService.selectedFilmId = '').called(1);
}, },
); );
testWidgets( testWidgets(
'toggle custom film', 'toggle custom film',
(tester) async { (tester) async {
when(() => storageService.selectedFilmId).thenReturn(mockCustomFilms.first.id); when(() => mockFilmsStorageService.selectedFilmId).thenReturn(mockCustomFilms.first.id);
await pumpTestWidget(tester, IAPProductStatus.purchased); await pumpTestWidget(tester, IAPProductStatus.purchased);
expectPredefinedFilmsCount(mockPredefinedFilms.length); expectPredefinedFilmsCount(mockPredefinedFilms.length);
expectCustomFilmsCount(mockCustomFilms.length); expectCustomFilmsCount(mockCustomFilms.length);
@ -151,8 +153,8 @@ void main() {
expectFilmsInUseCount(mockPredefinedFilms.length - 1 + mockCustomFilms.length + 1); expectFilmsInUseCount(mockPredefinedFilms.length - 1 + mockCustomFilms.length + 1);
expectSelectedFilmName(''); expectSelectedFilmName('');
verify(() => storageService.toggleFilm(mockCustomFilms.first, false)).called(1); verify(() => mockFilmsStorageService.toggleFilm(mockCustomFilms.first, false)).called(1);
verify(() => storageService.selectedFilmId = '').called(1); verify(() => mockFilmsStorageService.selectedFilmId = '').called(1);
}, },
); );
}, },
@ -161,7 +163,7 @@ void main() {
testWidgets( testWidgets(
'selectFilm', 'selectFilm',
(tester) async { (tester) async {
when(() => storageService.selectedFilmId).thenReturn(''); when(() => mockFilmsStorageService.selectedFilmId).thenReturn('');
await pumpTestWidget(tester, IAPProductStatus.purchased); await pumpTestWidget(tester, IAPProductStatus.purchased);
expectSelectedFilmName(''); expectSelectedFilmName('');
@ -173,16 +175,16 @@ void main() {
await tester.pump(); await tester.pump();
expectSelectedFilmName(mockCustomFilms.first.name); expectSelectedFilmName(mockCustomFilms.first.name);
verify(() => storageService.selectedFilmId = mockPredefinedFilms.first.id).called(1); verify(() => mockFilmsStorageService.selectedFilmId = mockPredefinedFilms.first.id).called(1);
verify(() => storageService.selectedFilmId = mockCustomFilms.first.id).called(1); verify(() => mockFilmsStorageService.selectedFilmId = mockCustomFilms.first.id).called(1);
}, },
); );
testWidgets( testWidgets(
'Custom film CRUD', 'Custom film CRUD',
(tester) async { (tester) async {
when(() => storageService.selectedFilmId).thenReturn(''); when(() => mockFilmsStorageService.selectedFilmId).thenReturn('');
when(() => storageService.getCustomFilms()).thenAnswer((_) => Future.value({})); when(() => mockFilmsStorageService.getCustomFilms()).thenAnswer((_) => Future.value({}));
await pumpTestWidget(tester, IAPProductStatus.purchased); await pumpTestWidget(tester, IAPProductStatus.purchased);
expectPredefinedFilmsCount(mockPredefinedFilms.length); expectPredefinedFilmsCount(mockPredefinedFilms.length);
expectCustomFilmsCount(0); expectCustomFilmsCount(0);
@ -197,16 +199,16 @@ void main() {
expectCustomFilmsCount(1); expectCustomFilmsCount(1);
expectFilmsInUseCount(mockPredefinedFilms.length + 1 + 1); expectFilmsInUseCount(mockPredefinedFilms.length + 1 + 1);
expectSelectedFilmName(mockCustomFilms.first.name); expectSelectedFilmName(mockCustomFilms.first.name);
verify(() => storageService.addFilm(mockCustomFilms.first)).called(1); verify(() => mockFilmsStorageService.addFilm(mockCustomFilms.first)).called(1);
verify(() => storageService.toggleFilm(mockCustomFilms.first, true)).called(1); verify(() => mockFilmsStorageService.toggleFilm(mockCustomFilms.first, true)).called(1);
verify(() => storageService.selectedFilmId = mockCustomFilms.first.id).called(1); verify(() => mockFilmsStorageService.selectedFilmId = mockCustomFilms.first.id).called(1);
const editedFilmName = 'Edited custom film 2x'; const editedFilmName = 'Edited custom film 2x';
final editedFilm = mockCustomFilms.first.copyWith(name: editedFilmName); final editedFilm = mockCustomFilms.first.copyWith(name: editedFilmName);
await tester.filmsProvider.updateCustomFilm(editedFilm); await tester.filmsProvider.updateCustomFilm(editedFilm);
await tester.pump(); await tester.pump();
expectSelectedFilmName(editedFilm.name); expectSelectedFilmName(editedFilm.name);
verify(() => storageService.updateFilm(editedFilm)).called(1); verify(() => mockFilmsStorageService.updateFilm(editedFilm)).called(1);
await tester.filmsProvider.deleteCustomFilm(editedFilm); await tester.filmsProvider.deleteCustomFilm(editedFilm);
await tester.pump(); await tester.pump();
@ -214,8 +216,8 @@ void main() {
expectCustomFilmsCount(0); expectCustomFilmsCount(0);
expectFilmsInUseCount(mockPredefinedFilms.length + 0 + 1); expectFilmsInUseCount(mockPredefinedFilms.length + 0 + 1);
expectSelectedFilmName(''); expectSelectedFilmName('');
verify(() => storageService.deleteFilm(editedFilm)).called(1); verify(() => mockFilmsStorageService.deleteFilm(editedFilm)).called(1);
verify(() => storageService.selectedFilmId = '').called(1); verify(() => mockFilmsStorageService.selectedFilmId = '').called(1);
}, },
); );
} }
@ -305,6 +307,6 @@ const mockCustomFilms = [
]; ];
extension on List<Film> { extension on List<Film> {
Map<String, ({T value, bool isUsed})> toFilmsMap<T extends Film>() => Map<String, ({T film, bool isUsed})> toFilmsMap<T extends Film>() =>
Map.fromEntries(map((e) => MapEntry(e.id, (value: e as T, isUsed: true)))); Map.fromEntries(map((e) => MapEntry(e.id, (film: e as T, isUsed: true))));
} }

View file

@ -10,15 +10,15 @@ import 'package:mocktail/mocktail.dart';
import '../../../../../application_mock.dart'; import '../../../../../application_mock.dart';
import 'utils.dart'; import 'utils.dart';
class _MockEquipmentProfilesStorageService extends Mock implements EquipmentProfilesStorageService {} class _MockIAPStorageService extends Mock implements IAPStorageService {}
void main() { void main() {
late final _MockEquipmentProfilesStorageService storageService; late final _MockIAPStorageService mockIAPStorageService;
setUpAll(() { setUpAll(() {
storageService = _MockEquipmentProfilesStorageService(); mockIAPStorageService = _MockIAPStorageService();
when(() => storageService.getProfiles()).thenAnswer((_) async => _mockEquipmentProfiles.toTogglableMap()); when(() => mockIAPStorageService.equipmentProfiles).thenReturn(_mockEquipmentProfiles);
when(() => storageService.selectedEquipmentProfileId).thenReturn(''); when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn('');
}); });
Future<void> pumpApplication(WidgetTester tester) async { Future<void> pumpApplication(WidgetTester tester) async {
@ -31,8 +31,8 @@ void main() {
price: '0.0\$', price: '0.0\$',
), ),
], ],
child: EquipmentProfilesProvider( child: EquipmentProfileProvider(
storageService: storageService, storageService: mockIAPStorageService,
child: const WidgetTestApplicationMock( child: const WidgetTestApplicationMock(
child: Row(children: [Expanded(child: EquipmentProfilePicker())]), child: Row(children: [Expanded(child: EquipmentProfilePicker())]),
), ),
@ -59,7 +59,7 @@ void main() {
testWidgets( testWidgets(
'None', 'None',
(tester) async { (tester) async {
when(() => storageService.selectedEquipmentProfileId).thenReturn(''); when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn('');
await pumpApplication(tester); await pumpApplication(tester);
expectReadingValueContainerText(S.current.none); expectReadingValueContainerText(S.current.none);
await tester.openAnimatedPicker<EquipmentProfilePicker>(); await tester.openAnimatedPicker<EquipmentProfilePicker>();
@ -70,7 +70,7 @@ void main() {
testWidgets( testWidgets(
'Praktica + Zenitar', 'Praktica + Zenitar',
(tester) async { (tester) async {
when(() => storageService.selectedEquipmentProfileId).thenReturn(_mockEquipmentProfiles.first.id); when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn(_mockEquipmentProfiles.first.id);
await pumpApplication(tester); await pumpApplication(tester);
expectReadingValueContainerText(_mockEquipmentProfiles.first.name); expectReadingValueContainerText(_mockEquipmentProfiles.first.name);
await tester.openAnimatedPicker<EquipmentProfilePicker>(); await tester.openAnimatedPicker<EquipmentProfilePicker>();
@ -79,18 +79,6 @@ 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<EquipmentProfilePicker>();
expectRadioListTile<EquipmentProfile>(S.current.none, isSelected: true);
expectRadioListTile<EquipmentProfile>(_mockEquipmentProfiles[1].name);
},
);
} }
final _mockEquipmentProfiles = [ final _mockEquipmentProfiles = [

View file

@ -18,7 +18,7 @@ void main() {
setUpAll(() { setUpAll(() {
mockFilmsStorageService = _MockFilmsStorageService(); mockFilmsStorageService = _MockFilmsStorageService();
when(() => mockFilmsStorageService.getPredefinedFilms()).thenAnswer( when(() => mockFilmsStorageService.getPredefinedFilms()).thenAnswer(
(_) => Future.value(Map.fromEntries(_films.map((e) => MapEntry(e.id, (value: e, isUsed: true))))), (_) => Future.value(Map.fromEntries(_films.map((e) => MapEntry(e.id, (film: e, isUsed: true))))),
); );
when(() => mockFilmsStorageService.getCustomFilms()).thenAnswer( when(() => mockFilmsStorageService.getCustomFilms()).thenAnswer(
(_) => Future.value({}), (_) => Future.value({}),
@ -36,7 +36,7 @@ void main() {
), ),
], ],
child: FilmsProvider( child: FilmsProvider(
storageService: mockFilmsStorageService, filmsStorageService: mockFilmsStorageService,
child: const WidgetTestApplicationMock( child: const WidgetTestApplicationMock(
child: Row( child: Row(
children: [ children: [

View file

@ -8,26 +8,14 @@ import 'package:mocktail/mocktail.dart';
import '../../../function_mock.dart'; import '../../../function_mock.dart';
class _MockEquipmentProfilesStorageService extends Mock implements EquipmentProfilesStorageService {} class _MockIAPStorageService extends Mock implements IAPStorageService {}
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
final storageService = _MockEquipmentProfilesStorageService(); final storageService = _MockIAPStorageService();
final equipmentProfileProviderKey = GlobalKey<EquipmentProfileProviderState>();
final onDidChangeDependencies = MockValueChanged<EquipmentProfile>(); final onDidChangeDependencies = MockValueChanged<EquipmentProfile>();
setUp(() {
registerFallbackValue(_customProfiles.first);
when(() => storageService.addProfile(any<EquipmentProfile>())).thenAnswer((_) async {});
when(
() => storageService.updateProfile(
id: any<String>(named: 'id'),
name: any<String>(named: 'name'),
),
).thenAnswer((_) async {});
when(() => storageService.deleteProfile(any<String>())).thenAnswer((_) async {});
when(() => storageService.getProfiles()).thenAnswer((_) => Future.value(_customProfiles.toTogglableMap()));
});
tearDown(() { tearDown(() {
reset(onDidChangeDependencies); reset(onDidChangeDependencies);
reset(storageService); reset(storageService);
@ -43,7 +31,8 @@ void main() {
price: '0.0\$', price: '0.0\$',
), ),
], ],
child: EquipmentProfilesProvider( child: EquipmentProfileProvider(
key: equipmentProfileProviderKey,
storageService: storageService, storageService: storageService,
child: MaterialApp( child: MaterialApp(
home: EquipmentProfileListener( home: EquipmentProfileListener(
@ -59,10 +48,11 @@ void main() {
testWidgets( testWidgets(
'Trigger `onDidChangeDependencies` by selecting a new profile', 'Trigger `onDidChangeDependencies` by selecting a new profile',
(tester) async { (tester) async {
when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles));
when(() => storageService.selectedEquipmentProfileId).thenReturn(''); when(() => storageService.selectedEquipmentProfileId).thenReturn('');
await pumpTestWidget(tester); await pumpTestWidget(tester);
tester.equipmentProfilesProvider.selectProfile(_customProfiles[0]); equipmentProfileProviderKey.currentState!.setProfile(_customProfiles[0]);
await tester.pump(); await tester.pump();
verify(() => onDidChangeDependencies.onChanged(_customProfiles[0])).called(1); verify(() => onDidChangeDependencies.onChanged(_customProfiles[0])).called(1);
}, },
@ -71,17 +61,18 @@ void main() {
testWidgets( testWidgets(
'Trigger `onDidChangeDependencies` by updating the selected profile', 'Trigger `onDidChangeDependencies` by updating the selected profile',
(tester) async { (tester) async {
when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles));
when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id); when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id);
await pumpTestWidget(tester); await pumpTestWidget(tester);
final updatedProfile1 = _customProfiles[0].copyWith(name: 'Test 1 updated'); final updatedProfile1 = _customProfiles[0].copyWith(name: 'Test 1 updated');
await tester.equipmentProfilesProvider.updateProfile(updatedProfile1); equipmentProfileProviderKey.currentState!.updateProfile(updatedProfile1);
await tester.pump(); await tester.pump();
verify(() => onDidChangeDependencies.onChanged(updatedProfile1)).called(1); verify(() => onDidChangeDependencies.onChanged(updatedProfile1)).called(1);
/// Verify that updating the not selected profile doesn't trigger the callback /// Verify that updating the not selected profile doesn't trigger the callback
final updatedProfile2 = _customProfiles[1].copyWith(name: 'Test 2 updated'); final updatedProfile2 = _customProfiles[1].copyWith(name: 'Test 2 updated');
await tester.equipmentProfilesProvider.updateProfile(updatedProfile2); equipmentProfileProviderKey.currentState!.updateProfile(updatedProfile2);
await tester.pump(); await tester.pump();
verifyNever(() => onDidChangeDependencies.onChanged(updatedProfile2)); verifyNever(() => onDidChangeDependencies.onChanged(updatedProfile2));
}, },
@ -90,24 +81,18 @@ void main() {
testWidgets( testWidgets(
"Don't trigger `onDidChangeDependencies` by updating the unselected profile", "Don't trigger `onDidChangeDependencies` by updating the unselected profile",
(tester) async { (tester) async {
when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles));
when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id); when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id);
await pumpTestWidget(tester); await pumpTestWidget(tester);
final updatedProfile2 = _customProfiles[1].copyWith(name: 'Test 2 updated'); final updatedProfile2 = _customProfiles[1].copyWith(name: 'Test 2 updated');
await tester.equipmentProfilesProvider.updateProfile(updatedProfile2); equipmentProfileProviderKey.currentState!.updateProfile(updatedProfile2);
await tester.pump(); await tester.pump();
verifyNever(() => onDidChangeDependencies.onChanged(updatedProfile2)); verifyNever(() => onDidChangeDependencies.onChanged(updatedProfile2));
}, },
); );
} }
extension on WidgetTester {
EquipmentProfilesProviderState get equipmentProfilesProvider {
final BuildContext context = element(find.byType(MaterialApp));
return EquipmentProfilesProvider.of(context);
}
}
final List<EquipmentProfile> _customProfiles = [ final List<EquipmentProfile> _customProfiles = [
const EquipmentProfile( const EquipmentProfile(
id: '1', id: '1',