api update and flutter update
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AppConstants {
|
||||
static const String _serverUrlKey = 'server_base_url';
|
||||
|
||||
// Ambil base URL dari SharedPreferences
|
||||
static Future<String?> getServerUrl() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_serverUrlKey);
|
||||
}
|
||||
|
||||
// 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;
|
||||
await prefs.setString(_serverUrlKey, 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';
|
||||
|
||||
// Timeouts
|
||||
static const Duration connectTimeout = Duration(seconds: 10);
|
||||
static const Duration receiveTimeout = Duration(seconds: 30);
|
||||
static const Duration pingTimeout = Duration(seconds: 5);
|
||||
|
||||
// Location intervals
|
||||
static const int locationIntervalWalkMs = 5000;
|
||||
static const int locationIntervalIdleMs = 30000;
|
||||
|
||||
// YOLO
|
||||
static const String yoloModelPath = 'assets/models/yolov8n.tflite';
|
||||
static const String yoloLabelsPath = 'assets/models/labels.txt';
|
||||
static const int yoloInputSize = 640;
|
||||
|
||||
// Agora - ganti dengan App ID dari agora.io
|
||||
static const String agoraAppId = 'YOUR_AGORA_APP_ID';
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
abstract class Failure {
|
||||
final String message;
|
||||
const Failure(this.message);
|
||||
}
|
||||
|
||||
class ServerFailure extends Failure {
|
||||
const ServerFailure(super.message);
|
||||
}
|
||||
|
||||
class NetworkFailure extends Failure {
|
||||
const NetworkFailure(super.message);
|
||||
}
|
||||
|
||||
class AuthFailure extends Failure {
|
||||
const AuthFailure(super.message);
|
||||
}
|
||||
|
||||
class NotFoundFailure extends Failure {
|
||||
const NotFoundFailure(super.message);
|
||||
}
|
||||
|
||||
class PairingFailure extends Failure {
|
||||
const PairingFailure(super.message);
|
||||
}
|
||||
|
||||
class ValidationFailure extends Failure {
|
||||
const ValidationFailure(super.message);
|
||||
}
|
||||
|
||||
class CacheFailure extends Failure {
|
||||
const CacheFailure(super.message);
|
||||
}
|
||||
|
||||
class ConnectionFailure extends Failure {
|
||||
const ConnectionFailure(super.message);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
class ApiClient {
|
||||
late Dio _dio;
|
||||
final SecureStorage _secureStorage;
|
||||
String? _baseUrl;
|
||||
|
||||
ApiClient(this._secureStorage);
|
||||
|
||||
Future<void> init(String serverUrl) async {
|
||||
_baseUrl = AppConstants.buildApiUrl(serverUrl);
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: _baseUrl!,
|
||||
connectTimeout: AppConstants.connectTimeout,
|
||||
receiveTimeout: AppConstants.receiveTimeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
));
|
||||
_dio.interceptors.addAll([
|
||||
_AuthInterceptor(_secureStorage, _dio),
|
||||
_ErrorInterceptor(),
|
||||
LogInterceptor(requestBody: true, responseBody: true),
|
||||
]);
|
||||
}
|
||||
|
||||
Dio get dio => _dio;
|
||||
|
||||
String? get baseUrl => _baseUrl;
|
||||
}
|
||||
|
||||
// ── Auth Interceptor: inject token & auto-refresh on 401 ──────────────────────
|
||||
class _AuthInterceptor extends Interceptor {
|
||||
final SecureStorage _storage;
|
||||
final Dio _dio;
|
||||
bool _refreshing = false;
|
||||
|
||||
_AuthInterceptor(this._storage, this._dio);
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
||||
final token = await _storage.getAccessToken();
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
if (err.response?.statusCode == 401 && !_refreshing) {
|
||||
_refreshing = true;
|
||||
try {
|
||||
final refresh = await _storage.getRefreshToken();
|
||||
if (refresh == null) {
|
||||
_refreshing = false;
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
final res = await _dio.post('/auth/refresh',
|
||||
data: {'refreshToken': refresh},
|
||||
options: Options(headers: {'Authorization': null}));
|
||||
|
||||
if (res.statusCode == 200 && res.data['success'] == true) {
|
||||
final data = res.data['data'];
|
||||
await _storage.saveTokens(
|
||||
accessToken: data['accessToken'],
|
||||
refreshToken: data['refreshToken'] ?? refresh,
|
||||
role: data['role'],
|
||||
userId: data['userId'].toString(),
|
||||
displayName: data['displayName'],
|
||||
uniqueUserId: data['uniqueUserId'],
|
||||
);
|
||||
// Retry original request
|
||||
err.requestOptions.headers['Authorization'] =
|
||||
'Bearer ${data['accessToken']}';
|
||||
final retryRes = await _dio.fetch(err.requestOptions);
|
||||
_refreshing = false;
|
||||
handler.resolve(retryRes);
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
_refreshing = false;
|
||||
await _storage.clearAll();
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error Interceptor: normalize errors ───────────────────────────────────────
|
||||
class _ErrorInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
// Let the calling code handle it — just pass through
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:vibration/vibration.dart';
|
||||
|
||||
class HapticService {
|
||||
Future<bool> get _hasVibrator async => (await Vibration.hasVibrator()) ?? false;
|
||||
|
||||
Future<void> obstacleVeryClose() async {
|
||||
if (!await _hasVibrator) return;
|
||||
Vibration.vibrate(pattern: [0, 500, 100, 500, 100, 500]);
|
||||
}
|
||||
|
||||
Future<void> obstacleClose() async {
|
||||
if (!await _hasVibrator) return;
|
||||
Vibration.vibrate(pattern: [0, 300, 100, 300]);
|
||||
}
|
||||
|
||||
Future<void> obstacleMedium() async {
|
||||
if (!await _hasVibrator) return;
|
||||
Vibration.vibrate(duration: 150);
|
||||
}
|
||||
|
||||
Future<void> sosTriggered() async {
|
||||
if (!await _hasVibrator) return;
|
||||
Vibration.vibrate(pattern: [0, 1000, 200, 1000, 200, 1000]);
|
||||
}
|
||||
|
||||
Future<void> callIncoming() async {
|
||||
if (!await _hasVibrator) return;
|
||||
Vibration.vibrate(pattern: [0, 500, 500, 500, 500, 500, 500, 500]);
|
||||
}
|
||||
|
||||
Future<void> success() async {
|
||||
if (!await _hasVibrator) return;
|
||||
Vibration.vibrate(duration: 80);
|
||||
}
|
||||
|
||||
Future<void> stop() async => Vibration.cancel();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:speech_to_text/speech_to_text.dart';
|
||||
|
||||
class SttService {
|
||||
final SpeechToText _stt = SpeechToText();
|
||||
bool _available = false;
|
||||
bool _listening = false;
|
||||
Function(String)? onResult;
|
||||
|
||||
Future<bool> init() async {
|
||||
_available = await _stt.initialize(
|
||||
onError: (e) => _onError(e),
|
||||
onStatus: (s) => _onStatus(s),
|
||||
);
|
||||
return _available;
|
||||
}
|
||||
|
||||
Future<void> startListening() async {
|
||||
if (!_available || _listening) return;
|
||||
_listening = true;
|
||||
await _stt.listen(
|
||||
onResult: (result) {
|
||||
if (result.finalResult && result.recognizedWords.isNotEmpty) {
|
||||
onResult?.call(result.recognizedWords.toLowerCase().trim());
|
||||
}
|
||||
},
|
||||
listenFor: const Duration(seconds: 10),
|
||||
pauseFor: const Duration(seconds: 3),
|
||||
localeId: 'id_ID',
|
||||
cancelOnError: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> stopListening() async {
|
||||
_listening = false;
|
||||
await _stt.stop();
|
||||
}
|
||||
|
||||
bool get isListening => _listening;
|
||||
bool get isAvailable => _available;
|
||||
|
||||
void _onError(dynamic error) {
|
||||
_listening = false;
|
||||
// Auto-restart setelah error
|
||||
Future.delayed(const Duration(seconds: 1), startListening);
|
||||
}
|
||||
|
||||
void _onStatus(String status) {
|
||||
if (status == 'done' || status == 'notListening') {
|
||||
_listening = false;
|
||||
// Auto-restart agar selalu mendengarkan
|
||||
Future.delayed(const Duration(milliseconds: 500), startListening);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter_tts/flutter_tts.dart';
|
||||
|
||||
class TtsService {
|
||||
final FlutterTts _tts = FlutterTts();
|
||||
final List<String> _queue = [];
|
||||
bool _speaking = false;
|
||||
String _lastSpoken = '';
|
||||
|
||||
Future<void> init({String language = 'id-ID', double pitch = 1.0, double rate = 0.5}) async {
|
||||
await _tts.setLanguage(language);
|
||||
await _tts.setPitch(pitch);
|
||||
await _tts.setSpeechRate(rate);
|
||||
await _tts.setVolume(1.0);
|
||||
_tts.setCompletionHandler(() {
|
||||
_speaking = false;
|
||||
_processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
/// Tambah ke antrian - tidak memotong yg sedang bicara
|
||||
void speak(String text) {
|
||||
if (text.isEmpty) return;
|
||||
_queue.add(text);
|
||||
if (!_speaking) _processQueue();
|
||||
}
|
||||
|
||||
/// Potong TTS sekarang dan langsung ucapkan ini - untuk obstacle alert
|
||||
Future<void> speakImmediate(String text) async {
|
||||
if (text.isEmpty) return;
|
||||
_queue.clear();
|
||||
await _tts.stop();
|
||||
_speaking = true;
|
||||
_lastSpoken = text;
|
||||
await _tts.speak(text);
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_queue.clear();
|
||||
_speaking = false;
|
||||
await _tts.stop();
|
||||
}
|
||||
|
||||
String get lastSpoken => _lastSpoken;
|
||||
bool get isSpeaking => _speaking;
|
||||
|
||||
Future<void> setLanguage(String lang) async => _tts.setLanguage(lang);
|
||||
Future<void> setPitch(double pitch) async => _tts.setPitch(pitch);
|
||||
Future<void> setRate(double rate) async => _tts.setSpeechRate(rate);
|
||||
|
||||
void repeatLast() {
|
||||
if (_lastSpoken.isNotEmpty) speak(_lastSpoken);
|
||||
}
|
||||
|
||||
void _processQueue() {
|
||||
if (_queue.isEmpty || _speaking) return;
|
||||
final text = _queue.removeAt(0);
|
||||
_speaking = true;
|
||||
_lastSpoken = text;
|
||||
_tts.speak(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'tts_service.dart';
|
||||
import 'stt_service.dart';
|
||||
|
||||
enum VoiceCommandKey {
|
||||
openWalkguide, startWalkguide, stopWalkguide,
|
||||
callGuardian, openNotification, readAllNotif,
|
||||
openSos, sendSos, whereAmI,
|
||||
openActivity, openNavigation, openSettings,
|
||||
repeatLast, stopTts,
|
||||
}
|
||||
|
||||
class VoiceCommand {
|
||||
final VoiceCommandKey key;
|
||||
final String phrase;
|
||||
final bool enabled;
|
||||
const VoiceCommand({required this.key, required this.phrase, required this.enabled});
|
||||
}
|
||||
|
||||
/// Callback yang dipanggil saat command terdeteksi
|
||||
/// Registered oleh router/screen yang relevan
|
||||
typedef CommandCallback = void Function(VoiceCommandKey key);
|
||||
|
||||
class VoiceCommandHandler {
|
||||
final SttService _stt;
|
||||
final TtsService _tts;
|
||||
|
||||
List<VoiceCommand> _commands = [];
|
||||
CommandCallback? onCommand;
|
||||
|
||||
VoiceCommandHandler(this._stt, this._tts);
|
||||
|
||||
void loadCommands(List<VoiceCommand> commands) {
|
||||
_commands = commands;
|
||||
_stt.onResult = _processText;
|
||||
}
|
||||
|
||||
void loadDefaultCommands() {
|
||||
_commands = const [
|
||||
VoiceCommand(key: VoiceCommandKey.openWalkguide, phrase: 'open walkguide', enabled: true),
|
||||
VoiceCommand(key: VoiceCommandKey.startWalkguide, phrase: 'start walkguide', enabled: true),
|
||||
VoiceCommand(key: VoiceCommandKey.stopWalkguide, phrase: 'stop walkguide', enabled: true),
|
||||
VoiceCommand(key: VoiceCommandKey.callGuardian, phrase: 'call guardian', enabled: true),
|
||||
VoiceCommand(key: VoiceCommandKey.openNotification, phrase: 'open notifications', enabled: true),
|
||||
VoiceCommand(key: VoiceCommandKey.readAllNotif, phrase: 'read all my notifications', enabled: true),
|
||||
VoiceCommand(key: VoiceCommandKey.openSos, phrase: 'open sos', enabled: true),
|
||||
VoiceCommand(key: VoiceCommandKey.sendSos, phrase: 'send sos', enabled: true),
|
||||
VoiceCommand(key: VoiceCommandKey.whereAmI, phrase: 'where am i', enabled: true),
|
||||
VoiceCommand(key: VoiceCommandKey.openActivity, phrase: 'open activity log', enabled: true),
|
||||
VoiceCommand(key: VoiceCommandKey.openNavigation, phrase: 'open navigation', enabled: true),
|
||||
VoiceCommand(key: VoiceCommandKey.openSettings, phrase: 'open settings', enabled: true),
|
||||
VoiceCommand(key: VoiceCommandKey.repeatLast, phrase: 'repeat', enabled: true),
|
||||
VoiceCommand(key: VoiceCommandKey.stopTts, phrase: 'stop', enabled: true),
|
||||
];
|
||||
_stt.onResult = _processText;
|
||||
}
|
||||
|
||||
void _processText(String text) {
|
||||
final lower = text.toLowerCase().trim();
|
||||
for (final cmd in _commands) {
|
||||
if (!cmd.enabled) continue;
|
||||
if (lower.contains(cmd.phrase.toLowerCase())) {
|
||||
_handleCommand(cmd.key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleCommand(VoiceCommandKey key) {
|
||||
onCommand?.call(key);
|
||||
// Built-in actions for TTS-only commands
|
||||
if (key == VoiceCommandKey.repeatLast) _tts.repeatLast();
|
||||
if (key == VoiceCommandKey.stopTts) _tts.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class SecureStorage {
|
||||
static const _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
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';
|
||||
|
||||
Future<void> saveTokens({
|
||||
required String accessToken,
|
||||
required String refreshToken,
|
||||
required String role,
|
||||
required String userId,
|
||||
String? displayName,
|
||||
String? uniqueUserId,
|
||||
}) async {
|
||||
await Future.wait([
|
||||
_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),
|
||||
]);
|
||||
}
|
||||
|
||||
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<void> clearAll() async => _storage.deleteAll();
|
||||
}
|
||||
Reference in New Issue
Block a user