Fix local dev config and ALOTTTT OFF FLUTTER, im tired boss..
This commit is contained in:
+64
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/ai/obstacle_analyzer.dart';
|
||||
import '../domain/repositories/walk_guide_repository.dart';
|
||||
|
||||
class WalkGuideState {
|
||||
final bool active;
|
||||
final DetectionResult? latestDetection;
|
||||
final String status;
|
||||
|
||||
const WalkGuideState({
|
||||
this.active = false,
|
||||
this.latestDetection,
|
||||
this.status = 'Ready',
|
||||
});
|
||||
|
||||
WalkGuideState copyWith({
|
||||
bool? active,
|
||||
DetectionResult? latestDetection,
|
||||
String? status,
|
||||
}) {
|
||||
return WalkGuideState(
|
||||
active: active ?? this.active,
|
||||
latestDetection: latestDetection ?? this.latestDetection,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WalkGuideCubit extends Cubit<WalkGuideState> {
|
||||
final WalkGuideRepository _repository;
|
||||
|
||||
WalkGuideCubit(this._repository) : super(const WalkGuideState());
|
||||
|
||||
Future<void> start() async {
|
||||
emit(state.copyWith(active: true, status: 'WalkGuide active'));
|
||||
await _repository.startSession();
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
emit(state.copyWith(active: false, status: 'WalkGuide stopped'));
|
||||
await _repository.stopSession();
|
||||
}
|
||||
|
||||
void updateStatus(String status) {
|
||||
emit(state.copyWith(status: status));
|
||||
}
|
||||
|
||||
void clearDetection({String status = 'Ready'}) {
|
||||
emit(WalkGuideState(active: state.active, status: status));
|
||||
}
|
||||
|
||||
Future<void> recordObstacle(DetectionResult detection) async {
|
||||
emit(state.copyWith(latestDetection: detection, status: detection.spokenId));
|
||||
await _repository.logObstacle({
|
||||
'label': detection.label,
|
||||
'confidence': detection.confidence,
|
||||
'direction': detection.directionName,
|
||||
'estimatedDist': detection.estimatedDistance,
|
||||
'lat': null,
|
||||
'lng': null,
|
||||
});
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../../../../core/network/api_client.dart';
|
||||
import '../../../../core/services/offline_queue_service.dart';
|
||||
import '../../domain/repositories/walk_guide_repository.dart';
|
||||
|
||||
class WalkGuideRepositoryImpl implements WalkGuideRepository {
|
||||
final ApiClient _apiClient;
|
||||
final OfflineQueueService _offlineQueue;
|
||||
|
||||
const WalkGuideRepositoryImpl(this._apiClient, this._offlineQueue);
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> startSession() {
|
||||
return _post('/user/walkguide/start', const {});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> stopSession() {
|
||||
return _post('/user/walkguide/stop', const {});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> logObstacle(Map<String, dynamic> payload) {
|
||||
return _post('/user/obstacle', payload);
|
||||
}
|
||||
|
||||
Future<Either<Failure, void>> _post(
|
||||
String path,
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
try {
|
||||
await _apiClient.dio.post(path, data: payload);
|
||||
return const Right(null);
|
||||
} catch (_) {
|
||||
await _offlineQueue.enqueue(OfflineRequest(
|
||||
method: 'POST',
|
||||
path: path,
|
||||
body: payload,
|
||||
createdAt: DateTime.now(),
|
||||
));
|
||||
return const Right(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
class WalkSession {
|
||||
final String sessionId;
|
||||
final DateTime startedAt;
|
||||
final DateTime? stoppedAt;
|
||||
final int obstacleCount;
|
||||
|
||||
const WalkSession({
|
||||
required this.sessionId,
|
||||
required this.startedAt,
|
||||
this.stoppedAt,
|
||||
this.obstacleCount = 0,
|
||||
});
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
|
||||
import '../../../../core/errors/failures.dart';
|
||||
|
||||
abstract class WalkGuideRepository {
|
||||
Future<Either<Failure, void>> startSession();
|
||||
Future<Either<Failure, void>> stopSession();
|
||||
Future<Either<Failure, void>> logObstacle(Map<String, dynamic> payload);
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export '../../walk_guide_screen.dart';
|
||||
@@ -5,14 +5,16 @@ import 'dart:async';
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../app/injection_container.dart';
|
||||
import '../../core/ai/detection_export.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/services/haptic_service.dart';
|
||||
import '../../core/ai/obstacle_alert_strategy.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
import '../../core/services/location_reporter_service.dart';
|
||||
import '../../core/services/tts_service.dart';
|
||||
import 'application/walk_guide_cubit.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WalkGuideScreen
|
||||
@@ -26,15 +28,19 @@ class WalkGuideScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
bool _active = false;
|
||||
String _status = 'Ready';
|
||||
late final WalkGuideCubit _cubit;
|
||||
CameraController? _camera;
|
||||
DetectionResult? _lastDetection;
|
||||
bool _processingFrame = false;
|
||||
DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
DateTime _lastModelWarningAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cubit = sl<WalkGuideCubit>();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final camera = _camera;
|
||||
@@ -43,25 +49,23 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
}
|
||||
_camera?.dispose();
|
||||
sl<LocationReporterService>().stop();
|
||||
_cubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _toggle() async {
|
||||
final next = !_active;
|
||||
final next = !_cubit.state.active;
|
||||
if (next) {
|
||||
await _startCamera();
|
||||
await sl<LocationReporterService>().start(walkGuideActive: true);
|
||||
await _cubit.start();
|
||||
_cubit.updateStatus(_activeStatusText());
|
||||
} else {
|
||||
await _stopCamera();
|
||||
await sl<LocationReporterService>().stop();
|
||||
await _cubit.stop();
|
||||
_cubit.clearDetection(status: 'Stopped');
|
||||
}
|
||||
setState(() {
|
||||
_active = next;
|
||||
_status = next ? _activeStatusText() : 'Stopped';
|
||||
});
|
||||
await sl<ApiClient>()
|
||||
.dio
|
||||
.post(next ? '/user/walkguide/start' : '/user/walkguide/stop');
|
||||
sl<TtsService>().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan');
|
||||
}
|
||||
|
||||
@@ -80,7 +84,8 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
|
||||
Future<void> _startCamera() async {
|
||||
if (_camera != null) return;
|
||||
try {
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
final cameras = await availableCameras();
|
||||
if (cameras.isEmpty) return;
|
||||
final backCamera = cameras.firstWhere(
|
||||
@@ -98,33 +103,40 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
await controller.dispose();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await controller.startImageStream(_onCameraImage);
|
||||
} catch (_) {
|
||||
setState(() => _status = kIsWeb
|
||||
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
|
||||
: 'Camera preview aktif, tapi image stream belum tersedia.');
|
||||
}
|
||||
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);
|
||||
} catch (_) {
|
||||
setState(() => _status = 'Camera unavailable.');
|
||||
}
|
||||
},
|
||||
onError: (_) => _cubit.updateStatus('Camera unavailable.'),
|
||||
fallback: 'Camera unavailable.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _stopCamera() async {
|
||||
final camera = _camera;
|
||||
_camera = null;
|
||||
if (camera == null) return;
|
||||
try {
|
||||
if (camera.value.isStreamingImages) {
|
||||
await camera.stopImageStream();
|
||||
}
|
||||
} catch (_) {}
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
if (camera.value.isStreamingImages) {
|
||||
await camera.stopImageStream();
|
||||
}
|
||||
},
|
||||
onError: (_) {},
|
||||
fallback: 'Camera stream already stopped.',
|
||||
);
|
||||
await camera.dispose();
|
||||
}
|
||||
|
||||
void _onCameraImage(CameraImage image) {
|
||||
if (!_active || _processingFrame) return;
|
||||
if (!_cubit.state.active || _processingFrame) return;
|
||||
final now = DateTime.now();
|
||||
if (now.difference(_lastInferenceAt) < const Duration(milliseconds: 900)) {
|
||||
return;
|
||||
@@ -141,7 +153,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
final now = DateTime.now();
|
||||
if (now.difference(_lastModelWarningAt) > const Duration(seconds: 3)) {
|
||||
_lastModelWarningAt = now;
|
||||
setState(() => _status = detector.isReady
|
||||
_cubit.updateStatus(detector.isReady
|
||||
? 'Scanning... ${detector.diagnosticsSummary}'
|
||||
: 'YOLO model belum siap. Pastikan file model tersedia di assets/models.');
|
||||
}
|
||||
@@ -154,8 +166,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
DetectionResult detection, {
|
||||
bool forceAlert = false,
|
||||
}) async {
|
||||
_lastDetection = detection;
|
||||
setState(() => _status =
|
||||
_cubit.updateStatus(
|
||||
'Obstacle: ${ObstacleAnalyzer.spokenLabel(detection.label)} ${detection.directionName} ${detection.estimatedDistance}');
|
||||
|
||||
final now = DateTime.now();
|
||||
@@ -165,94 +176,94 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
}
|
||||
_lastAlertAt = now;
|
||||
|
||||
try {
|
||||
await sl<ApiClient>().dio.post('/user/obstacle', data: {
|
||||
'label': detection.label,
|
||||
'confidence': detection.confidence,
|
||||
'direction': detection.directionName,
|
||||
'estimatedDist': detection.estimatedDistance,
|
||||
'lat': null,
|
||||
'lng': null,
|
||||
});
|
||||
} catch (_) {}
|
||||
await sl<HapticService>().obstacleClose();
|
||||
await sl<TtsService>().speakImmediate(detection.spokenId);
|
||||
await runFriendlyAction(
|
||||
() => _cubit.recordObstacle(detection),
|
||||
onError: (_) {},
|
||||
fallback: 'Obstacle tersimpan offline.',
|
||||
);
|
||||
await sl<ObstacleAlertStrategy>().alert(detection);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _Page(
|
||||
title: 'WalkGuide',
|
||||
subtitle: 'On-device AI detection surface',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.go('/user/benchmark'),
|
||||
icon: const Icon(Icons.speed)),
|
||||
IconButton(
|
||||
onPressed: () => context.go('/user/pairing'),
|
||||
icon: const Icon(Icons.link)),
|
||||
],
|
||||
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 (_lastDetection?.box != null)
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter: _DetectionOverlayPainter(_lastDetection!),
|
||||
return BlocBuilder<WalkGuideCubit, WalkGuideState>(
|
||||
bloc: _cubit,
|
||||
builder: (context, state) => _Page(
|
||||
title: 'WalkGuide',
|
||||
subtitle: 'On-device AI detection surface',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.go('/user/benchmark'),
|
||||
icon: const Icon(Icons.speed)),
|
||||
IconButton(
|
||||
onPressed: () => context.go('/user/pairing'),
|
||||
icon: const Icon(Icons.link)),
|
||||
],
|
||||
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: _active ? 'AI ACTIVE' : 'STANDBY',
|
||||
color: _active ? Colors.green : Colors.orange)),
|
||||
if (_lastDetection != null)
|
||||
Positioned(
|
||||
top: 64,
|
||||
left: 16,
|
||||
child: _Pill(
|
||||
text:
|
||||
'${ObstacleAnalyzer.spokenLabel(_lastDetection!.label)} ${_lastDetection!.directionName}',
|
||||
color: Colors.redAccent),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: Text(_status,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700))),
|
||||
],
|
||||
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))),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _toggle,
|
||||
icon: Icon(_active ? Icons.stop : Icons.play_arrow),
|
||||
label: Text(_active ? 'Stop' : 'Start'))),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _toggle,
|
||||
icon:
|
||||
Icon(state.active ? Icons.stop : Icons.play_arrow),
|
||||
label: Text(state.active ? 'Stop' : 'Start'))),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user