ML-191 Add an ability to add a generic film, that will accept a formula (#195)

* sync with resources

* separated `ExpandableSectionList` as widget

* fixed generic type

* implemented `FilmsScreen` (wip)

* made `SliverScreen` title a widget

* [`FilmEditScreen`] wip

* [`FilmEditScreen`] added validation

* fixed title overflow for `SliverScreen`

* [`FilmEditScreen`] separated add and edit blocs

* [`FilmEditScreen`] split into separate components

* added bottom widget to `SliverScreen`

* implemented films list tabs fo `FilmsScreen`

* added films screen to navigation

* replaced explicit routes names with enum values

* implemented CRUD for custom films

* added placeholder for empty custom films list

* added `FilmsStorageService`

* fixed unit tests

* fixed integration tests

* lint

* fixed golden tests

* added iap stub methods

* added custom films to features list

* use 2.0.0 resouces

* fixed film picket tests

* migrated to iap 1.0.1

* autofocus film name field

* wait for the film to edited

* migrated to iap 1.1.0

* typo

* wait for storage initialization

* migrated to iap 1.1.1

* fixed films initialization

* added conditions to films model `updateShouldNotifyDependent`

* typo

* fixed select film discard notify

* covered films model `updateShouldNotifyDependent`
This commit is contained in:
Vadim 2024-11-03 20:16:01 +01:00 committed by GitHub
parent d938be61c4
commit c66381f813
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1770 additions and 396 deletions

View file

@ -5,5 +5,6 @@ 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/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

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

View file

@ -8,10 +8,4 @@ class IAPStorageService {
List<EquipmentProfile> get equipmentProfiles => []; List<EquipmentProfile> get equipmentProfiles => [];
set equipmentProfiles(List<EquipmentProfile> profiles) {} set equipmentProfiles(List<EquipmentProfile> profiles) {}
Film get selectedFilm => const Film.other();
set selectedFilm(Film value) {}
List<Film> get filmsInUse => [];
set filmsInUse(List<Film> profiles) {}
} }

View file

@ -1,11 +1,10 @@
name: m3_lightmeter_iap name: m3_lightmeter_iap
description: IAP stubs for the M3 Lightmeter app. description: IAP stubs for the M3 Lightmeter app.
version: 0.2.0 version: 1.0.0
publish_to: 'none' publish_to: 'none'
environment: environment:
sdk: '>=2.19.2 <3.0.0' sdk: ">=3.0.0 <4.0.0"
flutter: ">=1.17.0"
dependencies: dependencies:
flutter: flutter:
@ -13,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: v1.4.0 ref: v2.0.0
shared_preferences: 2.2.0 shared_preferences: 2.2.0
dev_dependencies: dev_dependencies:

View file

