Files
votianlt/app/lib/tasks/photo_capture_screen.dart

679 lines
23 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:camera/camera.dart';
import 'package:file_picker/file_picker.dart';
import 'package:file_selector/file_selector.dart' as fsel;
import 'package:votianlt_app/services/developer.dart' as developer;
import '../l10n/app_localizations.dart';
import '../models/tasks/photo_task.dart';
import '../widgets/offline_banner.dart';
class PhotoCaptureScreen extends StatefulWidget {
final PhotoTask task;
final Function(List<Uint8List>) onPhotosCompleted;
const PhotoCaptureScreen({
super.key,
required this.task,
required this.onPhotosCompleted,
});
@override
State<PhotoCaptureScreen> createState() => _PhotoCaptureScreenState();
}
class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
CameraController? _cameraController; // Android/iOS/Web
List<CameraDescription>? _cameras;
final List<Uint8List> _capturedPhotos = [];
final PageController _pageController = PageController();
int _currentPhotoIndex = 0;
bool _isCameraInitialized = false;
bool _isCameraSupportedOnThisPlatform = false;
bool _useFilePickerMode = false; // desktop fallback
@override
void initState() {
super.initState();
_detectPlatformSupportAndInit();
}
@override
void dispose() {
_cameraController?.dispose();
_pageController.dispose();
super.dispose();
}
void _detectPlatformSupportAndInit() {
// Requirement: Desktop (macOS/Windows/Linux) uses file picker; Android/iOS uses camera.
if (kIsWeb) {
// Keep web behavior using camera if available
_isCameraSupportedOnThisPlatform = true;
_initializeCamera();
return;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
_isCameraSupportedOnThisPlatform = true; // enable capture button
_useFilePickerMode = false;
_initializeCamera();
return;
case TargetPlatform.macOS:
case TargetPlatform.windows:
case TargetPlatform.linux:
// Desktop → use file picker instead of camera
_isCameraSupportedOnThisPlatform = true; // enable button
_useFilePickerMode = true;
return;
default:
_isCameraSupportedOnThisPlatform = false;
}
}
Future<void> _initializeCamera() async {
try {
// Android/iOS/Web camera initialization
_cameras = await availableCameras();
if (_cameras != null && _cameras!.isNotEmpty) {
_cameraController = CameraController(
_cameras![0],
ResolutionPreset.medium,
);
await _cameraController!.initialize();
if (mounted) {
setState(() {
_isCameraInitialized = true;
});
}
}
} catch (e, stackTrace) {
developer.log('Error initializing camera: $e', name: 'PhotoCaptureScreen');
developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppLocalizations.of(context).cameraError}: $e')),
);
}
}
}
Future<void> _capturePhoto() async {
try {
// Camera plugin path (Android/iOS/Web)
if (_cameraController != null && _isCameraInitialized) {
final XFile photo = await _cameraController!.takePicture();
final Uint8List photoBytes = await photo.readAsBytes();
setState(() {
_capturedPhotos.add(photoBytes);
});
// Navigate to the newly added photo (even if it's the first one). If the
// PageView is not attached yet, _showPhotoAt will schedule a post-frame jump.
_showPhotoAt(_capturedPhotos.length - 1);
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).cameraNotReady)),
);
}
}
} catch (e, stackTrace) {
developer.log('Error capturing photo: $e', name: 'PhotoCaptureScreen');
developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppLocalizations.of(context).photoError}: $e')),
);
}
}
}
Future<void> _pickPhotoFromFile() async {
try {
// Use file_selector for desktop and web for robust platform support
final bool useFileSelector = kIsWeb ||
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux;
if (useFileSelector) {
final typeGroup = fsel.XTypeGroup(
label: 'images',
extensions: ['jpg', 'jpeg', 'png', 'heic', 'bmp', 'gif', 'webp'],
);
final fsel.XFile? picked = await fsel.openFile(acceptedTypeGroups: [typeGroup]);
if (picked != null) {
final data = await picked.readAsBytes();
setState(() {
_capturedPhotos.add(data);
});
_showPhotoAt(_capturedPhotos.length - 1);
}
} else {
// On Android/iOS, use file_picker which integrates with platform pickers
final result = await FilePicker.platform.pickFiles(
allowMultiple: false,
type: FileType.image,
withData: true,
);
if (result != null && result.files.isNotEmpty) {
final file = result.files.first;
final bytes = file.bytes;
if (bytes != null) {
setState(() {
_capturedPhotos.add(bytes);
});
_showPhotoAt(_capturedPhotos.length - 1);
} else {
// On some platforms, bytes may be null if withData was false; try path
final path = file.path;
if (path != null) {
final data = await XFile(path).readAsBytes();
setState(() {
_capturedPhotos.add(data);
});
if (_capturedPhotos.length > 1) {
_showPhotoAt(_capturedPhotos.length - 1);
}
}
}
}
}
} catch (e, stackTrace) {
developer.log('Error picking photo from file: $e', name: 'PhotoCaptureScreen');
developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppLocalizations.of(context).photoError}: $e')),
);
}
}
}
void _deletePhoto(int index) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(AppLocalizations.of(context).deletePhoto),
content: Text(AppLocalizations.of(context).deletePhotoConfirm),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context).cancel),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
// Remove and navigate to a valid photo if any remain
int targetIndex = _currentPhotoIndex;
setState(() {
_capturedPhotos.removeAt(index);
if (_capturedPhotos.isEmpty) {
_currentPhotoIndex = 0;
} else {
if (targetIndex >= _capturedPhotos.length) {
targetIndex = _capturedPhotos.length - 1;
}
_currentPhotoIndex = targetIndex;
}
});
if (_capturedPhotos.isNotEmpty) {
_showPhotoAt(_currentPhotoIndex);
}
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: Text(AppLocalizations.of(context).delete, style: const TextStyle(color: Colors.white)),
),
],
);
},
);
}
bool get _canComplete {
return _capturedPhotos.length >= widget.task.minPhotoCount &&
_capturedPhotos.length <= widget.task.maxPhotoCount;
}
bool get _canTakeMore {
return _capturedPhotos.length < widget.task.maxPhotoCount;
}
bool get _isDesktopPlatform {
if (kIsWeb) return false;
switch (defaultTargetPlatform) {
case TargetPlatform.macOS:
case TargetPlatform.windows:
case TargetPlatform.linux:
return true;
default:
return false;
}
}
void _showPhotoAt(int index) {
if (_capturedPhotos.isEmpty) return;
final int clamped = index.clamp(0, _capturedPhotos.length - 1);
if (!mounted) return;
// Always schedule navigation after the current frame so that
// PageView has rebuilt with the latest itemCount before we jump/animate.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (_pageController.hasClients) {
try {
_pageController.animateToPage(
clamped,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
);
} catch (e, stackTrace) {
developer.log('Error animating to page: $e', name: 'PhotoCaptureScreen');
developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
_pageController.jumpToPage(clamped);
}
setState(() {
_currentPhotoIndex = clamped;
});
}
});
}
void _goToPreviousPhoto() {
if (_currentPhotoIndex > 0) {
_showPhotoAt(_currentPhotoIndex - 1);
}
}
void _goToNextPhoto() {
if (_currentPhotoIndex < _capturedPhotos.length - 1) {
_showPhotoAt(_currentPhotoIndex + 1);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context).photoCapture),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
actions: [
if (_canComplete)
TextButton(
onPressed: () {
widget.onPhotosCompleted(_capturedPhotos);
Navigator.of(context).pop();
},
child: Text(
AppLocalizations.of(context).finish,
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
],
),
body: Column(
children: [
OfflineBanner(),
// Task info header
Container(
width: double.infinity,
padding: EdgeInsets.all(16),
color: Colors.grey[100],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${AppLocalizations.of(context).requiredPhotos}: ${widget.task.minPhotoCount}-${widget.task.maxPhotoCount}',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
Text(
'${AppLocalizations.of(context).photosTaken}: ${_capturedPhotos.length}',
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
],
),
),
// Camera preview, photo gallery or empty state
Expanded(
child: _capturedPhotos.isEmpty
? _buildCameraOrEmptyState()
: _buildPhotoGallery(),
),
// Bottom controls
Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.3),
spreadRadius: 1,
blurRadius: 5,
offset: Offset(0, -3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
// Camera or file select button
Expanded(
child: ElevatedButton.icon(
onPressed: _canTakeMore && _isCameraSupportedOnThisPlatform
? (_useFilePickerMode
? _pickPhotoFromFile
: (_isCameraInitialized ? _capturePhoto : null))
: null,
icon: Icon(_useFilePickerMode ? Icons.photo_library : Icons.camera_alt),
label: Text(
!_isCameraSupportedOnThisPlatform
? AppLocalizations.of(context).cameraNotSupportedOnPlatform
: (!_canTakeMore
? AppLocalizations.of(context).maxPhotosReached
: (_useFilePickerMode
? AppLocalizations.of(context).selectPhoto
: (_isCameraInitialized ? AppLocalizations.of(context).takePhoto : (defaultTargetPlatform == TargetPlatform.macOS ? AppLocalizations.of(context).cameraReadyNoPreview : AppLocalizations.of(context).cameraLoading)))),
),
style: ElevatedButton.styleFrom(
backgroundColor: _canTakeMore && (_useFilePickerMode || _isCameraInitialized) ? Colors.blue : Colors.grey,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 12),
),
),
),
if (_capturedPhotos.isNotEmpty) ...[
SizedBox(width: 16),
// Delete current photo button
ElevatedButton.icon(
onPressed: () => _deletePhoto(_currentPhotoIndex),
icon: Icon(Icons.delete),
label: Text(AppLocalizations.of(context).delete),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 12, horizontal: 16),
),
),
],
],
),
SizedBox(height: 12),
// Bottom 'Fertig' button placed under the row
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _canComplete
? () {
widget.onPhotosCompleted(_capturedPhotos);
Navigator.of(context).pop();
}
: null,
style: ElevatedButton.styleFrom(
backgroundColor: _canComplete ? Colors.green : Colors.grey,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 14),
),
child: Text(AppLocalizations.of(context).finish, style: const TextStyle(fontWeight: FontWeight.bold)),
),
),
],
),
),
],
),
);
}
Widget _buildCameraOrEmptyState() {
// If platform not supported, show informative message
if (!_isCameraSupportedOnThisPlatform) {
return Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.desktop_windows, size: 80, color: Colors.grey[400]),
SizedBox(height: 16),
Text(
AppLocalizations.of(context).cameraNotAvailable,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.grey[700]),
),
SizedBox(height: 8),
Text(
AppLocalizations.of(context).cameraNotSupportedMessage,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
],
),
),
);
}
// macOS fallback: explain file selection
if (_useFilePickerMode) {
return Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.photo_library, size: 80, color: Colors.grey[400]),
SizedBox(height: 16),
Text(
AppLocalizations.of(context).addPhotos,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.grey[700]),
),
SizedBox(height: 8),
Text(
AppLocalizations.of(context).addPhotosInstruction,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
],
),
),
);
}
// Show camera preview if available on any platform
if (_isCameraInitialized && _cameraController != null) {
return Container(
margin: EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.3),
spreadRadius: 2,
blurRadius: 8,
offset: Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CameraPreview(_cameraController!),
),
);
}
// When camera is not available, show empty state
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.camera_alt,
size: 80,
color: Colors.grey[400],
),
SizedBox(height: 16),
Text(
AppLocalizations.of(context).cameraInitializing,
style: TextStyle(
fontSize: 18,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 8),
Text(
AppLocalizations.of(context).cameraLoadingMessage,
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildPhotoGallery() {
return Column(
children: [
// Photo counter (if more than one photo)
if (_capturedPhotos.length > 1)
Container(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text(
'${_currentPhotoIndex + 1} ${AppLocalizations.of(context).photoOf} ${_capturedPhotos.length}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.grey[700],
),
),
),
// Photo viewer with swipe gestures and navigation arrows
Expanded(
child: Stack(
children: [
PageView.builder(
controller: _pageController,
onPageChanged: (index) {
setState(() {
_currentPhotoIndex = index;
});
},
itemCount: _capturedPhotos.length,
itemBuilder: (context, index) {
return Container(
margin: EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.3),
spreadRadius: 2,
blurRadius: 8,
offset: Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(
_capturedPhotos[index],
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[300],
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size: 50, color: Colors.grey[600]),
SizedBox(height: 8),
Text(AppLocalizations.of(context).photoError),
],
),
),
);
},
),
),
);
},
),
if (_capturedPhotos.length > 1 && _isDesktopPlatform)
Positioned(
left: 8,
top: 0,
bottom: 0,
child: Center(
child: IconButton(
onPressed: _currentPhotoIndex > 0
? _goToPreviousPhoto
: null,
icon: Icon(Icons.chevron_left, size: 36),
style: IconButton.styleFrom(
backgroundColor: Colors.white.withValues(alpha: 0.7),
),
),
),
),
if (_capturedPhotos.length > 1 && _isDesktopPlatform)
Positioned(
right: 8,
top: 0,
bottom: 0,
child: Center(
child: IconButton(
onPressed: _currentPhotoIndex < _capturedPhotos.length - 1
? _goToNextPhoto
: null,
icon: Icon(Icons.chevron_right, size: 36),
style: IconButton.styleFrom(
backgroundColor: Colors.white.withValues(alpha: 0.7),
),
),
),
),
],
),
),
// Photo indicators (if more than one photo)
if (_capturedPhotos.length > 1)
Container(
padding: EdgeInsets.symmetric(vertical: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: _capturedPhotos.asMap().entries.map((entry) {
return Container(
width: 8,
height: 8,
margin: EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _currentPhotoIndex == entry.key
? Colors.blue
: Colors.grey[400],
),
);
}).toList(),
),
),
],
);
}
}