first commit
This commit is contained in:
@@ -0,0 +1,579 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:screenshot/screenshot.dart';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_mjpeg/flutter_mjpeg.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'yolo_detector.dart';
|
||||
import 'preview_screen.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
enum ImageFilterType { none, greyscale, invert}
|
||||
|
||||
class CameraStreamScreen extends StatefulWidget {
|
||||
final int cameraIndex;
|
||||
|
||||
const CameraStreamScreen({super.key, required this.cameraIndex});
|
||||
|
||||
@override
|
||||
State<CameraStreamScreen> createState() => _CameraStreamScreenState();
|
||||
}
|
||||
|
||||
class _CameraStreamScreenState extends State<CameraStreamScreen> with SingleTickerProviderStateMixin {
|
||||
String? _streamUrl;
|
||||
bool _isError = false;
|
||||
bool _isCapturing = false;
|
||||
final ScreenshotController _screenshotController = ScreenshotController();
|
||||
final TransformationController _transformationController = TransformationController();
|
||||
ImageFilterType _selectedFilter = ImageFilterType.none;
|
||||
|
||||
|
||||
late YOLODetector _yoloDetector;
|
||||
int _numberOfBullets = 5;
|
||||
|
||||
late AnimationController _zoomAnimController;
|
||||
Animation<Matrix4>? _zoomAnimation;
|
||||
Matrix4? _originalMatrix;
|
||||
|
||||
Color _bulletColor = Colors.blue; // default/fallback bullet color
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStreamUrl();
|
||||
_loadBulletCount();
|
||||
_loadSelectedFilter();
|
||||
_loadBulletColor(); // 👈 add this to actually load the saved bullet color
|
||||
|
||||
|
||||
YOLODetector.create().then((detector) => _yoloDetector = detector);
|
||||
|
||||
_setSystemBarStyle(); // 👈 set colors
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); // 👈 show bars
|
||||
|
||||
_keepScreenAwake();
|
||||
|
||||
_zoomAnimController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadBulletColor() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final selected = prefs.getString('bulletColor') ?? 'Blue';
|
||||
setState(() {
|
||||
_bulletColor = _getColorFromName(selected);
|
||||
});
|
||||
}
|
||||
|
||||
Color _getColorFromName(String colorName) {
|
||||
switch (colorName.toLowerCase()) {
|
||||
case 'blue':
|
||||
return Colors.blue;
|
||||
case 'green':
|
||||
return Colors.green;
|
||||
case 'yellow':
|
||||
return Colors.yellow;
|
||||
case 'purple':
|
||||
return Colors.purple;
|
||||
case 'cyan':
|
||||
return Colors.cyan;
|
||||
case 'teal':
|
||||
return Colors.teal;
|
||||
case 'indigo':
|
||||
return Colors.indigo;
|
||||
case 'pink':
|
||||
return Colors.pink;
|
||||
case 'lime':
|
||||
return Colors.lime;
|
||||
case 'mint':
|
||||
return Colors.greenAccent;
|
||||
case 'sky':
|
||||
return Colors.orange;
|
||||
case 'violet':
|
||||
return Colors.deepPurpleAccent;
|
||||
default:
|
||||
return Colors.blue; // fallback
|
||||
}
|
||||
}
|
||||
|
||||
void _setSystemBarStyle() {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.grey[900], // ✅ match your AppBar
|
||||
statusBarIconBrightness: Brightness.light, // white icons
|
||||
systemNavigationBarColor: Colors.grey[900],
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadSelectedFilter() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final index = prefs.getInt('selectedFilter');
|
||||
if (index != null && index >= 0 && index < ImageFilterType.values.length) {
|
||||
setState(() {
|
||||
_selectedFilter = ImageFilterType.values[index];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _keepScreenAwake() async {
|
||||
try {
|
||||
await WakelockPlus.enable();
|
||||
} catch (e) {
|
||||
debugPrint("⚠️ Could not keep screen awake: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadStreamUrl() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
String? url = prefs.getString('cameraIP_${widget.cameraIndex}');
|
||||
|
||||
setState(() {
|
||||
_streamUrl = (url != null && url.trim().isNotEmpty)
|
||||
? (url.startsWith(RegExp(r"https?://")) ? url : "http://$url")
|
||||
: null;
|
||||
_isError = _streamUrl == null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadBulletCount() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
_numberOfBullets = prefs.getInt('bulletCount') ?? 5;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _captureAndPreviewImage() async {
|
||||
if (_streamUrl == null || _isCapturing) return;
|
||||
|
||||
setState(() => _isCapturing = true);
|
||||
_originalMatrix = _transformationController.value;
|
||||
|
||||
try {
|
||||
final targetMatrix = Matrix4.identity()..scale(1.0);
|
||||
final isZoomedIn = !_matrixEquals(_originalMatrix!, targetMatrix);
|
||||
|
||||
if (isZoomedIn) {
|
||||
_zoomAnimController.reset();
|
||||
_zoomAnimation = Matrix4Tween(
|
||||
begin: _originalMatrix,
|
||||
end: targetMatrix,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _zoomAnimController,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
|
||||
void updateZoom() {
|
||||
_transformationController.value = _zoomAnimation!.value;
|
||||
}
|
||||
|
||||
_zoomAnimController.addListener(updateZoom);
|
||||
await _zoomAnimController.forward();
|
||||
_zoomAnimController.removeListener(updateZoom);
|
||||
}
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
final imageBytes = await _screenshotController.capture();
|
||||
if (imageBytes == null || imageBytes.isEmpty) return;
|
||||
|
||||
final decoded = img.decodeImage(imageBytes);
|
||||
|
||||
if (decoded == null) return;
|
||||
final width = decoded.width;
|
||||
final height = decoded.height;
|
||||
final size = width < height ? width : height;
|
||||
final offsetX = (width - size) ~/ 2;
|
||||
final offsetY = (height - size) ~/ 2;
|
||||
final cropped = img.copyCrop(decoded, x: offsetX, y: offsetY, width: size, height: size);
|
||||
|
||||
// Debugging: log the selected filter
|
||||
debugPrint("📸 Selected filter: $_selectedFilter");
|
||||
|
||||
// Create the output directory and path
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final path = '${dir.path}/ip_capture_temp.jpg';
|
||||
img.Image processed = cropped;
|
||||
|
||||
// Apply the selected filter
|
||||
switch (_selectedFilter) {
|
||||
case ImageFilterType.greyscale:
|
||||
processed = img.grayscale(cropped);
|
||||
break;
|
||||
|
||||
case ImageFilterType.invert:
|
||||
processed = invertImage(cropped); // ✅ use your custom invert function
|
||||
break;
|
||||
case ImageFilterType.none:
|
||||
break;
|
||||
}
|
||||
|
||||
// Save the processed image to a file
|
||||
final file = File(path)..writeAsBytesSync(img.encodeJpg(processed));
|
||||
|
||||
// Detect objects in the image using YOLO
|
||||
final detections = await _yoloDetector.detect(file);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Navigate to the preview screen
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PreviewScreen(
|
||||
imagePath: file.path,
|
||||
detections: detections,
|
||||
bulletCount: _numberOfBullets,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint("❌ Capture error: $e");
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("Capture failed: $e")),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
_zoomAnimController.reset();
|
||||
_zoomAnimation = Matrix4Tween(
|
||||
begin: _transformationController.value,
|
||||
end: _originalMatrix!,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _zoomAnimController,
|
||||
curve: Curves.easeIn,
|
||||
));
|
||||
|
||||
void restoreZoom() {
|
||||
_transformationController.value = _zoomAnimation!.value;
|
||||
}
|
||||
|
||||
_zoomAnimController.addListener(restoreZoom);
|
||||
await _zoomAnimController.forward();
|
||||
_zoomAnimController.removeListener(restoreZoom);
|
||||
|
||||
setState(() => _isCapturing = false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
img.Image invertImage(img.Image source) {
|
||||
final result = img.Image.from(source);
|
||||
for (int y = 0; y < result.height; y++) {
|
||||
for (int x = 0; x < result.width; x++) {
|
||||
final pixel = result.getPixel(x, y);
|
||||
result.setPixelRgb(x, y, 255 - pixel.r, 255 - pixel.g, 255 - pixel.b);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool _matrixEquals(Matrix4 a, Matrix4 b) {
|
||||
for (int i = 0; i < 16; i++) {
|
||||
if ((a.storage[i] - b.storage[i]).abs() > 0.001) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void _showBulletDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.grey[900],
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0), // Increased padding for better spacing
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 16.0),
|
||||
child: Text(
|
||||
'Select the number of bullets',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: List.generate(5, (index) {
|
||||
final bulletNum = index + 1;
|
||||
final isSelected = _numberOfBullets == bulletNum;
|
||||
return ElevatedButton(
|
||||
onPressed: () {
|
||||
_selectBulletCount(bulletNum);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor:
|
||||
isSelected ? _bulletColor : Colors.grey[800],
|
||||
foregroundColor: Colors.white,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'$bulletNum',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight:
|
||||
isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showFilterDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.grey[900],
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Choose a Filter',
|
||||
style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
alignment: WrapAlignment.center,
|
||||
children: ImageFilterType.values.map((f) {
|
||||
final isSelected = _selectedFilter == f;
|
||||
final label = f.toString().split('.').last;
|
||||
return ElevatedButton(
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
_selectedFilter = f;
|
||||
});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('selectedFilter', f.index); // Save filter index
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isSelected ? _bulletColor : Colors.grey[800],
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: Text(label[0].toUpperCase() + label.substring(1)),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _selectBulletCount(int number) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
_numberOfBullets = number;
|
||||
});
|
||||
await prefs.setInt('bulletCount', number);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_zoomAnimController.dispose();
|
||||
_yoloDetector.close();
|
||||
_restoreScreenSettings();
|
||||
|
||||
_setSystemBarStyle(); // restore grey[900] on back
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
Future<void> _restoreScreenSettings() async {
|
||||
try {
|
||||
await WakelockPlus.disable();
|
||||
} catch (e) {
|
||||
debugPrint("⚠️ Could not restore screen settings: $e");
|
||||
}
|
||||
}
|
||||
|
||||
ColorFilter _buildColorFilter() {
|
||||
switch (_selectedFilter) {
|
||||
case ImageFilterType.greyscale:
|
||||
return const ColorFilter.matrix([
|
||||
0.2126, 0.7152, 0.0722, 0, 0,
|
||||
0.2126, 0.7152, 0.0722, 0, 0,
|
||||
0.2126, 0.7152, 0.0722, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
]);
|
||||
case ImageFilterType.invert:
|
||||
return const ColorFilter.matrix([
|
||||
-1, 0, 0, 0, 255,
|
||||
0, -1, 0, 0, 255,
|
||||
0, 0, -1, 0, 255,
|
||||
0, 0, 0, 1, 0,
|
||||
]);
|
||||
default:
|
||||
return const ColorFilter.mode(Colors.transparent, BlendMode.dst);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("Target ${widget.cameraIndex}"),
|
||||
backgroundColor: Colors.grey[900],
|
||||
foregroundColor: Colors.white,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Text(
|
||||
'$_numberOfBullets',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
onPressed: _showBulletDialog,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.filter_alt, color: Colors.white),
|
||||
onPressed: _showFilterDialog,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
Screenshot(
|
||||
controller: _screenshotController,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Center(
|
||||
child: InteractiveViewer(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
constrained: false,
|
||||
boundaryMargin: EdgeInsets.zero,
|
||||
minScale: 1.0,
|
||||
maxScale: 5.0,
|
||||
transformationController: _transformationController,
|
||||
child: SizedBox(
|
||||
width: constraints.maxWidth,
|
||||
height: constraints.maxHeight,
|
||||
child: (_isError || _streamUrl == null)
|
||||
? _buildErrorContent()
|
||||
: RepaintBoundary(
|
||||
child: ColorFiltered(
|
||||
colorFilter: _buildColorFilter(),
|
||||
child: Mjpeg(
|
||||
stream: _streamUrl!,
|
||||
isLive: true,
|
||||
fit: BoxFit.contain,
|
||||
error: (context, error, stackTrace) => _buildErrorContent(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
if (_isCapturing)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
child: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(color: Colors.white),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
"Processing image...",
|
||||
style: TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
|
||||
floatingActionButton: _isError
|
||||
? null
|
||||
: FloatingActionButton(
|
||||
backgroundColor: Colors.white,
|
||||
onPressed: _captureAndPreviewImage,
|
||||
child: const Icon(Icons.camera_alt, color: Colors.black),
|
||||
),
|
||||
bottomNavigationBar: BottomAppBar(
|
||||
color: Colors.grey[900],
|
||||
shape: const CircularNotchedRectangle(),
|
||||
notchMargin: 8.0,
|
||||
child: SizedBox(
|
||||
height: 60,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorContent() {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.red, size: 100),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
"Unable to load camera stream",
|
||||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ElevatedButton(
|
||||
onPressed: _loadStreamUrl,
|
||||
child: const Text("Retry"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user