@ -47,7 +47,11 @@ void testE2E(String description) {
testWidgets( testWidgets(
description, description,
(tester) async { (tester) async {
await tester.pumpApplication(equipmentProfiles: [], filmsInUse: []); await tester.pumpApplication(
equipmentProfiles: [],
predefinedFilms: mockFilms.toFilmsMap(isUsed: true),
customFilms: {},
);
/// Create Praktica + Zenitar profile from scratch /// Create Praktica + Zenitar profile from scratch
await tester.openSettings(); await tester.openSettings();
@ -76,11 +80,6 @@ void testE2E(String description) {
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(2)); expect(find.text('1/1000 - B'), findsNWidgets(2));
await tester.navigatorPop(); await tester.navigatorPop();
/// Select some films
await tester.tap(find.text(S.current.filmsInUse));
await tester.pumpAndSettle();
await tester.setDialogFilterValues<Film>([mockFilms[0], mockFilms[1]], deselectAll: false);
await tester.navigatorPop(); await tester.navigatorPop();
/// Select some initial settings according to the selected gear and film /// Select some initial settings according to the selected gear and film

View file

@ -135,7 +135,7 @@ void testToggleLayoutFeatures(String description) {
testWidgets( testWidgets(
'Film picker', 'Film picker',
(tester) async { (tester) async {
await tester.pumpApplication(selectedFilm: mockFilms.first); await tester.pumpApplication(selectedFilmId: mockFilms.first.id);
await tester.takePhoto(); await tester.takePhoto();
expectPickerTitle<FilmPicker>(mockFilms.first.name); expectPickerTitle<FilmPicker>(mockFilms.first.name);
expectExtremeExposurePairs('f/1.0 - 1/320', 'f/45 - 12"'); expectExtremeExposurePairs('f/1.0 - 1/320', 'f/45 - 12"');

View file

@ -7,24 +7,27 @@ import 'package:mocktail/mocktail.dart';
class _MockIAPStorageService extends Mock implements IAPStorageService {} class _MockIAPStorageService extends Mock implements IAPStorageService {}
class _MockFilmsStorageService extends Mock implements FilmsStorageService {}
class MockIAPProviders extends StatefulWidget { class MockIAPProviders extends StatefulWidget {
final List<EquipmentProfile>? equipmentProfiles; final List<EquipmentProfile>? equipmentProfiles;
final String selectedEquipmentProfileId; final String selectedEquipmentProfileId;
final List<Film> availableFilms; final Map<String, SelectableFilm<Film>> predefinedFilms;
final List<Film> filmsInUse; final Map<String, SelectableFilm<FilmExponential>> customFilms;
final Film selectedFilm; final String selectedFilmId;
final Widget child; final Widget child;
const MockIAPProviders({ MockIAPProviders({
this.equipmentProfiles = const [], this.equipmentProfiles = const [],
this.selectedEquipmentProfileId = '', this.selectedEquipmentProfileId = '',
List<Film>? availableFilms, Map<String, SelectableFilm<Film>>? predefinedFilms,
List<Film>? filmsInUse, Map<String, SelectableFilm<FilmExponential>>? customFilms,
this.selectedFilm = const Film.other(), String? selectedFilmId,
required this.child, required this.child,
super.key, super.key,
}) : availableFilms = availableFilms ?? mockFilms, }) : predefinedFilms = predefinedFilms ?? mockFilms.toFilmsMap(),
filmsInUse = filmsInUse ?? mockFilms; customFilms = customFilms ?? mockFilms.toFilmsMap(),
selectedFilmId = selectedFilmId ?? const FilmStub().id;
@override @override
State<MockIAPProviders> createState() => _MockIAPProvidersState(); State<MockIAPProviders> createState() => _MockIAPProvidersState();
@ -32,6 +35,7 @@ class MockIAPProviders extends StatefulWidget {
class _MockIAPProvidersState extends State<MockIAPProviders> { class _MockIAPProvidersState extends State<MockIAPProviders> {
late final _MockIAPStorageService mockIAPStorageService; late final _MockIAPStorageService mockIAPStorageService;
late final _MockFilmsStorageService mockFilmsStorageService;
@override @override
void initState() { void initState() {
@ -39,8 +43,12 @@ class _MockIAPProvidersState extends State<MockIAPProviders> {
mockIAPStorageService = _MockIAPStorageService(); mockIAPStorageService = _MockIAPStorageService();
when(() => mockIAPStorageService.equipmentProfiles).thenReturn(widget.equipmentProfiles ?? mockEquipmentProfiles); when(() => mockIAPStorageService.equipmentProfiles).thenReturn(widget.equipmentProfiles ?? mockEquipmentProfiles);
when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn(widget.selectedEquipmentProfileId); when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn(widget.selectedEquipmentProfileId);
when(() => mockIAPStorageService.filmsInUse).thenReturn(widget.filmsInUse);
when(() => mockIAPStorageService.selectedFilm).thenReturn(widget.selectedFilm); mockFilmsStorageService = _MockFilmsStorageService();
when(() => mockFilmsStorageService.init()).thenAnswer((_) async {});
when(() => mockFilmsStorageService.getPredefinedFilms()).thenAnswer((_) => Future.value(widget.predefinedFilms));
when(() => mockFilmsStorageService.getCustomFilms()).thenAnswer((_) => Future.value(widget.customFilms));
when(() => mockFilmsStorageService.selectedFilmId).thenReturn(widget.selectedFilmId);
} }
@override @override
@ -48,8 +56,7 @@ class _MockIAPProvidersState extends State<MockIAPProviders> {
return EquipmentProfileProvider( return EquipmentProfileProvider(
storageService: mockIAPStorageService, storageService: mockIAPStorageService,
child: FilmsProvider( child: FilmsProvider(
storageService: mockIAPStorageService, filmsStorageService: mockFilmsStorageService,
availableFilms: widget.availableFilms,
child: widget.child, child: widget.child,
), ),
); );
@ -128,13 +135,52 @@ final mockEquipmentProfiles = [
), ),
]; ];
const mockFilms = [_MockFilm(100, 2), _MockFilm(400, 2), _MockFilm(3, 800), _MockFilm(400, 1.5)]; const mockFilms = [
_FilmMultiplying(id: '1', name: 'Mock film 1', iso: 100, reciprocityMultiplier: 2),
_FilmMultiplying(id: '2', name: 'Mock film 2', iso: 400, reciprocityMultiplier: 2),
_FilmMultiplying(id: '3', name: 'Mock film 3', iso: 800, reciprocityMultiplier: 3),
_FilmMultiplying(id: '4', name: 'Mock film 4', iso: 1200, reciprocityMultiplier: 1.5),
];
class _MockFilm extends Film { extension FilmMapper on List<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 {
final double reciprocityMultiplier; final double reciprocityMultiplier;
const _MockFilm(int iso, this.reciprocityMultiplier) : super('Mock film $iso x$reciprocityMultiplier', iso); const _FilmMultiplying({
String? id,
required String name,
required super.iso,
required this.reciprocityMultiplier,
}) : super(id: id ?? name, name: 'Mock film $iso x$reciprocityMultiplier', exponent: 1);
@override @override
double reciprocityFormula(double t) => t * reciprocityMultiplier; ShutterSpeedValue reciprocityFailure(ShutterSpeedValue shutterSpeed) {
if (shutterSpeed.isFraction) {
return shutterSpeed;
} else {
return ShutterSpeedValue(
shutterSpeed.rawValue * reciprocityMultiplier,
shutterSpeed.isFraction,
shutterSpeed.stopType,
);
}
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other.runtimeType != runtimeType) return false;
return other is _FilmMultiplying &&
other.id == id &&
other.name == name &&
other.iso == iso &&
other.reciprocityMultiplier == reciprocityMultiplier;
}
@override
int get hashCode => Object.hash(id, name, iso, reciprocityMultiplier, runtimeType);
} }

View file

@ -22,9 +22,9 @@ extension WidgetTesterCommonActions on WidgetTester {
IAPProductStatus productStatus = IAPProductStatus.purchased, IAPProductStatus productStatus = IAPProductStatus.purchased,
List<EquipmentProfile>? equipmentProfiles, List<EquipmentProfile>? equipmentProfiles,
String selectedEquipmentProfileId = '', String selectedEquipmentProfileId = '',
List<Film>? availableFilms, Map<String, SelectableFilm<Film>>? predefinedFilms,
List<Film>? filmsInUse, Map<String, SelectableFilm<FilmExponential>>? customFilms,
Film selectedFilm = const Film.other(), String selectedFilmId = '',
}) async { }) async {
await pumpWidget( await pumpWidget(
MockIAPProductsProvider( MockIAPProductsProvider(
@ -34,9 +34,9 @@ extension WidgetTesterCommonActions on WidgetTester {
child: MockIAPProviders( child: MockIAPProviders(
equipmentProfiles: equipmentProfiles, equipmentProfiles: equipmentProfiles,
selectedEquipmentProfileId: selectedEquipmentProfileId, selectedEquipmentProfileId: selectedEquipmentProfileId,
availableFilms: availableFilms, predefinedFilms: predefinedFilms,
filmsInUse: filmsInUse, customFilms: customFilms,
selectedFilm: selectedFilm, selectedFilmId: selectedFilmId,
child: const Application(), child: const Application(),
), ),
), ),

View file

@ -3,8 +3,12 @@ import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:lightmeter/data/models/supported_locale.dart'; import 'package:lightmeter/data/models/supported_locale.dart';
import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/navigation/modal_route_args_parser.dart';
import 'package:lightmeter/navigation/routes.dart';
import 'package:lightmeter/platform_config.dart'; import 'package:lightmeter/platform_config.dart';
import 'package:lightmeter/providers/user_preferences_provider.dart'; import 'package:lightmeter/providers/user_preferences_provider.dart';
import 'package:lightmeter/screens/film_edit/flow_film_edit.dart';
import 'package:lightmeter/screens/films/screen_films.dart';
import 'package:lightmeter/screens/lightmeter_pro/screen_lightmeter_pro.dart'; import 'package:lightmeter/screens/lightmeter_pro/screen_lightmeter_pro.dart';
import 'package:lightmeter/screens/metering/flow_metering.dart'; import 'package:lightmeter/screens/metering/flow_metering.dart';
import 'package:lightmeter/screens/settings/flow_settings.dart'; import 'package:lightmeter/screens/settings/flow_settings.dart';
@ -41,12 +45,15 @@ class Application extends StatelessWidget {
data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0),
child: child!, child: child!,
), ),
initialRoute: "metering", initialRoute: NavigationRoutes.meteringScreen.name,
routes: { routes: {
"metering": (_) => const ReleaseNotesFlow(child: MeteringFlow()), NavigationRoutes.meteringScreen.name: (_) => const ReleaseNotesFlow(child: MeteringFlow()),
"settings": (_) => const SettingsFlow(), NavigationRoutes.settingsScreen.name: (_) => const SettingsFlow(),
"lightmeterPro": (_) => LightmeterProScreen(), NavigationRoutes.filmsListScreen.name: (_) => const FilmsScreen(),
"timer": (context) => TimerFlow(args: ModalRoute.of(context)!.settings.arguments! as TimerFlowArgs), NavigationRoutes.filmAddScreen.name: (_) => const FilmEditFlow(args: FilmEditArgs()),
NavigationRoutes.filmEditScreen.name: (context) => FilmEditFlow(args: context.routeArgs<FilmEditArgs>()),
NavigationRoutes.proFeaturesScreen.name: (_) => LightmeterProScreen(),
NavigationRoutes.timerScreen.name: (context) => TimerFlow(args: context.routeArgs<TimerFlowArgs>()),
}, },
), ),
); );

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:lightmeter/data/analytics/analytics.dart'; import 'package:lightmeter/data/analytics/analytics.dart';
import 'package:lightmeter/data/analytics/api/analytics_firebase.dart'; import 'package:lightmeter/data/analytics/api/analytics_firebase.dart';
import 'package:lightmeter/data/caffeine_service.dart'; import 'package:lightmeter/data/caffeine_service.dart';
@ -18,32 +19,47 @@ import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:platform/platform.dart'; import 'package:platform/platform.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
class ApplicationWrapper extends StatelessWidget { class ApplicationWrapper extends StatefulWidget {
final Environment env; final Environment env;
final Widget child; final Widget child;
const ApplicationWrapper(this.env, {required this.child, super.key}); const ApplicationWrapper(this.env, {required this.child, super.key});
@override
State<ApplicationWrapper> createState() => _ApplicationWrapperState();
}
class _ApplicationWrapperState extends State<ApplicationWrapper> {
late final remoteConfigService = widget.env.buildType != BuildType.dev
? const RemoteConfigService(LightmeterAnalytics(api: LightmeterAnalyticsFirebase()))
: const MockRemoteConfigService();
late final IAPStorageService iapStorageService;
late final UserPreferencesService userPreferencesService;
late final bool hasLightSensor;
final filmsStorageService = FilmsStorageService();
late final Future<void> _initFuture;
@override
void initState() {
super.initState();
_initFuture = _initialize();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final remoteConfigService = env.buildType != BuildType.dev
? const RemoteConfigService(LightmeterAnalytics(api: LightmeterAnalyticsFirebase()))
: const MockRemoteConfigService();
return FutureBuilder( return FutureBuilder(
future: Future.wait<dynamic>([ future: _initFuture,
SharedPreferences.getInstance(), builder: (context, snapshot) {
const LightSensorService(LocalPlatform()).hasSensor(), if (snapshot.error != null) {
remoteConfigService.activeAndFetchFeatures(), return Center(child: Text(snapshot.error!.toString()));
]), } else if (snapshot.connectionState == ConnectionState.done) {
builder: (_, snapshot) {
if (snapshot.data != null) {
final iapService = IAPStorageService(snapshot.data![0] as SharedPreferences);
final userPreferencesService = UserPreferencesService(snapshot.data![0] as SharedPreferences);
final hasLightSensor = snapshot.data![1] as bool;
return ServicesProvider( return ServicesProvider(
analytics: const LightmeterAnalytics(api: LightmeterAnalyticsFirebase()), analytics: const LightmeterAnalytics(api: LightmeterAnalyticsFirebase()),
caffeineService: const CaffeineService(), caffeineService: const CaffeineService(),
environment: env.copyWith(hasLightSensor: hasLightSensor), environment: widget.env.copyWith(hasLightSensor: hasLightSensor),
hapticsService: const HapticsService(), hapticsService: const HapticsService(),
lightSensorService: const LightSensorService(LocalPlatform()), lightSensorService: const LightSensorService(LocalPlatform()),
permissionsService: const PermissionsService(), permissionsService: const PermissionsService(),
@ -52,25 +68,41 @@ class ApplicationWrapper extends StatelessWidget {
child: RemoteConfigProvider( child: RemoteConfigProvider(
remoteConfigService: remoteConfigService, remoteConfigService: remoteConfigService,
child: EquipmentProfileProvider( child: EquipmentProfileProvider(
storageService: iapService, storageService: iapStorageService,
child: FilmsProvider( child: FilmsProvider(
storageService: iapService, filmsStorageService: filmsStorageService,
onInitialized: _onFilmsProviderInitialized,
child: UserPreferencesProvider( child: UserPreferencesProvider(
hasLightSensor: hasLightSensor, hasLightSensor: hasLightSensor,
userPreferencesService: userPreferencesService, userPreferencesService: userPreferencesService,
child: child, child: widget.child,
), ),
), ),
), ),
), ),
); );
} else if (snapshot.error != null) {
return Center(child: Text(snapshot.error!.toString()));
} }
// TODO(@vodemn): maybe user splashscreen instead
return const SizedBox(); return const SizedBox();
}, },
); );
} }
Future<void> _initialize() async {
await Future.wait([
SharedPreferences.getInstance(),
const LightSensorService(LocalPlatform()).hasSensor(),
remoteConfigService.activeAndFetchFeatures(),
filmsStorageService.init(),
]).then((value) {
final sharedPrefs = (value[0] as SharedPreferences?)!;
iapStorageService = IAPStorageService(sharedPrefs);
userPreferencesService = UserPreferencesService(sharedPrefs);
hasLightSensor = value[1] as bool? ?? false;
});
}
void _onFilmsProviderInitialized() {
FlutterNativeSplash.remove();
}
} }

View file

@ -9,6 +9,7 @@ enum AppFeature {
spotMetering, spotMetering,
histogram, histogram,
listOfFilms, listOfFilms,
customFilms,
equipmentProfiles, equipmentProfiles,
timer, timer,
mainScreenCustomization; mainScreenCustomization;
@ -33,6 +34,8 @@ enum AppFeature {
return S.of(context).featureHistogram; return S.of(context).featureHistogram;
case AppFeature.listOfFilms: case AppFeature.listOfFilms:
return S.of(context).featureListOfFilms; return S.of(context).featureListOfFilms;
case AppFeature.customFilms:
return S.of(context).featureCustomFilms;
case AppFeature.equipmentProfiles: case AppFeature.equipmentProfiles:
return S.of(context).featureEquipmentProfiles; return S.of(context).featureEquipmentProfiles;
case AppFeature.timer: case AppFeature.timer:

View file

@ -67,8 +67,6 @@
"equipmentProfile": "Equipment profile", "equipmentProfile": "Equipment profile",
"equipmentProfiles": "Equipment profiles", "equipmentProfiles": "Equipment profiles",
"tapToAdd": "Tap to add", "tapToAdd": "Tap to add",
"filmsInUse": "Films in use",
"filmsInUseDescription": "Select films which you use.",
"general": "General", "general": "General",
"keepScreenOn": "Keep screen on", "keepScreenOn": "Keep screen on",
"haptics": "Haptics", "haptics": "Haptics",
@ -116,6 +114,7 @@
"featureSpotMetering": "Spot metering", "featureSpotMetering": "Spot metering",
"featureHistogram": "Histogram", "featureHistogram": "Histogram",
"featureListOfFilms": "List of 20+ films with reciprocity formulas", "featureListOfFilms": "List of 20+ films with reciprocity formulas",
"featureCustomFilms": "Ability to create custom films",
"featureEquipmentProfiles": "Equipment profiles", "featureEquipmentProfiles": "Equipment profiles",
"featureTimer": "Built-in timer for long exposure", "featureTimer": "Built-in timer for long exposure",
"featureMeteringScreenLayout": "Customizable main screen", "featureMeteringScreenLayout": "Customizable main screen",
@ -150,5 +149,16 @@
} }
} }
}, },
"close": "Close" "close": "Close",
"films": "Films",
"filmsInUse": "Films in use",
"filmsInUseDescription": "Select films which you use.",
"filmsCustom": "Custom films",
"addFilmTitle": "Add film",
"editFilmTitle": "Edit film",
"filmFormula": "Formula",
"filmFormulaExponential": "T=t^Rf",
"filmFormulaExponentialRf": "Rf",
"filmFormulaExponentialRfPlaceholder": "1.3",
"name": "Name"
} }

View file

@ -117,6 +117,7 @@
"featureSpotMetering": "Mesure spot", "featureSpotMetering": "Mesure spot",
"featureHistogram": "Histogramme", "featureHistogram": "Histogramme",
"featureListOfFilms": "Liste de plus de 20 films avec des formules de correction", "featureListOfFilms": "Liste de plus de 20 films avec des formules de correction",
"featureCustomFilms": "Possibilité de créer des films personnalisés",
"featureEquipmentProfiles": "Profils de l'équipement", "featureEquipmentProfiles": "Profils de l'équipement",
"featureTimer": "Minuteur intégré pour longues expositions", "featureTimer": "Minuteur intégré pour longues expositions",
"featureMeteringScreenLayout": "Écran principal personnalisable", "featureMeteringScreenLayout": "Écran principal personnalisable",

View file

@ -116,6 +116,7 @@
"featureSpotMetering": "Точечный замер", "featureSpotMetering": "Точечный замер",
"featureHistogram": "Гистограмма", "featureHistogram": "Гистограмма",
"featureListOfFilms": "Список из 20+ плёнок с формулами коррекции", "featureListOfFilms": "Список из 20+ плёнок с формулами коррекции",
"featureCustomFilms": "Возможность создания собственных плёнок",
"featureEquipmentProfiles": "Профили оборудования", "featureEquipmentProfiles": "Профили оборудования",
"featureTimer": "Встроенный таймер для длинных выдержек", "featureTimer": "Встроенный таймер для длинных выдержек",
"featureMeteringScreenLayout": "Настраиваемый главный экран", "featureMeteringScreenLayout": "Настраиваемый главный экран",

View file

@ -115,6 +115,7 @@
"featureSpotMetering": "点测光", "featureSpotMetering": "点测光",
"featureHistogram": "直方图", "featureHistogram": "直方图",
"featureListOfFilms": "20多部电影的修正公式列表", "featureListOfFilms": "20多部电影的修正公式列表",
"featureCustomFilms": "创建自定义胶片的能力",
"featureEquipmentProfiles": "设备配置文件", "featureEquipmentProfiles": "设备配置文件",
"featureTimer": "内置长曝光计时器", "featureTimer": "内置长曝光计时器",
"featureMeteringScreenLayout": "可自定义的主屏幕", "featureMeteringScreenLayout": "可自定义的主屏幕",

View file

@ -0,0 +1,7 @@
import 'package:flutter/widgets.dart';
extension ModalRouteArgsParser on BuildContext {
T routeArgs<T>() {
return ModalRoute.of(this)!.settings.arguments! as T;
}
}

View file

@ -0,0 +1,9 @@
enum NavigationRoutes {
meteringScreen,
settingsScreen,
filmsListScreen,
filmAddScreen,
filmEditScreen,
proFeaturesScreen,
timerScreen,
}

View file

@ -1,17 +1,17 @@
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';
class FilmsProvider extends StatefulWidget { class FilmsProvider extends StatefulWidget {
final IAPStorageService storageService; final FilmsStorageService filmsStorageService;
final List<Film>? availableFilms; final VoidCallback? onInitialized;
final Widget child; final Widget child;
const FilmsProvider({ const FilmsProvider({
required this.storageService, required this.filmsStorageService,
this.availableFilms, this.onInitialized,
required this.child, required this.child,
super.key, super.key,
}); });
@ -25,82 +25,156 @@ class FilmsProvider extends StatefulWidget {
} }
class FilmsProviderState extends State<FilmsProvider> { class FilmsProviderState extends State<FilmsProvider> {
late List<Film> _filmsInUse; final Map<String, SelectableFilm<Film>> predefinedFilms = {};
late Film _selected; final Map<String, SelectableFilm<FilmExponential>> customFilms = {};
String _selectedId = '';
Film get _selectedFilm => customFilms[_selectedId]?.film ?? predefinedFilms[_selectedId]?.film ?? const FilmStub();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_filmsInUse = widget.storageService.filmsInUse; _init();
_selected = widget.storageService.selectedFilm;
_discardSelectedIfNotIncluded();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Films( return Films(
values: [ predefinedFilms: context.isPro ? predefinedFilms : {},
const Film.other(), customFilms: context.isPro ? customFilms : {},
...widget.availableFilms ?? films, selected: context.isPro ? _selectedFilm : const FilmStub(),
],
filmsInUse: [
const Film.other(),
if (context.isPro) ..._filmsInUse,
],
selected: context.isPro ? _selected : const Film.other(),
child: widget.child, child: widget.child,
); );
} }
void setFilm(Film film) { Future<void> _init() async {
if (_selected != film) { _selectedId = widget.filmsStorageService.selectedFilmId;
_selected = film; predefinedFilms.addAll(await widget.filmsStorageService.getPredefinedFilms());
widget.storageService.selectedFilm = film; customFilms.addAll(await widget.filmsStorageService.getCustomFilms());
_discardSelectedIfNotIncluded();
if (mounted) setState(() {});
widget.onInitialized?.call();
}
/* Both type of films **/
Future<void> toggleFilm(Film film, bool enabled) async {
if (predefinedFilms.containsKey(film.id)) {
predefinedFilms[film.id] = (film: film, isUsed: enabled);
} else if (customFilms.containsKey(film.id)) {
customFilms[film.id] = (film: film as FilmExponential, isUsed: enabled);
} else {
return;
}
await widget.filmsStorageService.toggleFilm(film, enabled);
_discardSelectedIfNotIncluded();
setState(() {});
}
void selectFilm(Film film) {
if (_selectedFilm != film) {
_selectedId = film.id;
widget.filmsStorageService.selectedFilmId = _selectedId;
setState(() {}); setState(() {});
} }
} }
void saveFilms(List<Film> films) { /* Custom films **/
_filmsInUse = films;
widget.storageService.filmsInUse = films; Future<void> addCustomFilm(FilmExponential film) async {
// ignore: avoid_redundant_argument_values
await widget.filmsStorageService.addFilm(film, isUsed: true);
customFilms[film.id] = (film: film, isUsed: true);
setState(() {});
}
Future<void> updateCustomFilm(FilmExponential film) async {
await widget.filmsStorageService.updateFilm(film);
customFilms[film.id] = (film: film, isUsed: customFilms[film.id]!.isUsed);
setState(() {});
}
Future<void> deleteCustomFilm(FilmExponential film) async {
await widget.filmsStorageService.deleteFilm(film);
customFilms.remove(film.id);
_discardSelectedIfNotIncluded(); _discardSelectedIfNotIncluded();
setState(() {}); setState(() {});
} }
void _discardSelectedIfNotIncluded() { void _discardSelectedIfNotIncluded() {
if (_selected != const Film.other() && !_filmsInUse.contains(_selected)) { if (_selectedId == const FilmStub().id) {
_selected = const Film.other(); return;
widget.storageService.selectedFilm = const Film.other(); }
final isSelectedUsed = predefinedFilms[_selectedId]?.isUsed ?? customFilms[_selectedId]?.isUsed ?? false;
if (!isSelectedUsed) {
_selectedId = const FilmStub().id;
widget.filmsStorageService.selectedFilmId = _selectedId;
} }
} }
} }
class Films extends SelectableInheritedModel<Film> { enum _FilmsModelAspect {
final List<Film> filmsInUse; customFilms,
predefinedFilms,
filmsInUse,
selected,
}
class Films extends InheritedModel<_FilmsModelAspect> {
final Map<String, SelectableFilm<Film>> predefinedFilms;
@protected
final Map<String, SelectableFilm<FilmExponential>> customFilms;
final Film selected;
const Films({ const Films({
super.key, required this.predefinedFilms,
required super.values, required this.customFilms,
required this.filmsInUse, required this.selected,
required super.selected,
required super.child, required super.child,
}); });
/// [Film.other()] + all the custom fields with actual reciprocity formulas static List<Film> predefinedFilmsOf<T>(BuildContext context) {
static List<Film> of(BuildContext context) { return InheritedModel.inheritFrom<Films>(context, aspect: _FilmsModelAspect.predefinedFilms)!
return InheritedModel.inheritFrom<Films>(context)!.values; .predefinedFilms
.values
.map((value) => value.film)
.toList();
} }
/// [Film.other()] + films in use selected by user static List<FilmExponential> customFilmsOf<T>(BuildContext context) {
return InheritedModel.inheritFrom<Films>(context, aspect: _FilmsModelAspect.customFilms)!
.customFilms
.values
.map((value) => value.film)
.toList();
}
/// [FilmStub()] + films in use selected by user
static List<Film> inUseOf<T>(BuildContext context) { static List<Film> inUseOf<T>(BuildContext context) {
return InheritedModel.inheritFrom<Films>( final model = InheritedModel.inheritFrom<Films>(context, aspect: _FilmsModelAspect.filmsInUse)!;
context, return [
aspect: SelectableAspect.list, const FilmStub(),
)! ...model.customFilms.values.where((e) => e.isUsed).map((e) => e.film),
.filmsInUse; ...model.predefinedFilms.values.where((e) => e.isUsed).map((e) => e.film),
];
} }
static Film selectedOf(BuildContext context) { static Film selectedOf(BuildContext context) {
return InheritedModel.inheritFrom<Films>(context, aspect: SelectableAspect.selected)!.selected; return InheritedModel.inheritFrom<Films>(context, aspect: _FilmsModelAspect.selected)!.selected;
}
@override
bool updateShouldNotify(Films _) => true;
@override
bool updateShouldNotifyDependent(Films oldWidget, Set<_FilmsModelAspect> dependencies) {
return (dependencies.contains(_FilmsModelAspect.selected) && oldWidget.selected != selected) ||
((dependencies.contains(_FilmsModelAspect.predefinedFilms) ||
dependencies.contains(_FilmsModelAspect.filmsInUse)) &&
const DeepCollectionEquality().equals(oldWidget.predefinedFilms, predefinedFilms)) ||
((dependencies.contains(_FilmsModelAspect.customFilms) ||
dependencies.contains(_FilmsModelAspect.filmsInUse)) &&
const DeepCollectionEquality().equals(oldWidget.customFilms, customFilms));
} }
} }

