484 lines
16 KiB
Dart
484 lines
16 KiB
Dart
import 'package:camera/camera.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'dart:async';
|
||
import 'media_preview_screen.dart';
|
||
|
||
class CameraScreen extends StatefulWidget {
|
||
const CameraScreen({super.key});
|
||
|
||
@override
|
||
State<CameraScreen> createState() => _CameraScreenState();
|
||
}
|
||
|
||
enum FlashModeType { off, autoCapture, alwaysCapture, torch }
|
||
|
||
class _CameraScreenState extends State<CameraScreen> {
|
||
CameraController? _controller;
|
||
List<CameraDescription> _cameras = [];
|
||
|
||
int _cameraIndex = 0;
|
||
bool _isRecording = false;
|
||
bool _isLockedRecording = false;
|
||
|
||
FlashModeType _flashMode = FlashModeType.off;
|
||
double _minZoom = 1.0;
|
||
double _maxZoom = 1.0;
|
||
double _currentZoom = 1.0;
|
||
|
||
bool _showZoomSlider = false;
|
||
|
||
Future<void>? _initFuture;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_initFuture = _init();
|
||
}
|
||
|
||
Future<void> _init() async {
|
||
_cameras = await availableCameras();
|
||
await _initCamera();
|
||
}
|
||
|
||
Future<void> _initCamera() async {
|
||
final camera = _cameras[_cameraIndex];
|
||
|
||
final controller = CameraController(
|
||
camera,
|
||
ResolutionPreset.high,
|
||
enableAudio: true,
|
||
);
|
||
|
||
await controller.initialize();
|
||
_minZoom = await controller.getMinZoomLevel();
|
||
_maxZoom = await controller.getMaxZoomLevel();
|
||
_currentZoom = _minZoom;
|
||
if (!mounted) return;
|
||
|
||
setState(() {
|
||
_controller = controller;
|
||
});
|
||
}
|
||
|
||
// Обновленный метод смены камеры (поддерживает переключение во время записи)
|
||
Future<void> _switchCamera() async {
|
||
if (_cameras.length < 2) return;
|
||
|
||
_cameraIndex = (_cameraIndex + 1) % _cameras.length;
|
||
final newCamera = _cameras[_cameraIndex];
|
||
|
||
if (_controller != null && _controller!.value.isInitialized) {
|
||
try {
|
||
// Меняем описание камеры «на лету» без dispose контроллера
|
||
await _controller!.setDescription(newCamera);
|
||
|
||
// Обновляем параметры зума для новой линзы
|
||
_minZoom = await _controller!.getMinZoomLevel();
|
||
_maxZoom = await _controller!.getMaxZoomLevel();
|
||
_currentZoom = _currentZoom.clamp(_minZoom, _maxZoom);
|
||
} catch (e) {
|
||
// Если динамическая смена не удалась, делаем полный перезапуск
|
||
await _initCamera();
|
||
}
|
||
} else {
|
||
await _initCamera();
|
||
}
|
||
|
||
setState(() {});
|
||
}
|
||
|
||
Future<void> _cycleFlashMode() async {
|
||
if (_controller == null) return;
|
||
|
||
switch (_flashMode) {
|
||
case FlashModeType.off:
|
||
_flashMode = FlashModeType.autoCapture;
|
||
await _controller!.setFlashMode(FlashMode.off);
|
||
break;
|
||
|
||
case FlashModeType.autoCapture:
|
||
_flashMode = FlashModeType.alwaysCapture;
|
||
await _controller!.setFlashMode(FlashMode.off);
|
||
break;
|
||
|
||
case FlashModeType.alwaysCapture:
|
||
_flashMode = FlashModeType.torch;
|
||
await _controller!.setFlashMode(FlashMode.torch);
|
||
break;
|
||
|
||
case FlashModeType.torch:
|
||
_flashMode = FlashModeType.off;
|
||
await _controller!.setFlashMode(FlashMode.off);
|
||
break;
|
||
}
|
||
|
||
setState(() {});
|
||
}
|
||
|
||
Future<void> _takePhoto() async {
|
||
if (_controller == null) return;
|
||
|
||
bool usedTorch = false;
|
||
|
||
if (_flashMode == FlashModeType.alwaysCapture ||
|
||
_flashMode == FlashModeType.autoCapture) {
|
||
await _controller!.setFlashMode(FlashMode.torch);
|
||
usedTorch = true;
|
||
await Future.delayed(const Duration(milliseconds: 120));
|
||
}
|
||
|
||
final file = await _controller!.takePicture();
|
||
|
||
if (usedTorch) {
|
||
await _controller!.setFlashMode(FlashMode.off);
|
||
}
|
||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||
final result = await Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => MediaPreviewScreen(path: file.path, isVideo: false),
|
||
),
|
||
);
|
||
|
||
if (result == true && mounted) {
|
||
Navigator.pop(context, (file, 'image'));
|
||
}
|
||
});
|
||
}
|
||
|
||
bool usedTorch = false;
|
||
Future<void> _startVideo() async {
|
||
if (_controller == null || _isRecording) return;
|
||
if (_flashMode == FlashModeType.alwaysCapture ||
|
||
_flashMode == FlashModeType.autoCapture) {
|
||
await _controller!.setFlashMode(FlashMode.torch);
|
||
usedTorch = true;
|
||
await Future.delayed(const Duration(milliseconds: 120));
|
||
}
|
||
|
||
await _controller!.startVideoRecording();
|
||
|
||
setState(() {
|
||
_isRecording = true;
|
||
_isLockedRecording = false; // Сбрасываем фиксацию при новом старте
|
||
});
|
||
}
|
||
|
||
Future<void> _stopVideo() async {
|
||
if (_controller == null || !_isRecording) return;
|
||
if (usedTorch) {
|
||
await _controller!.setFlashMode(FlashMode.off);
|
||
}
|
||
|
||
final file = await _controller!.stopVideoRecording();
|
||
|
||
setState(() {
|
||
_isRecording = false;
|
||
_isLockedRecording = false;
|
||
});
|
||
|
||
final result = await Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => MediaPreviewScreen(path: file.path, isVideo: true),
|
||
),
|
||
);
|
||
|
||
if (result == true && mounted) {
|
||
Navigator.pop(context, (file, 'video'));
|
||
}
|
||
}
|
||
|
||
Future<void> _setZoom(double zoom) async {
|
||
if (_controller == null) return;
|
||
|
||
final clamped = zoom.clamp(_minZoom, _maxZoom);
|
||
|
||
await _controller!.setZoomLevel(clamped);
|
||
|
||
setState(() {
|
||
_currentZoom = clamped;
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller?.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
backgroundColor: Colors.black,
|
||
body: FutureBuilder(
|
||
future: _initFuture,
|
||
builder: (context, snapshot) {
|
||
if (_controller == null || !_controller!.value.isInitialized) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
|
||
final isLandscape =
|
||
MediaQuery.of(context).orientation == Orientation.landscape;
|
||
final previewSize = _controller!.value.previewSize!;
|
||
|
||
final previewWidth = isLandscape
|
||
? previewSize.width
|
||
: previewSize.height;
|
||
final previewHeight = isLandscape
|
||
? previewSize.height
|
||
: previewSize.width;
|
||
|
||
return Stack(
|
||
children: [
|
||
// 📷 Camera preview
|
||
Positioned.fill(
|
||
child: FittedBox(
|
||
fit: BoxFit.cover,
|
||
child: SizedBox(
|
||
width: previewWidth,
|
||
height: previewHeight,
|
||
child: GestureDetector(
|
||
onScaleStart: (_) {
|
||
setState(() {
|
||
_showZoomSlider = true;
|
||
});
|
||
},
|
||
onScaleUpdate: (details) {
|
||
final zoom = (_currentZoom * details.scale).clamp(
|
||
_minZoom,
|
||
_maxZoom,
|
||
);
|
||
_setZoom(zoom);
|
||
},
|
||
child: CameraPreview(_controller!),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// 🌑 top gradient
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
height: 120,
|
||
child: Container(
|
||
decoration: const BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [Colors.black87, Colors.transparent],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// 🔘 top controls
|
||
Positioned(
|
||
top: 50,
|
||
left: 20,
|
||
right: 20,
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
// Flash (left)
|
||
IconButton(
|
||
onPressed: _cycleFlashMode,
|
||
icon: Icon(switch (_flashMode) {
|
||
FlashModeType.off => Icons.flash_off,
|
||
FlashModeType.autoCapture => Icons.flash_auto,
|
||
FlashModeType.alwaysCapture => Icons.flash_on,
|
||
FlashModeType.torch => Icons.highlight,
|
||
}, color: Colors.white),
|
||
),
|
||
|
||
// Camera switch (right)
|
||
IconButton(
|
||
onPressed: _switchCamera,
|
||
icon: const Icon(Icons.cameraswitch, color: Colors.white),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// 🔘 capture button (center bottom)
|
||
Positioned(
|
||
bottom: isLandscape ? 30 : 90,
|
||
left: 0,
|
||
right: 0,
|
||
child: Column(
|
||
children: [
|
||
GestureDetector(
|
||
onTap: () {
|
||
if (_isLockedRecording) {
|
||
// Если запись зафиксирована, повторный тап останавливает её
|
||
_stopVideo();
|
||
} else {
|
||
// Иначе обычное фото
|
||
_takePhoto();
|
||
}
|
||
},
|
||
onLongPressStart: (_) => _startVideo(),
|
||
onLongPressMoveUpdate: (details) {
|
||
if (_isRecording && !_isLockedRecording) {
|
||
// Если ведем запись и тянем палец вверх (значение dy уменьшается)
|
||
if (details.localOffsetFromOrigin.dy < -60) {
|
||
setState(() {
|
||
_isLockedRecording = true;
|
||
});
|
||
}
|
||
}
|
||
},
|
||
onLongPressEnd: (_) {
|
||
// Если запись зафиксирована, при отпускании пальца НЕ останавливаем съемку
|
||
if (!_isLockedRecording) {
|
||
_stopVideo();
|
||
}
|
||
},
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 150),
|
||
width: _isRecording ? 80 : 72,
|
||
height: _isRecording ? 80 : 72,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: _isRecording ? Colors.red : Colors.white,
|
||
border: Border.all(color: Colors.white, width: 4),
|
||
),
|
||
child: _isLockedRecording
|
||
? const Icon(
|
||
Icons.stop,
|
||
color: Colors.white,
|
||
size: 32,
|
||
)
|
||
: null, // Показываем иконку стоп, когда запись зафиксирована
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: 16),
|
||
|
||
Text(
|
||
_isLockedRecording
|
||
? "Запись зафиксирована. Нажмите для остановки."
|
||
: "Нажмите для фото, удерживайте для съемки",
|
||
style: const TextStyle(
|
||
color: Colors.white70,
|
||
fontSize: 13,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// 🔴 recording indicator
|
||
if (_isRecording)
|
||
Positioned(
|
||
top: 50,
|
||
left: 0,
|
||
right: 0,
|
||
child: Center(
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 8,
|
||
height: 8,
|
||
decoration: const BoxDecoration(
|
||
color: Colors.red,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
_isLockedRecording ? "REC (LOCK)" : "REC",
|
||
style: const TextStyle(
|
||
color: Colors.red,
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 14,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
if (_showZoomSlider)
|
||
Positioned(
|
||
bottom: isLandscape ? 120 : 200,
|
||
left: 20,
|
||
right: 20,
|
||
child: Center(
|
||
child: Container(
|
||
child: Row(
|
||
children: [
|
||
GestureDetector(
|
||
onTap: () {
|
||
final newZoom = (_currentZoom - 0.5).clamp(
|
||
_minZoom,
|
||
_maxZoom,
|
||
);
|
||
_setZoom(newZoom);
|
||
},
|
||
child: const Text(
|
||
'−',
|
||
style: TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 18,
|
||
),
|
||
),
|
||
),
|
||
|
||
const SizedBox(width: 8),
|
||
|
||
Expanded(
|
||
child: SliderTheme(
|
||
data: SliderTheme.of(context).copyWith(
|
||
trackHeight: 2,
|
||
activeTrackColor: Colors.white,
|
||
inactiveTrackColor: Colors.white24,
|
||
thumbColor: Colors.white,
|
||
overlayColor: Colors.white24,
|
||
thumbShape: const RoundSliderThumbShape(
|
||
enabledThumbRadius: 6,
|
||
),
|
||
),
|
||
child: Slider(
|
||
value: _currentZoom,
|
||
min: _minZoom,
|
||
max: _maxZoom,
|
||
onChanged: (value) {
|
||
_setZoom(value);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
|
||
const SizedBox(width: 8),
|
||
GestureDetector(
|
||
onTap: () {
|
||
final newZoom = (_currentZoom + 0.5).clamp(
|
||
_minZoom,
|
||
_maxZoom,
|
||
);
|
||
_setZoom(newZoom);
|
||
},
|
||
child: const Text(
|
||
'+',
|
||
style: TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 18,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|