a bunch of flutter updates

This commit is contained in:
Wowieee4
2026-05-08 20:27:57 +07:00
parent 53e612e221
commit 22ebbb7db0
44 changed files with 3351 additions and 1240 deletions
@@ -0,0 +1,2 @@
export 'obstacle_analyzer.dart';
export 'yolo_detector.dart';
@@ -0,0 +1,37 @@
enum ObstacleDirection { left, center, right }
class DetectionResult {
final String label;
final double confidence;
final ObstacleDirection direction;
final String estimatedDistance;
const DetectionResult({
required this.label,
required this.confidence,
required this.direction,
required this.estimatedDistance,
});
String get directionName => direction.name.toUpperCase();
String get spokenId {
final area = switch (direction) {
ObstacleDirection.left => 'kiri',
ObstacleDirection.center => 'tengah',
ObstacleDirection.right => 'kanan',
};
return 'Hati-hati, $label di $area. Jarak $estimatedDistance.';
}
}
class ObstacleAnalyzer {
DetectionResult analyzeFallback({String label = 'person', double confidence = 0.86}) {
return DetectionResult(
label: label,
confidence: confidence,
direction: ObstacleDirection.center,
estimatedDistance: 'Close',
);
}
}
@@ -0,0 +1,47 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../constants/app_constants.dart';
import 'obstacle_analyzer.dart';
class YoloDetector {
final ObstacleAnalyzer _analyzer;
List<String> _labels = const [];
bool _ready = false;
YoloDetector(this._analyzer);
bool get isReady => _ready;
Future<void> init() async {
try {
_labels = (await rootBundle.loadString(AppConstants.yoloLabelsPath))
.split('\n')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
if (kIsWeb) {
_ready = false;
return;
}
await rootBundle.load(await AppConstants.getSelectedYoloModelPath());
_ready = true;
} catch (e) {
debugPrint('YOLO fallback mode: $e');
_ready = false;
}
}
Future<DetectionResult?> detectFallback() async {
if (_ready) {
// Full tensor pre/post-processing belongs here once yolov8n.tflite is present.
// The app keeps a deterministic fallback so demo flows remain testable.
}
final label = _labels.isNotEmpty ? _labels.first : 'person';
return _analyzer.analyzeFallback(label: label);
}
void dispose() {
_ready = false;
}
}
@@ -2,6 +2,7 @@ import 'package:shared_preferences/shared_preferences.dart';
class AppConstants {
static const String _serverUrlKey = 'server_base_url';
static const String _selectedYoloModelKey = 'selected_yolo_model';
// Ambil base URL dari SharedPreferences
static Future<String?> getServerUrl() async {
@@ -12,18 +13,29 @@ class AppConstants {
// Simpan URL setelah berhasil connect
static Future<void> setServerUrl(String url) async {
final prefs = await SharedPreferences.getInstance();
// Trim trailing slash
final cleaned = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
final cleaned = normalizeServerUrl(url);
await prefs.setString(_serverUrlKey, cleaned);
}
static String normalizeServerUrl(String url) {
var cleaned = url.trim();
if (!cleaned.startsWith('http://') && !cleaned.startsWith('https://')) {
cleaned = 'http://$cleaned';
}
while (cleaned.endsWith('/')) {
cleaned = cleaned.substring(0, cleaned.length - 1);
}
return cleaned;
}
static Future<void> clearServerUrl() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_serverUrlKey);
}
static String buildApiUrl(String baseUrl) => '$baseUrl/api/v1';
static String buildWsUrl(String baseUrl) => baseUrl.replaceFirst('http', 'ws') + '/ws';
static String buildWsUrl(String baseUrl) =>
'${baseUrl.replaceFirst('http', 'ws')}/ws';
// Timeouts
static const Duration connectTimeout = Duration(seconds: 10);
@@ -39,6 +51,16 @@ class AppConstants {
static const String yoloLabelsPath = 'assets/models/labels.txt';
static const int yoloInputSize = 640;
static Future<String> getSelectedYoloModelPath() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_selectedYoloModelKey) ?? yoloModelPath;
}
static Future<void> setSelectedYoloModelPath(String path) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_selectedYoloModelKey, path);
}
// Agora - ganti dengan App ID dari agora.io
static const String agoraAppId = 'YOUR_AGORA_APP_ID';
}
}
@@ -14,6 +14,7 @@ class ApiClient {
_dio = Dio(BaseOptions(
baseUrl: _baseUrl!,
connectTimeout: AppConstants.connectTimeout,
sendTimeout: AppConstants.connectTimeout,
receiveTimeout: AppConstants.receiveTimeout,
headers: {
'Content-Type': 'application/json',
@@ -97,4 +98,4 @@ class _ErrorInterceptor extends Interceptor {
// Let the calling code handle it — just pass through
handler.next(err);
}
}
}
@@ -0,0 +1,54 @@
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
import 'package:flutter/foundation.dart';
import '../constants/app_constants.dart';
import '../network/api_client.dart';
class CallService {
final ApiClient _apiClient;
RtcEngine? _engine;
CallService(this._apiClient);
Future<Map<String, dynamic>?> requestToken(String channelName) async {
final res = await _apiClient.dio.post('/shared/call/token', data: {'channelName': channelName});
final data = res.data['data'];
return data is Map<String, dynamic> ? data : null;
}
Future<void> notifyIncomingCall({required int receiverId, required String channelName}) async {
await _apiClient.dio.post('/shared/call/notify', data: {
'receiverId': receiverId,
'channelName': channelName,
});
}
Future<bool> joinChannel({required String channelName, int uid = 0}) async {
try {
final tokenData = await requestToken(channelName);
final token = tokenData?['token']?.toString();
_engine ??= createAgoraRtcEngine();
await _engine!.initialize(const RtcEngineContext(appId: AppConstants.agoraAppId));
await _engine!.enableAudio();
await _engine!.joinChannel(
token: token ?? '',
channelId: channelName,
uid: uid,
options: const ChannelMediaOptions(),
);
return true;
} catch (e) {
debugPrint('Agora join skipped: $e');
return false;
}
}
Future<void> leave() async {
await _engine?.leaveChannel();
}
Future<void> dispose() async {
await _engine?.release();
_engine = null;
}
}
@@ -0,0 +1,63 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../network/api_client.dart';
class FcmService {
final ApiClient _apiClient;
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin();
FcmService(this._apiClient);
Future<void> init() async {
if (kIsWeb) return;
try {
await _localNotifications.initialize(
const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
),
);
await _messaging.requestPermission(alert: true, badge: true, sound: true);
final token = await _messaging.getToken();
if (token != null) await syncToken(token);
FirebaseMessaging.instance.onTokenRefresh.listen(syncToken);
FirebaseMessaging.onMessage.listen((message) {
debugPrint('FCM foreground: ${message.data}');
_showLocalNotification(message);
});
} catch (e) {
debugPrint('FCM init skipped: $e');
}
}
Future<void> syncToken(String token) async {
try {
await _apiClient.dio.put('/auth/fcm-token', data: {'fcmToken': token});
} catch (e) {
debugPrint('FCM token sync skipped: $e');
}
}
Future<void> _showLocalNotification(RemoteMessage message) async {
final notification = message.notification;
final title = notification?.title ?? message.data['title']?.toString() ?? 'WalkGuide';
final body = notification?.body ?? message.data['body']?.toString() ?? 'Ada update baru';
await _localNotifications.show(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
title,
body,
const NotificationDetails(
android: AndroidNotificationDetails(
'walkguide_alerts',
'WalkGuide Alerts',
channelDescription: 'SOS, pairing, call, and guardian notifications',
importance: Importance.max,
priority: Priority.high,
),
),
payload: message.data['type']?.toString(),
);
}
}
@@ -1,7 +1,7 @@
import 'package:vibration/vibration.dart';
class HapticService {
Future<bool> get _hasVibrator async => (await Vibration.hasVibrator()) ?? false;
Future<bool> get _hasVibrator async => Vibration.hasVibrator();
Future<void> obstacleVeryClose() async {
if (!await _hasVibrator) return;
@@ -34,4 +34,4 @@ class HapticService {
}
Future<void> stop() async => Vibration.cancel();
}
}
@@ -0,0 +1,53 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:geolocator/geolocator.dart';
import '../constants/app_constants.dart';
import '../network/api_client.dart';
import 'offline_queue_service.dart';
class LocationReporterService {
final ApiClient _apiClient;
final OfflineQueueService _offlineQueue;
Timer? _timer;
LocationReporterService(this._apiClient, this._offlineQueue);
Future<void> start({bool walkGuideActive = false}) async {
await stop();
final interval = Duration(
milliseconds: walkGuideActive ? AppConstants.locationIntervalWalkMs : AppConstants.locationIntervalIdleMs,
);
await _sendOnce();
_timer = Timer.periodic(interval, (_) => _sendOnce());
}
Future<void> stop() async {
_timer?.cancel();
_timer = null;
}
Future<void> _sendOnce() async {
try {
await Geolocator.requestPermission();
final position = await Geolocator.getCurrentPosition();
await _apiClient.dio.post('/user/location', data: {
'lat': position.latitude,
'lng': position.longitude,
'accuracy': position.accuracy,
'speed': position.speed,
'heading': position.heading,
});
} on DioException catch (_) {
await _offlineQueue.enqueue(OfflineRequest(
method: 'POST',
path: '/user/location',
body: const {'lat': null, 'lng': null},
createdAt: DateTime.now(),
));
} catch (_) {
// GPS permission can be unavailable during desktop/web testing.
}
}
}
@@ -0,0 +1,90 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../network/api_client.dart';
class OfflineRequest {
final String method;
final String path;
final Map<String, dynamic> body;
final DateTime createdAt;
OfflineRequest({
required this.method,
required this.path,
required this.body,
required this.createdAt,
});
Map<String, dynamic> toJson() => {
'method': method,
'path': path,
'body': body,
'createdAt': createdAt.toIso8601String(),
};
factory OfflineRequest.fromJson(Map<String, dynamic> json) => OfflineRequest(
method: json['method'] as String,
path: json['path'] as String,
body: Map<String, dynamic>.from(json['body'] as Map),
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
class OfflineQueueService {
static const _key = 'offline_request_queue';
Future<void> enqueue(OfflineRequest request) async {
final prefs = await SharedPreferences.getInstance();
final queue = await readAll();
queue.add(request);
await prefs.setString(_key, jsonEncode(queue.map((e) => e.toJson()).toList()));
}
Future<List<OfflineRequest>> readAll() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_key);
if (raw == null || raw.isEmpty) return [];
final decoded = jsonDecode(raw) as List<dynamic>;
return decoded.map((e) => OfflineRequest.fromJson(Map<String, dynamic>.from(e as Map))).toList();
}
Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_key);
}
Future<int> syncPending(ApiClient apiClient) async {
final queue = await readAll();
if (queue.isEmpty) return 0;
final remaining = <OfflineRequest>[];
var synced = 0;
for (final request in queue) {
try {
switch (request.method.toUpperCase()) {
case 'POST':
await apiClient.dio.post(request.path, data: request.body);
break;
case 'PUT':
await apiClient.dio.put(request.path, data: request.body);
break;
default:
await apiClient.dio.get(request.path);
}
synced++;
} catch (_) {
remaining.add(request);
}
}
final prefs = await SharedPreferences.getInstance();
if (remaining.isEmpty) {
await prefs.remove(_key);
} else {
await prefs.setString(_key, jsonEncode(remaining.map((e) => e.toJson()).toList()));
}
return synced;
}
}
@@ -1,3 +1,5 @@
// ignore_for_file: deprecated_member_use
import 'package:speech_to_text/speech_to_text.dart';
class SttService {
@@ -51,4 +53,4 @@ class SttService {
Future.delayed(const Duration(milliseconds: 500), startListening);
}
}
}
}
@@ -1,4 +1,3 @@
import 'package:get_it/get_it.dart';
import 'tts_service.dart';
import 'stt_service.dart';
@@ -72,4 +71,4 @@ class VoiceCommandHandler {
if (key == VoiceCommandKey.repeatLast) _tts.repeatLast();
if (key == VoiceCommandKey.stopTts) _tts.stop();
}
}
}
@@ -0,0 +1,41 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../constants/app_constants.dart';
import '../storage/secure_storage.dart';
class WebSocketService {
final SecureStorage _storage;
WebSocketChannel? _channel;
StreamSubscription? _subscription;
WebSocketService(this._storage);
Future<void> connect(String serverUrl, {void Function(dynamic event)? onMessage}) async {
await disconnect();
final token = await _storage.getAccessToken();
final wsUrl = Uri.parse('${AppConstants.buildWsUrl(serverUrl)}${token == null ? '' : '?token=$token'}');
try {
_channel = WebSocketChannel.connect(wsUrl);
_subscription = _channel!.stream.listen(
onMessage ?? (event) => debugPrint('$event'),
onError: (Object error, StackTrace stackTrace) => debugPrint('$error'),
);
} catch (e) {
debugPrint('WebSocket connect skipped: $e');
}
}
void send(Object message) {
_channel?.sink.add(message);
}
Future<void> disconnect() async {
await _subscription?.cancel();
_subscription = null;
await _channel?.sink.close();
_channel = null;
}
}
@@ -1,16 +1,26 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
class SecureStorage {
static const _storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
static const _keyAccess = 'access_token';
static const _keyAccess = 'access_token';
static const _keyRefresh = 'refresh_token';
static const _keyRole = 'user_role';
static const _keyUserId = 'user_id';
static const _keyName = 'display_name';
static const _keyUid = 'unique_user_id';
static const _keyRole = 'user_role';
static const _keyUserId = 'user_id';
static const _keyName = 'display_name';
static const _keyUid = 'unique_user_id';
static const _keys = [
_keyAccess,
_keyRefresh,
_keyRole,
_keyUserId,
_keyName,
_keyUid,
];
Future<void> saveTokens({
required String accessToken,
@@ -20,22 +30,56 @@ class SecureStorage {
String? displayName,
String? uniqueUserId,
}) async {
if (kIsWeb) {
final prefs = await SharedPreferences.getInstance();
await Future.wait([
prefs.setString(_keyAccess, accessToken),
prefs.setString(_keyRefresh, refreshToken),
prefs.setString(_keyRole, role),
prefs.setString(_keyUserId, userId),
if (displayName != null) prefs.setString(_keyName, displayName),
if (uniqueUserId != null) prefs.setString(_keyUid, uniqueUserId),
]);
return;
}
await Future.wait([
_storage.write(key: _keyAccess, value: accessToken),
_storage.write(key: _keyAccess, value: accessToken),
_storage.write(key: _keyRefresh, value: refreshToken),
_storage.write(key: _keyRole, value: role),
_storage.write(key: _keyUserId, value: userId),
if (displayName != null) _storage.write(key: _keyName, value: displayName),
if (uniqueUserId != null) _storage.write(key: _keyUid, value: uniqueUserId),
_storage.write(key: _keyRole, value: role),
_storage.write(key: _keyUserId, value: userId),
if (displayName != null)
_storage.write(key: _keyName, value: displayName),
if (uniqueUserId != null)
_storage.write(key: _keyUid, value: uniqueUserId),
]);
}
Future<String?> getAccessToken() async => _storage.read(key: _keyAccess);
Future<String?> getRefreshToken() async => _storage.read(key: _keyRefresh);
Future<String?> getUserRole() async => _storage.read(key: _keyRole);
Future<String?> getUserId() async => _storage.read(key: _keyUserId);
Future<String?> getDisplayName() async => _storage.read(key: _keyName);
Future<String?> getUniqueUserId() async => _storage.read(key: _keyUid);
Future<String?> getAccessToken() async => _read(_keyAccess);
Future<String?> getRefreshToken() async => _read(_keyRefresh);
Future<String?> getUserRole() async => _read(_keyRole);
Future<String?> getUserId() async => _read(_keyUserId);
Future<String?> getDisplayName() async => _read(_keyName);
Future<String?> getUniqueUserId() async => _read(_keyUid);
Future<void> clearAll() async => _storage.deleteAll();
}
Future<void> clearAll() async {
if (kIsWeb) {
final prefs = await SharedPreferences.getInstance();
await Future.wait(_keys.map(prefs.remove));
return;
}
await _storage.deleteAll();
}
Future<String?> _read(String key) async {
if (kIsWeb) {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(key);
}
try {
return await _storage.read(key: key);
} catch (_) {
return null;
}
}
}
@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class AppColors {
static const primary = Color(0xFF1A56DB);
static const danger = Color(0xFFDC2626);
static const success = Color(0xFF16A34A);
static const surface = Color(0xFFF8FAFC);
static const text = Color(0xFF0F172A);
static const muted = Color(0xFF64748B);
}
@@ -0,0 +1,8 @@
String compactDateTime(DateTime value) {
final y = value.year.toString().padLeft(4, '0');
final m = value.month.toString().padLeft(2, '0');
final d = value.day.toString().padLeft(2, '0');
final hh = value.hour.toString().padLeft(2, '0');
final mm = value.minute.toString().padLeft(2, '0');
return '$y-$m-$d $hh:$mm';
}