View file

@ -67,7 +67,8 @@ ThemeData themeFrom(Color primaryColor, Brightness brightness) {
style: ListTileStyle.list, style: ListTileStyle.list,
iconColor: scheme.onSurface, iconColor: scheme.onSurface,
textColor: scheme.onSurface, textColor: scheme.onSurface,
subtitleTextStyle: theme.textTheme.bodyMedium!.copyWith(color: scheme.onSurfaceVariant), subtitleTextStyle: theme.textTheme.bodyMedium,
leadingAndTrailingTextStyle: theme.textTheme.bodyMedium,
), ),
); );
} }

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:lightmeter/application.dart'; import 'package:lightmeter/application.dart';
import 'package:lightmeter/application_wrapper.dart'; import 'package:lightmeter/application_wrapper.dart';
import 'package:lightmeter/constants.dart'; import 'package:lightmeter/constants.dart';
@ -16,7 +17,8 @@ const _errorsLogger = LightmeterAnalytics(api: LightmeterAnalyticsFirebase());
Future<void> runLightmeterApp(Environment env) async { Future<void> runLightmeterApp(Environment env) async {
runZonedGuarded( runZonedGuarded(
() async { () async {
WidgetsFlutterBinding.ensureInitialized(); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
if (env.buildType == BuildType.prod) { if (env.buildType == BuildType.prod) {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
} }
@ -28,6 +30,7 @@ Future<void> runLightmeterApp(Environment env) async {
products: [ products: [
IAPProduct( IAPProduct(
storeId: IAPProductType.paidFeatures.storeId, storeId: IAPProductType.paidFeatures.storeId,
status: IAPProductStatus.purchased,
price: '0.0\$', price: '0.0\$',
), ),
], ],

View file

@ -0,0 +1,132 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:lightmeter/providers/films_provider.dart';
import 'package:lightmeter/screens/film_edit/event_film_edit.dart';
import 'package:lightmeter/screens/film_edit/state_film_edit.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
import 'package:uuid/uuid.dart';
class FilmEditBloc extends Bloc<FilmEditEvent, FilmEditState> {
static const _defaultFilm = FilmExponential(name: '', iso: 100, exponent: 1.3);
final FilmsProviderState filmsProvider;
final FilmExponential _originalFilm;
FilmExponential _newFilm;
final bool _isEdit;
factory FilmEditBloc(
FilmsProviderState filmsProvider, {
required FilmExponential? film,
required bool isEdit,
}) =>
film != null
? FilmEditBloc._(
filmsProvider,
film,
isEdit,
)
: FilmEditBloc._(
filmsProvider,
_defaultFilm,
isEdit,
);
FilmEditBloc._(
this.filmsProvider,
FilmExponential film,
this._isEdit,
) : _originalFilm = film,
_newFilm = film,
super(
FilmEditState(
name: film.name,
isoValue: IsoValue.values.firstWhere((element) => element.value == film.iso),
exponent: film.exponent,
canSave: false,
),
) {
on<FilmEditEvent>(
(event, emit) async {
switch (event) {
case final FilmEditNameChangedEvent e:
await _onNameChanged(e, emit);
case final FilmEditIsoChangedEvent e:
await _onIsoChanged(e, emit);
case final FilmEditExpChangedEvent e:
await _onExpChanged(e, emit);
case FilmEditSaveEvent():
await _onSave(event, emit);
case FilmEditDeleteEvent():
await _onDelete(event, emit);
}
},
);
}
Future<void> _onNameChanged(FilmEditNameChangedEvent event, Emitter emit) async {
_newFilm = _newFilm.copyWith(name: event.name);
emit(
state.copyWith(
name: event.name,
canSave: _canSave(event.name, state.exponent),
),
);
}
Future<void> _onIsoChanged(FilmEditIsoChangedEvent event, Emitter emit) async {
_newFilm = _newFilm.copyWith(iso: event.iso.value);
emit(
state.copyWith(
isoValue: event.iso,
canSave: _canSave(state.name, state.exponent),
),
);
}
Future<void> _onExpChanged(FilmEditExpChangedEvent event, Emitter emit) async {
if (event.exponent != null) {
_newFilm = _newFilm.copyWith(exponent: event.exponent);
}
emit(
FilmEditState(
name: state.name,
isoValue: state.isoValue,
exponent: event.exponent,
canSave: _canSave(state.name, event.exponent),
),
);
}
Future<void> _onSave(FilmEditSaveEvent _, Emitter emit) async {
emit(state.copyWith(isLoading: true));
if (_isEdit) {
await filmsProvider.updateCustomFilm(
FilmExponential(
id: _originalFilm.id,
name: state.name,
iso: state.isoValue.value,
exponent: state.exponent!,
),
);
} else {
await filmsProvider.addCustomFilm(
FilmExponential(
id: const Uuid().v1(),
name: state.name,
iso: state.isoValue.value,
exponent: state.exponent!,
),
);
}
emit(state.copyWith(isLoading: false));
}
Future<void> _onDelete(FilmEditDeleteEvent _, Emitter emit) async {
emit(state.copyWith(isLoading: true));
await filmsProvider.deleteCustomFilm(_originalFilm);
emit(state.copyWith(isLoading: false));
}
bool _canSave(String name, double? exponent) {
return name.isNotEmpty && exponent != null && _newFilm != _originalFilm;
}
}

View file

@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/shared/text_field/widget_text_field.dart';
class FilmEditExponentialFormulaInput extends StatefulWidget {
final double? value;
final ValueChanged<double?> onChanged;
const FilmEditExponentialFormulaInput({
super.key,
required this.value,
required this.onChanged,
});
@override
State<FilmEditExponentialFormulaInput> createState() => _FilmEditExponentialFormulaInputState();
}
class _FilmEditExponentialFormulaInputState extends State<FilmEditExponentialFormulaInput> {
TextStyle get style =>
Theme.of(context).textTheme.bodyMedium!.copyWith(color: Theme.of(context).listTileTheme.textColor);
@override
Widget build(BuildContext context) {
return Column(
children: [
ListTile(
leading: const Icon(Icons.show_chart),
title: Text(S.of(context).filmFormula),
trailing: Text(S.of(context).filmFormulaExponential),
),
ListTile(
leading: const SizedBox(),
title: Text(
S.of(context).filmFormulaExponentialRf,
style: Theme.of(context).listTileTheme.titleTextStyle,
),
trailing: SizedBox(
width: _textInputWidth(context),
child: LightmeterTextField(
initialValue: widget.value?.toString() ?? '',
inputFormatters: [FilteringTextInputFormatter.allow(RegExp("[0-9.]"))],
onChanged: (value) {
widget.onChanged(double.tryParse(value));
},
textAlign: TextAlign.end,
style: style,
),
),
),
],
);
}
double _textInputWidth(BuildContext context) {
final textPainter = TextPainter(
text: TextSpan(text: widget.value.toString(), style: style),
maxLines: 1,
textDirection: TextDirection.ltr,
)..layout();
return textPainter.maxIntrinsicWidth + Dimens.grid4;
}
}

View file

@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/dialog_picker/widget_picker_dialog.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class FilmEditIsoPicker extends StatelessWidget {
final IsoValue selected;
final ValueChanged<IsoValue> onChanged;
const FilmEditIsoPicker({
required this.selected,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: const Icon(Icons.iso),
title: Text(S.of(context).iso),
trailing: Text(selected.value.toString()),
onTap: () {
showDialog<IsoValue>(
context: context,
builder: (_) => Dialog(
child: DialogPicker<IsoValue>(
icon: Icons.iso,
title: S.of(context).iso,
subtitle: S.of(context).filmSpeed,
values: IsoValue.values,
initialValue: selected,
itemTitleBuilder: (_, value) => Text(value.value.toString()),
onSelect: (value) {
onChanged(value);
Navigator.of(context).pop();
},
onCancel: () {
Navigator.of(context).pop();
},
),
),
);
},
);
}
}

View file

@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/shared/text_field/widget_text_field.dart';
class FilmEditNameField extends StatelessWidget {
final String name;
final ValueChanged<String> onChanged;
const FilmEditNameField({
required this.name,
required this.onChanged,
super.key,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(
left: Dimens.paddingM,
top: Dimens.paddingS / 2,
right: Dimens.paddingL,
bottom: Dimens.paddingS / 2,
),
child: LightmeterTextField(
initialValue: name,
autofocus: true,
maxLength: 48,
hintText: S.of(context).name,
onChanged: onChanged,
style: Theme.of(context).listTileTheme.titleTextStyle,
leading: const Icon(Icons.edit_outlined),
),
);
}
}

View file

@ -0,0 +1,31 @@
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
sealed class FilmEditEvent {
const FilmEditEvent();
}
class FilmEditNameChangedEvent extends FilmEditEvent {
final String name;
const FilmEditNameChangedEvent(this.name);
}
class FilmEditIsoChangedEvent extends FilmEditEvent {
final IsoValue iso;
const FilmEditIsoChangedEvent(this.iso);
}
class FilmEditExpChangedEvent extends FilmEditEvent {
final double? exponent;
const FilmEditExpChangedEvent(this.exponent);
}
class FilmEditSaveEvent extends FilmEditEvent {
const FilmEditSaveEvent();
}
class FilmEditDeleteEvent extends FilmEditEvent {
const FilmEditDeleteEvent();
}

View file

@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:lightmeter/providers/films_provider.dart';
import 'package:lightmeter/screens/film_edit/bloc_film_edit.dart';
import 'package:lightmeter/screens/film_edit/screen_film_edit.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class FilmEditArgs {
final FilmExponential? film;
const FilmEditArgs({this.film});
}
class FilmEditFlow extends StatelessWidget {
final FilmEditArgs args;
const FilmEditFlow({required this.args, super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => FilmEditBloc(
FilmsProvider.of(context),
film: args.film,
isEdit: args.film != null,
),
child: FilmEditScreen(isEdit: args.film != null),
);
}
}

View file

@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/film_edit/bloc_film_edit.dart';
import 'package:lightmeter/screens/film_edit/components/exponential_formula_input/widget_input_exponential_formula_film_edit.dart';
import 'package:lightmeter/screens/film_edit/components/iso_picker/widget_picker_iso_film_edit.dart';
import 'package:lightmeter/screens/film_edit/components/name_field/widget_field_name_film_edit.dart';
import 'package:lightmeter/screens/film_edit/event_film_edit.dart';
import 'package:lightmeter/screens/film_edit/state_film_edit.dart';
import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart';
class FilmEditScreen extends StatefulWidget {
final bool isEdit;
const FilmEditScreen({
required this.isEdit,
super.key,
});
@override
State<FilmEditScreen> createState() => _FilmEditScreenState();
}
class _FilmEditScreenState extends State<FilmEditScreen> {
@override
Widget build(BuildContext context) {
return BlocConsumer<FilmEditBloc, FilmEditState>(
listenWhen: (previous, current) => previous.isLoading != current.isLoading,
listener: (context, state) {
if (state.isLoading) {
FocusScope.of(context).unfocus();
} else {
Navigator.of(context).pop();
}
},
buildWhen: (previous, current) => previous.isLoading != current.isLoading,
builder: (context, state) => IgnorePointer(
ignoring: state.isLoading,
child: SliverScreen(
title: Text(widget.isEdit ? S.of(context).editFilmTitle : S.of(context).addFilmTitle),
appBarActions: [
BlocBuilder<FilmEditBloc, FilmEditState>(
buildWhen: (previous, current) => previous.canSave != current.canSave,
builder: (context, state) => IconButton(
onPressed: state.canSave
? () {
context.read<FilmEditBloc>().add(const FilmEditSaveEvent());
}
: null,
icon: const Icon(Icons.save),
),
),
if (widget.isEdit)
IconButton(
onPressed: () {
context.read<FilmEditBloc>().add(const FilmEditDeleteEvent());
},
icon: const Icon(Icons.delete),
),
],
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Dimens.paddingM,
0,
Dimens.paddingM,
Dimens.paddingM,
),
child: Card(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Dimens.paddingM),
child: Opacity(
opacity: state.isLoading ? Dimens.disabledOpacity : Dimens.enabledOpacity,
child: Column(
children: [
BlocBuilder<FilmEditBloc, FilmEditState>(
buildWhen: (previous, current) => previous.name != current.name,
builder: (context, state) => FilmEditNameField(
name: state.name,
onChanged: (value) {
context.read<FilmEditBloc>().add(FilmEditNameChangedEvent(value));
},
),
),
BlocBuilder<FilmEditBloc, FilmEditState>(
buildWhen: (previous, current) => previous.isoValue != current.isoValue,
builder: (context, state) => FilmEditIsoPicker(
selected: state.isoValue,
onChanged: (value) {
context.read<FilmEditBloc>().add(FilmEditIsoChangedEvent(value));
},
),
),
BlocBuilder<FilmEditBloc, FilmEditState>(
buildWhen: (previous, current) => previous.exponent != current.exponent,
builder: (context, state) => FilmEditExponentialFormulaInput(
value: state.exponent,
onChanged: (value) {
context.read<FilmEditBloc>().add(FilmEditExpChangedEvent(value));
},
),
),
],
),
),
),
),
),
),
SliverToBoxAdapter(child: SizedBox(height: MediaQuery.paddingOf(context).bottom)),
],
),
),
);
}
}

