feat(chat): камера в шторке вложений — съёмка и живой предпросмотр
Плитка камеры делает снимок через системную камеру (image_picker) вместо заглушки «скоро появится». Когда доступ к камере выдан, плитка показывает живой предпросмотр с задней камеры (camera, CameraX) с корректным освобождением при сворачивании приложения; статус разрешения проверяется молча через permission_handler.
This commit is contained in:
@@ -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<Uint8List?> thumbnail(int size) => file.readAsBytes();
|
||||
|
||||
@override
|
||||
Future<File?> 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;
|
||||
|
||||
@@ -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<AttachmentSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onCameraTap() {
|
||||
showCustomNotification(
|
||||
context,
|
||||
AppLocalizations.of(context)!.attachSheetCameraComingSoon,
|
||||
);
|
||||
Future<void> _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<void> _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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -2235,6 +2235,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get attachSheetCameraComingSoon => 'Камера скоро появится';
|
||||
|
||||
@override
|
||||
String get attachSheetCameraError => 'Не удалось открыть камеру';
|
||||
|
||||
@override
|
||||
String get attachSheetSendFileTitle => 'Отправить файл';
|
||||
|
||||
|
||||
@@ -721,6 +721,7 @@
|
||||
"attachSheetGallery": "Галерея",
|
||||
"attachSheetPoll": "Опрос",
|
||||
"attachSheetCameraComingSoon": "Камера скоро появится",
|
||||
"attachSheetCameraError": "Не удалось открыть камеру",
|
||||
"attachSheetSendFileTitle": "Отправить файл",
|
||||
"attachSheetSendFileSubtitle": "Документ, архив или любой другой файл",
|
||||
"attachSheetChooseFileButton": "Выбрать файл",
|
||||
|
||||
+192
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user