diff --git a/lib/core/media/gallery_source.dart b/lib/core/media/gallery_source.dart index 81e5086..a334cdf 100644 --- a/lib/core/media/gallery_source.dart +++ b/lib/core/media/gallery_source.dart @@ -32,6 +32,35 @@ class PickedPhoto { const PickedPhoto({required this.item, this.editedFile}); } +class CameraGalleryItem implements GalleryItem { + final File file; + (int, int)? _dimensions; + + CameraGalleryItem(this.file); + + @override + String get id => file.path; + + @override + bool get isVideo => false; + + @override + Duration? get duration => null; + + @override + File? get localFile => file; + + @override + Future thumbnail(int size) => file.readAsBytes(); + + @override + Future originFile() async => file; + + @override + Future<(int, int)?> dimensions() async => + _dimensions ??= await imageFileDimensions(file); +} + Future<(int, int)?> imageFileDimensions(File file) async { ui.ImmutableBuffer? buffer; ui.ImageDescriptor? descriptor; diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index 86f7b59..9e448ed 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -1,8 +1,11 @@ import 'dart:io'; +import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:komet/backend/modules/contacts.dart'; import 'package:komet/core/config/app_frost.dart'; @@ -199,11 +202,17 @@ class _AttachmentSheetState extends State { ); } - void _onCameraTap() { - showCustomNotification( - context, - AppLocalizations.of(context)!.attachSheetCameraComingSoon, - ); + Future _onCameraTap() async { + final l10n = AppLocalizations.of(context)!; + XFile? shot; + try { + shot = await ImagePicker().pickImage(source: ImageSource.camera); + } catch (_) { + if (mounted) showCustomNotification(context, l10n.attachSheetCameraError); + return; + } + if (shot == null || !mounted) return; + _openPreview(CameraGalleryItem(File(shot.path))); } void _sendSelection({GalleryItem? fallback}) { @@ -873,34 +882,156 @@ class _KeepAlivePageState extends State<_KeepAlivePage> } } -class _CameraTile extends StatelessWidget { +class _CameraTile extends StatefulWidget { final VoidCallback onTap; final ColorScheme cs; const _CameraTile({required this.onTap, required this.cs}); + @override + State<_CameraTile> createState() => _CameraTileState(); +} + +class _CameraTileState extends State<_CameraTile> with WidgetsBindingObserver { + CameraController? _controller; + bool _starting = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _maybeStartPreview(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _maybeStartPreview(); + } else if (state == AppLifecycleState.inactive || + state == AppLifecycleState.paused) { + _stopPreview(); + } + } + + Future _maybeStartPreview() async { + if (_starting || _controller != null) return; + if (!(Platform.isAndroid || Platform.isIOS)) return; + if (!await Permission.camera.status.isGranted) return; + _starting = true; + try { + final cameras = await availableCameras(); + if (cameras.isEmpty || !mounted) return; + final back = cameras.firstWhere( + (c) => c.lensDirection == CameraLensDirection.back, + orElse: () => cameras.first, + ); + final controller = CameraController( + back, + ResolutionPreset.low, + enableAudio: false, + ); + await controller.initialize(); + if (!mounted) { + await controller.dispose(); + return; + } + setState(() => _controller = controller); + } catch (_) { + // keep the fallback tile + } finally { + _starting = false; + } + } + + void _stopPreview() { + final controller = _controller; + if (controller == null) return; + _controller = null; + controller.dispose(); + if (mounted) setState(() {}); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _controller?.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final cs = widget.cs; + final controller = _controller; + final hasPreview = controller != null && controller.value.isInitialized; + return GestureDetector( - onTap: onTap, - child: Container( - color: cs.surfaceContainerHighest, - alignment: Alignment.center, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Symbols.photo_camera, - size: 34, - color: cs.onSurface, - weight: 400, + onTap: widget.onTap, + child: Stack( + fit: StackFit.expand, + children: [ + if (hasPreview) + _buildPreview(controller) + else + Container(color: cs.surfaceContainerHighest), + if (hasPreview) + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x00000000), Color(0x66000000)], + ), + ), ), - const SizedBox(height: 6), - Text( - AppLocalizations.of(context)!.attachSheetCamera, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + Align( + alignment: hasPreview ? Alignment.bottomLeft : Alignment.center, + child: Padding( + padding: const EdgeInsets.all(8), + child: hasPreview + ? const Icon( + Symbols.photo_camera, + size: 22, + color: Colors.white, + weight: 500, + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Symbols.photo_camera, + size: 34, + color: cs.onSurface, + weight: 400, + ), + const SizedBox(height: 6), + Text( + AppLocalizations.of(context)!.attachSheetCamera, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), ), - ], + ), + ], + ), + ); + } + + Widget _buildPreview(CameraController controller) { + final size = controller.value.previewSize; + if (size == null) { + return Container(color: widget.cs.surfaceContainerHighest); + } + return ClipRect( + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: size.height, + height: size.width, + child: CameraPreview(controller), ), ), ); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2cc1dee..626d8e8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -956,6 +956,7 @@ "attachSheetGallery": "Gallery", "attachSheetPoll": "Poll", "attachSheetCameraComingSoon": "Camera is coming soon", + "attachSheetCameraError": "Couldn't open the camera", "attachSheetSendFileTitle": "Send a file", "attachSheetSendFileSubtitle": "A document, archive, or any other file", "attachSheetChooseFileButton": "Choose file", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 885303c..330d0aa 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4274,6 +4274,12 @@ abstract class AppLocalizations { /// **'Camera is coming soon'** String get attachSheetCameraComingSoon; + /// No description provided for @attachSheetCameraError. + /// + /// In en, this message translates to: + /// **'Couldn\'t open the camera'** + String get attachSheetCameraError; + /// No description provided for @attachSheetSendFileTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 33c5f80..7c29b40 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2222,6 +2222,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get attachSheetCameraComingSoon => 'Camera is coming soon'; + @override + String get attachSheetCameraError => 'Couldn\'t open the camera'; + @override String get attachSheetSendFileTitle => 'Send a file'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e7df9e0..646e519 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2235,6 +2235,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get attachSheetCameraComingSoon => 'Камера скоро появится'; + @override + String get attachSheetCameraError => 'Не удалось открыть камеру'; + @override String get attachSheetSendFileTitle => 'Отправить файл'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 933fceb..b278fb1 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -721,6 +721,7 @@ "attachSheetGallery": "Галерея", "attachSheetPoll": "Опрос", "attachSheetCameraComingSoon": "Камера скоро появится", + "attachSheetCameraError": "Не удалось открыть камеру", "attachSheetSendFileTitle": "Отправить файл", "attachSheetSendFileSubtitle": "Документ, архив или любой другой файл", "attachSheetChooseFileButton": "Выбрать файл", diff --git a/pubspec.lock b/pubspec.lock index 7139c80..4ffe3c3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -129,6 +129,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + camera: + dependency: "direct main" + description: + name: camera + sha256: "4142a19a38e388d3bab444227636610ba88982e36dff4552d5191a86f65dc437" + url: "https://pub.dev" + source: hosted + version: "0.11.4" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577" + url: "https://pub.dev" + source: hosted + version: "0.6.30" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b" + url: "https://pub.dev" + source: hosted + version: "0.9.23+2" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "4524ca6eb4176b066864036ad4fe02c3e4863e63b77eadc21a5bf56824f43498" + url: "https://pub.dev" + source: hosted + version: "2.13.1" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "1245a480a113437f8d46d19c0fb90cea9db921436d9cf2ba5fb11854a1312693" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" characters: dependency: transitive description: @@ -297,6 +337,38 @@ packages: url: "https://pub.dev" source: hosted version: "8.3.7" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" firebase_core: dependency: "direct main" description: @@ -717,6 +789,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.9.1" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" + url: "https://pub.dev" + source: hosted + version: "0.8.13+19" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: "direct main" description: @@ -1116,6 +1252,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420" + url: "https://pub.dev" + source: hosted + version: "9.4.10" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: @@ -1457,6 +1641,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 49998d4..ba25ac2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,6 +54,9 @@ dependencies: flutter_timezone: ^5.0.1 timezone: ^0.11.0 file_picker: ^8.0.0 + image_picker: ^1.1.2 + camera: ^0.11.0 + permission_handler: ^11.3.1 archive: ^4.0.9 geolocator: ^13.0.0 photo_manager: ^3.0.0