View file

@ -0,0 +1,32 @@
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class FilmEditState {
final String name;
final IsoValue isoValue;
final double? exponent;
final bool canSave;
final bool isLoading;
const FilmEditState({
required this.name,
required this.isoValue,
required this.exponent,
required this.canSave,
this.isLoading = false,
});
FilmEditState copyWith({
String? name,
IsoValue? isoValue,
double? exponent,
bool? canSave,
bool? isLoading,
}) =>
FilmEditState(
name: name ?? this.name,
isoValue: isoValue ?? this.isoValue,
exponent: exponent ?? this.exponent,
canSave: canSave ?? this.canSave,
isLoading: isLoading ?? this.isLoading,
);
}

View file

@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/navigation/routes.dart';
import 'package:lightmeter/providers/films_provider.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/film_edit/flow_film_edit.dart';
import 'package:lightmeter/screens/shared/sliver_placeholder/widget_sliver_placeholder.dart';
import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class FilmsScreen extends StatefulWidget {
const FilmsScreen({super.key});
@override
State<FilmsScreen> createState() => _FilmsScreenState();
}
class _FilmsScreenState extends State<FilmsScreen> with SingleTickerProviderStateMixin {
late final tabController = TabController(length: 2, vsync: this);
@override
void initState() {
super.initState();
tabController.addListener(() {
setState(() {});
});
}
@override
void dispose() {
tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SliverScreen(
title: Text(S.of(context).films),
bottom: TabBar(
controller: tabController,
tabs: [
Tab(text: S.of(context).filmsInUse),
Tab(text: S.of(context).filmsCustom),
],
),
appBarActions: [
AnimatedBuilder(
animation: tabController,
builder: (context, _) => AnimatedSwitcher(
duration: Dimens.switchDuration,
child: tabController.index == 0
? const SizedBox.shrink()
: IconButton(
onPressed: _addFilm,
icon: const Icon(Icons.add_outlined),
tooltip: S.of(context).tooltipAdd,
),
),
),
],
slivers: [
if (tabController.index == 0)
_FilmsListBuilder(
films: Films.predefinedFilmsOf(context).toList(),
onFilmSelected: FilmsProvider.of(context).toggleFilm,
)
else if (tabController.index == 1 && Films.customFilmsOf(context).isNotEmpty)
_FilmsListBuilder<FilmExponential>(
films: Films.customFilmsOf(context).toList(),
onFilmSelected: FilmsProvider.of(context).toggleFilm,
onFilmEdit: _editFilm,
)
else
SliverPlaceholder(onTap: _addFilm),
],
);
}
void _addFilm() {
Navigator.of(context).pushNamed(
NavigationRoutes.filmAddScreen.name,
arguments: const FilmEditArgs(),
);
}
void _editFilm(FilmExponential film) {
Navigator.of(context).pushNamed(
NavigationRoutes.filmEditScreen.name,
arguments: FilmEditArgs(film: film),
);
}
}
class _FilmsListBuilder<T extends Film> extends StatelessWidget {
final List<T> films;
final void Function(T film, bool value) onFilmSelected;
final void Function(T film)? onFilmEdit;
const _FilmsListBuilder({
required this.films,
required this.onFilmSelected,
this.onFilmEdit,
});
@override
Widget build(BuildContext context) {
return SliverList.builder(
itemCount: films.length,
itemBuilder: (_, index) => Padding(
padding: EdgeInsets.fromLTRB(
Dimens.paddingM,
index == 0 ? Dimens.paddingM : 0,
Dimens.paddingM,
index == films.length - 1 ? Dimens.paddingM + MediaQuery.paddingOf(context).bottom : 0.0,
),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: index == 0 ? const Radius.circular(Dimens.borderRadiusL) : Radius.zero,
bottom: index == films.length - 1 ? const Radius.circular(Dimens.borderRadiusL) : Radius.zero,
),
),
child: Padding(
padding: EdgeInsets.only(
top: index == 0 ? Dimens.paddingM : 0.0,
bottom: index == films.length - 1 ? Dimens.paddingM : 0.0,
),
child: CheckboxListTile(
controlAffinity: ListTileControlAffinity.leading,
value: Films.inUseOf(context).contains(films[index]),
title: Text(films[index].name),
onChanged: (value) {
onFilmSelected(films[index], value ?? false);
},
secondary: onFilmEdit != null
? IconButton(
onPressed: () => onFilmEdit!(films[index]),
icon: const Icon(Icons.edit),
)
: null,
),
),
),
),
);
}
}

View file

