mirror of
https://github.com/vodemn/m3_lightmeter.git
synced 2024-11-22 07:20:39 +00:00
Hide Pro features from the metering screen (#147)
* implemented `MockCameraContainerBloc` to stub camera on simulator * hide pro features from metering screen * disable pro features in settings * use closed child background color in `AnimatedDialog` * adjust `AnimatedDialogPicker` to items count * close `AnimatedDialog` through context * cleanup * fixed `ReadingValueContainer` text color * removed legacy translations * fixed tests * fixed `AnimatedDialog` scaling * added `evFromImage` test * added no EXIF test to `evFromImage`
This commit is contained in:
parent
a2b4c88256
commit
73d0c32323
34 changed files with 665 additions and 349 deletions
15
.vscode/launch.json
vendored
15
.vscode/launch.json
vendored
|
@ -82,5 +82,20 @@
|
||||||
],
|
],
|
||||||
"program": "${workspaceFolder}/lib/main_dev.dart",
|
"program": "${workspaceFolder}/lib/main_dev.dart",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "dev-simulator",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "dart",
|
||||||
|
"flutterMode": "debug",
|
||||||
|
"args": [
|
||||||
|
"--flavor",
|
||||||
|
"dev",
|
||||||
|
"--dart-define",
|
||||||
|
"cameraPreviewAspectRatio=240/320",
|
||||||
|
"--dart-define",
|
||||||
|
"cameraStubImage=assets/camera_stub_image.jpg"
|
||||||
|
],
|
||||||
|
"program": "${workspaceFolder}/lib/main_dev.dart",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
|
@ -3,6 +3,7 @@ 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/platform_config.dart';
|
||||||
import 'package:lightmeter/providers/user_preferences_provider.dart';
|
import 'package:lightmeter/providers/user_preferences_provider.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';
|
||||||
|
@ -17,13 +18,13 @@ class Application extends StatelessWidget {
|
||||||
return AnnotatedRegion(
|
return AnnotatedRegion(
|
||||||
value: SystemUiOverlayStyle(
|
value: SystemUiOverlayStyle(
|
||||||
statusBarColor: Colors.transparent,
|
statusBarColor: Colors.transparent,
|
||||||
statusBarBrightness:
|
statusBarBrightness: systemIconsBrightness == Brightness.light ? Brightness.dark : Brightness.light,
|
||||||
systemIconsBrightness == Brightness.light ? Brightness.dark : Brightness.light,
|
|
||||||
statusBarIconBrightness: systemIconsBrightness,
|
statusBarIconBrightness: systemIconsBrightness,
|
||||||
systemNavigationBarColor: Colors.transparent,
|
systemNavigationBarColor: Colors.transparent,
|
||||||
systemNavigationBarIconBrightness: systemIconsBrightness,
|
systemNavigationBarIconBrightness: systemIconsBrightness,
|
||||||
),
|
),
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: !PlatformConfig.isTest,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
locale: Locale(UserPreferencesProvider.localeOf(context).intlName),
|
locale: Locale(UserPreferencesProvider.localeOf(context).intlName),
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [
|
||||||
|
|
|
@ -96,10 +96,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lightmeterPro": "Lightmeter Pro",
|
|
||||||
"buyLightmeterPro": "Buy Lightmeter Pro",
|
|
||||||
"lightmeterProDescription": "Unlocks extra features:\n \u2022 Equipment profiles containing filters for aperture, shutter speed, and more\n \u2022 List of films with compensation for what's known as reciprocity failure\n \u2022 Spot metering\n \u2022 Histogram\n\nThe source code of Lightmeter is available on GitHub. You are welcome to compile it yourself. However, if you want to support the development and receive new features and updates, consider purchasing Lightmeter Pro.",
|
|
||||||
"buy": "Buy",
|
|
||||||
"proFeatures": "Pro features",
|
"proFeatures": "Pro features",
|
||||||
"unlockProFeatures": "Unlock Pro features",
|
"unlockProFeatures": "Unlock Pro features",
|
||||||
"unlockProFeaturesDescription": "Unlock professional features:\n \u2022 Equipment profiles containing filters for aperture, shutter speed, and more\n \u2022 List of films with compensation for what's known as reciprocity failure\n \u2022 Spot metering\n \u2022 Histogram\n\nBy unlocking Pro features you support the development and make it possible to add new features to the app.",
|
"unlockProFeaturesDescription": "Unlock professional features:\n \u2022 Equipment profiles containing filters for aperture, shutter speed, and more\n \u2022 List of films with compensation for what's known as reciprocity failure\n \u2022 Spot metering\n \u2022 Histogram\n\nBy unlocking Pro features you support the development and make it possible to add new features to the app.",
|
||||||
|
|
|
@ -96,10 +96,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buyLightmeterPro": "Acheter Lightmeter Pro",
|
|
||||||
"lightmeterPro": "Lightmeter Pro",
|
|
||||||
"lightmeterProDescription": "Débloque des fonctionnalités supplémentaires:\n \u2022 Profils d'équipement contenant des filtres pour l'ouverture, la vitesse d'obturation et plus encore\n \u2022 Liste de films avec une compensation pour ce que l'on appelle l'échec de réciprocité\n \u2022 Mesure spot\n \u2022 Histogramme\n\nLe code source du Lightmeter est disponible sur GitHub. Vous pouvez le compiler vous-même. Cependant, si vous souhaitez soutenir le développement et recevoir de nouvelles fonctionnalités et mises à jour, envisagez d'acheter Lightmeter Pro.",
|
|
||||||
"buy": "Acheter",
|
|
||||||
"proFeatures": "Fonctionnalités professionnelles",
|
"proFeatures": "Fonctionnalités professionnelles",
|
||||||
"unlockProFeatures": "Déverrouiller les fonctionnalités professionnelles",
|
"unlockProFeatures": "Déverrouiller les fonctionnalités professionnelles",
|
||||||
"unlockProFeaturesDescription": "Déverrouillez des fonctions professionnelles:\n \u2022 Profils d'équipement contenant des filtres pour l'ouverture, la vitesse d'obturation et plus encore, ainsi qu'une liste de films avec compensation pour ce que l'on appelle l'échec de réciprocité\n \u2022 Mesure spot\n \u2022 Histogramme\n\nEn débloquant les fonctionnalités Pro, vous soutenez le développement et permettez d'ajouter de nouvelles fonctionnalités à l'application.",
|
"unlockProFeaturesDescription": "Déverrouillez des fonctions professionnelles:\n \u2022 Profils d'équipement contenant des filtres pour l'ouverture, la vitesse d'obturation et plus encore, ainsi qu'une liste de films avec compensation pour ce que l'on appelle l'échec de réciprocité\n \u2022 Mesure spot\n \u2022 Histogramme\n\nEn débloquant les fonctionnalités Pro, vous soutenez le développement et permettez d'ajouter de nouvelles fonctionnalités à l'application.",
|
||||||
|
|
|
@ -96,10 +96,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buyLightmeterPro": "Купить Lightmeter Pro",
|
|
||||||
"lightmeterPro": "Lightmeter Pro",
|
|
||||||
"lightmeterProDescription": "Даёт доступ к различным функциям:\n \u2022 Профили оборудования, содержащие фильтры для диафрагмы, выдержки и других значений\n \u2022 Список пленок с компенсацией эффекта Шварцшильда\n \u2022 Точечный замер\n \u2022 Гистограмма\n\nИсходный код Lightmeter доступен на GitHub. Вы можете собрать его самостоятельно. Однако если вы хотите поддержать разработку и получать новые функции и обновления, то приобретите Lightmeter Pro.",
|
|
||||||
"buy": "Купить",
|
|
||||||
"proFeatures": "Профессиональные настройки",
|
"proFeatures": "Профессиональные настройки",
|
||||||
"unlockProFeatures": "Разблокировать профессиональные настройки",
|
"unlockProFeatures": "Разблокировать профессиональные настройки",
|
||||||
"unlockProFeaturesDescription": "Вы можете разблокировать профессиональные настройки:\n \u2022 Профили оборудования, содержащие фильтры для диафрагмы, выдержки и других значений\n \u2022 Список пленок с компенсацией эффекта Шварцшильда\n \u2022 Точечный замер\n \u2022 Гистограмма\n\nПолучая доступ к профессиональным настройкам, вы поддерживаете разработку и делаете возможным появление новых функций в приложении.",
|
"unlockProFeaturesDescription": "Вы можете разблокировать профессиональные настройки:\n \u2022 Профили оборудования, содержащие фильтры для диафрагмы, выдержки и других значений\n \u2022 Список пленок с компенсацией эффекта Шварцшильда\n \u2022 Точечный замер\n \u2022 Гистограмма\n\nПолучая доступ к профессиональным настройкам, вы поддерживаете разработку и делаете возможным появление новых функций в приложении.",
|
||||||
|
|
|
@ -96,10 +96,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buyLightmeterPro": "购买 Lightmeter Pro",
|
|
||||||
"lightmeterPro": "Lightmeter Pro",
|
|
||||||
"lightmeterProDescription": "解锁额外功能:\n \u2022 配置文件,其中包含光圈、快门速度等参数\n \u2022 胶卷列表,对胶片倒易率失效进行曝光补偿\n \u2022 点测光\n \u2022 直方图\n\n您可以在 GitHub 上获取 Lightmeter 的源代码,欢迎自行编译。不过,如果您想支持开发并获得新功能和更新,请考虑购买 Lightmeter Pro。",
|
|
||||||
"buy": "购买",
|
|
||||||
"proFeatures": "专业功能",
|
"proFeatures": "专业功能",
|
||||||
"unlockProFeatures": "解锁专业功能",
|
"unlockProFeatures": "解锁专业功能",
|
||||||
"unlockProFeaturesDescription": "\n \u2022 配置文件,其中包含光圈、快门速度等参数\n \u2022 胶卷列表,对胶片倒易率失效进行曝光补偿\n \u2022 点测光\n \u2022 直方图\n\n通过解锁专业版功能,您可以支持开发工作,帮助为应用程序添加新功能。",
|
"unlockProFeaturesDescription": "\n \u2022 配置文件,其中包含光圈、快门速度等参数\n \u2022 胶卷列表,对胶片倒易率失效进行曝光补偿\n \u2022 点测光\n \u2022 直方图\n\n通过解锁专业版功能,您可以支持开发工作,帮助为应用程序添加新功能。",
|
||||||
|
|
|
@ -7,4 +7,6 @@ class PlatformConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
static String get cameraStubImage => const String.fromEnvironment('cameraStubImage');
|
static String get cameraStubImage => const String.fromEnvironment('cameraStubImage');
|
||||||
|
|
||||||
|
static bool get isTest => cameraStubImage.isNotEmpty;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,6 @@ import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:camera/camera.dart';
|
import 'package:camera/camera.dart';
|
||||||
import 'package:exif/exif.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
@ -17,7 +16,9 @@ import 'package:lightmeter/screens/metering/components/camera_container/event_co
|
||||||
import 'package:lightmeter/screens/metering/components/camera_container/models/camera_error_type.dart';
|
import 'package:lightmeter/screens/metering/components/camera_container/models/camera_error_type.dart';
|
||||||
import 'package:lightmeter/screens/metering/components/camera_container/state_container_camera.dart';
|
import 'package:lightmeter/screens/metering/components/camera_container/state_container_camera.dart';
|
||||||
import 'package:lightmeter/screens/metering/components/shared/ev_source_base/bloc_base_ev_source.dart';
|
import 'package:lightmeter/screens/metering/components/shared/ev_source_base/bloc_base_ev_source.dart';
|
||||||
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
|
import 'package:lightmeter/utils/ev_from_bytes.dart';
|
||||||
|
|
||||||
|
part 'mock_bloc_container_camera.dart';
|
||||||
|
|
||||||
class CameraContainerBloc extends EvSourceBlocBase<CameraContainerEvent, CameraContainerState> {
|
class CameraContainerBloc extends EvSourceBlocBase<CameraContainerEvent, CameraContainerState> {
|
||||||
final MeteringInteractor _meteringInteractor;
|
final MeteringInteractor _meteringInteractor;
|
||||||
|
@ -213,33 +214,15 @@ class CameraContainerBloc extends EvSourceBlocBase<CameraContainerEvent, CameraC
|
||||||
Future<double?> _takePhoto() async {
|
Future<double?> _takePhoto() async {
|
||||||
try {
|
try {
|
||||||
// https://github.com/flutter/flutter/issues/84957#issuecomment-1661155095
|
// https://github.com/flutter/flutter/issues/84957#issuecomment-1661155095
|
||||||
|
|
||||||
late final Uint8List bytes;
|
|
||||||
if (PlatformConfig.cameraStubImage.isNotEmpty) {
|
|
||||||
bytes = (await rootBundle.load(PlatformConfig.cameraStubImage)).buffer.asUint8List();
|
|
||||||
} else {
|
|
||||||
await _cameraController!.setFocusMode(FocusMode.locked);
|
await _cameraController!.setFocusMode(FocusMode.locked);
|
||||||
await _cameraController!.setExposureMode(ExposureMode.locked);
|
await _cameraController!.setExposureMode(ExposureMode.locked);
|
||||||
final file = await _cameraController!.takePicture();
|
final file = await _cameraController!.takePicture();
|
||||||
await _cameraController!.setFocusMode(FocusMode.auto);
|
await _cameraController!.setFocusMode(FocusMode.auto);
|
||||||
await _cameraController!.setExposureMode(ExposureMode.auto);
|
await _cameraController!.setExposureMode(ExposureMode.auto);
|
||||||
bytes = await file.readAsBytes();
|
final bytes = await file.readAsBytes();
|
||||||
Directory(file.path).deleteSync(recursive: true);
|
Directory(file.path).deleteSync(recursive: true);
|
||||||
}
|
|
||||||
|
|
||||||
final tags = await readExifFromBytes(bytes);
|
return await evFromImage(bytes);
|
||||||
final iso = double.tryParse("${tags["EXIF ISOSpeedRatings"]}");
|
|
||||||
final apertureValueRatio = (tags["EXIF FNumber"]?.values as IfdRatios?)?.ratios.first;
|
|
||||||
final speedValueRatio = (tags["EXIF ExposureTime"]?.values as IfdRatios?)?.ratios.first;
|
|
||||||
if (iso == null || apertureValueRatio == null || speedValueRatio == null) {
|
|
||||||
log('Error parsing EXIF: ${tags.keys}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final aperture = apertureValueRatio.numerator / apertureValueRatio.denominator;
|
|
||||||
final speed = speedValueRatio.numerator / speedValueRatio.denominator;
|
|
||||||
|
|
||||||
return log2(math.pow(aperture, 2)) - log2(speed) - log2(iso / 100);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -0,0 +1,80 @@
|
||||||
|
part of 'bloc_container_camera.dart';
|
||||||
|
|
||||||
|
class MockCameraContainerBloc extends CameraContainerBloc {
|
||||||
|
MockCameraContainerBloc(
|
||||||
|
super._meteringInteractor,
|
||||||
|
super.communicationBloc,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> _onRequestPermission(_, Emitter emit) async {
|
||||||
|
add(const InitializeEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> _onOpenAppSettings(_, Emitter emit) async {
|
||||||
|
_meteringInteractor.openAppSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> _onInitialize(_, Emitter emit) async {
|
||||||
|
emit(const CameraLoadingState());
|
||||||
|
try {
|
||||||
|
_cameraController = CameraController(
|
||||||
|
const CameraDescription(name: '0', lensDirection: CameraLensDirection.back, sensorOrientation: 0),
|
||||||
|
ResolutionPreset.low,
|
||||||
|
enableAudio: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
_zoomRange = const RangeValues(1, 6);
|
||||||
|
_currentZoom = _zoomRange!.start;
|
||||||
|
|
||||||
|
_exposureOffsetRange = const RangeValues(-4, 4);
|
||||||
|
_exposureStep = 0.1;
|
||||||
|
_currentExposureOffset = 0.0;
|
||||||
|
|
||||||
|
emit(CameraInitializedState(_cameraController!));
|
||||||
|
|
||||||
|
_emitActiveState(emit);
|
||||||
|
} catch (e) {
|
||||||
|
emit(const CameraErrorState(CameraErrorType.other));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> _onZoomChanged(ZoomChangedEvent event, Emitter emit) async {
|
||||||
|
if (event.value >= _zoomRange!.start && event.value <= _zoomRange!.end) {
|
||||||
|
_currentZoom = event.value;
|
||||||
|
_emitActiveState(emit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> _onExposureOffsetChanged(ExposureOffsetChangedEvent event, Emitter emit) async {
|
||||||
|
_currentExposureOffset = event.value;
|
||||||
|
_emitActiveState(emit);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> _onExposureOffsetResetEvent(ExposureOffsetResetEvent event, Emitter emit) async {
|
||||||
|
_meteringInteractor.quickVibration();
|
||||||
|
add(const ExposureOffsetChangedEvent(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> _onExposureSpotChangedEvent(ExposureSpotChangedEvent event, Emitter emit) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get _canTakePhoto => PlatformConfig.cameraStubImage.isNotEmpty;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<double?> _takePhoto() async {
|
||||||
|
try {
|
||||||
|
final bytes = (await rootBundle.load(PlatformConfig.cameraStubImage)).buffer.asUint8List();
|
||||||
|
return await evFromImage(bytes);
|
||||||
|
} catch (e) {
|
||||||
|
log(e.toString());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:lightmeter/data/models/exposure_pair.dart';
|
import 'package:lightmeter/data/models/exposure_pair.dart';
|
||||||
|
import 'package:lightmeter/platform_config.dart';
|
||||||
import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart';
|
import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart';
|
||||||
import 'package:lightmeter/screens/metering/components/camera_container/bloc_container_camera.dart';
|
import 'package:lightmeter/screens/metering/components/camera_container/bloc_container_camera.dart';
|
||||||
import 'package:lightmeter/screens/metering/components/camera_container/event_container_camera.dart';
|
import 'package:lightmeter/screens/metering/components/camera_container/event_container_camera.dart';
|
||||||
|
@ -30,12 +31,18 @@ class CameraContainerProvider extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider(
|
return BlocProvider<CameraContainerBloc>(
|
||||||
lazy: false,
|
lazy: false,
|
||||||
create: (context) => CameraContainerBloc(
|
create: (context) => (PlatformConfig.cameraStubImage.isNotEmpty
|
||||||
|
? MockCameraContainerBloc(
|
||||||
MeteringInteractorProvider.of(context),
|
MeteringInteractorProvider.of(context),
|
||||||
context.read<MeteringCommunicationBloc>(),
|
context.read<MeteringCommunicationBloc>(),
|
||||||
)..add(const RequestPermissionEvent()),
|
)
|
||||||
|
: CameraContainerBloc(
|
||||||
|
MeteringInteractorProvider.of(context),
|
||||||
|
context.read<MeteringCommunicationBloc>(),
|
||||||
|
))
|
||||||
|
..add(const RequestPermissionEvent()),
|
||||||
child: CameraContainer(
|
child: CameraContainer(
|
||||||
fastest: fastest,
|
fastest: fastest,
|
||||||
slowest: slowest,
|
slowest: slowest,
|
||||||
|
|
|
@ -5,7 +5,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:lightmeter/data/models/exposure_pair.dart';
|
import 'package:lightmeter/data/models/exposure_pair.dart';
|
||||||
import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
|
import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
|
||||||
import 'package:lightmeter/platform_config.dart';
|
import 'package:lightmeter/platform_config.dart';
|
||||||
import 'package:lightmeter/providers/user_preferences_provider.dart';
|
|
||||||
import 'package:lightmeter/res/dimens.dart';
|
import 'package:lightmeter/res/dimens.dart';
|
||||||
import 'package:lightmeter/screens/metering/components/camera_container/bloc_container_camera.dart';
|
import 'package:lightmeter/screens/metering/components/camera_container/bloc_container_camera.dart';
|
||||||
import 'package:lightmeter/screens/metering/components/camera_container/components/camera_controls/widget_camera_controls.dart';
|
import 'package:lightmeter/screens/metering/components/camera_container/components/camera_controls/widget_camera_controls.dart';
|
||||||
|
@ -17,6 +16,8 @@ import 'package:lightmeter/screens/metering/components/camera_container/state_co
|
||||||
import 'package:lightmeter/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart';
|
import 'package:lightmeter/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart';
|
||||||
import 'package:lightmeter/screens/metering/components/shared/metering_top_bar/widget_top_bar_metering.dart';
|
import 'package:lightmeter/screens/metering/components/shared/metering_top_bar/widget_top_bar_metering.dart';
|
||||||
import 'package:lightmeter/screens/metering/components/shared/readings_container/widget_container_readings.dart';
|
import 'package:lightmeter/screens/metering/components/shared/readings_container/widget_container_readings.dart';
|
||||||
|
import 'package:lightmeter/utils/context_utils.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 CameraContainer extends StatelessWidget {
|
class CameraContainer extends StatelessWidget {
|
||||||
|
@ -101,28 +102,25 @@ class CameraContainer extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
double _meteringContainerHeight(BuildContext context) {
|
double _meteringContainerHeight(BuildContext context) {
|
||||||
|
final isPro = IAPProducts.isPurchased(context, IAPProductType.paidFeatures);
|
||||||
double enabledFeaturesHeight = 0;
|
double enabledFeaturesHeight = 0;
|
||||||
if (UserPreferencesProvider.meteringScreenFeatureOf(
|
if (!isPro) {
|
||||||
context,
|
enabledFeaturesHeight += Dimens.readingContainerSingleValueHeight;
|
||||||
MeteringScreenLayoutFeature.equipmentProfiles,
|
enabledFeaturesHeight += Dimens.paddingS;
|
||||||
)) {
|
} else {
|
||||||
|
if (context.meteringFeature(MeteringScreenLayoutFeature.equipmentProfiles)) {
|
||||||
enabledFeaturesHeight += Dimens.readingContainerSingleValueHeight;
|
enabledFeaturesHeight += Dimens.readingContainerSingleValueHeight;
|
||||||
enabledFeaturesHeight += Dimens.paddingS;
|
enabledFeaturesHeight += Dimens.paddingS;
|
||||||
}
|
}
|
||||||
if (UserPreferencesProvider.meteringScreenFeatureOf(
|
if (context.meteringFeature(MeteringScreenLayoutFeature.filmPicker)) {
|
||||||
context,
|
enabledFeaturesHeight += Dimens.readingContainerSingleValueHeight;
|
||||||
MeteringScreenLayoutFeature.extremeExposurePairs,
|
enabledFeaturesHeight += Dimens.paddingS;
|
||||||
)) {
|
}
|
||||||
|
}
|
||||||
|
if (context.meteringFeature(MeteringScreenLayoutFeature.extremeExposurePairs)) {
|
||||||
enabledFeaturesHeight += Dimens.readingContainerDoubleValueHeight;
|
enabledFeaturesHeight += Dimens.readingContainerDoubleValueHeight;
|
||||||
enabledFeaturesHeight += Dimens.paddingS;
|
enabledFeaturesHeight += Dimens.paddingS;
|
||||||
}
|
}
|
||||||
if (UserPreferencesProvider.meteringScreenFeatureOf(
|
|
||||||
context,
|
|
||||||
MeteringScreenLayoutFeature.filmPicker,
|
|
||||||
)) {
|
|
||||||
enabledFeaturesHeight += Dimens.readingContainerSingleValueHeight;
|
|
||||||
enabledFeaturesHeight += Dimens.paddingS;
|
|
||||||
}
|
|
||||||
|
|
||||||
return enabledFeaturesHeight + Dimens.readingContainerSingleValueHeight; // ISO & ND
|
return enabledFeaturesHeight + Dimens.readingContainerSingleValueHeight; // ISO & ND
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
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/animated_dialog/widget_dialog_animated.dart';
|
||||||
|
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart';
|
||||||
|
import 'package:lightmeter/screens/shared/pro_features_dialog/widget_dialog_pro_features.dart';
|
||||||
|
|
||||||
|
class LightmeterProAnimatedDialog extends StatelessWidget {
|
||||||
|
const LightmeterProAnimatedDialog({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedDialog(
|
||||||
|
closedChild: ReadingValueContainer(
|
||||||
|
color: Theme.of(context).colorScheme.errorContainer,
|
||||||
|
textColor: Theme.of(context).colorScheme.onErrorContainer,
|
||||||
|
values: [
|
||||||
|
ReadingValue(
|
||||||
|
label: S.of(context).proFeatures,
|
||||||
|
value: S.of(context).unlock,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
openedChild: const ProFeaturesDialog(),
|
||||||
|
openedSize: Size.fromHeight(const ProFeaturesDialog().height(context)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,15 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lightmeter/res/dimens.dart';
|
import 'package:lightmeter/res/dimens.dart';
|
||||||
|
|
||||||
|
mixin AnimatedDialogClosedChild on Widget {
|
||||||
|
Color backgroundColor(BuildContext context);
|
||||||
|
}
|
||||||
|
|
||||||
class AnimatedDialog extends StatefulWidget {
|
class AnimatedDialog extends StatefulWidget {
|
||||||
final Size? openedSize;
|
final Size? openedSize;
|
||||||
final Widget? closedChild;
|
final AnimatedDialogClosedChild? closedChild;
|
||||||
final Widget? openedChild;
|
final Widget? openedChild;
|
||||||
final Widget? child;
|
final Widget? child;
|
||||||
|
|
||||||
|
@ -15,6 +21,9 @@ class AnimatedDialog extends StatefulWidget {
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static Future<void>? maybeClose(BuildContext context) =>
|
||||||
|
context.findAncestorWidgetOfExactType<_AnimatedOverlay>()?.onDismiss();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AnimatedDialog> createState() => AnimatedDialogState();
|
State<AnimatedDialog> createState() => AnimatedDialogState();
|
||||||
}
|
}
|
||||||
|
@ -95,7 +104,7 @@ class AnimatedDialogState extends State<AnimatedDialog> with SingleTickerProvide
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
_foregroundColorAnimation = ColorTween(
|
_foregroundColorAnimation = ColorTween(
|
||||||
begin: Theme.of(context).colorScheme.primaryContainer,
|
begin: widget.closedChild?.backgroundColor(context) ?? Theme.of(context).colorScheme.primaryContainer,
|
||||||
end: Theme.of(context).colorScheme.surface,
|
end: Theme.of(context).colorScheme.surface,
|
||||||
).animate(_defaultCurvedAnimation);
|
).animate(_defaultCurvedAnimation);
|
||||||
|
|
||||||
|
@ -135,13 +144,14 @@ class AnimatedDialogState extends State<AnimatedDialog> with SingleTickerProvide
|
||||||
if (renderBox != null) {
|
if (renderBox != null) {
|
||||||
final size = MediaQuery.sizeOf(context);
|
final size = MediaQuery.sizeOf(context);
|
||||||
final padding = MediaQuery.paddingOf(context);
|
final padding = MediaQuery.paddingOf(context);
|
||||||
|
final maxWidth = size.width - padding.horizontal - Dimens.dialogMargin.horizontal;
|
||||||
|
final maxHeight = size.height - padding.vertical - Dimens.dialogMargin.vertical;
|
||||||
_closedSize = _key.currentContext!.size!;
|
_closedSize = _key.currentContext!.size!;
|
||||||
_sizeTween = SizeTween(
|
_sizeTween = SizeTween(
|
||||||
begin: _closedSize,
|
begin: _closedSize,
|
||||||
end: widget.openedSize ??
|
end: Size(
|
||||||
Size(
|
min(widget.openedSize?.width ?? double.maxFinite, maxWidth),
|
||||||
size.width - padding.horizontal - Dimens.dialogMargin.horizontal,
|
min(widget.openedSize?.height ?? double.maxFinite, maxHeight),
|
||||||
size.height - padding.vertical - Dimens.dialogMargin.vertical,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_sizeAnimation = _sizeTween.animate(_defaultCurvedAnimation);
|
_sizeAnimation = _sizeTween.animate(_defaultCurvedAnimation);
|
||||||
|
@ -181,7 +191,6 @@ class AnimatedDialogState extends State<AnimatedDialog> with SingleTickerProvide
|
||||||
onDismiss: close,
|
onDismiss: close,
|
||||||
builder: widget.closedChild != null && widget.openedChild != null
|
builder: widget.closedChild != null && widget.openedChild != null
|
||||||
? (_) => _AnimatedSwitcher(
|
? (_) => _AnimatedSwitcher(
|
||||||
sizeAnimation: _sizeAnimation,
|
|
||||||
closedOpacityAnimation: _closedOpacityAnimation,
|
closedOpacityAnimation: _closedOpacityAnimation,
|
||||||
openedOpacityAnimation: _openedOpacityAnimation,
|
openedOpacityAnimation: _openedOpacityAnimation,
|
||||||
closedSize: _sizeTween.begin!,
|
closedSize: _sizeTween.begin!,
|
||||||
|
@ -223,7 +232,7 @@ class _AnimatedOverlay extends StatelessWidget {
|
||||||
final Animation<double> borderRadiusAnimation;
|
final Animation<double> borderRadiusAnimation;
|
||||||
final Animation<Color?> foregroundColorAnimation;
|
final Animation<Color?> foregroundColorAnimation;
|
||||||
final Animation<double> elevationAnimation;
|
final Animation<double> elevationAnimation;
|
||||||
final VoidCallback onDismiss;
|
final Future<void> Function() onDismiss;
|
||||||
final Widget? child;
|
final Widget? child;
|
||||||
final Widget Function(BuildContext context)? builder;
|
final Widget Function(BuildContext context)? builder;
|
||||||
|
|
||||||
|
@ -281,7 +290,6 @@ class _AnimatedOverlay extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AnimatedSwitcher extends StatelessWidget {
|
class _AnimatedSwitcher extends StatelessWidget {
|
||||||
final Animation<Size?> sizeAnimation;
|
|
||||||
final Animation<double> closedOpacityAnimation;
|
final Animation<double> closedOpacityAnimation;
|
||||||
final Animation<double> openedOpacityAnimation;
|
final Animation<double> openedOpacityAnimation;
|
||||||
final Size closedSize;
|
final Size closedSize;
|
||||||
|
@ -290,7 +298,6 @@ class _AnimatedSwitcher extends StatelessWidget {
|
||||||
final Widget openedChild;
|
final Widget openedChild;
|
||||||
|
|
||||||
const _AnimatedSwitcher({
|
const _AnimatedSwitcher({
|
||||||
required this.sizeAnimation,
|
|
||||||
required this.closedOpacityAnimation,
|
required this.closedOpacityAnimation,
|
||||||
required this.openedOpacityAnimation,
|
required this.openedOpacityAnimation,
|
||||||
required this.closedSize,
|
required this.closedSize,
|
||||||
|
@ -306,18 +313,22 @@ class _AnimatedSwitcher extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Opacity(
|
Opacity(
|
||||||
opacity: closedOpacityAnimation.value,
|
opacity: closedOpacityAnimation.value,
|
||||||
child: Transform.scale(
|
child: FittedBox(
|
||||||
scale: sizeAnimation.value!.width / closedSize.width,
|
child: SizedBox.fromSize(
|
||||||
child: SizedBox(
|
size: closedSize,
|
||||||
width: closedSize.width,
|
|
||||||
child: closedChild,
|
child: closedChild,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Opacity(
|
Opacity(
|
||||||
opacity: openedOpacityAnimation.value,
|
opacity: openedOpacityAnimation.value,
|
||||||
|
child: FittedBox(
|
||||||
|
child: SizedBox.fromSize(
|
||||||
|
size: openedSize,
|
||||||
child: openedChild,
|
child: openedChild,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +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/res/dimens.dart';
|
import 'package:lightmeter/res/dimens.dart';
|
||||||
|
import 'package:lightmeter/screens/shared/transparent_dialog/widget_dialog_transparent.dart';
|
||||||
|
|
||||||
typedef DialogPickerItemTitleBuilder<T> = Widget Function(BuildContext context, T value);
|
typedef DialogPickerItemTitleBuilder<T> = Widget Function(BuildContext context, T value);
|
||||||
typedef DialogPickerItemTrailingBuilder<T> = Widget? Function(T selected, T value);
|
typedef DialogPickerItemTrailingBuilder<T> = Widget? Function(T selected, T value);
|
||||||
|
@ -29,6 +30,14 @@ class DialogPicker<T> extends StatefulWidget {
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
double height(BuildContext context) => TransparentDialog.height(
|
||||||
|
context,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
scrollableContent: true,
|
||||||
|
contextHeight: Dimens.grid56 * values.length,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<DialogPicker<T>> createState() => _DialogPickerState<T>();
|
State<DialogPicker<T>> createState() => _DialogPickerState<T>();
|
||||||
}
|
}
|
||||||
|
@ -46,42 +55,11 @@ class _DialogPickerState<T> extends State<DialogPicker<T>> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return TransparentDialog(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
icon: widget.icon,
|
||||||
children: [
|
title: widget.title,
|
||||||
Column(
|
subtitle: widget.subtitle,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
content: Expanded(
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: Dimens.dialogTitlePadding,
|
|
||||||
child: Icon(widget.icon),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: Dimens.dialogIconTitlePadding,
|
|
||||||
child: Text(
|
|
||||||
widget.title,
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.subtitle != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(
|
|
||||||
Dimens.paddingL,
|
|
||||||
0,
|
|
||||||
Dimens.paddingL,
|
|
||||||
Dimens.paddingM,
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
widget.subtitle!,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
|
@ -105,26 +83,17 @@ class _DialogPickerState<T> extends State<DialogPicker<T>> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
scrollableContent: true,
|
||||||
Padding(
|
actions: [
|
||||||
padding: Dimens.dialogActionsPadding,
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
const Spacer(),
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: widget.onCancel,
|
onPressed: widget.onCancel,
|
||||||
child: Text(S.of(context).cancel),
|
child: Text(S.of(context).cancel),
|
||||||
),
|
),
|
||||||
const SizedBox(width: Dimens.grid16),
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => widget.onSelect(_selectedValue),
|
onPressed: () => widget.onSelect(_selectedValue),
|
||||||
child: Text(S.of(context).select),
|
child: Text(S.of(context).select),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@ class AnimatedDialogPicker<T> extends StatefulWidget {
|
||||||
final DialogPickerItemTitleBuilder<T> itemTitleBuilder;
|
final DialogPickerItemTitleBuilder<T> itemTitleBuilder;
|
||||||
final DialogPickerItemTrailingBuilder<T>? itemTrailingBuilder;
|
final DialogPickerItemTrailingBuilder<T>? itemTrailingBuilder;
|
||||||
final ValueChanged<T> onChanged;
|
final ValueChanged<T> onChanged;
|
||||||
final Widget closedChild;
|
final AnimatedDialogClosedChild closedChild;
|
||||||
|
|
||||||
const AnimatedDialogPicker({
|
const AnimatedDialogPicker({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
|
@ -37,10 +37,7 @@ class _AnimatedDialogPickerState<T> extends State<AnimatedDialogPicker<T>> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedDialog(
|
final dialogPicker = DialogPicker<T>(
|
||||||
key: _key,
|
|
||||||
closedChild: widget.closedChild,
|
|
||||||
openedChild: DialogPicker<T>(
|
|
||||||
icon: widget.icon,
|
icon: widget.icon,
|
||||||
title: widget.title,
|
title: widget.title,
|
||||||
subtitle: widget.subtitle,
|
subtitle: widget.subtitle,
|
||||||
|
@ -54,7 +51,12 @@ class _AnimatedDialogPickerState<T> extends State<AnimatedDialogPicker<T>> {
|
||||||
onSelect: (value) {
|
onSelect: (value) {
|
||||||
_key.currentState?.close().then((_) => widget.onChanged(value));
|
_key.currentState?.close().then((_) => widget.onChanged(value));
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
|
return AnimatedDialog(
|
||||||
|
key: _key,
|
||||||
|
closedChild: widget.closedChild,
|
||||||
|
openedChild: dialogPicker,
|
||||||
|
openedSize: Size.fromHeight(dialogPicker.height(context)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lightmeter/res/dimens.dart';
|
import 'package:lightmeter/res/dimens.dart';
|
||||||
|
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/animated_dialog/widget_dialog_animated.dart';
|
||||||
|
|
||||||
class ReadingValue {
|
class ReadingValue {
|
||||||
final String label;
|
final String label;
|
||||||
|
@ -11,11 +12,15 @@ class ReadingValue {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class ReadingValueContainer extends StatelessWidget {
|
class ReadingValueContainer extends StatelessWidget implements AnimatedDialogClosedChild {
|
||||||
late final List<Widget> _items;
|
late final List<Widget> _items;
|
||||||
|
final Color? color;
|
||||||
|
final Color? textColor;
|
||||||
|
|
||||||
ReadingValueContainer({
|
ReadingValueContainer({
|
||||||
required List<ReadingValue> values,
|
required List<ReadingValue> values,
|
||||||
|
this.color,
|
||||||
|
this.textColor,
|
||||||
super.key,
|
super.key,
|
||||||
}) {
|
}) {
|
||||||
_items = [];
|
_items = [];
|
||||||
|
@ -23,21 +28,26 @@ class ReadingValueContainer extends StatelessWidget {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
_items.add(const SizedBox(height: Dimens.grid8));
|
_items.add(const SizedBox(height: Dimens.grid8));
|
||||||
}
|
}
|
||||||
_items.add(_ReadingValueBuilder(values[i]));
|
_items.add(_ReadingValueBuilder(values[i], textColor: textColor));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ReadingValueContainer.singleValue({
|
ReadingValueContainer.singleValue({
|
||||||
required ReadingValue value,
|
required ReadingValue value,
|
||||||
|
this.color,
|
||||||
|
this.textColor,
|
||||||
super.key,
|
super.key,
|
||||||
}) : _items = [_ReadingValueBuilder(value)];
|
}) : _items = [_ReadingValueBuilder(value, textColor: textColor)];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Color backgroundColor(BuildContext context) => color ?? Theme.of(context).colorScheme.primaryContainer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(Dimens.borderRadiusM),
|
borderRadius: BorderRadius.circular(Dimens.borderRadiusM),
|
||||||
child: ColoredBox(
|
child: ColoredBox(
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
color: backgroundColor(context),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(Dimens.paddingM),
|
padding: const EdgeInsets.all(Dimens.paddingM),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
@ -53,20 +63,21 @@ class ReadingValueContainer extends StatelessWidget {
|
||||||
|
|
||||||
class _ReadingValueBuilder extends StatelessWidget {
|
class _ReadingValueBuilder extends StatelessWidget {
|
||||||
final ReadingValue reading;
|
final ReadingValue reading;
|
||||||
|
final Color? textColor;
|
||||||
|
|
||||||
const _ReadingValueBuilder(this.reading);
|
const _ReadingValueBuilder(this.reading, {this.textColor});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
final textColor = Theme.of(context).colorScheme.onPrimaryContainer;
|
final color = textColor ?? Theme.of(context).colorScheme.onPrimaryContainer;
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
reading.label,
|
reading.label,
|
||||||
style: textTheme.labelMedium?.copyWith(color: textColor),
|
style: textTheme.labelMedium?.copyWith(color: color),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.visible,
|
overflow: TextOverflow.visible,
|
||||||
softWrap: false,
|
softWrap: false,
|
||||||
|
@ -76,7 +87,7 @@ class _ReadingValueBuilder extends StatelessWidget {
|
||||||
duration: Dimens.switchDuration,
|
duration: Dimens.switchDuration,
|
||||||
child: Text(
|
child: Text(
|
||||||
reading.value,
|
reading.value,
|
||||||
style: textTheme.titleMedium?.copyWith(color: textColor),
|
style: textTheme.titleMedium?.copyWith(color: color),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
softWrap: false,
|
softWrap: false,
|
||||||
|
|
|
@ -2,13 +2,15 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:lightmeter/data/models/exposure_pair.dart';
|
import 'package:lightmeter/data/models/exposure_pair.dart';
|
||||||
import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
|
import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
|
||||||
import 'package:lightmeter/providers/equipment_profile_provider.dart';
|
import 'package:lightmeter/providers/equipment_profile_provider.dart';
|
||||||
import 'package:lightmeter/providers/user_preferences_provider.dart';
|
|
||||||
import 'package:lightmeter/res/dimens.dart';
|
import 'package:lightmeter/res/dimens.dart';
|
||||||
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart';
|
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart';
|
||||||
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart';
|
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart';
|
||||||
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart';
|
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart';
|
||||||
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart';
|
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart';
|
||||||
|
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/lightmeter_pro/widget_lightmeter_pro.dart';
|
||||||
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart';
|
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart';
|
||||||
|
import 'package:lightmeter/utils/context_utils.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 ReadingsContainer extends StatelessWidget {
|
class ReadingsContainer extends StatelessWidget {
|
||||||
|
@ -31,30 +33,26 @@ class ReadingsContainer extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final isPro = IAPProducts.isPurchased(context, IAPProductType.paidFeatures);
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (UserPreferencesProvider.meteringScreenFeatureOf(
|
if (!isPro) ...[
|
||||||
context,
|
const LightmeterProAnimatedDialog(),
|
||||||
MeteringScreenLayoutFeature.equipmentProfiles,
|
const _InnerPadding(),
|
||||||
)) ...[
|
],
|
||||||
|
if (isPro && context.meteringFeature(MeteringScreenLayoutFeature.equipmentProfiles)) ...[
|
||||||
const EquipmentProfilePicker(),
|
const EquipmentProfilePicker(),
|
||||||
const _InnerPadding(),
|
const _InnerPadding(),
|
||||||
],
|
],
|
||||||
if (UserPreferencesProvider.meteringScreenFeatureOf(
|
if (context.meteringFeature(MeteringScreenLayoutFeature.extremeExposurePairs)) ...[
|
||||||
context,
|
|
||||||
MeteringScreenLayoutFeature.extremeExposurePairs,
|
|
||||||
)) ...[
|
|
||||||
ExtremeExposurePairsContainer(
|
ExtremeExposurePairsContainer(
|
||||||
fastest: fastest,
|
fastest: fastest,
|
||||||
slowest: slowest,
|
slowest: slowest,
|
||||||
),
|
),
|
||||||
const _InnerPadding(),
|
const _InnerPadding(),
|
||||||
],
|
],
|
||||||
if (UserPreferencesProvider.meteringScreenFeatureOf(
|
if (isPro && context.meteringFeature(MeteringScreenLayoutFeature.filmPicker)) ...[
|
||||||
context,
|
|
||||||
MeteringScreenLayoutFeature.filmPicker,
|
|
||||||
)) ...[
|
|
||||||
FilmPicker(selectedIso: iso),
|
FilmPicker(selectedIso: iso),
|
||||||
const _InnerPadding(),
|
const _InnerPadding(),
|
||||||
],
|
],
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lightmeter/data/models/feature.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/res/dimens.dart';
|
import 'package:lightmeter/res/dimens.dart';
|
||||||
import 'package:lightmeter/screens/settings/utils/show_buy_pro_dialog.dart';
|
import 'package:lightmeter/screens/shared/pro_features_dialog/widget_dialog_pro_features.dart';
|
||||||
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
|
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
|
||||||
|
|
||||||
class BuyProListTile extends StatelessWidget {
|
class BuyProListTile extends StatelessWidget {
|
||||||
|
@ -12,18 +9,17 @@ class BuyProListTile extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final unlockFeaturesEnabled = RemoteConfig.isEnabled(context, Feature.unlockProFeaturesText);
|
|
||||||
final status = IAPProducts.productOf(context, IAPProductType.paidFeatures)?.status;
|
final status = IAPProducts.productOf(context, IAPProductType.paidFeatures)?.status;
|
||||||
final isPending = status == IAPProductStatus.purchased || status == null;
|
final isPending = status == IAPProductStatus.purchased || status == null;
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: const Icon(Icons.star),
|
leading: const Icon(Icons.star),
|
||||||
title: Text(unlockFeaturesEnabled ? S.of(context).unlockProFeatures : S.of(context).buyLightmeterPro),
|
title: Text(S.of(context).unlockProFeatures),
|
||||||
onTap: !isPending
|
onTap: !isPending
|
||||||
? () {
|
? () {
|
||||||
showBuyProDialog(context);
|
showDialog(
|
||||||
ServicesProvider.of(context)
|
context: context,
|
||||||
.analytics
|
builder: (_) => const Dialog(child: ProFeaturesDialog()),
|
||||||
.logUnlockProFeatures(unlockFeaturesEnabled ? 'Unlock Pro features' : 'Buy Lightmeter Pro');
|
);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
trailing: isPending
|
trailing: isPending
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lightmeter/data/models/feature.dart';
|
|
||||||
import 'package:lightmeter/generated/l10n.dart';
|
import 'package:lightmeter/generated/l10n.dart';
|
||||||
import 'package:lightmeter/providers/remote_config_provider.dart';
|
|
||||||
import 'package:lightmeter/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart';
|
import 'package:lightmeter/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart';
|
||||||
import 'package:lightmeter/screens/settings/components/shared/settings_section/widget_settings_section.dart';
|
import 'package:lightmeter/screens/settings/components/shared/settings_section/widget_settings_section.dart';
|
||||||
|
|
||||||
|
@ -11,9 +9,7 @@ class LightmeterProSettingsSection extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SettingsSection(
|
return SettingsSection(
|
||||||
title: RemoteConfig.isEnabled(context, Feature.unlockProFeaturesText)
|
title: S.of(context).proFeatures,
|
||||||
? S.of(context).proFeatures
|
|
||||||
: S.of(context).lightmeterPro,
|
|
||||||
children: const [BuyProListTile()],
|
children: const [BuyProListTile()],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import 'package:lightmeter/providers/equipment_profile_provider.dart';
|
||||||
import 'package:lightmeter/providers/films_provider.dart';
|
import 'package:lightmeter/providers/films_provider.dart';
|
||||||
import 'package:lightmeter/providers/user_preferences_provider.dart';
|
import 'package:lightmeter/providers/user_preferences_provider.dart';
|
||||||
import 'package:lightmeter/screens/settings/components/shared/dialog_switch/widget_dialog_switch.dart';
|
import 'package:lightmeter/screens/settings/components/shared/dialog_switch/widget_dialog_switch.dart';
|
||||||
|
import 'package:lightmeter/utils/context_utils.dart';
|
||||||
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
|
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
|
||||||
|
|
||||||
class MeteringScreenLayoutListTile extends StatelessWidget {
|
class MeteringScreenLayoutListTile extends StatelessWidget {
|
||||||
|
@ -24,6 +25,15 @@ class MeteringScreenLayoutListTile extends StatelessWidget {
|
||||||
description: S.of(context).meteringScreenLayoutHint,
|
description: S.of(context).meteringScreenLayoutHint,
|
||||||
values: UserPreferencesProvider.meteringScreenConfigOf(context),
|
values: UserPreferencesProvider.meteringScreenConfigOf(context),
|
||||||
titleAdapter: _toStringLocalized,
|
titleAdapter: _toStringLocalized,
|
||||||
|
enabledAdapter: (value) {
|
||||||
|
switch (value) {
|
||||||
|
case MeteringScreenLayoutFeature.equipmentProfiles:
|
||||||
|
case MeteringScreenLayoutFeature.filmPicker:
|
||||||
|
return context.isPro;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
onSave: (value) {
|
onSave: (value) {
|
||||||
if (!value[MeteringScreenLayoutFeature.equipmentProfiles]!) {
|
if (!value[MeteringScreenLayoutFeature.equipmentProfiles]!) {
|
||||||
EquipmentProfileProvider.of(context).setProfile(EquipmentProfiles.of(context).first);
|
EquipmentProfileProvider.of(context).setProfile(EquipmentProfiles.of(context).first);
|
||||||
|
|
|
@ -1,6 +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/res/dimens.dart';
|
import 'package:lightmeter/res/dimens.dart';
|
||||||
|
import 'package:lightmeter/screens/settings/components/shared/disable/widget_disable.dart';
|
||||||
|
|
||||||
typedef StringAdapter<T> = String Function(BuildContext context, T value);
|
typedef StringAdapter<T> = String Function(BuildContext context, T value);
|
||||||
|
|
||||||
|
@ -11,6 +12,7 @@ class DialogSwitch<T> extends StatefulWidget {
|
||||||
final Map<T, bool> values;
|
final Map<T, bool> values;
|
||||||
final StringAdapter<T> titleAdapter;
|
final StringAdapter<T> titleAdapter;
|
||||||
final StringAdapter<T>? subtitleAdapter;
|
final StringAdapter<T>? subtitleAdapter;
|
||||||
|
final bool Function(T value)? enabledAdapter;
|
||||||
final ValueChanged<Map<T, bool>> onSave;
|
final ValueChanged<Map<T, bool>> onSave;
|
||||||
|
|
||||||
const DialogSwitch({
|
const DialogSwitch({
|
||||||
|
@ -20,6 +22,7 @@ class DialogSwitch<T> extends StatefulWidget {
|
||||||
required this.values,
|
required this.values,
|
||||||
required this.titleAdapter,
|
required this.titleAdapter,
|
||||||
this.subtitleAdapter,
|
this.subtitleAdapter,
|
||||||
|
this.enabledAdapter,
|
||||||
required this.onSave,
|
required this.onSave,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
@ -52,9 +55,12 @@ class _DialogSwitchState<T> extends State<DialogSwitch<T>> {
|
||||||
],
|
],
|
||||||
ListView(
|
ListView(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
children: _features.entries
|
children: _features.entries.map(
|
||||||
.map(
|
(entry) {
|
||||||
(entry) => SwitchListTile(
|
final isEnabled = widget.enabledAdapter?.call(entry.key) ?? true;
|
||||||
|
return Disable(
|
||||||
|
disable: !isEnabled,
|
||||||
|
child: SwitchListTile(
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: Dimens.dialogTitlePadding.left),
|
contentPadding: EdgeInsets.symmetric(horizontal: Dimens.dialogTitlePadding.left),
|
||||||
title: Text(widget.titleAdapter(context, entry.key)),
|
title: Text(widget.titleAdapter(context, entry.key)),
|
||||||
subtitle: widget.subtitleAdapter != null
|
subtitle: widget.subtitleAdapter != null
|
||||||
|
@ -63,15 +69,16 @@ class _DialogSwitchState<T> extends State<DialogSwitch<T>> {
|
||||||
style: Theme.of(context).listTileTheme.subtitleTextStyle,
|
style: Theme.of(context).listTileTheme.subtitleTextStyle,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
value: _features[entry.key]!,
|
value: isEnabled && _features[entry.key]!,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_features.update(entry.key, (_) => value);
|
_features.update(entry.key, (_) => value);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.toList(),
|
},
|
||||||
|
).toList(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lightmeter/res/dimens.dart';
|
||||||
|
|
||||||
|
class Disable extends StatelessWidget {
|
||||||
|
final bool disable;
|
||||||
|
final Widget? child;
|
||||||
|
|
||||||
|
const Disable({
|
||||||
|
this.disable = true,
|
||||||
|
this.child,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Opacity(
|
||||||
|
opacity: disable ? Dimens.disabledOpacity : Dimens.enabledOpacity,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: disable,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lightmeter/res/dimens.dart';
|
import 'package:lightmeter/screens/settings/components/shared/disable/widget_disable.dart';
|
||||||
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
|
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
|
||||||
|
|
||||||
/// Depends on the product status and replaces [onTap] with purchase callback
|
/// Depends on the product status and replaces [onTap] with purchase callback
|
||||||
|
@ -23,12 +23,12 @@ class IAPListTile extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isPurchased = IAPProducts.isPurchased(context, IAPProductType.paidFeatures);
|
final isPurchased = IAPProducts.isPurchased(context, IAPProductType.paidFeatures);
|
||||||
return Opacity(
|
return Disable(
|
||||||
opacity: isPurchased ? Dimens.enabledOpacity : Dimens.disabledOpacity,
|
disable: !isPurchased,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: leading,
|
leading: leading,
|
||||||
title: title,
|
title: title,
|
||||||
onTap: isPurchased ? onTap : null,
|
onTap: onTap,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:lightmeter/data/models/dynamic_colors_state.dart';
|
import 'package:lightmeter/data/models/dynamic_colors_state.dart';
|
||||||
import 'package:lightmeter/generated/l10n.dart';
|
import 'package:lightmeter/generated/l10n.dart';
|
||||||
import 'package:lightmeter/providers/user_preferences_provider.dart';
|
import 'package:lightmeter/providers/user_preferences_provider.dart';
|
||||||
import 'package:lightmeter/res/dimens.dart';
|
import 'package:lightmeter/screens/settings/components/shared/disable/widget_disable.dart';
|
||||||
import 'package:lightmeter/screens/settings/components/theme/components/primary_color/components/primary_color_picker_dialog/widget_dialog_picker_primary_color.dart';
|
import 'package:lightmeter/screens/settings/components/theme/components/primary_color/components/primary_color_picker_dialog/widget_dialog_picker_primary_color.dart';
|
||||||
|
|
||||||
class PrimaryColorListTile extends StatelessWidget {
|
class PrimaryColorListTile extends StatelessWidget {
|
||||||
|
@ -11,14 +11,11 @@ class PrimaryColorListTile extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (UserPreferencesProvider.dynamicColorStateOf(context) == DynamicColorState.enabled) {
|
if (UserPreferencesProvider.dynamicColorStateOf(context) == DynamicColorState.enabled) {
|
||||||
return Opacity(
|
return Disable(
|
||||||
opacity: Dimens.disabledOpacity,
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: const Icon(Icons.palette),
|
leading: const Icon(Icons.palette),
|
||||||
title: Text(S.of(context).primaryColor),
|
title: Text(S.of(context).primaryColor),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ListTile(
|
return ListTile(
|
||||||
|
|
|
@ -1,59 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:lightmeter/data/models/feature.dart';
|
|
||||||
import 'package:lightmeter/generated/l10n.dart';
|
|
||||||
import 'package:lightmeter/providers/remote_config_provider.dart';
|
|
||||||
import 'package:lightmeter/res/dimens.dart';
|
|
||||||
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
|
|
||||||
|
|
||||||
Future<void> showBuyProDialog(BuildContext context) {
|
|
||||||
final unlockFeaturesEnabled = RemoteConfig.isEnabled(context, Feature.unlockProFeaturesText);
|
|
||||||
|
|
||||||
Widget splitDescription() {
|
|
||||||
final description =
|
|
||||||
unlockFeaturesEnabled ? S.of(context).unlockProFeaturesDescription : S.of(context).lightmeterProDescription;
|
|
||||||
final paragraphs = description.split('\n\n');
|
|
||||||
final features = paragraphs.first.split('\n \u2022 ').sublist(1);
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(paragraphs.first.split('\n \u2022 ').first),
|
|
||||||
...features.map(
|
|
||||||
(f) => Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text('\u2022 '),
|
|
||||||
Flexible(child: Text(f)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text('\n${paragraphs.last}'),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => AlertDialog(
|
|
||||||
icon: const Icon(Icons.star),
|
|
||||||
titlePadding: Dimens.dialogIconTitlePadding,
|
|
||||||
title: Text(unlockFeaturesEnabled ? S.of(context).proFeatures : S.of(context).lightmeterPro),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingL),
|
|
||||||
content: SingleChildScrollView(child: splitDescription()),
|
|
||||||
actionsPadding: Dimens.dialogActionsPadding,
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: Navigator.of(context).pop,
|
|
||||||
child: Text(S.of(context).cancel),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
IAPProductsProvider.maybeOf(context)?.buy(IAPProductType.paidFeatures);
|
|
||||||
},
|
|
||||||
child: Text(unlockFeaturesEnabled ? S.of(context).unlock : S.of(context).buy),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lightmeter/generated/l10n.dart';
|
||||||
|
import 'package:lightmeter/res/dimens.dart';
|
||||||
|
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/animated_dialog/widget_dialog_animated.dart';
|
||||||
|
import 'package:lightmeter/screens/shared/transparent_dialog/widget_dialog_transparent.dart';
|
||||||
|
import 'package:lightmeter/utils/text_height.dart';
|
||||||
|
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
|
||||||
|
|
||||||
|
class ProFeaturesDialog extends StatelessWidget {
|
||||||
|
const ProFeaturesDialog({super.key});
|
||||||
|
|
||||||
|
double height(BuildContext context) => TransparentDialog.height(
|
||||||
|
context,
|
||||||
|
title: S.of(context).proFeatures,
|
||||||
|
contextHeight: dialogTextHeight(
|
||||||
|
context,
|
||||||
|
S.of(context).unlockProFeaturesDescription,
|
||||||
|
Theme.of(context).textTheme.bodyMedium,
|
||||||
|
Dimens.paddingL * 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TransparentDialog(
|
||||||
|
icon: Icons.star,
|
||||||
|
title: S.of(context).proFeatures,
|
||||||
|
scrollableContent: false,
|
||||||
|
content: Flexible(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: Dimens.paddingL),
|
||||||
|
child: Text(
|
||||||
|
S.of(context).unlockProFeaturesDescription,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => _close(context),
|
||||||
|
child: Text(S.of(context).cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
_close(context).then((_) => IAPProductsProvider.maybeOf(context)?.buy(IAPProductType.paidFeatures));
|
||||||
|
},
|
||||||
|
child: Text(S.of(context).unlock),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _close(BuildContext context) async => AnimatedDialog.maybeClose(context) ?? Navigator.of(context).pop();
|
||||||
|
}
|
|
@ -0,0 +1,104 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lightmeter/res/dimens.dart';
|
||||||
|
import 'package:lightmeter/utils/text_height.dart';
|
||||||
|
|
||||||
|
class TransparentDialog extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final Widget content;
|
||||||
|
final bool scrollableContent;
|
||||||
|
final List<Widget> actions;
|
||||||
|
|
||||||
|
const TransparentDialog({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
required this.content,
|
||||||
|
required this.scrollableContent,
|
||||||
|
this.actions = const [],
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
static double height(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
String? subtitle,
|
||||||
|
required double contextHeight,
|
||||||
|
bool scrollableContent = false,
|
||||||
|
}) {
|
||||||
|
double height = IconTheme.of(context).size! + Dimens.dialogTitlePadding.vertical;
|
||||||
|
height += dialogTextHeight(
|
||||||
|
context,
|
||||||
|
title,
|
||||||
|
Theme.of(context).textTheme.headlineSmall,
|
||||||
|
Dimens.dialogIconTitlePadding.horizontal,
|
||||||
|
) +
|
||||||
|
Dimens.dialogIconTitlePadding.vertical;
|
||||||
|
if (subtitle != null) {
|
||||||
|
height += dialogTextHeight(
|
||||||
|
context,
|
||||||
|
subtitle,
|
||||||
|
Theme.of(context).textTheme.bodyMedium,
|
||||||
|
Dimens.dialogIconTitlePadding.horizontal,
|
||||||
|
) +
|
||||||
|
Dimens.dialogIconTitlePadding.vertical;
|
||||||
|
}
|
||||||
|
height += contextHeight;
|
||||||
|
if (scrollableContent) height += 1;
|
||||||
|
return height += 48 + Dimens.dialogActionsPadding.vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: Dimens.dialogTitlePadding,
|
||||||
|
child: Icon(icon),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: Dimens.dialogIconTitlePadding,
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (subtitle != null)
|
||||||
|
Padding(
|
||||||
|
padding: Dimens.dialogIconTitlePadding,
|
||||||
|
child: Text(
|
||||||
|
subtitle!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (scrollableContent) const Divider(),
|
||||||
|
content,
|
||||||
|
if (scrollableContent) const Divider(),
|
||||||
|
Padding(
|
||||||
|
padding: Dimens.dialogActionsPadding,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: _actions().toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Iterable<Widget> _actions() sync* {
|
||||||
|
for (int i = 0; i < actions.length; i++) {
|
||||||
|
yield i == 0 ? const Spacer() : const SizedBox(width: Dimens.grid16);
|
||||||
|
yield actions[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
12
lib/utils/context_utils.dart
Normal file
12
lib/utils/context_utils.dart
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
|
||||||
|
import 'package:lightmeter/providers/user_preferences_provider.dart';
|
||||||
|
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
|
||||||
|
|
||||||
|
extension BuildContextUtils on BuildContext {
|
||||||
|
bool meteringFeature(MeteringScreenLayoutFeature feature) {
|
||||||
|
return UserPreferencesProvider.meteringScreenFeatureOf(this, feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isPro => IAPProducts.isPurchased(this, IAPProductType.paidFeatures);
|
||||||
|
}
|
27
lib/utils/ev_from_bytes.dart
Normal file
27
lib/utils/ev_from_bytes.dart
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import 'dart:developer';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:exif/exif.dart';
|
||||||
|
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
|
||||||
|
|
||||||
|
Future<double?> evFromImage(Uint8List bytes) async {
|
||||||
|
try {
|
||||||
|
final tags = await readExifFromBytes(bytes);
|
||||||
|
final iso = double.tryParse("${tags["EXIF ISOSpeedRatings"]}");
|
||||||
|
final apertureValueRatio = (tags["EXIF FNumber"]?.values as IfdRatios?)?.ratios.first;
|
||||||
|
final speedValueRatio = (tags["EXIF ExposureTime"]?.values as IfdRatios?)?.ratios.first;
|
||||||
|
if (iso == null || apertureValueRatio == null || speedValueRatio == null) {
|
||||||
|
log('Error parsing EXIF: ${tags.keys}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final aperture = apertureValueRatio.numerator / apertureValueRatio.denominator;
|
||||||
|
final speed = speedValueRatio.numerator / speedValueRatio.denominator;
|
||||||
|
|
||||||
|
return log2(math.pow(aperture, 2)) - log2(speed) - log2(iso / 100);
|
||||||
|
} catch (e) {
|
||||||
|
log(e.toString());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
29
lib/utils/text_height.dart
Normal file
29
lib/utils/text_height.dart
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lightmeter/res/dimens.dart';
|
||||||
|
|
||||||
|
double dialogTextHeight(
|
||||||
|
BuildContext context,
|
||||||
|
String text,
|
||||||
|
TextStyle? style,
|
||||||
|
double textPadding,
|
||||||
|
) =>
|
||||||
|
textHeight(
|
||||||
|
text,
|
||||||
|
style,
|
||||||
|
MediaQuery.sizeOf(context).width - Dimens.dialogMargin.horizontal - textPadding,
|
||||||
|
);
|
||||||
|
|
||||||
|
double textHeight(
|
||||||
|
String text,
|
||||||
|
TextStyle? style,
|
||||||
|
double maxWidth,
|
||||||
|
) {
|
||||||
|
final TextPainter titlePainter = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: text,
|
||||||
|
style: style,
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout(maxWidth: maxWidth);
|
||||||
|
return titlePainter.height;
|
||||||
|
}
|
|
@ -2,12 +2,18 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:lightmeter/generated/l10n.dart';
|
import 'package:lightmeter/generated/l10n.dart';
|
||||||
import 'package:lightmeter/res/theme.dart';
|
import 'package:lightmeter/res/theme.dart';
|
||||||
|
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
|
||||||
|
|
||||||
/// Provides [MaterialApp] with default theme and "en" localization
|
/// Provides [MaterialApp] with default theme and "en" localization
|
||||||
class WidgetTestApplicationMock extends StatelessWidget {
|
class WidgetTestApplicationMock extends StatelessWidget {
|
||||||
|
final IAPProductStatus productStatus;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
const WidgetTestApplicationMock({required this.child, super.key});
|
const WidgetTestApplicationMock({
|
||||||
|
this.productStatus = IAPProductStatus.purchased,
|
||||||
|
required this.child,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:lightmeter/generated/l10n.dart';
|
||||||
|
import 'package:lightmeter/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart';
|
||||||
|
import 'package:lightmeter/screens/settings/components/lightmeter_pro/widget_settings_section_lightmeter_pro.dart';
|
||||||
|
import 'package:lightmeter/screens/shared/transparent_dialog/widget_dialog_transparent.dart';
|
||||||
|
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
|
||||||
|
|
||||||
|
import '../../../application_mock.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Future<void> pumpApplication(WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
IAPProducts(
|
||||||
|
products: [
|
||||||
|
IAPProduct(
|
||||||
|
storeId: IAPProductType.paidFeatures.storeId,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const WidgetTestApplicationMock(
|
||||||
|
child: LightmeterProSettingsSection(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'`showBuyProDialog` and buy',
|
||||||
|
(tester) async {
|
||||||
|
await pumpApplication(tester);
|
||||||
|
await tester.tap(find.byType(BuyProListTile));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(TransparentDialog), findsOneWidget);
|
||||||
|
expect(find.text(S.current.proFeatures), findsNWidgets(2));
|
||||||
|
expect(find.text(S.current.cancel), findsOneWidget);
|
||||||
|
expect(find.text(S.current.unlock), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text(S.current.unlock));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(TransparentDialog), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'`showBuyProDialog` and cancel',
|
||||||
|
(tester) async {
|
||||||
|
await pumpApplication(tester);
|
||||||
|
await tester.tap(find.byType(BuyProListTile));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(TransparentDialog), findsOneWidget);
|
||||||
|
expect(find.text(S.current.proFeatures), findsNWidgets(2));
|
||||||
|
expect(find.text(S.current.cancel), findsOneWidget);
|
||||||
|
expect(find.text(S.current.unlock), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text(S.current.cancel));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(TransparentDialog), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,61 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:lightmeter/data/models/feature.dart';
|
|
||||||
import 'package:lightmeter/generated/l10n.dart';
|
|
||||||
import 'package:lightmeter/providers/remote_config_provider.dart';
|
|
||||||
import 'package:lightmeter/screens/settings/utils/show_buy_pro_dialog.dart';
|
|
||||||
|
|
||||||
import '../../../application_mock.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
Future<void> pumpApplication(WidgetTester tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
RemoteConfig(
|
|
||||||
config: const {Feature.unlockProFeaturesText: false},
|
|
||||||
child: WidgetTestApplicationMock(
|
|
||||||
child: Builder(
|
|
||||||
builder: (context) => ElevatedButton(
|
|
||||||
onPressed: () => showBuyProDialog(context),
|
|
||||||
child: const SizedBox(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
}
|
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'`showBuyProDialog` and buy',
|
|
||||||
(tester) async {
|
|
||||||
await pumpApplication(tester);
|
|
||||||
await tester.tap(find.byType(ElevatedButton));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.byType(AlertDialog), findsOneWidget);
|
|
||||||
expect(find.text(S.current.lightmeterPro), findsOneWidget);
|
|
||||||
expect(find.text(S.current.cancel), findsOneWidget);
|
|
||||||
expect(find.text(S.current.buy), findsOneWidget);
|
|
||||||
|
|
||||||
await tester.tap(find.text(S.current.buy));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.byType(AlertDialog), findsNothing);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'`showBuyProDialog` and cancel',
|
|
||||||
(tester) async {
|
|
||||||
await pumpApplication(tester);
|
|
||||||
await tester.tap(find.byType(ElevatedButton));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.byType(AlertDialog), findsOneWidget);
|
|
||||||
expect(find.text(S.current.lightmeterPro), findsOneWidget);
|
|
||||||
expect(find.text(S.current.cancel), findsOneWidget);
|
|
||||||
expect(find.text(S.current.buy), findsOneWidget);
|
|
||||||
|
|
||||||
await tester.tap(find.text(S.current.cancel));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.byType(AlertDialog), findsNothing);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
24
test/utils/ev_from_bytes_test.dart
Normal file
24
test/utils/ev_from_bytes_test.dart
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:lightmeter/utils/ev_from_bytes.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('evFromImage', () {
|
||||||
|
test(
|
||||||
|
'camera_stub_image.jpg',
|
||||||
|
() {
|
||||||
|
final bytes = File('assets/camera_stub_image.jpg').readAsBytesSync();
|
||||||
|
expectLater(evFromImage(bytes), completion(8.25230310752341));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'no EXIF',
|
||||||
|
() {
|
||||||
|
final bytes = File('assets/launcher_icon_dev_512.png').readAsBytesSync();
|
||||||
|
expectLater(evFromImage(bytes), completion(null));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in a new issue