220 lines
7.0 KiB
Dart
220 lines
7.0 KiB
Dart
import 'package:camera/camera.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||
import 'package:permission_handler/permission_handler.dart';
|
||
|
||
import '../../core/log/app_logger.dart';
|
||
import '../../core/theme/app_colors.dart';
|
||
import '../../core/theme/app_spacing.dart';
|
||
|
||
/// 应用内拍照:不跳出系统相机 Activity,避免与 Flutter Surface / 截屏防护等叠加导致黑屏卡死。
|
||
class InAppCameraPage extends StatefulWidget {
|
||
const InAppCameraPage({super.key});
|
||
|
||
static final _log = AppLogger('InAppCamera');
|
||
|
||
@override
|
||
State<InAppCameraPage> createState() => _InAppCameraPageState();
|
||
}
|
||
|
||
class _InAppCameraPageState extends State<InAppCameraPage> {
|
||
CameraController? _controller;
|
||
bool _initializing = true;
|
||
bool _capturing = false;
|
||
String? _error;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => _prepareCamera());
|
||
}
|
||
|
||
Future<void> _prepareCamera() async {
|
||
final status = await Permission.camera.request();
|
||
if (!status.isGranted) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_initializing = false;
|
||
_error = 'Camera permission is required';
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
final cameras = await availableCameras();
|
||
if (cameras.isEmpty) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_initializing = false;
|
||
_error = 'No camera available';
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
final back = cameras.where((c) => c.lensDirection == CameraLensDirection.back);
|
||
final selected = back.isEmpty ? cameras.first : back.first;
|
||
|
||
final c = CameraController(
|
||
selected,
|
||
ResolutionPreset.medium,
|
||
enableAudio: false,
|
||
);
|
||
await c.initialize();
|
||
if (!mounted) {
|
||
await c.dispose();
|
||
return;
|
||
}
|
||
setState(() {
|
||
_controller = c;
|
||
_initializing = false;
|
||
});
|
||
} catch (e, st) {
|
||
InAppCameraPage._log.e('Camera init failed', e, st);
|
||
if (mounted) {
|
||
setState(() {
|
||
_initializing = false;
|
||
_error = 'Could not open camera';
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller?.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _onShutter() async {
|
||
final c = _controller;
|
||
if (c == null || !c.value.isInitialized || _capturing) return;
|
||
setState(() => _capturing = true);
|
||
try {
|
||
final shot = await c.takePicture();
|
||
if (!mounted) return;
|
||
Navigator.of(context, rootNavigator: true).pop<String>(shot.path);
|
||
} catch (e, st) {
|
||
InAppCameraPage._log.e('takePicture failed', e, st);
|
||
if (mounted) setState(() => _capturing = false);
|
||
}
|
||
}
|
||
|
||
void _onClose() {
|
||
Navigator.of(context, rootNavigator: true).pop<String>(null);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final c = _controller;
|
||
final showPreview =
|
||
!_initializing && _error == null && c != null && c.value.isInitialized;
|
||
|
||
return Scaffold(
|
||
backgroundColor: Colors.black,
|
||
body: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
if (showPreview)
|
||
Positioned.fill(
|
||
child: ColoredBox(
|
||
color: Colors.black,
|
||
// 勿再包一层自定义 aspect 的 SizedBox/FittedBox:[CameraPreview] 已在竖屏下使用
|
||
// 1/value.aspectRatio + Android [RotatedBox],外层乱算比例会把它压扁。
|
||
child: Center(
|
||
child: CameraPreview(c),
|
||
),
|
||
),
|
||
),
|
||
SafeArea(
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
if (_initializing)
|
||
const Center(
|
||
child:
|
||
CircularProgressIndicator(color: AppColors.primary),
|
||
)
|
||
else if (_error != null)
|
||
Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
LucideIcons.camera_off,
|
||
size: 48,
|
||
color: AppColors.surface.withValues(alpha: 0.6),
|
||
),
|
||
const SizedBox(height: AppSpacing.md),
|
||
Text(
|
||
_error!,
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
color: AppColors.surface.withValues(alpha: 0.85),
|
||
fontSize: 15,
|
||
),
|
||
),
|
||
const SizedBox(height: AppSpacing.lg),
|
||
TextButton(
|
||
onPressed: _onClose,
|
||
child: const Text('Close'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
if (showPreview)
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 24,
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||
children: [
|
||
IconButton(
|
||
onPressed: _capturing ? null : _onClose,
|
||
icon: Icon(
|
||
LucideIcons.x,
|
||
color: AppColors.surface.withValues(alpha: 0.9),
|
||
size: 28,
|
||
),
|
||
),
|
||
GestureDetector(
|
||
onTap: _capturing ? null : _onShutter,
|
||
child: Container(
|
||
width: 72,
|
||
height: 72,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
border: Border.all(
|
||
color: AppColors.surface,
|
||
width: 4,
|
||
),
|
||
color: AppColors.surface.withValues(alpha: 0.2),
|
||
),
|
||
child: _capturing
|
||
? const Padding(
|
||
padding: EdgeInsets.all(20),
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: AppColors.surface,
|
||
),
|
||
)
|
||
: null,
|
||
),
|
||
),
|
||
const SizedBox(width: 48),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|