@ -21,7 +21,7 @@ class LightmeterProScreen extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: SliverScreen( child: SliverScreen(
title: S.of(context).proFeaturesTitle, title: Text(S.of(context).proFeaturesTitle),
slivers: [ slivers: [
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(

View file

@ -19,7 +19,7 @@ class FilmPicker extends StatelessWidget {
selectedValue: Films.selectedOf(context), selectedValue: Films.selectedOf(context),
values: Films.inUseOf(context), values: Films.inUseOf(context),
itemTitleBuilder: (_, value) => Text(value.name.isEmpty ? S.of(context).none : value.name), itemTitleBuilder: (_, value) => Text(value.name.isEmpty ? S.of(context).none : value.name),
onChanged: FilmsProvider.of(context).setFilm, onChanged: FilmsProvider.of(context).selectFilm,
closedChild: ReadingValueContainer.singleValue( closedChild: ReadingValueContainer.singleValue(
value: ReadingValue( value: ReadingValue(
label: _label(context), label: _label(context),
@ -30,7 +30,7 @@ class FilmPicker extends StatelessWidget {
} }
String _label(BuildContext context) { String _label(BuildContext context) {
if (Films.selectedOf(context) == const Film.other() || Films.selectedOf(context).iso == selectedIso.value) { if (Films.selectedOf(context) == const FilmStub() || Films.selectedOf(context).iso == selectedIso.value) {
return S.of(context).film; return S.of(context).film;
} }

View file

@ -1,5 +1,6 @@
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/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart';
class LightmeterProAnimatedDialog extends StatelessWidget { class LightmeterProAnimatedDialog extends StatelessWidget {
@ -9,7 +10,7 @@ class LightmeterProAnimatedDialog extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
Navigator.of(context).pushNamed("lightmeterPro"); Navigator.of(context).pushNamed(NavigationRoutes.proFeaturesScreen.name);
}, },
child: ReadingValueContainer( child: ReadingValueContainer(
color: Theme.of(context).colorScheme.secondary, color: Theme.of(context).colorScheme.secondary,

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:lightmeter/data/models/ev_source_type.dart'; import 'package:lightmeter/data/models/ev_source_type.dart';
import 'package:lightmeter/data/models/exposure_pair.dart'; import 'package:lightmeter/data/models/exposure_pair.dart';
import 'package:lightmeter/navigation/routes.dart';
import 'package:lightmeter/providers/equipment_profile_provider.dart'; import 'package:lightmeter/providers/equipment_profile_provider.dart';
import 'package:lightmeter/providers/services_provider.dart'; import 'package:lightmeter/providers/services_provider.dart';
import 'package:lightmeter/providers/user_preferences_provider.dart'; import 'package:lightmeter/providers/user_preferences_provider.dart';
@ -36,7 +37,7 @@ class MeteringScreen extends StatelessWidget {
onNdChanged: (value) => context.read<MeteringBloc>().add(NdChangedEvent(value)), onNdChanged: (value) => context.read<MeteringBloc>().add(NdChangedEvent(value)),
onExposurePairTap: (value) => pushNamed( onExposurePairTap: (value) => pushNamed(
context, context,
'timer', NavigationRoutes.timerScreen.name,
arguments: TimerFlowArgs( arguments: TimerFlowArgs(
exposurePair: value, exposurePair: value,
isoValue: state.iso, isoValue: state.iso,
@ -55,7 +56,10 @@ class MeteringScreen extends StatelessWidget {
? UserPreferencesProvider.of(context).toggleEvSourceType ? UserPreferencesProvider.of(context).toggleEvSourceType
: null, : null,
onMeasure: () => context.read<MeteringBloc>().add(const MeasureEvent()), onMeasure: () => context.read<MeteringBloc>().add(const MeasureEvent()),
onSettings: () => pushNamed(context, 'settings'), onSettings: () => pushNamed(
context,
NavigationRoutes.settingsScreen.name,
),
), ),
), ),
], ],

View file

@ -1,5 +1,6 @@
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/res/dimens.dart'; import 'package:lightmeter/res/dimens.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
@ -15,7 +16,7 @@ class BuyProListTile extends StatelessWidget {
title: Text(S.of(context).getPro), title: Text(S.of(context).getPro),
onTap: !isPending onTap: !isPending
? () { ? () {
Navigator.of(context).pushNamed("lightmeterPro"); Navigator.of(context).pushNamed(NavigationRoutes.proFeaturesScreen.name);
} }
: null, : null,
trailing: isPending trailing: isPending

View file

@ -4,7 +4,7 @@ import 'package:lightmeter/providers/equipment_profile_provider.dart';
import 'package:lightmeter/res/dimens.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_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/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_name_dialog/widget_dialog_equipment_profile_name.dart';
import 'package:lightmeter/screens/shared/icon_placeholder/widget_icon_placeholder.dart'; import 'package:lightmeter/screens/shared/sliver_placeholder/widget_sliver_placeholder.dart';
import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart'; import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
@ -28,7 +28,7 @@ class _EquipmentProfilesScreenState extends State<EquipmentProfilesScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverScreen( return SliverScreen(
title: S.of(context).equipmentProfiles, title: Text(S.of(context).equipmentProfiles),
appBarActions: [ appBarActions: [
IconButton( IconButton(
onPressed: _addProfile, onPressed: _addProfile,
@ -37,12 +37,7 @@ class _EquipmentProfilesScreenState extends State<EquipmentProfilesScreen> {
), ),
], ],
slivers: profilesCount == 1 slivers: profilesCount == 1
? [ ? [SliverPlaceholder(onTap: _addProfile)]
SliverFillRemaining(
hasScrollBody: false,
child: _EquipmentProfilesListPlaceholder(onTap: _addProfile),
),
]
: [ : [
SliverList( SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
@ -131,32 +126,3 @@ class _EquipmentProfilesScreenState extends State<EquipmentProfilesScreen> {
} }
} }
} }
class _EquipmentProfilesListPlaceholder extends StatelessWidget {
final VoidCallback onTap;
const _EquipmentProfilesListPlaceholder({required this.onTap});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: Dimens.sliverAppBarExpandedHeight),
child: FractionallySizedBox(
widthFactor: 1 / 1.618,
child: Center(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(Dimens.paddingL),
child: IconPlaceholder(
icon: Icons.add_outlined,
text: S.of(context).tapToAdd,
),
),
),
),
),
);
}
}

View file

@ -1,9 +1,7 @@
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/providers/films_provider.dart'; import 'package:lightmeter/navigation/routes.dart';
import 'package:lightmeter/screens/settings/components/shared/dialog_filter/widget_dialog_filter.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 FilmsListTile extends StatelessWidget { class FilmsListTile extends StatelessWidget {
const FilmsListTile({super.key}); const FilmsListTile({super.key});
@ -12,24 +10,8 @@ class FilmsListTile extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IAPListTile( return IAPListTile(
leading: const Icon(Icons.camera_roll_outlined), leading: const Icon(Icons.camera_roll_outlined),
title: Text(S.of(context).filmsInUse), title: Text(S.of(context).films),
onTap: () { onTap: () => Navigator.of(context).pushNamed(NavigationRoutes.filmsListScreen.name),
showDialog<List<Film>>(
context: context,
builder: (_) => DialogFilter<Film>(
icon: const Icon(Icons.camera_roll_outlined),
title: S.of(context).filmsInUse,
description: S.of(context).filmsInUseDescription,
values: Films.of(context).sublist(1),
selectedValues: Films.inUseOf(context),
titleAdapter: (_, value) => value.name,
),
).then((values) {
if (values != null) {
FilmsProvider.of(context).saveFilms(values);
}
});
},
); );
} }
} }

View file

@ -39,7 +39,7 @@ class MeteringScreenLayoutListTile extends StatelessWidget {
EquipmentProfileProvider.of(context).setProfile(EquipmentProfiles.of(context).first); EquipmentProfileProvider.of(context).setProfile(EquipmentProfiles.of(context).first);
} }
if (!value[MeteringScreenLayoutFeature.filmPicker]!) { if (!value[MeteringScreenLayoutFeature.filmPicker]!) {
FilmsProvider.of(context).setFilm(const Film.other()); FilmsProvider.of(context).selectFilm(const FilmStub());
} }
UserPreferencesProvider.of(context).setMeteringScreenLayout(value); UserPreferencesProvider.of(context).setMeteringScreenLayout(value);
}, },

View file

@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/res/dimens.dart';
class ExpandableSectionNameDialog extends StatefulWidget {
final String title;
final String hint;
final String initialValue;
const ExpandableSectionNameDialog({
this.initialValue = '',
required this.title,
required this.hint,
super.key,
});
@override
State<ExpandableSectionNameDialog> createState() => _ExpandableSectionNameDialogState();
}
class _ExpandableSectionNameDialogState extends State<ExpandableSectionNameDialog> {
late final _nameController = TextEditingController(text: widget.initialValue);
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
icon: const Icon(Icons.edit_outlined),
titlePadding: Dimens.dialogIconTitlePadding,
title: Text(widget.title),
content: TextField(
autofocus: true,
controller: _nameController,
decoration: InputDecoration(hintText: widget.hint),
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text(S.of(context).cancel),
),
ValueListenableBuilder(
valueListenable: _nameController,
builder: (_, value, __) => TextButton(
onPressed: value.text.isNotEmpty ? () => Navigator.of(context).pop(value.text) : null,
child: Text(S.of(context).save),
),
),
],
);
}
}

View file

@ -0,0 +1,184 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/res/dimens.dart';
class ExpandableSectionListItem extends StatefulWidget {
final String title;
final VoidCallback onTitleTap;
final VoidCallback onExpand;
final List<IconButton> actions;
final List<Widget> children;
const ExpandableSectionListItem({
required this.title,
required this.onTitleTap,
required this.onExpand,
required this.actions,
required this.children,
super.key,
});
static ExpandableSectionListItemState of(BuildContext context) {
return context.findAncestorStateOfType<ExpandableSectionListItemState>()!;
}
@override
State<ExpandableSectionListItem> createState() => ExpandableSectionListItemState();
}
class ExpandableSectionListItemState extends State<ExpandableSectionListItem> with TickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
duration: Dimens.durationM,
vsync: this,
);
bool get _expanded => _controller.isCompleted;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Dimens.paddingM),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM),
title: Row(
children: [
_AnimatedNameLeading(controller: _controller),
const SizedBox(width: Dimens.grid8),
Flexible(
child: Text(
widget.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
trailing: _AnimatedArrowButton(
controller: _controller,
onPressed: () => _expanded ? collapse() : expand(),
),
onTap: () => _expanded ? widget.onTitleTap() : expand(),
),
_AnimatedContent(
controller: _controller,
actions: widget.actions,
children: widget.children,
),
],
),
),
);
}
void expand() {
widget.onExpand();
_controller.forward();
SchedulerBinding.instance.addPostFrameCallback((_) {
Future.delayed(_controller.duration!).then((_) {
Scrollable.ensureVisible(
context,
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
duration: _controller.duration!,
);
});
});
}
void collapse() {
_controller.reverse();
}
}
class _AnimatedNameLeading extends AnimatedWidget {
const _AnimatedNameLeading({required AnimationController controller}) : super(listenable: controller);
Animation<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 _AnimatedContent extends AnimatedWidget {
final List<IconButton> actions;
final List<Widget> children;
const _AnimatedContent({
required AnimationController controller,
required this.actions,
required this.children,
}) : 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 * (children.length + 1),
),
// https://github.com/gskinnerTeam/flutter-folio/pull/62
child: Opacity(
opacity: _progress.value,
child: Column(
children: [
...children,
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM),
trailing: Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: actions,
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/settings/components/shared/expandable_section_list/components/expandable_section_list_item/widget_expandable_section_list_item.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
typedef _WidgetBuilder<W, T extends Identifiable> = W Function(BuildContext context, T value);
class ExpandableSectionList<T extends Identifiable> extends StatefulWidget {
final List<T> values;
final VoidCallback onSectionTitleTap;
final _WidgetBuilder<List<Widget>, T> contentBuilder;
final _WidgetBuilder<List<IconButton>, T> actionsBuilder;
const ExpandableSectionList({
required this.values,
required this.onSectionTitleTap,
required this.contentBuilder,
required this.actionsBuilder,
super.key,
});
@override
State<ExpandableSectionList> createState() => _ExpandableSectionListState<T>();
}
class _ExpandableSectionListState<T extends Identifiable> extends State<ExpandableSectionList<T>> {
final Map<String, GlobalKey<ExpandableSectionListItemState>> keysMap = {};
@override
void didChangeDependencies() {
super.didChangeDependencies();
_updateProfilesKeys();
}
@override
Widget build(BuildContext context) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = widget.values[index];
return Padding(
padding: EdgeInsets.fromLTRB(
Dimens.paddingM,
index == 0 ? Dimens.paddingM : 0,
Dimens.paddingM,
Dimens.paddingM,
),
child: ExpandableSectionListItem(
key: keysMap[item.id],
title: item.name,
onTitleTap: widget.onSectionTitleTap,
onExpand: () => _keepExpandedAt(index),
actions: widget.actionsBuilder(context, item),
children: widget.contentBuilder(context, item),
),
);
},
childCount: widget.values.length,
),
);
}
void _keepExpandedAt(int index) {
keysMap.values.toList().getRange(0, index).forEach((element) {
element.currentState?.collapse();
});
keysMap.values.toList().getRange(index + 1, keysMap.length).forEach((element) {
element.currentState?.collapse();
});
}
void _updateProfilesKeys() {
if (widget.values.length > keysMap.length) {
// item added
final List<String> idsToAdd = [];
for (final item in widget.values) {
if (!keysMap.keys.contains(item.id)) idsToAdd.add(item.id);
}
for (final id in idsToAdd) {
keysMap[id] = GlobalKey<ExpandableSectionListItemState>(debugLabel: id);
}
idsToAdd.clear();
} else if (widget.values.length < keysMap.length) {
// item deleted
final List<String> idsToDelete = [];
for (final id in keysMap.keys) {
if (!widget.values.any((p) => p.id == id)) idsToDelete.add(id);
}
idsToDelete.forEach(keysMap.remove);
idsToDelete.clear();
} else {
// item updated, no need to updated keys
}
}
}

