413 lines
12 KiB
Dart
413 lines
12 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;
|
||
|
||
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;
|
||
|
||
await _controller?.dispose();
|
||
|
||
_cameraIndex = (_cameraIndex + 1) % _cameras.length;
|
||
|
||
setState(() => _controller = null);
|
||
await _initCamera();
|
||
}
|
||
|
||
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) {
|
||
await _controller!.setFlashMode(FlashMode.torch);
|
||
usedTorch = true;
|
||
await Future.delayed(const Duration(milliseconds: 120));
|
||
}
|
||
|
||
if (_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) {
|
||
await _controller!.setFlashMode(FlashMode.torch);
|
||
usedTorch = true;
|
||
await Future.delayed(const Duration(milliseconds: 120));
|
||
}
|
||
|
||
if (_flashMode == FlashModeType.autoCapture) {
|
||
await _controller!.setFlashMode(FlashMode.torch);
|
||
usedTorch = true;
|
||
await Future.delayed(const Duration(milliseconds: 120));
|
||
}
|
||
|
||
await _controller!.startVideoRecording();
|
||
|
||
setState(() => _isRecording = true);
|
||
}
|
||
|
||
Future<void> _stopVideo() async {
|
||
if (_controller == null || !_isRecording) return;
|
||
if (usedTorch) {
|
||
await _controller!.setFlashMode(FlashMode.off);
|
||
}
|
||
|
||
final file = await _controller!.stopVideoRecording();
|
||
|
||
setState(() => _isRecording = 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());
|
||
}
|
||
|
||
return Stack(
|
||
children: [
|
||
// 📷 Camera preview (full screen, Telegram style crop)
|
||
Positioned.fill(
|
||
child: FittedBox(
|
||
fit: BoxFit.cover,
|
||
child: SizedBox(
|
||
width: _controller!.value.previewSize!.height,
|
||
height: _controller!.value.previewSize!.width,
|
||
child: GestureDetector(
|
||
onScaleStart: (_) {
|
||
setState(() {
|
||
_showZoomSlider = true;
|
||
});
|
||
},
|
||
|
||
onScaleUpdate: (details) {
|
||
final zoom = (_currentZoom * details.scale).clamp(
|
||
_minZoom,
|
||
_maxZoom,
|
||
);
|
||
|
||
_setZoom(zoom);
|
||
},
|
||
child: CameraPreview(_controller!),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// 🌑 top gradient (Telegram feel)
|
||
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: 90,
|
||
left: 0,
|
||
right: 0,
|
||
child: Column(
|
||
children: [
|
||
GestureDetector(
|
||
onTap: _takePhoto,
|
||
onLongPressStart: (_) => _startVideo(),
|
||
onLongPressEnd: (_) => _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),
|
||
),
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: 16),
|
||
|
||
const Text(
|
||
"Нажмите для фото, удерживайте для съемки",
|
||
style: TextStyle(color: Colors.white70, fontSize: 13),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// 🔴 recording indicator
|
||
if (_isRecording)
|
||
const Positioned(
|
||
top: 50,
|
||
left: 0,
|
||
right: 0,
|
||
child: Center(
|
||
child: Text(
|
||
"REC",
|
||
style: TextStyle(
|
||
color: Colors.red,
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 14,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (_showZoomSlider)
|
||
Positioned(
|
||
bottom: 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,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|