mirror of
https://github.com/vodemn/m3_lightmeter.git
synced 2024-11-21 23:10:40 +00:00
Merge 8bc35633a7
into d938be61c4
This commit is contained in:
commit
95ace309b4
54 changed files with 1736 additions and 424 deletions
|
@ -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 = [];
|
||||||
|
|
32
iap/lib/src/data/films_storage_service.dart
Normal file
32
iap/lib/src/data/films_storage_service.dart
Normal 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 {};
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"');
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -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>()),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,51 @@ 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<ApplicationWrapper> createState() => _ApplicationWrapperState();
|
||||||
final remoteConfigService = env.buildType != BuildType.dev
|
}
|
||||||
|
|
||||||
|
class _ApplicationWrapperState extends State<ApplicationWrapper> {
|
||||||
|
late final remoteConfigService = widget.env.buildType != BuildType.dev
|
||||||
? const RemoteConfigService(LightmeterAnalytics(api: LightmeterAnalyticsFirebase()))
|
? const RemoteConfigService(LightmeterAnalytics(api: LightmeterAnalyticsFirebase()))
|
||||||
: const MockRemoteConfigService();
|
: const MockRemoteConfigService();
|
||||||
return FutureBuilder(
|
|
||||||
future: Future.wait<dynamic>([
|
late final IAPStorageService iapStorageService;
|
||||||
SharedPreferences.getInstance(),
|
late final UserPreferencesService userPreferencesService;
|
||||||
const LightSensorService(LocalPlatform()).hasSensor(),
|
late final bool hasLightSensor;
|
||||||
remoteConfigService.activeAndFetchFeatures(),
|
|
||||||
]),
|
final filmsStorageService = FilmsStorageService();
|
||||||
builder: (_, snapshot) {
|
final filmsProviderKey = GlobalKey<FilmsProviderState>();
|
||||||
if (snapshot.data != null) {
|
|
||||||
final iapService = IAPStorageService(snapshot.data![0] as SharedPreferences);
|
late final Future<void> _initFuture;
|
||||||
final userPreferencesService = UserPreferencesService(snapshot.data![0] as SharedPreferences);
|
|
||||||
final hasLightSensor = snapshot.data![1] as bool;
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initFuture = _initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FilmsProvider(
|
||||||
|
key: filmsProviderKey,
|
||||||
|
filmsStorageService: filmsStorageService,
|
||||||
|
child: FutureBuilder(
|
||||||
|
future: _initFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.error != null) {
|
||||||
|
return Center(child: Text(snapshot.error!.toString()));
|
||||||
|
} else if (snapshot.connectionState == ConnectionState.done) {
|
||||||
return ServicesProvider(
|
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 +72,35 @@ class ApplicationWrapper extends StatelessWidget {
|
||||||
child: RemoteConfigProvider(
|
child: RemoteConfigProvider(
|
||||||
remoteConfigService: remoteConfigService,
|
remoteConfigService: remoteConfigService,
|
||||||
child: EquipmentProfileProvider(
|
child: EquipmentProfileProvider(
|
||||||
storageService: iapService,
|
storageService: iapStorageService,
|
||||||
child: FilmsProvider(
|
|
||||||
storageService: iapService,
|
|
||||||
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((_) => filmsProviderKey.currentState?.init()),
|
||||||
|
]).then((value) {
|
||||||
|
final sharedPrefs = (value[0] as SharedPreferences?)!;
|
||||||
|
iapStorageService = IAPStorageService(sharedPrefs);
|
||||||
|
userPreferencesService = UserPreferencesService(sharedPrefs);
|
||||||
|
hasLightSensor = value[1] as bool? ?? false;
|
||||||
|
FlutterNativeSplash.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
|
@ -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",
|
||||||
|
|
|
@ -116,6 +116,7 @@
|
||||||
"featureSpotMetering": "Точечный замер",
|
"featureSpotMetering": "Точечный замер",
|
||||||
"featureHistogram": "Гистограмма",
|
"featureHistogram": "Гистограмма",
|
||||||
"featureListOfFilms": "Список из 20+ плёнок с формулами коррекции",
|
"featureListOfFilms": "Список из 20+ плёнок с формулами коррекции",
|
||||||
|
"featureCustomFilms": "Возможность создания собственных плёнок",
|
||||||
"featureEquipmentProfiles": "Профили оборудования",
|
"featureEquipmentProfiles": "Профили оборудования",
|
||||||
"featureTimer": "Встроенный таймер для длинных выдержек",
|
"featureTimer": "Встроенный таймер для длинных выдержек",
|
||||||
"featureMeteringScreenLayout": "Настраиваемый главный экран",
|
"featureMeteringScreenLayout": "Настраиваемый главный экран",
|
||||||
|
|
|
@ -115,6 +115,7 @@
|
||||||
"featureSpotMetering": "点测光",
|
"featureSpotMetering": "点测光",
|
||||||
"featureHistogram": "直方图",
|
"featureHistogram": "直方图",
|
||||||
"featureListOfFilms": "20多部电影的修正公式列表",
|
"featureListOfFilms": "20多部电影的修正公式列表",
|
||||||
|
"featureCustomFilms": "创建自定义胶片的能力",
|
||||||
"featureEquipmentProfiles": "设备配置文件",
|
"featureEquipmentProfiles": "设备配置文件",
|
||||||
"featureTimer": "内置长曝光计时器",
|
"featureTimer": "内置长曝光计时器",
|
||||||
"featureMeteringScreenLayout": "可自定义的主屏幕",
|
"featureMeteringScreenLayout": "可自定义的主屏幕",
|
||||||
|
|
7
lib/navigation/modal_route_args_parser.dart
Normal file
7
lib/navigation/modal_route_args_parser.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
9
lib/navigation/routes.dart
Normal file
9
lib/navigation/routes.dart
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
enum NavigationRoutes {
|
||||||
|
meteringScreen,
|
||||||
|
settingsScreen,
|
||||||
|
filmsListScreen,
|
||||||
|
filmAddScreen,
|
||||||
|
filmEditScreen,
|
||||||
|
proFeaturesScreen,
|
||||||
|
timerScreen,
|
||||||
|
}
|
|
@ -1,17 +1,14 @@
|
||||||
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 Widget child;
|
final Widget child;
|
||||||
|
|
||||||
const FilmsProvider({
|
const FilmsProvider({
|
||||||
required this.storageService,
|
required this.filmsStorageService,
|
||||||
this.availableFilms,
|
|
||||||
required this.child,
|
required this.child,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
@ -25,82 +22,144 @@ 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 = '';
|
||||||
|
|
||||||
@override
|
Film get _selectedFilm => customFilms[_selectedId]?.film ?? predefinedFilms[_selectedId]?.film ?? const FilmStub();
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_filmsInUse = widget.storageService.filmsInUse;
|
|
||||||
_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(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
customFilmsList,
|
||||||
|
predefinedFilmsList,
|
||||||
|
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.predefinedFilmsList)!
|
||||||
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.customFilmsList)!
|
||||||
|
.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) {
|
||||||
|
// TODO: reduce unnecessary notifications
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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\$',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
132
lib/screens/film_edit/bloc_film_edit.dart
Normal file
132
lib/screens/film_edit/bloc_film_edit.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
31
lib/screens/film_edit/event_film_edit.dart
Normal file
31
lib/screens/film_edit/event_film_edit.dart
Normal 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();
|
||||||
|
}
|
30
lib/screens/film_edit/flow_film_edit.dart
Normal file
30
lib/screens/film_edit/flow_film_edit.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
118
lib/screens/film_edit/screen_film_edit.dart
Normal file
118
lib/screens/film_edit/screen_film_edit.dart
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
32
lib/screens/film_edit/state_film_edit.dart
Normal file
32
lib/screens/film_edit/state_film_edit.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
147
lib/screens/films/screen_films.dart
Normal file
147
lib/screens/films/screen_films.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
},
|
},
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,21 +25,50 @@ class SliverScreen extends StatelessWidget {
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
slivers: <Widget>[
|
slivers: <Widget>[
|
||||||
SliverAppBar(
|
_AppBar(
|
||||||
pinned: true,
|
title: title,
|
||||||
|
appBarActions: appBarActions,
|
||||||
|
bottom: bottom,
|
||||||
|
),
|
||||||
|
...slivers,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
automaticallyImplyLeading: false,
|
||||||
expandedHeight: Dimens.sliverAppBarExpandedHeight,
|
expandedHeight: Dimens.sliverAppBarExpandedHeight + (bottom?.preferredSize.height ?? 0.0),
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
titlePadding: const EdgeInsets.all(Dimens.paddingM),
|
titlePadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM),
|
||||||
title: Text(
|
title: DefaultTextStyle(
|
||||||
title,
|
style: Theme.of(context).textTheme.headlineSmall!.copyWith(color: Theme.of(context).colorScheme.onSurface),
|
||||||
style: TextStyle(
|
maxLines: 2,
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
overflow: TextOverflow.ellipsis,
|
||||||
fontSize: Dimens.grid24,
|
child: _Title(
|
||||||
|
actionsCount: appBarActions.length + (Navigator.of(context).canPop() ? 1 : 0),
|
||||||
|
bottomSize: bottom?.preferredSize.height ?? 0.0,
|
||||||
|
child: title,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
bottom: bottom,
|
||||||
actions: [
|
actions: [
|
||||||
...appBarActions,
|
...appBarActions,
|
||||||
if (Navigator.of(context).canPop())
|
if (Navigator.of(context).canPop())
|
||||||
|
@ -46,9 +78,42 @@ class SliverScreen extends StatelessWidget {
|
||||||
tooltip: S.of(context).tooltipClose,
|
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,
|
||||||
),
|
),
|
||||||
...slivers,
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
33
lib/screens/shared/text_field/widget_text_field.dart
Normal file
33
lib/screens/shared/text_field/widget_text_field.dart
Normal 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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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!;
|
||||||
|
}
|
||||||
|
|
10
pubspec.yaml
10
pubspec.yaml
|
@ -24,17 +24,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.0
|
||||||
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 +49,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 +63,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:
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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,16 +41,20 @@ 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('Predefined films count: $count'), findsOneWidget);
|
||||||
|
}
|
||||||
|
|
||||||
|
void expectCustomFilmsCount(int count) {
|
||||||
|
expect(find.text('Custom films count: $count'), findsOneWidget);
|
||||||
}
|
}
|
||||||
|
|
||||||
void expectFilmsInUseCount(int count) {
|
void expectFilmsInUseCount(int count) {
|
||||||
|
@ -54,17 +69,21 @@ void main() {
|
||||||
'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,126 +112,126 @@ 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(
|
testWidgets(
|
||||||
'Discard selected (by filmsInUse list update)',
|
'selectFilm',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
when(() => mockIAPStorageService.selectedFilm).thenReturn(mockFilms.first);
|
when(() => mockFilmsStorageService.selectedFilmId).thenReturn('');
|
||||||
when(() => mockIAPStorageService.filmsInUse).thenReturn(mockFilms);
|
|
||||||
|
|
||||||
/// Init
|
|
||||||
await pumpTestWidget(tester, IAPProductStatus.purchased);
|
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('');
|
expectSelectedFilmName('');
|
||||||
|
|
||||||
verify(() => mockIAPStorageService.filmsInUse = mockFilms.skip(1).toList()).called(1);
|
tester.filmsProvider.selectFilm(mockPredefinedFilms.first);
|
||||||
verify(() => mockIAPStorageService.selectedFilm = const Film.other()).called(1);
|
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 MaterialApp(
|
||||||
|
@ -218,59 +239,30 @@ class _Application extends StatelessWidget {
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Text("Films count: ${Films.of(context).length}"),
|
Text("Predefined films count: ${Films.predefinedFilmsOf(context).length}"),
|
||||||
|
Text("Custom films count: ${Films.customFilmsOf(context).length}"),
|
||||||
Text("Films in use count: ${Films.inUseOf(context).length}"),
|
Text("Films in use count: ${Films.inUseOf(context).length}"),
|
||||||
Text("Selected film: ${Films.selectedOf(context).name}"),
|
Text("Selected film: ${Films.selectedOf(context).name}"),
|
||||||
_filmRow(context, 0),
|
|
||||||
_filmRow(context, 1),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _filmRow(BuildContext context, int index) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
ElevatedButton(
|
|
||||||
key: saveFilmsButtonKey(index),
|
|
||||||
onPressed: () {
|
|
||||||
FilmsProvider.of(context).saveFilms(mockFilms.skip(index).toList());
|
|
||||||
},
|
|
||||||
child: const Text("Save filmsInUse"),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
key: setFilmButtonKey(index),
|
|
||||||
onPressed: () {
|
|
||||||
FilmsProvider.of(context).setFilm(mockFilms[index]);
|
|
||||||
},
|
|
||||||
child: const Text("Set film"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockFilms = [_MockFilm2x(), _MockFilm3x(), _MockFilm4x()];
|
const mockPredefinedFilms = [
|
||||||
|
FilmExponential(id: '1', name: 'Mock film 2x', iso: 400, exponent: 1.34),
|
||||||
|
FilmExponential(id: '2', name: 'Mock film 3x', iso: 800, exponent: 1.34),
|
||||||
|
FilmExponential(id: '3', name: 'Mock film 4x', iso: 1200, exponent: 1.34),
|
||||||
|
];
|
||||||
|
|
||||||
class _MockFilm2x extends Film {
|
const mockCustomFilms = [
|
||||||
const _MockFilm2x() : super('Mock film 2x', 400);
|
FilmExponential(id: '1abc', name: 'Mock custom film 2x', iso: 400, exponent: 1.34),
|
||||||
|
FilmExponential(id: '2abc', name: 'Mock custom film 3x', iso: 800, exponent: 1.34),
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
extension on List<Film> {
|
||||||
double reciprocityFormula(double t) => t * 2;
|
Map<String, ({T film, bool isUsed})> toFilmsMap<T extends Film>() =>
|
||||||
}
|
Map.fromEntries(map((e) => MapEntry(e.id, (film: e as T, isUsed: true))));
|
||||||
|
|
||||||
class _MockFilm3x extends Film {
|
|
||||||
const _MockFilm3x() : super('Mock film 3x', 800);
|
|
||||||
|
|
||||||
@override
|
|
||||||
double reciprocityFormula(double t) => t * 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MockFilm4x extends Film {
|
|
||||||
const _MockFilm4x() : super('Mock film 4x', 1600);
|
|
||||||
|
|
||||||
@override
|
|
||||||
double reciprocityFormula(double t) => t * 4;
|
|
||||||
}
|
}
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 301 KiB |
|
@ -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: [
|
||||||
|
|
|
@ -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 |
Loading…
Reference in a new issue