diff --git a/android/app/build.gradle b/android/app/build.gradle index c9ca0aa..5f0c906 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -43,7 +43,7 @@ android { } defaultConfig { - minSdkVersion flutter.minSdkVersion + minSdkVersion 21 targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/lib/data/ev_source/camera/bloc_camera.dart b/lib/data/ev_source/camera/bloc_camera.dart new file mode 100644 index 0000000..382e855 --- /dev/null +++ b/lib/data/ev_source/camera/bloc_camera.dart @@ -0,0 +1,126 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; +import 'package:camera/camera.dart'; +import 'package:exif/exif.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; +import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' as communication_event; +import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' as communication_states; +import 'package:lightmeter/utils/log_2.dart'; + +import 'event_camera.dart'; +import 'state_camera.dart'; + +class CameraBloc extends Bloc { + final MeteringCommunicationBloc _communicationBloc; + late final StreamSubscription _communicationSubscription; + + late final _WidgetsBindingObserver _observer; + CameraController? _cameraController; + CameraController? get cameraController => _cameraController; + + CameraBloc(this._communicationBloc) : super(const CameraInitState()) { + _communicationSubscription = _communicationBloc.stream.listen(_onCommunicationState); + + _observer = _WidgetsBindingObserver(_appLifecycleStateObserver); + WidgetsBinding.instance.addObserver(_observer); + + on(_onInitialized); + + add(const InitializeEvent()); + } + + @override + Future close() async { + WidgetsBinding.instance.removeObserver(_observer); + _cameraController?.dispose(); + await _communicationSubscription.cancel(); + super.close(); + } + + void _onCommunicationState(communication_states.MeteringCommunicationState communicationState) { + if (communicationState is communication_states.MeasureState) { + _takePhoto().then((ev100) { + if (ev100 != null) { + _communicationBloc.add(communication_event.MeasuredEvent(ev100)); + } + }); + } + } + + Future _onInitialized(_, Emitter emit) async { + emit(const CameraLoadingState()); + try { + final cameras = await availableCameras(); + _cameraController = CameraController( + cameras.firstWhere( + (camera) => camera.lensDirection == CameraLensDirection.back, + orElse: () => cameras.last, + ), + ResolutionPreset.medium, + enableAudio: false, + ); + + await _cameraController!.initialize(); + await _cameraController!.setFlashMode(FlashMode.off); + emit(CameraReadyState(_cameraController!)); + } catch (e) { + emit(const CameraErrorState()); + } + } + + Future _takePhoto() async { + if (_cameraController == null || + !_cameraController!.value.isInitialized || + _cameraController!.value.isTakingPicture) { + return null; + } + + try { + final file = await _cameraController!.takePicture(); + final Uint8List bytes = await file.readAsBytes(); + Directory(file.path).deleteSync(recursive: true); + + final tags = await readExifFromBytes(bytes); + final iso = double.parse("${tags["EXIF ISOSpeedRatings"]}"); + final apertureValueRatio = (tags["EXIF FNumber"]!.values as IfdRatios).ratios.first; + final aperture = apertureValueRatio.numerator / apertureValueRatio.denominator; + final speedValueRatio = (tags["EXIF ExposureTime"]!.values as IfdRatios).ratios.first; + final speed = speedValueRatio.numerator / speedValueRatio.denominator; + + return log2(pow(aperture, 2)) - log2(speed) - log2(iso / 100); + } on CameraException catch (e) { + debugPrint('Error: ${e.code}\nError Message: ${e.description}'); + return null; + } + } + + Future _appLifecycleStateObserver(AppLifecycleState state) async { + switch (state) { + case AppLifecycleState.resumed: + add(const InitializeEvent()); + break; + case AppLifecycleState.paused: + case AppLifecycleState.inactive: + _cameraController?.dispose(); + _cameraController = null; + break; + default: + } + } +} + +/// This is needed only because we cannot use `with` with mixins +class _WidgetsBindingObserver with WidgetsBindingObserver { + final ValueChanged onLifecycleStateChanged; + + _WidgetsBindingObserver(this.onLifecycleStateChanged); + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + onLifecycleStateChanged(state); + } +} diff --git a/lib/data/ev_source/camera/event_camera.dart b/lib/data/ev_source/camera/event_camera.dart new file mode 100644 index 0000000..099f150 --- /dev/null +++ b/lib/data/ev_source/camera/event_camera.dart @@ -0,0 +1,7 @@ +abstract class CameraEvent { + const CameraEvent(); +} + +class InitializeEvent extends CameraEvent { + const InitializeEvent(); +} \ No newline at end of file diff --git a/lib/data/ev_source/camera/state_camera.dart b/lib/data/ev_source/camera/state_camera.dart new file mode 100644 index 0000000..1364310 --- /dev/null +++ b/lib/data/ev_source/camera/state_camera.dart @@ -0,0 +1,23 @@ +import 'package:camera/camera.dart'; + +abstract class CameraState { + const CameraState(); +} + +class CameraInitState extends CameraState { + const CameraInitState(); +} + +class CameraLoadingState extends CameraState { + const CameraLoadingState(); +} + +class CameraReadyState extends CameraState { + final CameraController controller; + + const CameraReadyState(this.controller); +} + +class CameraErrorState extends CameraState { + const CameraErrorState(); +} diff --git a/lib/main.dart b/lib/main.dart index a3b598f..5b740d5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,16 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:lightmeter/data/permissions_service.dart'; import 'package:lightmeter/screens/settings/settings_screen.dart'; import 'package:provider/provider.dart'; import 'generated/l10n.dart'; -import 'models/photography_value.dart'; import 'res/theme.dart'; -import 'screens/metering/metering_bloc.dart'; -import 'screens/metering/metering_screen.dart'; +import 'screens/metering/flow_metering.dart'; import 'utils/stop_type_provider.dart'; void main() { @@ -45,30 +42,27 @@ class _ApplicationState extends State { return Provider( create: (context) => PermissionsService(), child: StopTypeProvider( - child: BlocProvider( - create: (context) => MeteringBloc(context.read()), - child: MaterialApp( - theme: ThemeData( - useMaterial3: true, - colorScheme: lightColorScheme, - ), - localizationsDelegates: const [ - S.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: S.delegate.supportedLocales, - builder: (context, child) => MediaQuery( - data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), - child: child!, - ), - home: const MeteringScreen(), - routes: { - "metering": (context) => const MeteringScreen(), - "settings": (context) => const SettingsScreen(), - }, + child: MaterialApp( + theme: ThemeData( + useMaterial3: true, + colorScheme: lightColorScheme, ), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), + child: child!, + ), + home: const MeteringFlow(), + routes: { + "metering": (context) => const MeteringFlow(), + "settings": (context) => const SettingsScreen(), + }, ), ), ); diff --git a/lib/screens/metering/metering_bloc.dart b/lib/screens/metering/bloc_metering.dart similarity index 72% rename from lib/screens/metering/metering_bloc.dart rename to lib/screens/metering/bloc_metering.dart index 74a6fad..18358b3 100644 --- a/lib/screens/metering/metering_bloc.dart +++ b/lib/screens/metering/bloc_metering.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -7,19 +8,25 @@ import 'package:lightmeter/models/iso_value.dart'; import 'package:lightmeter/models/nd_value.dart'; import 'package:lightmeter/models/photography_value.dart'; import 'package:lightmeter/models/shutter_speed_value.dart'; +import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' as communication_events; +import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' as communication_states; import 'package:lightmeter/utils/log_2.dart'; -import 'metering_event.dart'; -import 'metering_state.dart'; +import 'communication/bloc_communication_metering.dart'; +import 'event_metering.dart'; +import 'state_metering.dart'; class MeteringBloc extends Bloc { + final MeteringCommunicationBloc _communicationBloc; + late final StreamSubscription _communicationSubscription; + List get _apertureValues => apertureValues.whereStopType(stopType); List get _shutterSpeedValues => shutterSpeedValues.whereStopType(stopType); final _random = Random(); StopType stopType; - MeteringBloc(this.stopType) + MeteringBloc(this._communicationBloc, this.stopType) : super( MeteringState( iso: isoValues.where((element) => element.value == 100).first, @@ -29,14 +36,42 @@ class MeteringBloc extends Bloc { exposurePairs: [], ), ) { + _communicationSubscription = _communicationBloc.stream + .where((state) => state is communication_states.ScreenState) + .map((state) => state as communication_states.ScreenState) + .listen(_onCommunicationState); + on(_onStopTypeChanged); on(_onIsoChanged); on(_onNdChanged); - on(_onMeasure); + on((_, __) => _communicationBloc.add(const communication_events.MeasureEvent())); + on(_onMeasured); add(const MeasureEvent()); } + @override + Future close() async { + await _communicationSubscription.cancel(); + return super.close(); + } + + void _onCommunicationState(communication_states.ScreenState communicationState) { + if (communicationState is communication_states.MeasuredState) { + add(MeasuredEvent(communicationState.ev100)); + } + } + + /// https://www.scantips.com/lights/exposurecalc.html + Future measureEv() async { + final aperture = _apertureValues[_random.nextInt(_apertureValues.length)]; + final shutterSpeed = _shutterSpeedValues[_random.nextInt(_shutterSpeedValues.thirdStops().length)]; + final iso = isoValues[_random.nextInt(isoValues.thirdStops().length)]; + + final evAtSystemIso = log2(pow(aperture.value, 2).toDouble() / shutterSpeed.value); + return evAtSystemIso - log2(iso.value / state.iso.value); + } + void _onStopTypeChanged(StopTypeChangedEvent event, Emitter emit) { stopType = event.stopType; emit(MeteringState( @@ -70,15 +105,8 @@ class MeteringBloc extends Bloc { )); } - /// https://www.scantips.com/lights/exposurecalc.html - void _onMeasure(_, Emitter emit) { - final aperture = _apertureValues[_random.nextInt(_apertureValues.length)]; - final shutterSpeed = _shutterSpeedValues[_random.nextInt(_shutterSpeedValues.thirdStops().length)]; - final iso = isoValues[_random.nextInt(isoValues.thirdStops().length)]; - - final evAtSystemIso = log2(pow(aperture.value, 2).toDouble() / shutterSpeed.value); - final ev = evAtSystemIso - log2(iso.value / state.iso.value); - + void _onMeasured(MeasuredEvent event, Emitter emit) { + final ev = event.ev100 + log2(state.iso.value / 100); emit(MeteringState( iso: state.iso, ev: ev, diff --git a/lib/screens/metering/communication/bloc_communication_metering.dart b/lib/screens/metering/communication/bloc_communication_metering.dart new file mode 100644 index 0000000..b6fa04c --- /dev/null +++ b/lib/screens/metering/communication/bloc_communication_metering.dart @@ -0,0 +1,11 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'event_communication_metering.dart'; +import 'state_communication_metering.dart'; + +class MeteringCommunicationBloc extends Bloc { + MeteringCommunicationBloc() : super(const InitState()) { + on((_, emit) => emit(const MeasureState())); + on((event, emit) => emit(MeasuredState(event.ev100))); + } +} diff --git a/lib/screens/metering/communication/event_communication_metering.dart b/lib/screens/metering/communication/event_communication_metering.dart new file mode 100644 index 0000000..b8666e9 --- /dev/null +++ b/lib/screens/metering/communication/event_communication_metering.dart @@ -0,0 +1,21 @@ +abstract class MeteringCommunicationEvent { + const MeteringCommunicationEvent(); +} + +abstract class SourceEvent extends MeteringCommunicationEvent { + const SourceEvent(); +} + +abstract class ScreenEvent extends MeteringCommunicationEvent { + const ScreenEvent(); +} + +class MeasureEvent extends ScreenEvent { + const MeasureEvent(); +} + +class MeasuredEvent extends SourceEvent { + final double ev100; + + const MeasuredEvent(this.ev100); +} diff --git a/lib/screens/metering/communication/state_communication_metering.dart b/lib/screens/metering/communication/state_communication_metering.dart new file mode 100644 index 0000000..a41bef8 --- /dev/null +++ b/lib/screens/metering/communication/state_communication_metering.dart @@ -0,0 +1,25 @@ +abstract class MeteringCommunicationState { + const MeteringCommunicationState(); +} + +class InitState extends MeteringCommunicationState { + const InitState(); +} + +abstract class SourceState extends MeteringCommunicationState { + const SourceState(); +} + +abstract class ScreenState extends MeteringCommunicationState { + const ScreenState(); +} + +class MeasureState extends SourceState { + const MeasureState(); +} + +class MeasuredState extends ScreenState { + final double ev100; + + const MeasuredState(this.ev100); +} \ No newline at end of file diff --git a/lib/screens/metering/components/bottom_controls/components/zoom_slider.dart b/lib/screens/metering/components/bottom_controls/components/zoom_slider.dart new file mode 100644 index 0000000..9de55c7 --- /dev/null +++ b/lib/screens/metering/components/bottom_controls/components/zoom_slider.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/res/dimens.dart'; + +class ZoomSlider extends StatelessWidget { + const ZoomSlider({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: Dimens.paddingL), + child: Row( + children: [ + const Icon(Icons.zoom_out), + Expanded( + child: Slider( + value: 0, + onChanged: (value) {}, + ), + ), + const Icon(Icons.zoom_in), + ], + ), + ); + } +} diff --git a/lib/screens/metering/components/topbar/components/camera_preview.dart b/lib/screens/metering/components/topbar/components/camera_preview.dart index e69de29..c2a88e0 100644 --- a/lib/screens/metering/components/topbar/components/camera_preview.dart +++ b/lib/screens/metering/components/topbar/components/camera_preview.dart @@ -0,0 +1,57 @@ +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/data/ev_source/camera/bloc_camera.dart'; +import 'package:lightmeter/data/ev_source/camera/state_camera.dart'; + +class CameraView extends StatelessWidget { + const CameraView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: 3 / 4, + child: BlocBuilder( + builder: (context, state) { + if (state is CameraReadyState) { + final value = state.controller.value; + return ValueListenableBuilder( + valueListenable: state.controller, + builder: (_, __, ___) => AspectRatio( + aspectRatio: + _isLandscape(value) ? value.aspectRatio : (1 / value.aspectRatio), + child: RotatedBox( + quarterTurns: _getQuarterTurns(value), + child: state.controller.buildPreview(), + ), + ), + ); + } + return const ColoredBox(color: Colors.black); + }, + ), + ); + } + + bool _isLandscape(CameraValue value) { + return [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight] + .contains(_getApplicableOrientation(value)); + } + + int _getQuarterTurns(CameraValue value) { + final Map turns = { + DeviceOrientation.portraitUp: 0, + DeviceOrientation.landscapeRight: 1, + DeviceOrientation.portraitDown: 2, + DeviceOrientation.landscapeLeft: 3, + }; + return turns[_getApplicableOrientation(value)]!; + } + + DeviceOrientation _getApplicableOrientation(CameraValue value) { + return value.isRecordingVideo + ? value.recordingOrientation! + : (value.previewPauseOrientation ?? value.lockedCaptureOrientation ?? value.deviceOrientation); + } +} diff --git a/lib/screens/metering/components/topbar/topbar.dart b/lib/screens/metering/components/topbar/topbar.dart index afffc67..903414d 100644 --- a/lib/screens/metering/components/topbar/topbar.dart +++ b/lib/screens/metering/components/topbar/topbar.dart @@ -6,6 +6,7 @@ import 'package:lightmeter/models/nd_value.dart'; import 'package:lightmeter/models/photography_value.dart'; import 'package:lightmeter/res/dimens.dart'; +import 'components/camera_preview.dart'; import 'components/shared/animated_dialog.dart'; import 'components/dialog_picker.dart'; import 'components/reading_container.dart'; @@ -74,6 +75,13 @@ class MeteringTopBar extends StatelessWidget { ), ), const _InnerPadding(), + ReadingContainer.singleValue( + value: ReadingValue( + label: 'EV', + value: ev.toString(), + ), + ), + const _InnerPadding(), Row( children: [ Expanded( @@ -111,18 +119,8 @@ class MeteringTopBar extends StatelessWidget { const _InnerPadding(), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - AnimatedDialog( - openedSize: Size( - MediaQuery.of(context).size.width - Dimens.paddingM * 2, - (MediaQuery.of(context).size.width - Dimens.paddingM * 2) / 3 * 4, - ), - child: const AspectRatio( - aspectRatio: 3 / 4, - child: ColoredBox(color: Colors.black), - ), - ), + const CameraView(), ], ), ), diff --git a/lib/screens/metering/metering_event.dart b/lib/screens/metering/event_metering.dart similarity index 86% rename from lib/screens/metering/metering_event.dart rename to lib/screens/metering/event_metering.dart index 40e4ae7..275f03d 100644 --- a/lib/screens/metering/metering_event.dart +++ b/lib/screens/metering/event_metering.dart @@ -27,3 +27,9 @@ class NdChangedEvent extends MeteringEvent { class MeasureEvent extends MeteringEvent { const MeasureEvent(); } + +class MeasuredEvent extends MeteringEvent { + final double ev100; + + const MeasuredEvent(this.ev100); +} diff --git a/lib/screens/metering/flow_metering.dart b/lib/screens/metering/flow_metering.dart new file mode 100644 index 0000000..419e3a9 --- /dev/null +++ b/lib/screens/metering/flow_metering.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/data/ev_source/camera/bloc_camera.dart'; +import 'package:lightmeter/models/photography_value.dart'; +import 'package:lightmeter/screens/metering/bloc_metering.dart'; +import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; + +import 'screen_metering.dart'; + +class MeteringFlow extends StatelessWidget { + const MeteringFlow({super.key}); + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => MeteringCommunicationBloc()), + BlocProvider( + create: (context) => MeteringBloc( + context.read(), + context.read(), + ), + ), + BlocProvider(create: (context) => CameraBloc(context.read())), + ], + child: const MeteringScreen(), + ); + } +} diff --git a/lib/screens/metering/metering_screen.dart b/lib/screens/metering/screen_metering.dart similarity index 96% rename from lib/screens/metering/metering_screen.dart rename to lib/screens/metering/screen_metering.dart index 21e82bf..480dba0 100644 --- a/lib/screens/metering/metering_screen.dart +++ b/lib/screens/metering/screen_metering.dart @@ -7,9 +7,9 @@ import 'package:lightmeter/screens/settings/settings_screen.dart'; import 'components/bottom_controls/bottom_controls.dart'; import 'components/exposure_pairs_list/exposure_pairs_list.dart'; import 'components/topbar/topbar.dart'; -import 'metering_bloc.dart'; -import 'metering_event.dart'; -import 'metering_state.dart'; +import 'bloc_metering.dart'; +import 'event_metering.dart'; +import 'state_metering.dart'; class MeteringScreen extends StatefulWidget { const MeteringScreen({super.key}); diff --git a/lib/screens/metering/metering_state.dart b/lib/screens/metering/state_metering.dart similarity index 100% rename from lib/screens/metering/metering_state.dart rename to lib/screens/metering/state_metering.dart diff --git a/pubspec.yaml b/pubspec.yaml index ae49fe4..c9d5f79 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,12 +1,14 @@ name: lightmeter description: A new Flutter project. -publish_to: 'none' +publish_to: "none" version: 1.0.0+1 environment: - sdk: '>=2.18.0 <3.0.0' + sdk: ">=2.18.0 <3.0.0" dependencies: + camera: 0.10.0+4 + exif: 3.1.2 flutter: sdk: flutter flutter_bloc: ^8.1.1 @@ -61,4 +63,4 @@ flutter: # see https://flutter.dev/custom-fonts/#from-packages flutter_intl: - enabled: true \ No newline at end of file + enabled: true