View file

@ -33,7 +33,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ScaffoldMessenger( return ScaffoldMessenger(
child: SliverScreen( child: SliverScreen(
title: S.of(context).settings, title: Text(S.of(context).settings),
slivers: [ slivers: [
SliverList( SliverList(
delegate: SliverChildListDelegate( delegate: SliverChildListDelegate(

View file

@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/shared/icon_placeholder/widget_icon_placeholder.dart';
import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart';
class SliverPlaceholder extends StatelessWidget {
final VoidCallback onTap;
const SliverPlaceholder({required this.onTap});
@override
Widget build(BuildContext context) {
final sliverScreenBottomHeight =
context.findAncestorWidgetOfExactType<SliverScreen>()?.bottom?.preferredSize.height ?? 0.0;
return SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: EdgeInsets.only(bottom: Dimens.sliverAppBarExpandedHeight - sliverScreenBottomHeight),
child: FractionallySizedBox(
widthFactor: 1 / 1.618,
child: Center(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(Dimens.paddingL),
child: IconPlaceholder(
icon: Icons.add_outlined,
text: S.of(context).tapToAdd,
),
),
),
),
),
),
);
}
}

View file

@ -1,15 +1,18 @@
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/res/dimens.dart'; import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/utils/text_height.dart';
class SliverScreen extends StatelessWidget { class SliverScreen extends StatelessWidget {
final String title; final Widget title;
final List<Widget> appBarActions; final List<Widget> appBarActions;
final PreferredSizeWidget? bottom;
final List<Widget> slivers; final List<Widget> slivers;
const SliverScreen({ const SliverScreen({
required this.title, required this.title,
this.appBarActions = const [], this.appBarActions = const [],
this.bottom,
required this.slivers, required this.slivers,
super.key, super.key,
}); });
@ -22,30 +25,10 @@ class SliverScreen extends StatelessWidget {
bottom: false, bottom: false,
child: CustomScrollView( child: CustomScrollView(
slivers: <Widget>[ slivers: <Widget>[
SliverAppBar( _AppBar(
pinned: true, title: title,
automaticallyImplyLeading: false, appBarActions: appBarActions,
expandedHeight: Dimens.sliverAppBarExpandedHeight, bottom: bottom,
flexibleSpace: FlexibleSpaceBar(
centerTitle: false,
titlePadding: const EdgeInsets.all(Dimens.paddingM),
title: Text(
title,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: Dimens.grid24,
),
),
),
actions: [
...appBarActions,
if (Navigator.of(context).canPop())
IconButton(
onPressed: Navigator.of(context).pop,
icon: const Icon(Icons.close_outlined),
tooltip: S.of(context).tooltipClose,
),
],
), ),
...slivers, ...slivers,
], ],
@ -54,3 +37,85 @@ class SliverScreen extends StatelessWidget {
); );
} }
} }
class _AppBar extends StatelessWidget {
final Widget title;
final List<Widget> appBarActions;
final PreferredSizeWidget? bottom;
const _AppBar({
required this.title,
this.appBarActions = const [],
this.bottom,
});
@override
Widget build(BuildContext context) {
return SliverAppBar.large(
automaticallyImplyLeading: false,
expandedHeight: Dimens.sliverAppBarExpandedHeight + (bottom?.preferredSize.height ?? 0.0),
flexibleSpace: FlexibleSpaceBar(
centerTitle: false,
titlePadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM),
title: DefaultTextStyle(
style: Theme.of(context).textTheme.headlineSmall!.copyWith(color: Theme.of(context).colorScheme.onSurface),
maxLines: 2,
overflow: TextOverflow.ellipsis,
child: _Title(
actionsCount: appBarActions.length + (Navigator.of(context).canPop() ? 1 : 0),
bottomSize: bottom?.preferredSize.height ?? 0.0,
child: title,
),
),
),
bottom: bottom,
actions: [
...appBarActions,
if (Navigator.of(context).canPop())
IconButton(
onPressed: Navigator.of(context).pop,
icon: const Icon(Icons.close_outlined),
tooltip: S.of(context).tooltipClose,
),
],
);
}
}
class _Title extends StatelessWidget {
final Widget child;
final int actionsCount;
final double bottomSize;
final double actionsPadding;
const _Title({
required this.actionsCount,
required this.bottomSize,
required this.child,
}) : actionsPadding = Dimens.grid48 * actionsCount - Dimens.paddingM;
@override
Widget build(BuildContext context) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>()!;
final extentScale =
((settings.maxExtent - settings.currentExtent) / (settings.maxExtent - settings.minExtent)).clamp(0.0, 1.0);
final titleScale = Tween<double>(begin: 1.5, end: 1.0).transform(extentScale);
final maxFromTextToAppbar = settings.maxExtent - settings.minExtent - Dimens.paddingM;
final currentFromTextToAppbar = settings.currentExtent - settings.minExtent - Dimens.paddingM;
final actionsPaddingScale = (1 - currentFromTextToAppbar / maxFromTextToAppbar).clamp(0.0, 1.0);
return LayoutBuilder(
builder: (context, constraints) => Padding(
padding: EdgeInsets.only(bottom: (Dimens.paddingM * (1 - actionsPaddingScale) + bottomSize) / titleScale),
child: SizedBox(
height: DefaultTextStyle.of(context).style.lineHeight * 2,
width: constraints.maxWidth - (actionsPadding * actionsPaddingScale) / titleScale,
child: Align(
alignment: FractionalOffset(0.0, 0.5 + 0.5 * (1 - extentScale)),
child: child,
),
),
),
);
}
}

View file

@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
class LightmeterTextField extends TextFormField {
LightmeterTextField({
super.controller,
super.autofocus,
super.initialValue,
super.inputFormatters,
super.maxLength,
super.onChanged,
super.style,
super.textAlign,
Widget? leading,
String? hintText,
}) : super(
autovalidateMode: AutovalidateMode.onUserInteraction,
maxLines: 1,
decoration: InputDecoration(
counter: const SizedBox(),
contentPadding: EdgeInsets.zero,
errorStyle: const TextStyle(fontSize: 0),
icon: leading,
hintText: hintText,
),
validator: (value) {
if (value == null || value.isEmpty) {
return '';
} else {
return null;
}
},
);
}

View file

@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
enum SelectableAspect { list, selected } enum SelectableAspect { list, selected }
class SelectableInheritedModel<T> extends InheritedModel<SelectableAspect> { class SelectableInheritedModel<T> extends InheritedModel<SelectableAspect> {
const SelectableInheritedModel({ const SelectableInheritedModel({
super.key, super.key,

View file

@ -34,3 +34,7 @@ Size textSize(
)..layout(maxWidth: maxWidth); )..layout(maxWidth: maxWidth);
return titlePainter.size; return titlePainter.size;
} }
extension TextLineHeight on TextStyle {
double get lineHeight => fontSize! * height!;
}

View file

@ -13,6 +13,7 @@ dependencies:
camera: 0.10.5+2 camera: 0.10.5+2
camera_android_camerax: 0.6.1+1 camera_android_camerax: 0.6.1+1
clipboard: 0.1.3 clipboard: 0.1.3
collection: any
dynamic_color: 1.7.0 dynamic_color: 1.7.0
exif: 3.1.4 exif: 3.1.4
firebase_analytics: 10.6.2 firebase_analytics: 10.6.2
@ -24,17 +25,18 @@ dependencies:
flutter_bloc: 8.1.3 flutter_bloc: 8.1.3
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
flutter_native_splash: 2.3.5
intl: 0.18.1 intl: 0.18.1
intl_utils: 2.8.2 intl_utils: 2.8.2
light_sensor: 3.0.0 light_sensor: 3.0.0
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: v0.11.2 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: v1.4.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
@ -48,7 +50,6 @@ dev_dependencies:
args: 2.5.0 args: 2.5.0
bloc_test: 9.1.3 bloc_test: 9.1.3
build_runner: 2.4.6 build_runner: 2.4.6
flutter_native_splash: 2.3.5
flutter_test: flutter_test:
sdk: flutter sdk: flutter
golden_toolkit: 0.15.0 golden_toolkit: 0.15.0
@ -63,10 +64,6 @@ dev_dependencies:
test: 1.24.3 test: 1.24.3
dependency_overrides: dependency_overrides:
m3_lightmeter_resources:
git:
url: "https://github.com/vodemn/m3_lightmeter_resources"
ref: v1.4.0
material_color_utilities: 0.11.1 material_color_utilities: 0.11.1
flutter: flutter:

View file

@ -32,7 +32,7 @@ import 'models/screenshot_args.dart';
//https://stackoverflow.com/a/67186625/13167574 //https://stackoverflow.com/a/67186625/13167574
const _mockFilm = Film('Ilford HP5+', 400); const _mockFilm = FilmExponential(id: '1', name: 'Ilford HP5+', iso: 400, exponent: 1.34);
final Color _lightThemeColor = primaryColorsList[5]; final Color _lightThemeColor = primaryColorsList[5];
final Color _darkThemeColor = primaryColorsList[3]; final Color _darkThemeColor = primaryColorsList[3];
final ThemeData _themeLight = themeFrom(_lightThemeColor, Brightness.light); final ThemeData _themeLight = themeFrom(_lightThemeColor, Brightness.light);
@ -92,9 +92,9 @@ 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(
availableFilms: [_mockFilm], predefinedFilms: [_mockFilm].toFilmsMap(),
filmsInUse: [_mockFilm], customFilms: {},
selectedFilm: _mockFilm, selectedFilmId: _mockFilm.id,
); );
await tester.takePhoto(); await tester.takePhoto();
@ -132,9 +132,9 @@ 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(
availableFilms: [_mockFilm], predefinedFilms: [_mockFilm].toFilmsMap(),
filmsInUse: [_mockFilm], customFilms: {},
selectedFilm: _mockFilm, selectedFilmId: _mockFilm.id,
); );
await tester.takePhoto(); await tester.takePhoto();
@ -157,9 +157,9 @@ void main() {
color: _lightThemeColor, color: _lightThemeColor,
); );
await tester.pumpApplication( await tester.pumpApplication(
availableFilms: [_mockFilm], predefinedFilms: [_mockFilm].toFilmsMap(),
filmsInUse: [_mockFilm], customFilms: {},
selectedFilm: _mockFilm, selectedFilmId: _mockFilm.id,
); );
await tester.takePhoto(); await tester.takePhoto();

View file

@ -2,13 +2,25 @@ import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:light_sensor/light_sensor.dart'; import 'package:light_sensor/light_sensor.dart';
import 'package:lightmeter/application_wrapper.dart'; import 'package:lightmeter/data/analytics/analytics.dart';
import 'package:lightmeter/data/analytics/api/analytics_firebase.dart';
import 'package:lightmeter/data/caffeine_service.dart';
import 'package:lightmeter/data/haptics_service.dart';
import 'package:lightmeter/data/light_sensor_service.dart';
import 'package:lightmeter/data/models/supported_locale.dart'; import 'package:lightmeter/data/models/supported_locale.dart';
import 'package:lightmeter/data/permissions_service.dart';
import 'package:lightmeter/data/remote_config_service.dart';
import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/data/volume_events_service.dart';
import 'package:lightmeter/environment.dart'; import 'package:lightmeter/environment.dart';
import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/providers/remote_config_provider.dart';
import 'package:lightmeter/providers/services_provider.dart';
import 'package:lightmeter/providers/user_preferences_provider.dart'; import 'package:lightmeter/providers/user_preferences_provider.dart';
import 'package:lightmeter/res/theme.dart'; import 'package:lightmeter/res/theme.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:platform/platform.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../integration_test/mocks/paid_features_mock.dart'; import '../integration_test/mocks/paid_features_mock.dart';
import '../integration_test/utils/platform_channel_mock.dart'; import '../integration_test/utils/platform_channel_mock.dart';
@ -94,12 +106,11 @@ class _GoldenTestApplicationMockState extends State<GoldenTestApplicationMock> {
price: '0.0\$', price: '0.0\$',
), ),
], ],
child: ApplicationWrapper( child: _MockApplicationWrapper(
const Environment.dev(),
child: MockIAPProviders( child: MockIAPProviders(
equipmentProfiles: mockEquipmentProfiles, equipmentProfiles: mockEquipmentProfiles,
selectedEquipmentProfileId: mockEquipmentProfiles.first.id, selectedEquipmentProfileId: mockEquipmentProfiles.first.id,
selectedFilm: mockFilms.first, selectedFilmId: mockFilms.first.id,
child: Builder( child: Builder(
builder: (context) { builder: (context) {
return MaterialApp( return MaterialApp(
@ -126,3 +137,40 @@ class _GoldenTestApplicationMockState extends State<GoldenTestApplicationMock> {
); );
} }
} }
class _MockApplicationWrapper extends StatelessWidget {
final Widget child;
const _MockApplicationWrapper({required this.child});
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: SharedPreferences.getInstance(),
builder: (_, snapshot) {
if (snapshot.data != null) {
final userPreferencesService = UserPreferencesService(snapshot.data!);
return ServicesProvider(
analytics: const LightmeterAnalytics(api: LightmeterAnalyticsFirebase()),
caffeineService: const CaffeineService(),
environment: const Environment.dev().copyWith(hasLightSensor: true),
hapticsService: const HapticsService(),
lightSensorService: const LightSensorService(LocalPlatform()),
permissionsService: const PermissionsService(),
userPreferencesService: userPreferencesService,
volumeEventsService: const VolumeEventsService(LocalPlatform()),
child: RemoteConfigProvider(
remoteConfigService: const MockRemoteConfigService(),
child: UserPreferencesProvider(
hasLightSensor: true,
userPreferencesService: userPreferencesService,
child: child,
),
),
);
}
return const SizedBox();
},
);
}
}

View file

@ -5,18 +5,29 @@ 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 _MockIAPStorageService extends Mock implements IAPStorageService {} class _MockFilmsStorageService extends Mock implements FilmsStorageService {}
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
late _MockIAPStorageService mockIAPStorageService; late _MockFilmsStorageService mockFilmsStorageService;
setUpAll(() { setUpAll(() {
mockIAPStorageService = _MockIAPStorageService(); mockFilmsStorageService = _MockFilmsStorageService();
});
setUp(() {
registerFallbackValue(mockCustomFilms.first);
when(() => mockFilmsStorageService.toggleFilm(any<Film>(), any<bool>())).thenAnswer((_) async {});
when(() => mockFilmsStorageService.addFilm(any<FilmExponential>())).thenAnswer((_) async {});
when(() => mockFilmsStorageService.updateFilm(any<FilmExponential>())).thenAnswer((_) async {});
when(() => mockFilmsStorageService.deleteFilm(any<FilmExponential>())).thenAnswer((_) async {});
when(() => mockFilmsStorageService.getPredefinedFilms())
.thenAnswer((_) => Future.value(mockPredefinedFilms.toFilmsMap()));
when(() => mockFilmsStorageService.getCustomFilms()).thenAnswer((_) => Future.value(mockCustomFilms.toFilmsMap()));
}); });
tearDown(() { tearDown(() {
reset(mockIAPStorageService); reset(mockFilmsStorageService);
}); });
Future<void> pumpTestWidget(WidgetTester tester, IAPProductStatus productStatus) async { Future<void> pumpTestWidget(WidgetTester tester, IAPProductStatus productStatus) async {
@ -30,41 +41,49 @@ void main() {
), ),
], ],
child: FilmsProvider( child: FilmsProvider(
storageService: mockIAPStorageService, filmsStorageService: mockFilmsStorageService,
availableFilms: mockFilms,
child: const _Application(), child: const _Application(),
), ),
), ),
); );
await tester.pumpAndSettle();
} }
void expectFilmsCount(int count) { void expectPredefinedFilmsCount(int count) {
expect(find.text('Films count: $count'), findsOneWidget); expect(find.text(_PredefinedFilmsCount.text(count)), findsOneWidget);
}
void expectCustomFilmsCount(int count) {
expect(find.text(_CustomFilmsCount.text(count)), findsOneWidget);
} }
void expectFilmsInUseCount(int count) { void expectFilmsInUseCount(int count) {
expect(find.text('Films in use count: $count'), findsOneWidget); expect(find.text(_FilmsInUseCount.text(count)), findsOneWidget);
} }
void expectSelectedFilmName(String name) { void expectSelectedFilmName(String name) {
expect(find.text('Selected film: $name'), findsOneWidget); expect(find.text(_SelectedFilm.text(name)), findsOneWidget);
} }
group( group(
'FilmsProvider dependency on IAPProductStatus', 'FilmsProvider dependency on IAPProductStatus',
() { () {
setUp(() { setUp(() {
when(() => mockIAPStorageService.selectedFilm).thenReturn(mockFilms.first); when(() => mockFilmsStorageService.selectedFilmId).thenReturn(mockPredefinedFilms.first.id);
when(() => mockIAPStorageService.filmsInUse).thenReturn(mockFilms); when(() => mockFilmsStorageService.getPredefinedFilms())
.thenAnswer((_) => Future.value(mockPredefinedFilms.toFilmsMap()));
when(() => mockFilmsStorageService.getCustomFilms())
.thenAnswer((_) => Future.value(mockCustomFilms.toFilmsMap()));
}); });
testWidgets( testWidgets(
'IAPProductStatus.purchased - show all saved films', 'IAPProductStatus.purchased - show all saved films',
(tester) async { (tester) async {
await pumpTestWidget(tester, IAPProductStatus.purchased); await pumpTestWidget(tester, IAPProductStatus.purchased);
expectFilmsCount(mockFilms.length + 1); expectPredefinedFilmsCount(mockPredefinedFilms.length);
expectFilmsInUseCount(mockFilms.length + 1); expectCustomFilmsCount(mockCustomFilms.length);
expectSelectedFilmName(mockFilms.first.name); expectFilmsInUseCount(mockPredefinedFilms.length + mockCustomFilms.length + 1);
expectSelectedFilmName(mockPredefinedFilms.first.name);
}, },
); );
@ -72,7 +91,8 @@ void main() {
'IAPProductStatus.purchasable - show only default', 'IAPProductStatus.purchasable - show only default',
(tester) async { (tester) async {
await pumpTestWidget(tester, IAPProductStatus.purchasable); await pumpTestWidget(tester, IAPProductStatus.purchasable);
expectFilmsCount(mockFilms.length + 1); expectPredefinedFilmsCount(0);
expectCustomFilmsCount(0);
expectFilmsInUseCount(1); expectFilmsInUseCount(1);
expectSelectedFilmName(''); expectSelectedFilmName('');
}, },
@ -82,7 +102,8 @@ void main() {
'IAPProductStatus.pending - show only default', 'IAPProductStatus.pending - show only default',
(tester) async { (tester) async {
await pumpTestWidget(tester, IAPProductStatus.pending); await pumpTestWidget(tester, IAPProductStatus.pending);
expectFilmsCount(mockFilms.length + 1); expectPredefinedFilmsCount(0);
expectCustomFilmsCount(0);
expectFilmsInUseCount(1); expectFilmsInUseCount(1);
expectSelectedFilmName(''); expectSelectedFilmName('');
}, },
@ -91,186 +112,201 @@ void main() {
); );
group( group(
'FilmsProvider CRUD', 'toggleFilm',
() { () {
testWidgets( testWidgets(
'Select films in use', 'toggle predefined film',
(tester) async { (tester) async {
when(() => mockIAPStorageService.selectedFilm).thenReturn(const Film.other()); when(() => mockFilmsStorageService.selectedFilmId).thenReturn(mockPredefinedFilms.first.id);
when(() => mockIAPStorageService.filmsInUse).thenReturn([]);
/// Init
await pumpTestWidget(tester, IAPProductStatus.purchased); await pumpTestWidget(tester, IAPProductStatus.purchased);
expectFilmsCount(mockFilms.length + 1); expectPredefinedFilmsCount(mockPredefinedFilms.length);
expectFilmsInUseCount(1); expectCustomFilmsCount(mockCustomFilms.length);
expectFilmsInUseCount(mockPredefinedFilms.length + mockCustomFilms.length + 1);
expectSelectedFilmName(mockPredefinedFilms.first.name);
await tester.filmsProvider.toggleFilm(mockPredefinedFilms.first, false);
await tester.pump();
expectPredefinedFilmsCount(mockPredefinedFilms.length);
expectCustomFilmsCount(mockCustomFilms.length);
expectFilmsInUseCount(mockPredefinedFilms.length - 1 + mockCustomFilms.length + 1);
expectSelectedFilmName(''); expectSelectedFilmName('');
/// Select all filmsInUse verify(() => mockFilmsStorageService.toggleFilm(mockPredefinedFilms.first, false)).called(1);
await tester.tap(find.byKey(_Application.saveFilmsButtonKey(0))); verify(() => mockFilmsStorageService.selectedFilmId = '').called(1);
await tester.pumpAndSettle();
expectFilmsCount(mockFilms.length + 1);
expectFilmsInUseCount(mockFilms.length + 1);
expectSelectedFilmName('');
verify(() => mockIAPStorageService.filmsInUse = mockFilms.skip(0).toList()).called(1);
verifyNever(() => mockIAPStorageService.selectedFilm = const Film.other());
}, },
); );
testWidgets( testWidgets(
'Select film', 'toggle custom film',
(tester) async { (tester) async {
when(() => mockIAPStorageService.selectedFilm).thenReturn(const Film.other()); when(() => mockFilmsStorageService.selectedFilmId).thenReturn(mockCustomFilms.first.id);
when(() => mockIAPStorageService.filmsInUse).thenReturn(mockFilms);
/// Init
await pumpTestWidget(tester, IAPProductStatus.purchased); await pumpTestWidget(tester, IAPProductStatus.purchased);
expectFilmsCount(mockFilms.length + 1); expectPredefinedFilmsCount(mockPredefinedFilms.length);
expectFilmsInUseCount(mockFilms.length + 1); expectCustomFilmsCount(mockCustomFilms.length);
expectFilmsInUseCount(mockPredefinedFilms.length + mockCustomFilms.length + 1);
expectSelectedFilmName(mockCustomFilms.first.name);
await tester.filmsProvider.toggleFilm(mockCustomFilms.first, false);
await tester.pump();
expectPredefinedFilmsCount(mockPredefinedFilms.length);
expectCustomFilmsCount(mockCustomFilms.length);
expectFilmsInUseCount(mockPredefinedFilms.length - 1 + mockCustomFilms.length + 1);
expectSelectedFilmName(''); expectSelectedFilmName('');
/// Select all filmsInUse verify(() => mockFilmsStorageService.toggleFilm(mockCustomFilms.first, false)).called(1);
await tester.tap(find.byKey(_Application.setFilmButtonKey(0))); verify(() => mockFilmsStorageService.selectedFilmId = '').called(1);
await tester.pumpAndSettle();
expectFilmsCount(mockFilms.length + 1);
expectFilmsInUseCount(mockFilms.length + 1);
expectSelectedFilmName(mockFilms.first.name);
verifyNever(() => mockIAPStorageService.filmsInUse = any<List<Film>>());
verify(() => mockIAPStorageService.selectedFilm = mockFilms.first).called(1);
},
);
group(
'Coming from free app',
() {
testWidgets(
'Has selected film',
(tester) async {
when(() => mockIAPStorageService.selectedFilm).thenReturn(mockFilms[2]);
when(() => mockIAPStorageService.filmsInUse).thenReturn([]);
/// Init
await pumpTestWidget(tester, IAPProductStatus.purchased);
expectFilmsInUseCount(1);
expectSelectedFilmName('');
verifyNever(() => mockIAPStorageService.filmsInUse = any<List<Film>>());
verify(() => mockIAPStorageService.selectedFilm = const Film.other()).called(1);
},
);
testWidgets(
'None film selected',
(tester) async {
when(() => mockIAPStorageService.selectedFilm).thenReturn(const Film.other());
when(() => mockIAPStorageService.filmsInUse).thenReturn([]);
/// Init
await pumpTestWidget(tester, IAPProductStatus.purchased);
expectFilmsInUseCount(1);
expectSelectedFilmName('');
verifyNever(() => mockIAPStorageService.filmsInUse = any<List<Film>>());
verifyNever(() => mockIAPStorageService.selectedFilm = const Film.other());
},
);
},
);
testWidgets(
'Discard selected (by filmsInUse list update)',
(tester) async {
when(() => mockIAPStorageService.selectedFilm).thenReturn(mockFilms.first);
when(() => mockIAPStorageService.filmsInUse).thenReturn(mockFilms);
/// Init
await pumpTestWidget(tester, IAPProductStatus.purchased);
expectFilmsCount(mockFilms.length + 1);
expectFilmsInUseCount(mockFilms.length + 1);
expectSelectedFilmName(mockFilms.first.name);
/// Select all filmsInUse except the first one
await tester.tap(find.byKey(_Application.saveFilmsButtonKey(1)));
await tester.pumpAndSettle();
expectFilmsCount(mockFilms.length + 1);
expectFilmsInUseCount((mockFilms.length - 1) + 1);
expectSelectedFilmName('');
verify(() => mockIAPStorageService.filmsInUse = mockFilms.skip(1).toList()).called(1);
verify(() => mockIAPStorageService.selectedFilm = const Film.other()).called(1);
}, },
); );
}, },
); );
testWidgets(
'selectFilm',
(tester) async {
when(() => mockFilmsStorageService.selectedFilmId).thenReturn('');
await pumpTestWidget(tester, IAPProductStatus.purchased);
expectSelectedFilmName('');
tester.filmsProvider.selectFilm(mockPredefinedFilms.first);
await tester.pump();
expectSelectedFilmName(mockPredefinedFilms.first.name);
tester.filmsProvider.selectFilm(mockCustomFilms.first);
await tester.pump();
expectSelectedFilmName(mockCustomFilms.first.name);
verify(() => mockFilmsStorageService.selectedFilmId = mockPredefinedFilms.first.id).called(1);
verify(() => mockFilmsStorageService.selectedFilmId = mockCustomFilms.first.id).called(1);
},
);
testWidgets(
'Custom film CRUD',
(tester) async {
when(() => mockFilmsStorageService.selectedFilmId).thenReturn('');
when(() => mockFilmsStorageService.getCustomFilms()).thenAnswer((_) => Future.value({}));
await pumpTestWidget(tester, IAPProductStatus.purchased);
expectPredefinedFilmsCount(mockPredefinedFilms.length);
expectCustomFilmsCount(0);
expectFilmsInUseCount(mockPredefinedFilms.length + 0 + 1);
expectSelectedFilmName('');
await tester.filmsProvider.addCustomFilm(mockCustomFilms.first);
await tester.filmsProvider.toggleFilm(mockCustomFilms.first, true);
tester.filmsProvider.selectFilm(mockCustomFilms.first);
await tester.pump();
expectPredefinedFilmsCount(mockPredefinedFilms.length);
expectCustomFilmsCount(1);
expectFilmsInUseCount(mockPredefinedFilms.length + 1 + 1);
expectSelectedFilmName(mockCustomFilms.first.name);
verify(() => mockFilmsStorageService.addFilm(mockCustomFilms.first)).called(1);
verify(() => mockFilmsStorageService.toggleFilm(mockCustomFilms.first, true)).called(1);
verify(() => mockFilmsStorageService.selectedFilmId = mockCustomFilms.first.id).called(1);
const editedFilmName = 'Edited custom film 2x';
final editedFilm = mockCustomFilms.first.copyWith(name: editedFilmName);
await tester.filmsProvider.updateCustomFilm(editedFilm);
await tester.pump();
expectSelectedFilmName(editedFilm.name);
verify(() => mockFilmsStorageService.updateFilm(editedFilm)).called(1);
await tester.filmsProvider.deleteCustomFilm(editedFilm);
await tester.pump();
expectPredefinedFilmsCount(mockPredefinedFilms.length);
expectCustomFilmsCount(0);
expectFilmsInUseCount(mockPredefinedFilms.length + 0 + 1);
expectSelectedFilmName('');
verify(() => mockFilmsStorageService.deleteFilm(editedFilm)).called(1);
verify(() => mockFilmsStorageService.selectedFilmId = '').called(1);
},
);
}
extension on WidgetTester {
FilmsProviderState get filmsProvider {
final BuildContext context = element(find.byType(_Application));
return FilmsProvider.of(context);
}
} }
class _Application extends StatelessWidget { class _Application extends StatelessWidget {
const _Application(); const _Application();
static ValueKey saveFilmsButtonKey(int index) => ValueKey('saveFilmsButtonKey$index');
static ValueKey setFilmButtonKey(int index) => ValueKey('setFilmButtonKey$index');
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return const MaterialApp(
home: Scaffold( home: Scaffold(
body: Center( body: Center(
child: Column( child: Column(
children: [ children: [
Text("Films count: ${Films.of(context).length}"), _PredefinedFilmsCount(),
Text("Films in use count: ${Films.inUseOf(context).length}"), _CustomFilmsCount(),
Text("Selected film: ${Films.selectedOf(context).name}"), _FilmsInUseCount(),
_filmRow(context, 0), _SelectedFilm(),
_filmRow(context, 1),
], ],
), ),
), ),
), ),
); );
} }
}
Widget _filmRow(BuildContext context, int index) { class _PredefinedFilmsCount extends StatelessWidget {
return Row( static String text(int count) => "Predefined films count: $count";
children: [
ElevatedButton( const _PredefinedFilmsCount();
key: saveFilmsButtonKey(index),
onPressed: () { @override
FilmsProvider.of(context).saveFilms(mockFilms.skip(index).toList()); Widget build(BuildContext context) {
}, return Text(text(Films.predefinedFilmsOf(context).length));
child: const Text("Save filmsInUse"),
),
ElevatedButton(
key: setFilmButtonKey(index),
onPressed: () {
FilmsProvider.of(context).setFilm(mockFilms[index]);
},
child: const Text("Set film"),
),
],
);
} }
} }
const mockFilms = [_MockFilm2x(), _MockFilm3x(), _MockFilm4x()]; class _CustomFilmsCount extends StatelessWidget {
static String text(int count) => "Custom films count: $count";
class _MockFilm2x extends Film { const _CustomFilmsCount();
const _MockFilm2x() : super('Mock film 2x', 400);
@override @override
double reciprocityFormula(double t) => t * 2; Widget build(BuildContext context) {
return Text(text(Films.customFilmsOf(context).length));
}
} }
class _MockFilm3x extends Film { class _FilmsInUseCount extends StatelessWidget {
const _MockFilm3x() : super('Mock film 3x', 800); static String text(int count) => "Films in use count: $count";
const _FilmsInUseCount();
@override @override
double reciprocityFormula(double t) => t * 3; Widget build(BuildContext context) {
return Text(text(Films.inUseOf(context).length));
}
} }
class _MockFilm4x extends Film { class _SelectedFilm extends StatelessWidget {
const _MockFilm4x() : super('Mock film 4x', 1600); static String text(String name) => "Selected film: $name}";
const _SelectedFilm();
@override @override
double reciprocityFormula(double t) => t * 4; Widget build(BuildContext context) {
return Text(text(Films.selectedOf(context).name));
}
}
const mockPredefinedFilms = [
FilmExponential(id: '1', name: 'Mock film 2x', iso: 400, exponent: 1.34),
FilmExponential(id: '2', name: 'Mock film 3x', iso: 800, exponent: 1.34),
FilmExponential(id: '3', name: 'Mock film 4x', iso: 1200, exponent: 1.34),
];
const mockCustomFilms = [
FilmExponential(id: '1abc', name: 'Mock custom film 2x', iso: 400, exponent: 1.34),
FilmExponential(id: '2abc', name: 'Mock custom film 3x', iso: 800, exponent: 1.34),
];
extension on List<Film> {
Map<String, ({T film, bool isUsed})> toFilmsMap<T extends Film>() =>
Map.fromEntries(map((e) => MapEntry(e.id, (film: e as T, isUsed: true))));
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 301 KiB

View file

@ -50,9 +50,9 @@ extension WidgetTesterActions on WidgetTester {
}) async { }) async {
await pumpWidget( await pumpWidget(
Films( Films(
values: const [Film.other()], predefinedFilms: const {},
filmsInUse: const [Film.other()], customFilms: const {},
selected: const Film.other(), selected: const FilmStub(),
child: WidgetTestApplicationMock( child: WidgetTestApplicationMock(
child: Row( child: Row(
children: [ children: [

View file

@ -10,14 +10,19 @@ import 'package:mocktail/mocktail.dart';
import '../../../../../application_mock.dart'; import '../../../../../application_mock.dart';
import 'utils.dart'; import 'utils.dart';
class _MockIAPStorageService extends Mock implements IAPStorageService {} class _MockFilmsStorageService extends Mock implements FilmsStorageService {}
void main() { void main() {
late final _MockIAPStorageService mockIAPStorageService; late final _MockFilmsStorageService mockFilmsStorageService;
setUpAll(() { setUpAll(() {
mockIAPStorageService = _MockIAPStorageService(); mockFilmsStorageService = _MockFilmsStorageService();
when(() => mockIAPStorageService.filmsInUse).thenReturn(_films); when(() => mockFilmsStorageService.getPredefinedFilms()).thenAnswer(
(_) => Future.value(Map.fromEntries(_films.map((e) => MapEntry(e.id, (film: e, isUsed: true))))),
);
when(() => mockFilmsStorageService.getCustomFilms()).thenAnswer(
(_) => Future.value({}),
);
}); });
Future<void> pumpApplication(WidgetTester tester) async { Future<void> pumpApplication(WidgetTester tester) async {
@ -31,7 +36,7 @@ void main() {
), ),
], ],
child: FilmsProvider( child: FilmsProvider(
storageService: mockIAPStorageService, filmsStorageService: mockFilmsStorageService,
child: const WidgetTestApplicationMock( child: const WidgetTestApplicationMock(
child: Row( child: Row(
children: [ children: [
@ -49,9 +54,9 @@ void main() {
group('Film push/pull label', () { group('Film push/pull label', () {
testWidgets( testWidgets(
'Film.other()', 'FilmStub()',
(tester) async { (tester) async {
when(() => mockIAPStorageService.selectedFilm).thenReturn(const Film.other()); when(() => mockFilmsStorageService.selectedFilmId).thenReturn(const FilmStub().id);
await pumpApplication(tester); await pumpApplication(tester);
expectReadingValueContainerText(S.current.film); expectReadingValueContainerText(S.current.film);
expectReadingValueContainerText(S.current.none); expectReadingValueContainerText(S.current.none);
@ -61,7 +66,7 @@ void main() {
testWidgets( testWidgets(
'Film with the same ISO', 'Film with the same ISO',
(tester) async { (tester) async {
when(() => mockIAPStorageService.selectedFilm).thenReturn(_films[1]); when(() => mockFilmsStorageService.selectedFilmId).thenReturn(_films[1].id);
await pumpApplication(tester); await pumpApplication(tester);
expectReadingValueContainerText(S.current.film); expectReadingValueContainerText(S.current.film);
expectReadingValueContainerText(_films[1].name); expectReadingValueContainerText(_films[1].name);
@ -71,7 +76,7 @@ void main() {
testWidgets( testWidgets(
'Film with greater ISO', 'Film with greater ISO',
(tester) async { (tester) async {
when(() => mockIAPStorageService.selectedFilm).thenReturn(_films[2]); when(() => mockFilmsStorageService.selectedFilmId).thenReturn(_films[2].id);
await pumpApplication(tester); await pumpApplication(tester);
expectReadingValueContainerText(S.current.filmPull); expectReadingValueContainerText(S.current.filmPull);
expectReadingValueContainerText(_films[2].name); expectReadingValueContainerText(_films[2].name);
@ -81,7 +86,7 @@ void main() {
testWidgets( testWidgets(
'Film with lower ISO', 'Film with lower ISO',
(tester) async { (tester) async {
when(() => mockIAPStorageService.selectedFilm).thenReturn(_films[0]); when(() => mockFilmsStorageService.selectedFilmId).thenReturn(_films[0].id);
await pumpApplication(tester); await pumpApplication(tester);
expectReadingValueContainerText(S.current.filmPush); expectReadingValueContainerText(S.current.filmPush);
expectReadingValueContainerText(_films[0].name); expectReadingValueContainerText(_films[0].name);
@ -92,7 +97,7 @@ void main() {
testWidgets( testWidgets(
'Film picker shows only films in use', 'Film picker shows only films in use',
(tester) async { (tester) async {
when(() => mockIAPStorageService.selectedFilm).thenReturn(_films[0]); when(() => mockFilmsStorageService.selectedFilmId).thenReturn(_films[0].id);
await pumpApplication(tester); await pumpApplication(tester);
await tester.openAnimatedPicker<FilmPicker>(); await tester.openAnimatedPicker<FilmPicker>();
expectRadioListTile<Film>(S.current.none, isSelected: true); expectRadioListTile<Film>(S.current.none, isSelected: true);
@ -104,8 +109,8 @@ void main() {
} }
const _films = [ const _films = [
Film('ISO 100 Film', 100), FilmExponential(id: '1', name: 'ISO 100 Film', iso: 100, exponent: 1.34),
Film('ISO 400 Film', 400), FilmExponential(id: '2', name: 'ISO 400 Film', iso: 400, exponent: 1.34),
Film('ISO 800 Film', 800), FilmExponential(id: '3', name: 'ISO 800 Film', iso: 800, exponent: 1.34),
Film('ISO 1600 Film', 1600), FilmExponential(id: '4', name: 'ISO 1600 Film', iso: 1600, exponent: 1.34),
]; ];

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

After

Width:  |  Height:  |  Size: 494 KiB