399 lines
13 KiB
Dart
399 lines
13 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_mjpeg/flutter_mjpeg.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:firebase_database/firebase_database.dart';
|
|
import '../../widgets/costum_header.dart';
|
|
|
|
class LiveCameraPage extends StatefulWidget {
|
|
const LiveCameraPage({super.key});
|
|
|
|
@override
|
|
State<LiveCameraPage> createState() => _LiveCameraPageState();
|
|
}
|
|
|
|
class _LiveCameraPageState extends State<LiveCameraPage> {
|
|
String? _ipAddress;
|
|
bool _isStreamLoading = true;
|
|
bool _hasStreamError = false;
|
|
bool _isMotionSensorActive = false;
|
|
bool _hasCapturedRecently = false;
|
|
bool _isCapturingManually = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_fetchIpAddress();
|
|
_listenToMotionSensor();
|
|
}
|
|
|
|
void _listenToMotionSensor() {
|
|
final statusRef = FirebaseDatabase.instance.ref('sensorPIR/status');
|
|
|
|
statusRef.onValue.listen((event) async {
|
|
final status = event.snapshot.value;
|
|
|
|
print("📥 Status PIR: $status");
|
|
|
|
if (status == 1 || status == '1' || status == true) {
|
|
setState(() => _isMotionSensorActive = true);
|
|
|
|
if (!_hasCapturedRecently && _ipAddress != null) {
|
|
print("📸 Mulai capture otomatis...");
|
|
_hasCapturedRecently = true;
|
|
await _captureAndUploadImage();
|
|
|
|
Future.delayed(const Duration(seconds: 10), () {
|
|
_hasCapturedRecently = false;
|
|
print("🔁 Reset capture flag.");
|
|
});
|
|
} else {
|
|
print("⚠️ Sudah capture, tunggu delay.");
|
|
}
|
|
} else {
|
|
setState(() => _isMotionSensorActive = false);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _fetchIpAddress() async {
|
|
final ref = FirebaseDatabase.instance.ref('esp32cam/ip');
|
|
final snapshot = await ref.get();
|
|
if (snapshot.exists) {
|
|
setState(() {
|
|
_ipAddress = snapshot.value.toString();
|
|
});
|
|
print("📡 IP ESP32 ditemukan: $_ipAddress");
|
|
} else {
|
|
setState(() => _ipAddress = null);
|
|
print("❌ IP ESP32 tidak ditemukan.");
|
|
}
|
|
}
|
|
|
|
Future<void> _captureAndUploadImage() async {
|
|
if (_ipAddress == null) return;
|
|
|
|
final captureUrl = 'http://$_ipAddress/capture';
|
|
const cloudinaryUrl = 'https://api.cloudinary.com/v1_1/dd2elgipw/image/upload';
|
|
const uploadPreset = 'cam_upload';
|
|
|
|
try {
|
|
print("📷 Mengambil gambar dari $_ipAddress...");
|
|
|
|
// Coba capture maksimal 2 kali
|
|
http.Response? response;
|
|
for (int attempt = 1; attempt <= 2; attempt++) {
|
|
try {
|
|
response = await http
|
|
.get(Uri.parse(captureUrl))
|
|
.timeout(const Duration(seconds: 10));
|
|
if (response.statusCode == 200 && response.bodyBytes.isNotEmpty) {
|
|
print("✅ Capture berhasil pada attempt ke-$attempt");
|
|
break;
|
|
} else {
|
|
print("⚠️ Capture gagal (status: ${response.statusCode}), mencoba ulang...");
|
|
}
|
|
} catch (e) {
|
|
print("⚠️ Error saat capture ke-$attempt: $e");
|
|
if (attempt == 2) rethrow;
|
|
await Future.delayed(const Duration(seconds: 2));
|
|
}
|
|
}
|
|
|
|
if (response == null || response.bodyBytes.isEmpty) {
|
|
throw Exception('Gambar kosong atau tidak valid');
|
|
}
|
|
|
|
// Upload ke Cloudinary
|
|
final uploadRequest = http.MultipartRequest('POST', Uri.parse(cloudinaryUrl));
|
|
uploadRequest.fields['upload_preset'] = uploadPreset;
|
|
uploadRequest.files.add(
|
|
http.MultipartFile.fromBytes('file', response.bodyBytes, filename: 'snapshot.jpg'),
|
|
);
|
|
|
|
print("☁️ Upload ke Cloudinary...");
|
|
final uploadResponse = await uploadRequest.send();
|
|
final result = await http.Response.fromStream(uploadResponse);
|
|
|
|
if (uploadResponse.statusCode != 200) throw Exception('Upload gagal');
|
|
|
|
final data = jsonDecode(result.body);
|
|
final imageUrl = data['secure_url'];
|
|
|
|
// Simpan gambar dan log ke Firestore
|
|
await FirebaseFirestore.instance.collection('captures').add({
|
|
'imageUrl': imageUrl,
|
|
'timestamp': Timestamp.now(),
|
|
});
|
|
|
|
await FirebaseFirestore.instance.collection('History').add({
|
|
'motion': 'Mencurigakan',
|
|
'timestamp': DateTime.now().toIso8601String(),
|
|
});
|
|
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('✅ Gambar berhasil diunggah')),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
print("❌ Error saat capture: $e");
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('❌ Terjadi kesalahan saat capture: $e')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
const primaryColor = Color(0xFF5F59A6);
|
|
|
|
return Scaffold(
|
|
appBar: const CustomAppBar(title: 'AntiSpy Cam'),
|
|
body: _ipAddress == null
|
|
? const Center(child: CircularProgressIndicator())
|
|
: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
_buildStreamSection(primaryColor),
|
|
const SizedBox(height: 20),
|
|
_buildMotionSensorSection(primaryColor),
|
|
_buildDetectionHistorySection(primaryColor),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStreamSection(Color primaryColor) {
|
|
final streamUrl = 'http://$_ipAddress/stream';
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
const Text(
|
|
'Live Camera Feed',
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black87),
|
|
),
|
|
const SizedBox(height: 12),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(16),
|
|
child: Container(
|
|
height: 200,
|
|
color: Colors.black,
|
|
child: Stack(
|
|
children: [
|
|
Mjpeg(
|
|
stream: streamUrl,
|
|
isLive: true,
|
|
fit: BoxFit.cover,
|
|
loading: (context) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_isStreamLoading) {
|
|
setState(() => _isStreamLoading = false);
|
|
}
|
|
});
|
|
return const Center(child: CircularProgressIndicator(color: Colors.white));
|
|
},
|
|
error: (context, error, stack) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!_hasStreamError) {
|
|
setState(() {
|
|
_hasStreamError = true;
|
|
_isStreamLoading = false;
|
|
});
|
|
}
|
|
});
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
|
const SizedBox(height: 8),
|
|
Text('Gagal memuat stream', style: TextStyle(color: Colors.red[300])),
|
|
Text('Periksa koneksi kamera', style: TextStyle(color: Colors.red[300])),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
if (_isStreamLoading)
|
|
const Center(child: CircularProgressIndicator(color: Colors.white)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton.icon(
|
|
onPressed: _isCapturingManually
|
|
? null
|
|
: () async {
|
|
if (_ipAddress == null) return;
|
|
setState(() => _isCapturingManually = true);
|
|
await _captureAndUploadImage();
|
|
if (mounted) setState(() => _isCapturingManually = false);
|
|
},
|
|
icon: const Icon(Icons.camera_alt),
|
|
label: Text(_isCapturingManually ? 'Mengambil...' : 'Ambil Gambar'),
|
|
style: ElevatedButton.styleFrom(
|
|
foregroundColor: Colors.white,
|
|
backgroundColor: primaryColor,
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildMotionSensorSection(Color primaryColor) {
|
|
return _SensorStatusCard(
|
|
icon: Icons.motion_photos_on,
|
|
label: 'Sensor Gerak',
|
|
value: _isMotionSensorActive ? "Mencurigakan" : "Aktivitas Normal",
|
|
active: _isMotionSensorActive,
|
|
primaryColor: primaryColor,
|
|
);
|
|
}
|
|
|
|
Widget _buildDetectionHistorySection(Color primaryColor) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
'Riwayat Deteksi',
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black87),
|
|
),
|
|
const SizedBox(height: 12),
|
|
SizedBox(
|
|
height: 250,
|
|
child: StreamBuilder<QuerySnapshot>(
|
|
stream: FirebaseFirestore.instance
|
|
.collection('History')
|
|
.orderBy('timestamp', descending: true)
|
|
.snapshots(),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (snapshot.hasError) {
|
|
return const Text('Gagal memuat data riwayat.');
|
|
}
|
|
|
|
if (!snapshot.hasData || snapshot.data!.docs.isEmpty) {
|
|
return const Padding(
|
|
padding: EdgeInsets.all(8.0),
|
|
child: Text('Tidak ada riwayat deteksi', style: TextStyle(color: Colors.grey)),
|
|
);
|
|
}
|
|
|
|
final logs = snapshot.data!.docs.map((doc) {
|
|
final data = doc.data() as Map<String, dynamic>;
|
|
final motionType = data['motion'] ?? 'Tidak diketahui';
|
|
final timestampStr = data['timestamp'] ?? '';
|
|
String formattedTime = 'Tidak diketahui';
|
|
|
|
try {
|
|
final dt = DateTime.parse(timestampStr);
|
|
formattedTime = TimeOfDay.fromDateTime(dt).format(context);
|
|
} catch (_) {}
|
|
|
|
return DetectionLog(
|
|
time: formattedTime,
|
|
type: motionType,
|
|
);
|
|
}).toList();
|
|
|
|
return ListView.builder(
|
|
itemCount: logs.length,
|
|
itemBuilder: (context, index) =>
|
|
_buildDetectionLogItem(logs[index], primaryColor),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildDetectionLogItem(DetectionLog log, Color primaryColor) {
|
|
return Card(
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
child: ListTile(
|
|
leading: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: primaryColor.withOpacity(0.1),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(Icons.notifications_active, color: primaryColor),
|
|
),
|
|
title: Text(log.type, style: const TextStyle(fontWeight: FontWeight.w500)),
|
|
subtitle: Text("Waktu: ${log.time}"),
|
|
trailing: Icon(Icons.chevron_right, color: primaryColor),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class DetectionLog {
|
|
final String time;
|
|
final String type;
|
|
|
|
DetectionLog({required this.time, required this.type});
|
|
}
|
|
|
|
class _SensorStatusCard extends StatelessWidget {
|
|
final IconData icon;
|
|
final String label;
|
|
final String value;
|
|
final bool active;
|
|
final Color primaryColor;
|
|
|
|
const _SensorStatusCard({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.value,
|
|
required this.active,
|
|
required this.primaryColor,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedContainer(
|
|
duration: const Duration(milliseconds: 300),
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: active ? primaryColor.withOpacity(0.2) : primaryColor.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: active ? primaryColor : primaryColor.withOpacity(0.7),
|
|
width: 1.5,
|
|
),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 36, color: active ? primaryColor : primaryColor.withOpacity(0.8)),
|
|
const SizedBox(height: 8),
|
|
Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: active ? primaryColor : Colors.black54,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|