Update UI + Agora Call

This commit is contained in:
2026-05-27 19:36:01 +07:00
parent a629357e8c
commit 3cb32a4d69
42 changed files with 6516 additions and 1285 deletions
@@ -12,6 +12,7 @@ import '../../app/injection_container.dart';
import '../../core/ai/detection_export.dart';
import '../../core/ai/obstacle_alert_strategy.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/services/location_reporter_service.dart';
import '../../core/services/tts_service.dart';
import 'application/walk_guide_cubit.dart';
@@ -27,10 +28,15 @@ class WalkGuideScreen extends StatefulWidget {
State<WalkGuideScreen> createState() => _WalkGuideScreenState();
}
class _WalkGuideScreenState extends State<WalkGuideScreen> {
class _WalkGuideScreenState extends State<WalkGuideScreen>
with SingleTickerProviderStateMixin {
late final WalkGuideCubit _cubit;
late final AnimationController _scanCtrl;
CameraController? _camera;
bool _processingFrame = false;
bool _pairingLoading = true;
bool _paired = false;
String? _pairedName;
DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastModelWarningAt = DateTime.fromMillisecondsSinceEpoch(0);
@@ -39,6 +45,11 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
void initState() {
super.initState();
_cubit = sl<WalkGuideCubit>();
_scanCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2200),
)..repeat();
_loadPairingStatus();
}
@override
@@ -49,6 +60,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
}
_camera?.dispose();
sl<LocationReporterService>().stop();
_scanCtrl.dispose();
_cubit.close();
super.dispose();
}
@@ -56,6 +68,8 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
Future<void> _toggle() async {
final next = !_cubit.state.active;
if (next) {
final paired = await _ensurePaired();
if (!paired) return;
await _startCamera();
await sl<LocationReporterService>().start(walkGuideActive: true);
await _cubit.start();
@@ -69,6 +83,48 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
sl<TtsService>().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan');
}
Future<void> _loadPairingStatus() async {
await runFriendlyAction(
() async {
final res = await sl<ApiClient>()
.dio
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 6));
final data = res.data['data'];
if (!mounted) return;
setState(() {
_paired = data is Map && data['status'] == 'ACTIVE';
_pairedName = data is Map ? data['pairedWithName']?.toString() : null;
_pairingLoading = false;
});
},
onError: (_) {
if (!mounted) return;
setState(() => _pairingLoading = false);
},
fallback: 'Status pairing belum bisa dicek.',
);
}
Future<bool> _ensurePaired() async {
if (_paired) return true;
await _loadPairingStatus();
if (_paired) return true;
if (!mounted) return false;
sl<TtsService>().speak('Hubungkan Guardian terlebih dahulu.');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text(
'WalkGuide, SOS, dan panggilan aktif setelah pairing dengan Guardian.'),
action: SnackBarAction(
label: 'Pairing',
onPressed: () => context.go('/user/pairing'),
),
),
);
return false;
}
String _activeStatusText() {
final detector = sl<YoloDetector>();
if (kIsWeb) {
@@ -86,33 +142,33 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
if (_camera != null) return;
await runFriendlyAction(
() async {
final cameras = await availableCameras();
if (cameras.isEmpty) return;
final backCamera = cameras.firstWhere(
(camera) => camera.lensDirection == CameraLensDirection.back,
orElse: () => cameras.first,
);
final controller = CameraController(
backCamera,
ResolutionPreset.medium,
enableAudio: false,
imageFormatGroup: ImageFormatGroup.yuv420,
);
await controller.initialize();
if (!mounted) {
await controller.dispose();
return;
}
await runFriendlyAction(
() => controller.startImageStream(_onCameraImage),
onError: (_) {
_cubit.updateStatus(kIsWeb
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
: 'Camera preview aktif, tapi image stream belum tersedia.');
},
fallback: 'Camera preview aktif, tapi image stream belum tersedia.',
);
setState(() => _camera = controller);
final cameras = await availableCameras();
if (cameras.isEmpty) return;
final backCamera = cameras.firstWhere(
(camera) => camera.lensDirection == CameraLensDirection.back,
orElse: () => cameras.first,
);
final controller = CameraController(
backCamera,
ResolutionPreset.medium,
enableAudio: false,
imageFormatGroup: ImageFormatGroup.yuv420,
);
await controller.initialize();
if (!mounted) {
await controller.dispose();
return;
}
await runFriendlyAction(
() => controller.startImageStream(_onCameraImage),
onError: (_) {
_cubit.updateStatus(kIsWeb
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
: 'Camera preview aktif, tapi image stream belum tersedia.');
},
fallback: 'Camera preview aktif, tapi image stream belum tersedia.',
);
setState(() => _camera = controller);
},
onError: (_) => _cubit.updateStatus('Camera unavailable.'),
fallback: 'Camera unavailable.',
@@ -190,7 +246,9 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
bloc: _cubit,
builder: (context, state) => _Page(
title: 'WalkGuide',
subtitle: 'On-device AI detection surface',
subtitle: _paired
? 'Connected to ${_pairedName ?? 'Guardian'}'
: 'Pair with Guardian to unlock live protection',
actions: [
IconButton(
onPressed: () => context.go('/user/benchmark'),
@@ -202,64 +260,52 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
child: Column(
children: [
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
borderRadius: BorderRadius.circular(16)),
child: Stack(
children: [
if (_camera != null && _camera!.value.isInitialized)
Positioned.fill(child: CameraPreview(_camera!))
else
const Center(
child: Icon(Icons.videocam_outlined,
color: Colors.white30, size: 96)),
if (state.latestDetection?.box != null)
Positioned.fill(
child: CustomPaint(
painter:
_DetectionOverlayPainter(state.latestDetection!),
),
),
Positioned(
top: 16,
left: 16,
child: _Pill(
text: state.active ? 'AI ACTIVE' : 'STANDBY',
color:
state.active ? Colors.green : Colors.orange)),
if (state.latestDetection != null)
Positioned(
top: 64,
left: 16,
child: _Pill(
text:
'${ObstacleAnalyzer.spokenLabel(state.latestDetection!.label)} ${state.latestDetection!.directionName}',
color: Colors.redAccent),
),
Positioned(
left: 16,
right: 16,
bottom: 16,
child: Text(state.status,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w700))),
],
),
child: _VisionPanel(
state: state,
camera: _camera,
scanCtrl: _scanCtrl,
paired: _paired,
pairingLoading: _pairingLoading,
onPairingTap: () => context.go('/user/pairing'),
),
),
const SizedBox(height: 14),
_StatusStrip(
active: state.active,
paired: _paired,
latestDetection: state.latestDetection,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _toggle,
icon:
Icon(state.active ? Icons.stop : Icons.play_arrow),
label: Text(state.active ? 'Stop' : 'Start'))),
flex: 2,
child: FilledButton.icon(
onPressed: _pairingLoading ? null : _toggle,
icon: Icon(state.active ? Icons.stop : Icons.play_arrow),
label: Text(state.active ? 'Stop Scan' : 'Start Scan'),
),
),
const SizedBox(width: 10),
_ActionSquare(
icon: Icons.sos_outlined,
color: const Color(0xFFDC2626),
onTap: () async {
if (await _ensurePaired() && context.mounted) {
context.go('/user/sos');
}
},
),
const SizedBox(width: 10),
_ActionSquare(
icon: Icons.call_outlined,
color: const Color(0xFF059669),
onTap: () async {
if (await _ensurePaired() && context.mounted) {
context.go('/user/call');
}
},
),
],
),
],
@@ -269,6 +315,413 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
}
}
class _VisionPanel extends StatelessWidget {
final WalkGuideState state;
final CameraController? camera;
final AnimationController scanCtrl;
final bool paired;
final bool pairingLoading;
final VoidCallback onPairingTap;
const _VisionPanel({
required this.state,
required this.camera,
required this.scanCtrl,
required this.paired,
required this.pairingLoading,
required this.onPairingTap,
});
@override
Widget build(BuildContext context) {
final cameraReady = camera != null && camera!.value.isInitialized;
return ClipRRect(
borderRadius: BorderRadius.circular(28),
child: DecoratedBox(
decoration: const BoxDecoration(color: Color(0xFF07111F)),
child: Stack(
children: [
Positioned.fill(
child: cameraReady
? CameraPreview(camera!)
: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF07111F),
Color(0xFF0E2A3D),
Color(0xFF111827),
],
),
),
),
),
Positioned.fill(child: CustomPaint(painter: _HudGridPainter())),
if (state.active)
AnimatedBuilder(
animation: scanCtrl,
builder: (_, __) => Positioned(
left: 0,
right: 0,
top: 28 +
(MediaQuery.of(context).size.height *
0.38 *
scanCtrl.value),
child: Container(
height: 3,
decoration: BoxDecoration(
color: const Color(0xFF22D3EE).withValues(alpha: 0.8),
boxShadow: [
BoxShadow(
color:
const Color(0xFF22D3EE).withValues(alpha: 0.45),
blurRadius: 22,
spreadRadius: 4,
),
],
),
),
),
),
if (state.latestDetection?.box != null)
Positioned.fill(
child: CustomPaint(
painter: _DetectionOverlayPainter(state.latestDetection!),
),
),
Positioned(
top: 18,
left: 18,
right: 18,
child: Row(
children: [
_Pill(
text: state.active ? 'LIVE AI SCAN' : 'STANDBY',
color: state.active
? const Color(0xFF22C55E)
: const Color(0xFFF59E0B),
),
const SizedBox(width: 8),
_Pill(
text: paired ? 'GUARDIAN LINKED' : 'PAIRING REQUIRED',
color: paired
? const Color(0xFF38BDF8)
: const Color(0xFFF97316),
),
],
),
),
Center(
child: AnimatedScale(
duration: const Duration(milliseconds: 320),
scale: state.active ? 1.0 : 0.92,
child: Container(
width: 118,
height: 118,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.black.withValues(alpha: 0.28),
border: Border.all(
color: (state.active
? const Color(0xFF22D3EE)
: Colors.white)
.withValues(alpha: 0.34),
width: 2,
),
),
child: Icon(
cameraReady
? Icons.center_focus_strong
: Icons.videocam_off,
color: Colors.white.withValues(alpha: 0.68),
size: 48,
),
),
),
),
if (!paired && !pairingLoading)
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFF020617).withValues(alpha: 0.72),
),
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 70,
height: 70,
decoration: BoxDecoration(
color: const Color(0xFFFFFBEB)
.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(18),
border:
Border.all(color: const Color(0xFFF59E0B)),
),
child: const Icon(Icons.link_off,
color: Color(0xFFFBBF24), size: 34),
),
const SizedBox(height: 14),
const Text(
'Guardian belum terhubung',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 6),
const Text(
'Pairing dulu supaya SOS, panggilan, dan pemantauan live punya penerima yang jelas.',
textAlign: TextAlign.center,
style:
TextStyle(color: Colors.white70, height: 1.35),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onPairingTap,
icon: const Icon(Icons.link),
label: const Text('Buka Pairing'),
),
],
),
),
),
),
),
Positioned(
left: 18,
right: 18,
bottom: 18,
child: _GlassStatusBar(
status: state.status,
detection: state.latestDetection,
),
),
],
),
),
);
}
}
class _GlassStatusBar extends StatelessWidget {
final String status;
final DetectionResult? detection;
const _GlassStatusBar({required this.status, required this.detection});
@override
Widget build(BuildContext context) {
final label = detection == null
? status
: '${ObstacleAnalyzer.spokenLabel(detection!.label)} detected ${detection!.directionName}';
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.42),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withValues(alpha: 0.16)),
),
child: Row(
children: [
Icon(
detection == null ? Icons.sensors : Icons.warning_amber_rounded,
color: detection == null
? const Color(0xFF93C5FD)
: const Color(0xFFFBBF24),
),
const SizedBox(width: 10),
Expanded(
child: Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
height: 1.2,
),
),
),
],
),
);
}
}
class _StatusStrip extends StatelessWidget {
final bool active;
final bool paired;
final DetectionResult? latestDetection;
const _StatusStrip({
required this.active,
required this.paired,
required this.latestDetection,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _MetricChip(
icon: Icons.health_and_safety_outlined,
label: 'Guardian',
value: paired ? 'Linked' : 'Required',
color: paired ? const Color(0xFF059669) : const Color(0xFFD97706),
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricChip(
icon: Icons.radar_outlined,
label: 'Detector',
value: active ? 'Scanning' : 'Idle',
color: active ? const Color(0xFF2563EB) : const Color(0xFF64748B),
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricChip(
icon: Icons.visibility_outlined,
label: 'Obstacle',
value: latestDetection == null ? 'Clear' : 'Alert',
color: latestDetection == null
? const Color(0xFF64748B)
: const Color(0xFFDC2626),
),
),
],
);
}
}
class _MetricChip extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final Color color;
const _MetricChip({
required this.icon,
required this.label,
required this.value,
required this.color,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 14,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF64748B),
fontSize: 11,
fontWeight: FontWeight.w600,
)),
Text(value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: color,
fontSize: 13,
fontWeight: FontWeight.w900,
)),
],
),
),
],
),
);
}
}
class _ActionSquare extends StatelessWidget {
final IconData icon;
final Color color;
final VoidCallback onTap;
const _ActionSquare({
required this.icon,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: color,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: SizedBox(
width: 54,
height: 50,
child: Icon(icon, color: Colors.white),
),
),
);
}
}
class _HudGridPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final line = Paint()
..color = Colors.white.withValues(alpha: 0.045)
..strokeWidth = 1;
for (double x = 0; x < size.width; x += 42) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), line);
}
for (double y = 0; y < size.height; y += 42) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), line);
}
final center = Offset(size.width / 2, size.height / 2);
final ring = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.2
..color = const Color(0xFF22D3EE).withValues(alpha: 0.16);
for (final radius in [64.0, 112.0, 164.0]) {
canvas.drawCircle(center, radius, ring);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class _DetectionOverlayPainter extends CustomPainter {
final DetectionResult detection;
const _DetectionOverlayPainter(this.detection);
@@ -356,34 +809,67 @@ class _Page extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.w800)),
if (subtitle != null)
Text(subtitle!,
style: const TextStyle(color: Color(0xFF64748B))),
],
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFF8FAFC), Color(0xFFEFF6FF)],
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: const Color(0xFF2563EB),
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color:
const Color(0xFF2563EB).withValues(alpha: 0.28),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: const Icon(Icons.navigation_rounded,
color: Colors.white, size: 26),
),
),
...?actions,
],
),
const SizedBox(height: 16),
Expanded(child: child),
],
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF0F172A),
)),
if (subtitle != null)
Text(subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFF64748B))),
],
),
),
...?actions,
],
),
const SizedBox(height: 16),
Expanded(child: child),
],
),
),
),
);