1566 lines
59 KiB
Dart
1566 lines
59 KiB
Dart
import 'dart:async';
|
|
import 'package:bluetooth_serial_android/bluetooth_serial_android.dart';
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:firebase_auth/firebase_auth.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_application_1/core/enums/activity_access_state_enum.dart';
|
|
import 'package:flutter_application_1/core/enums/app_mode_enum.dart';
|
|
import 'package:flutter_application_1/core/enums/device_connection_state_enum.dart';
|
|
import 'package:flutter_application_1/core/enums/recording_state_enum.dart';
|
|
import 'package:flutter_application_1/core/enums/ride_mode_enum.dart';
|
|
import 'package:flutter_application_1/features/activity/ride_session_model.dart';
|
|
import 'package:flutter_application_1/features/device_connection/data/bluetooth_device_model.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
|
|
const String _sppUuid = '00001101-0000-1000-8000-00805F9B34FB';
|
|
const Duration _handshakeTimeout = Duration(seconds: 3); // handshake maksimal 3 detik di scan device
|
|
const Duration _pingInterval = Duration(seconds: 2); // kirim ping setiap 2 detik utk hitung ping
|
|
const Duration _pingStaleTimeout = Duration(seconds: 5); // timeout untuk ping yang tidak responsif
|
|
const Duration _telemetryStaleTimeout = Duration(milliseconds: 1200); // angka 0 di aplikasi kalau lebih dari 1,2 detik
|
|
const Duration _transportSilenceTimeout = Duration(seconds: 10); // jika tdk ada data dari esp selama 10 detik maka koneksi putus
|
|
const Duration _healthCheckInterval = Duration(milliseconds: 400);
|
|
const String _expectedHandshakeResponse = 'HELLO:SKATE_V1'; // kirim respon setelah aplikasi mengirim hello
|
|
const double _distancePerPulseKm = 0.000114668; // jarak per pulse dalam satuan KM
|
|
|
|
enum DeviceHandshakeResult { // hasil2 ketika handshake
|
|
success,
|
|
invalidDevice,
|
|
failed,
|
|
}
|
|
|
|
enum BluetoothPreparationResult { // hasil2 ketika menyiapkan Bluetooth
|
|
ready,
|
|
permissionDenied, //user tidak memberikan izin
|
|
bluetoothEnableDeclined, // user tdk mengaktifkan Bluetooth
|
|
failed,
|
|
}
|
|
|
|
class AppSessionViewModel extends ChangeNotifier {
|
|
AppSessionViewModel({
|
|
FirebaseAuth? auth,
|
|
FirebaseFirestore? firestore,
|
|
}) : _auth = auth ?? FirebaseAuth.instance,
|
|
_firestore = firestore ?? FirebaseFirestore.instance {
|
|
_platformChannel.setMethodCallHandler(_handlePlatformCall); // native kirim event ke flutter ( tombol volume )
|
|
}
|
|
|
|
static const MethodChannel _platformChannel =
|
|
MethodChannel('app.bluetooth.platform'); // jembatan (channel) antara Flutter dan kode native Android, terutama untuk scan Bluetooth dan menangkap tombol volume.
|
|
|
|
final FirebaseAuth _auth;
|
|
final FirebaseFirestore _firestore;
|
|
|
|
// STATUS BESAR APLIKASI
|
|
AppMode _appMode = AppMode.guestOffline; // mode aplikasi saat ini, bisa offline, online, dll
|
|
DeviceConnectionState _deviceConnectionState = DeviceConnectionState.initial; // status koneksi ke perangkat Bluetooth, seperti sedang memindai, terhubung, dll
|
|
RecordingState _recordingState = RecordingState.idle; // status perekaman aktivitas berkendara, seperti idle, recording, stopping, dll
|
|
RideMode _rideMode = RideMode.normal; // mode berkendara yang dipilih, seperti eco, normal, boost
|
|
|
|
static const int _pwmCalibrationMinDutyLimit = 0;
|
|
static const int _pwmCalibrationMaxDutyLimit = 1023;
|
|
|
|
final Map<RideMode, int> _calibrationMinDutyByMode = {
|
|
RideMode.eco: 420,
|
|
RideMode.normal: 520,
|
|
RideMode.boost: 620,
|
|
};
|
|
|
|
final Map<RideMode, int> _calibrationMaxDutyByMode = {
|
|
RideMode.eco: 620,
|
|
RideMode.normal: 820,
|
|
RideMode.boost: 1023,
|
|
};
|
|
|
|
bool _isLoggedIn = false; // status apakah pengguna sudah login atau belum
|
|
bool _isInternetAvailable = false; // status apakah koneksi internet tersedia atau tidak, penting untuk fitur online dan sinkronisasi data
|
|
bool _isDeviceConnected = false; // status apakah sudah terhubung ke perangkat Bluetooth atau belum, mempengaruhi akses ke fitur yang membutuhkan koneksi ke skateboard
|
|
bool _controlEnabled = false; // status apakah kontrol ke skateboard diizinkan atau tidak, biasanya diaktifkan setelah koneksi berhasil dan handshake sukses
|
|
bool _isAuthBusy = false; // status apakah sedang dalam proses autentikasi (login atau buat akun), digunakan untuk menampilkan indikator loading dan mencegah aksi ganda saat autentikasi berlangsung
|
|
bool _isBluetoothBusy = false; // status apakah sedang dalam proses yang berhubungan dengan Bluetooth (seperti memindai atau menghubungkan
|
|
bool _isReadLoopActive = false; // status apakah loop pembacaan data dari Bluetooth sedang aktif, penting untuk memastikan aplikasi terus menerima data telemetri dan status dari skateboard selama terhubung
|
|
bool _isActivityBusy = false; // status apakah sedang dalam proses yang berhubungan dengan aktivitas pengguna (seperti memuat data aktivitas atau menyimpan recording
|
|
bool _hasLoadedActivityData = false; // status apakah data aktivitas pengguna sudah pernah dimuat selama sesi saat ini, digunakan untuk menghindari pemuatan ulang yang tidak perlu kecuali diminta secara eksplisit
|
|
|
|
// ? = bisa null
|
|
// Data monitoring dari ESP32
|
|
double? _speedKmh;
|
|
int? _batteryPercent;
|
|
double? _batteryVoltage;
|
|
int? _pingMs;
|
|
|
|
// Pesan error/sukses
|
|
String? _authErrorMessage;
|
|
String? _authSuccessMessage;
|
|
String? _currentUserEmail;
|
|
String? _connectionErrorMessage;
|
|
String? _activityErrorMessage;
|
|
|
|
List<BluetoothDeviceModel> _availableBluetoothDevices = const [];
|
|
BluetoothDeviceModel? _selectedBluetoothDevice;
|
|
|
|
// Timer
|
|
Timer? _pingTimer; // timer untuk mengirim ping secara berkala ke skateboard untuk memantau kesehatan koneksi dan menghitung waktu respons (ping)
|
|
Timer? _healthTimer; // timer untuk memantau kesehatan koneksi secara berkala
|
|
Timer? _recordingTicker; // timer untuk merekam data aktivitas secara berkala
|
|
int? _pendingPingTimestamp; // timestamp untuk ping yang sedang menunggu respons
|
|
int? _lastIncomingPacketTimestamp; // timestamp untuk paket data terakhir yang diterima dari skateboard, digunakan untuk mendeteksi jika koneksi menjadi tidak responsif atau terputus
|
|
int? _lastTelemetryPacketTimestamp; // timestamp untuk paket data telemetri terakhir yang diterima, digunakan untuk menentukan apakah data telemetri sudah usang (stale) dan menampilkan indikator ke pengguna jika data tidak diperbarui dalam waktu yang wajar
|
|
int? _lastPongPacketTimestamp; // timestamp untuk paket data pong terakhir yang diterima sebagai respons terhadap ping, digunakan untuk menghitung waktu respons (ping) dan mendeteksi jika koneksi menjadi tidak responsif
|
|
|
|
bool _shouldShowConnectionLostDialog = false; // status apakah dialog kehilangan koneksi harus ditampilkan kepada pengguna, biasanya diaktifkan ketika koneksi ke skateboard terputus secara tidak terduga dan perlu memberi tahu pengguna untuk mengambil tindakan (seperti mencoba menyambung kembali)
|
|
bool _isHandlingUnexpectedDisconnect = false; // status apakah aplikasi sedang menangani proses pemutusan koneksi yang tidak terduga, digunakan untuk mencegah penanganan ganda atau konflik status ketika koneksi terputus secara tiba-tiba, seperti saat skateboard kehabisan baterai atau keluar dari jangkauan Bluetooth
|
|
String? _connectionLostDialogMessage; // pesan khusus yang akan ditampilkan di dialog kehilangan koneksi, jika null maka akan menggunakan pesan default yang menjelaskan bahwa koneksi ke skateboard terputus dan menyarankan pengguna untuk menghubungkan kembali melalui halaman Connect Device
|
|
bool _isAppInForeground = true; // status apakah aplikasi saat ini berada di latar depan (foreground) atau tidak, digunakan untuk mengelola perilaku aplikasi terkait koneksi Bluetooth dan penanganan tombol volume, seperti menonaktifkan kontrol saat aplikasi berada di latar belakang untuk mencegah aksi yang tidak disengaja pada skateboard
|
|
bool _isControlSurfaceActive = false; // status apakah permukaan kontrol (seperti tombol volume untuk akselerasi dan pengereman) sedang aktif dan dapat mempengaruhi kontrol skateboard, biasanya diaktifkan saat pengguna sedang dalam sesi berkendara yang aktif dan dinonaktifkan saat tidak dalam sesi atau saat aplikasi berada di latar belakang untuk mencegah aksi yang tidak disengaja pada skateboard
|
|
bool _isBlockingDialogVisible = false; // status apakah dialog yang memblokir interaksi pengguna saat ini sedang ditampilkan, digunakan untuk mengelola penanganan tombol volume dan kontrol permukaan lainnya, seperti menonaktifkan respons terhadap tombol volume saat dialog penting (seperti konfirmasi keluar atau peringatan kehilangan koneksi) sedang ditampilkan untuk memastikan pengguna tidak secara tidak sengaja mengirim perintah ke skateboard saat berinteraksi dengan dialog tersebut
|
|
bool _isThrottleButtonPressed = false; // status apakah tombol throttle (akselerasi) sedang ditekan, digunakan untuk mengelola kontrol akselerasi pada skateboard, seperti mengirim perintah akselerasi saat tombol ditekan dan menghentikan akselerasi saat tombol dilepas
|
|
bool _isBrakeButtonPressed = false; // status apakah tombol rem sedang ditekan, digunakan untuk mengelola kontrol pengereman pada skateboard, seperti mengirim perintah pengereman saat tombol ditekan dan menghentikan pengereman saat tombol dilepas
|
|
|
|
int _telemetryPulseTotal = 0; // jumlah total pulse yang telah diterima dari skateboard selama sesi saat ini, digunakan untuk menghitung jarak tempuh berdasarkan jumlah pulse dan faktor jarak per pulse, serta untuk merekam data aktivitas berkendara seperti jarak tempuh dalam recording session
|
|
double _telemetryDistanceKmTotal = 0.0; // jarak total dalam kilometer yang telah dihitung dari pulse yang diterima
|
|
|
|
int _recordingBaselinePulse = 0; // jumlah pulse dasar untuk perekaman, digunakan sebagai titik referensi untuk menghitung jarak tempuh selama perekaman
|
|
double _recordingBaselineDistanceKm = 0.0; // jarak dasar dalam kilometer untuk perekaman, digunakan sebagai titik referensi untuk menghitung jarak tempuh selama perekaman
|
|
DateTime? _recordingStartedAt; // timestamp saat perekaman dimulai
|
|
double _recordingDistanceKm = 0.0; // jarak yang telah ditempuh selama perekaman
|
|
|
|
// Summary activity dari firebase
|
|
double _totalDistanceKmRaw = 0.0;
|
|
int _totalRecordings = 0;
|
|
List<RideSessionModel> _recentRideSessions = const [];
|
|
|
|
AppMode get appMode => _appMode;
|
|
DeviceConnectionState get deviceConnectionState => _deviceConnectionState;
|
|
RecordingState get recordingState => _recordingState;
|
|
RideMode get rideMode => _rideMode;
|
|
int get selectedCalibrationMinDuty =>
|
|
_calibrationMinDutyByMode[_rideMode] ?? 520;
|
|
int get selectedCalibrationMaxDuty =>
|
|
_calibrationMaxDutyByMode[_rideMode] ?? 820;
|
|
|
|
String get selectedCalibrationMinDutyLabel =>
|
|
'$selectedCalibrationMinDuty / $_pwmCalibrationMaxDutyLimit';
|
|
String get selectedCalibrationMaxDutyLabel =>
|
|
'$selectedCalibrationMaxDuty / $_pwmCalibrationMaxDutyLimit';
|
|
|
|
bool get isLoggedIn => _isLoggedIn;
|
|
bool get isInternetAvailable => _isInternetAvailable;
|
|
bool get isDeviceConnected => _isDeviceConnected;
|
|
bool get controlEnabled => _controlEnabled;
|
|
bool get isRecordingActive => _recordingState == RecordingState.recording;
|
|
bool get isAuthBusy => _isAuthBusy;
|
|
bool get isBluetoothBusy => _isBluetoothBusy;
|
|
bool get isActivityBusy => _isActivityBusy;
|
|
|
|
double? get speedKmh => _speedKmh;
|
|
int? get batteryPercent => _batteryPercent;
|
|
double? get batteryVoltage => _batteryVoltage;
|
|
int? get pingMs => _pingMs;
|
|
String? get authErrorMessage => _authErrorMessage;
|
|
String? get authSuccessMessage => _authSuccessMessage;
|
|
String? get currentUserEmail => _currentUserEmail;
|
|
String? get connectionErrorMessage => _connectionErrorMessage;
|
|
String? get activityErrorMessage => _activityErrorMessage;
|
|
List<BluetoothDeviceModel> get availableBluetoothDevices =>
|
|
List.unmodifiable(_availableBluetoothDevices);
|
|
BluetoothDeviceModel? get selectedBluetoothDevice => _selectedBluetoothDevice;
|
|
bool get shouldShowConnectionLostDialog => _shouldShowConnectionLostDialog;
|
|
String get connectionLostDialogMessage =>
|
|
_connectionLostDialogMessage ??
|
|
'Koneksi ke skateboard terputus. Silakan hubungkan kembali dari halaman Connect Device.';
|
|
|
|
Duration get activeRecordingDuration {
|
|
final startedAt = _recordingStartedAt;
|
|
if (startedAt == null) {
|
|
return Duration.zero;
|
|
}
|
|
return DateTime.now().difference(startedAt);
|
|
} // durasi berkendara yang sedang berlangsung, dihitung dari waktu saat perekaman dimulai hingga saat ini, digunakan untuk menampilkan durasi perjalanan kepada pengguna selama sesi berkendara aktif
|
|
|
|
double get recordingDistanceKm => _recordingDistanceKm;
|
|
double get totalDistanceKmRaw => _totalDistanceKmRaw;
|
|
int get totalRecordings => _totalRecordings;
|
|
List<RideSessionModel> get recentRideSessions =>
|
|
List.unmodifiable(_recentRideSessions);
|
|
|
|
ActivityAccessState get activityAccessState {
|
|
if (!_isLoggedIn) {
|
|
return ActivityAccessState.unauthenticated; // disuruh login dulu
|
|
}
|
|
if (!_isInternetAvailable || !_isDeviceConnected) {
|
|
return ActivityAccessState.offlineBlocked; // harus online dan terhubung ke skateboard untuk akses aktivitas
|
|
}
|
|
return ActivityAccessState.ready; // aman semua, bisa akses aktivitas
|
|
}
|
|
|
|
// SIGN IN
|
|
Future<bool> loginWithEmailPassword({
|
|
required String email,
|
|
required String password,
|
|
}) async {
|
|
_setAuthBusy(true);
|
|
_clearAuthError(notify: false);
|
|
_authSuccessMessage = null;
|
|
|
|
try {
|
|
final credential = await _auth.signInWithEmailAndPassword(
|
|
email: email.trim(),
|
|
password: password,
|
|
);
|
|
|
|
await _ensureUserProfile(credential.user);
|
|
_hydrateSignedInState(email: credential.user?.email ?? email.trim());
|
|
return true;
|
|
} on FirebaseAuthException catch (error) {
|
|
_authErrorMessage = _mapFirebaseAuthError(error);
|
|
notifyListeners();
|
|
return false;
|
|
} on FirebaseException catch (_) {
|
|
_authErrorMessage =
|
|
'Login berhasil, tetapi profil pengguna gagal dimuat. Coba lagi.';
|
|
await _safeSignOut();
|
|
notifyListeners();
|
|
return false;
|
|
} catch (_) {
|
|
_authErrorMessage = 'Terjadi kesalahan tak terduga. Coba lagi.';
|
|
await _safeSignOut();
|
|
notifyListeners();
|
|
return false;
|
|
} finally {
|
|
_setAuthBusy(false);
|
|
}
|
|
}
|
|
|
|
// SIGN UP
|
|
Future<bool> createAccountWithEmailPassword({
|
|
required String email,
|
|
required String password,
|
|
}) async {
|
|
_setAuthBusy(true);
|
|
_clearAuthError(notify: false);
|
|
_authSuccessMessage = null;
|
|
|
|
try {
|
|
final credential = await _auth.createUserWithEmailAndPassword(
|
|
email: email.trim(),
|
|
password: password,
|
|
);
|
|
|
|
await _ensureUserProfile(credential.user, isNewUser: true);
|
|
_hydrateSignedInState(email: credential.user?.email ?? email.trim());
|
|
_authSuccessMessage = 'Akun berhasil dibuat. Silahkan login.';
|
|
return true;
|
|
} on FirebaseAuthException catch (error) {
|
|
_authErrorMessage = _mapFirebaseAuthError(error);
|
|
notifyListeners();
|
|
return false;
|
|
} on FirebaseException catch (_) {
|
|
_authErrorMessage =
|
|
'Akun berhasil dibuat, tetapi profil pengguna gagal disimpan. Coba lagi.';
|
|
await _safeSignOut();
|
|
notifyListeners();
|
|
return false;
|
|
} catch (_) {
|
|
_authErrorMessage = 'Terjadi kesalahan tak terduga. Coba lagi.';
|
|
await _safeSignOut();
|
|
notifyListeners();
|
|
return false;
|
|
} finally {
|
|
_setAuthBusy(false);
|
|
}
|
|
}
|
|
|
|
// Offline mode
|
|
void continueOfflineMode() {
|
|
_clearAuthError(notify: false);
|
|
_authSuccessMessage = null;
|
|
_isLoggedIn = false;
|
|
_isInternetAvailable = false;
|
|
_isDeviceConnected = false;
|
|
_controlEnabled = false;
|
|
_currentUserEmail = null;
|
|
_appMode = AppMode.guestOffline;
|
|
_resetActivityState(keepSummary: false, notify: false);
|
|
notifyListeners();
|
|
}
|
|
|
|
// Logout
|
|
Future<void> logoutToLogin() async {
|
|
await disconnectAndEndSession();
|
|
}
|
|
|
|
Future<void> disconnectAndEndSession() async {
|
|
_setAuthBusy(true);
|
|
|
|
try {
|
|
await _disconnectBluetoothTransport(clearDevices: false, notify: false);
|
|
await _safeSignOut();
|
|
} finally {
|
|
_resetToLoginState();
|
|
}
|
|
}
|
|
|
|
void setConnectFlowInitial() {
|
|
_deviceConnectionState = DeviceConnectionState.initial;
|
|
notifyListeners();
|
|
}
|
|
|
|
// Scan bluetooth
|
|
Future<bool> scanBluetoothDevices() async {
|
|
_clearConnectionError(notify: false);
|
|
_deviceConnectionState = DeviceConnectionState.scanning;
|
|
_isBluetoothBusy = true;
|
|
_selectedBluetoothDevice = null;
|
|
notifyListeners(); // memberitahu UI untuk memperbarui status menjadi scanning dan menampilkan indikator loading, serta menyembunyikan perangkat yang sebelumnya dipilih jika ada, karena kita akan memulai pemindaian ulang untuk mencari perangkat Bluetooth yang tersedia di sekitar pengguna.
|
|
|
|
try {
|
|
await _cancelNativeBluetoothDiscovery(); //
|
|
|
|
final preparationResult = await _prepareBluetoothForUse();
|
|
if (preparationResult != BluetoothPreparationResult.ready) { // jika Bluetooth tidak siap digunakan (misalnya karena izin ditolak atau pengguna menolak untuk mengaktifkan Bluetooth), kita akan mengatur pesan error yang sesuai dan mengubah status koneksi menjadi disconnected, lalu mengembalikan false untuk menunjukkan bahwa pemindaian tidak berhasil dilakukan.
|
|
_deviceConnectionState = DeviceConnectionState.disconnected;
|
|
return false;
|
|
}
|
|
|
|
final rawResults = await _platformChannel // memanggil metode native untuk memindai perangkat Bluetooth yang tersedia, hasilnya diharapkan berupa daftar perangkat dalam format mentah (raw) yang kemudian akan kita proses untuk membuat daftar perangkat Bluetooth yang terstruktur dan mudah digunakan dalam aplikasi.
|
|
.invokeMethod<List<dynamic>>('scanBluetoothDevices') ?? //
|
|
const <dynamic>[];
|
|
|
|
final Map<String, BluetoothDeviceModel> merged = {};
|
|
for (final rawEntry in rawResults) {
|
|
if (rawEntry is! Map) {
|
|
continue;
|
|
}
|
|
// Penentuan bluetooth berdasarkan alamatnya, jika ada duplikat alamat maka yang terbaru akan menggantikan yang lama di daftar hasil, karena alamat Bluetooth digunakan sebagai identifier unik untuk perangkat, sehingga jika ada beberapa entri dengan alamat yang sama, kita hanya perlu menyimpan satu entri terakhir yang ditemukan untuk alamat tersebut.
|
|
final entry = Map<String, dynamic>.from( //
|
|
rawEntry.map((key, value) => MapEntry(key.toString(), value)), //
|
|
);
|
|
final device = BluetoothDeviceModel.fromMap(entry); //
|
|
if (device.address.isEmpty) { // jika alamat perangkat kosong, kita abaikan entri ini karena alamat diperlukan untuk mengidentifikasi perangkat secara unik dan melakukan koneksi, jadi perangkat tanpa alamat tidak akan berguna dalam konteks aplikasi ini.
|
|
continue;
|
|
}
|
|
merged[device.address] = device;
|
|
}
|
|
// Diurutkan berdasarkan alphabet nama bluetoothnya
|
|
_availableBluetoothDevices = merged.values.toList()
|
|
..sort((a, b) =>
|
|
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()));
|
|
|
|
_deviceConnectionState = DeviceConnectionState.disconnected;
|
|
return true;
|
|
|
|
// Pesan error yang mungkin terjadi selama proses pemindaian Bluetooth, seperti Bluetooth yang tidak aktif, izin yang ditolak, atau kegagalan umum lainnya, kita akan menangkap pengecualian tersebut dan mengatur pesan error yang sesuai untuk memberi tahu pengguna tentang masalah yang terjadi, serta memastikan bahwa status koneksi diatur kembali ke disconnected untuk mencerminkan bahwa saat ini tidak ada koneksi Bluetooth yang aktif.
|
|
} on PlatformException catch (error) {
|
|
switch (error.code) {
|
|
case 'BLUETOOTH_OFF':
|
|
_connectionErrorMessage =
|
|
'Bluetooth belum aktif. Aktifkan Bluetooth lalu coba lagi.';
|
|
break;
|
|
case 'PERMISSION_DENIED':
|
|
_connectionErrorMessage =
|
|
'Izin lokasi diperlukan untuk memindai perangkat Bluetooth di Android 11.';
|
|
break;
|
|
case 'UNAVAILABLE':
|
|
_connectionErrorMessage =
|
|
'Bluetooth tidak tersedia pada perangkat ini.';
|
|
break;
|
|
case 'SCAN_IN_PROGRESS':
|
|
_connectionErrorMessage = null;
|
|
break;
|
|
default:
|
|
_connectionErrorMessage =
|
|
'Gagal memindai perangkat Bluetooth. Coba lagi.';
|
|
}
|
|
_deviceConnectionState = DeviceConnectionState.disconnected;
|
|
return error.code == 'SCAN_IN_PROGRESS';
|
|
} catch (_) {
|
|
_connectionErrorMessage =
|
|
'Gagal memindai perangkat Bluetooth. Coba lagi.';
|
|
_deviceConnectionState = DeviceConnectionState.disconnected;
|
|
return false;
|
|
} finally {
|
|
_isBluetoothBusy = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
// Ketika user memilih perangkat Bluetooth untuk dihubungkan
|
|
Future<DeviceHandshakeResult> connectToBluetoothDevice(
|
|
BluetoothDeviceModel device,
|
|
) async {
|
|
_clearConnectionError(notify: false);
|
|
_selectedBluetoothDevice = device;
|
|
_deviceConnectionState = DeviceConnectionState.connecting;
|
|
_isBluetoothBusy = true;
|
|
notifyListeners();
|
|
|
|
try {
|
|
await _cancelNativeBluetoothDiscovery();
|
|
|
|
final preparationResult = await _prepareBluetoothForUse();
|
|
if (preparationResult != BluetoothPreparationResult.ready) {
|
|
return DeviceHandshakeResult.failed;
|
|
}
|
|
|
|
final connected = await FlutterBluetoothSerial.connect( // aplikasi mencoba koneksi Bluetooth Classic ke alamat device selama maksimal 4 detik.
|
|
device.address,
|
|
uuid: _sppUuid,
|
|
timeoutMs: 4000,
|
|
);
|
|
|
|
if (!connected) {
|
|
_connectionErrorMessage =
|
|
'Gagal terhubung ke ${device.displayName}. Coba lagi.';
|
|
return DeviceHandshakeResult.failed;
|
|
}
|
|
|
|
_deviceConnectionState = DeviceConnectionState.handshaking; // setelah koneksi Bluetooth berhasil dibuat, kita mengubah status koneksi menjadi handshaking untuk menunjukkan bahwa kita sekarang dalam proses melakukan handshake dengan perangkat untuk memastikan bahwa perangkat yang terhubung adalah skateboard yang valid dan siap untuk berkomunikasi dengan aplikasi.
|
|
notifyListeners();
|
|
|
|
// Handshake berhasil
|
|
final handshakeResult = await _performHandshake();
|
|
if (handshakeResult == DeviceHandshakeResult.success) {
|
|
_markTransportConnected(device);
|
|
_startReadLoop();
|
|
_startPingTimer();
|
|
_startHealthTimer();
|
|
return DeviceHandshakeResult.success;
|
|
}
|
|
|
|
await _disconnectBluetoothTransport(clearDevices: false, notify: false);
|
|
|
|
// Handshake gagal
|
|
if (handshakeResult == DeviceHandshakeResult.invalidDevice) {
|
|
_connectionErrorMessage =
|
|
'Perangkat yang dipilih bukan skateboard yang valid.';
|
|
} else {
|
|
_connectionErrorMessage =
|
|
'Handshake gagal. Pastikan perangkat yang dipilih adalah skateboard yang benar.';
|
|
}
|
|
_selectedBluetoothDevice = null;
|
|
_deviceConnectionState = DeviceConnectionState.disconnected;
|
|
notifyListeners();
|
|
return handshakeResult;
|
|
} catch (_) {
|
|
_connectionErrorMessage = 'Koneksi Bluetooth gagal dibuat. Coba lagi.';
|
|
await _disconnectBluetoothTransport(clearDevices: false, notify: false);
|
|
_selectedBluetoothDevice = null;
|
|
_deviceConnectionState = DeviceConnectionState.disconnected;
|
|
notifyListeners();
|
|
return DeviceHandshakeResult.failed;
|
|
} finally {
|
|
_isBluetoothBusy = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> disconnectToConnectPage() async {// ketika user memutuskan untuk keluar dari sesi berkendara saat ini dan kembali ke halaman Connect Device untuk memilih perangkat Bluetooth yang lain, kita akan memutus koneksi Bluetooth yang sedang aktif, tetapi kita tidak akan menghapus daftar perangkat yang tersedia karena kita ingin mempertahankan hasil pemindaian terakhir untuk memudahkan pengguna dalam memilih perangkat lain tanpa harus memindai ulang, lalu kita akan mengubah status koneksi menjadi disconnected untuk mencerminkan bahwa saat ini tidak ada koneksi Bluetooth yang aktif, dan akhirnya memberitahu UI untuk memperbarui tampilan sesuai dengan status baru ini.
|
|
await _disconnectBluetoothTransport(clearDevices: false, notify: true);
|
|
}
|
|
|
|
void consumeConnectionLostDialog() {
|
|
_shouldShowConnectionLostDialog = false;
|
|
_connectionLostDialogMessage = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> reconnectLastDevice() async {
|
|
if (_selectedBluetoothDevice == null) {
|
|
_deviceConnectionState = DeviceConnectionState.reconnectRequired;
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
_deviceConnectionState = DeviceConnectionState.autoReconnecting;
|
|
notifyListeners();
|
|
|
|
final result = await connectToBluetoothDevice(_selectedBluetoothDevice!);
|
|
if (result != DeviceHandshakeResult.success) {
|
|
_deviceConnectionState = DeviceConnectionState.reconnectRequired;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
void setRideMode(RideMode mode) {
|
|
if (!_isDeviceConnected) {
|
|
return;
|
|
}
|
|
_rideMode = mode;
|
|
notifyListeners();
|
|
|
|
unawaited(_writeLine(_modeCommand(mode)));
|
|
}// ketika pengguna memilih mode berkendara yang berbeda (seperti eco, normal, boost) saat terhubung ke skateboard, kita akan memeriksa apakah saat ini ada koneksi ke perangkat Bluetooth yang aktif, jika tidak ada koneksi maka kita tidak akan melakukan apa-apa karena mode berkendara hanya relevan saat terhubung ke skateboard, tetapi jika ada koneksi maka kita akan memperbarui status mode berkendara di dalam aplikasi dan memberitahu UI untuk memperbarui tampilan sesuai dengan mode yang dipilih, lalu kita akan mengirim perintah khusus ke skateboard melalui koneksi Bluetooth untuk memberi tahu skateboard tentang perubahan mode berkendara yang dipilih oleh pengguna
|
|
|
|
void adjustSelectedMinDuty(int delta) {
|
|
_adjustSelectedPwmCalibration(minDelta: delta);
|
|
}
|
|
|
|
void adjustSelectedMaxDuty(int delta) {
|
|
_adjustSelectedPwmCalibration(maxDelta: delta);
|
|
}
|
|
|
|
void increaseSelectedMinPwm() {
|
|
adjustSelectedMinDuty(50);
|
|
}
|
|
|
|
void decreaseSelectedMinPwm() {
|
|
adjustSelectedMinDuty(-50);
|
|
}
|
|
|
|
void increaseSelectedMaxPwm() {
|
|
adjustSelectedMaxDuty(50);
|
|
}
|
|
|
|
void decreaseSelectedMaxPwm() {
|
|
adjustSelectedMaxDuty(-50);
|
|
}
|
|
|
|
Future<void> applySelectedPwmCalibration() async {
|
|
if (!_isDeviceConnected) {
|
|
return;
|
|
}
|
|
|
|
final modeName = _modeCalibrationName(_rideMode);
|
|
final minValue = selectedCalibrationMinDuty;
|
|
final maxValue = selectedCalibrationMaxDuty;
|
|
|
|
await _writeLine('CAL:$modeName:MIN=$minValue');
|
|
await _writeLine('CAL:$modeName:MAX=$maxValue');
|
|
}
|
|
|
|
Future<void> triggerEmergencyStop() async {
|
|
if (!_isDeviceConnected) {
|
|
return;
|
|
}
|
|
|
|
_isThrottleButtonPressed = false;
|
|
_isBrakeButtonPressed = false;
|
|
await _writeLine('ESTOP');
|
|
notifyListeners();
|
|
}
|
|
|
|
void _adjustSelectedPwmCalibration({
|
|
int minDelta = 0,
|
|
int maxDelta = 0,
|
|
}) {
|
|
final currentMin = selectedCalibrationMinDuty;
|
|
final currentMax = selectedCalibrationMaxDuty;
|
|
|
|
var nextMin = (currentMin + minDelta)
|
|
.clamp(_pwmCalibrationMinDutyLimit, _pwmCalibrationMaxDutyLimit)
|
|
.toInt();
|
|
var nextMax = (currentMax + maxDelta)
|
|
.clamp(_pwmCalibrationMinDutyLimit, _pwmCalibrationMaxDutyLimit)
|
|
.toInt();
|
|
|
|
if (nextMax < nextMin) {
|
|
if (minDelta != 0) {
|
|
nextMax = nextMin;
|
|
} else {
|
|
nextMin = nextMax;
|
|
}
|
|
}
|
|
|
|
_calibrationMinDutyByMode[_rideMode] = nextMin;
|
|
_calibrationMaxDutyByMode[_rideMode] = nextMax;
|
|
notifyListeners();
|
|
}
|
|
|
|
String _modeCommand(RideMode mode) {
|
|
return switch (mode) {
|
|
RideMode.eco => 'MODE:ECO',
|
|
RideMode.normal => 'MODE:NORMAL',
|
|
RideMode.boost => 'MODE:BOOST',
|
|
};
|
|
}
|
|
|
|
String _modeCalibrationName(RideMode mode) {
|
|
return switch (mode) {
|
|
RideMode.eco => 'ECO',
|
|
RideMode.normal => 'NORMAL',
|
|
RideMode.boost => 'BOOST',
|
|
};
|
|
}
|
|
|
|
void setControlSurfaceActive(bool value) {
|
|
if (_isControlSurfaceActive == value) {
|
|
return;
|
|
}
|
|
_isControlSurfaceActive = value;
|
|
unawaited(
|
|
_refreshHardwareButtonCapture(
|
|
triggerFailsafeOnDisable: !value,
|
|
),
|
|
);
|
|
}// ketika status permukaan kontrol (seperti tombol volume untuk akselerasi dan pengereman) diubah, kita akan memeriksa apakah status baru sama dengan status saat ini, jika sama maka kita tidak akan melakukan apa-apa karena tidak ada perubahan yang perlu diterapkan, tetapi jika berbeda maka kita akan memperbarui status permukaan kontrol di dalam aplikasi dan kemudian memanggil metode untuk menyegarkan penangkapan tombol hardware, yang akan mengaktifkan atau menonaktifkan respons terhadap tombol volume berdasarkan status permukaan kontrol yang baru, serta dapat memicu mode failsafe pada skateboard jika permukaan kontrol dinonaktifkan untuk memastikan keamanan pengguna saat tidak ingin menggunakan kontrol fisik pada skateboard.
|
|
|
|
Future<void> setBlockingDialogVisible(bool value) async {
|
|
if (_isBlockingDialogVisible == value) {
|
|
return;
|
|
}
|
|
|
|
_isBlockingDialogVisible = value;
|
|
await _refreshHardwareButtonCapture(
|
|
triggerFailsafeOnDisable: value,
|
|
);
|
|
}
|
|
|
|
Future<void> handleAppLifecycleStateChanged(AppLifecycleState state) async {
|
|
final isForeground = state == AppLifecycleState.resumed;
|
|
if (_isAppInForeground == isForeground) {
|
|
return;
|
|
}
|
|
|
|
_isAppInForeground = isForeground;
|
|
await _refreshHardwareButtonCapture(
|
|
triggerFailsafeOnDisable: !isForeground,
|
|
);
|
|
}// ketika status aplikasi berubah antara latar depan (foreground) dan latar belakang (background), kita akan memeriksa apakah status baru sama dengan status saat ini, jika sama maka kita tidak akan melakukan apa-apa karena tidak ada perubahan yang perlu diterapkan, tetapi jika berbeda maka kita akan memperbarui status aplikasi di dalam aplikasi dan kemudian memanggil metode untuk menyegarkan penangkapan tombol hardware, yang akan mengaktifkan atau menonaktifkan respons terhadap tombol volume berdasarkan apakah aplikasi berada di latar depan atau tidak, serta dapat memicu mode failsafe pada skateboard jika aplikasi berada di latar belakang untuk memastikan keamanan pengguna saat aplikasi tidak aktif.
|
|
|
|
Future<void> loadActivityData({bool force = false}) async {
|
|
if (!_isLoggedIn) {
|
|
return;
|
|
}
|
|
if (_hasLoadedActivityData && !force) {
|
|
return;
|
|
}
|
|
|
|
_setActivityBusy(true);
|
|
_activityErrorMessage = null;
|
|
|
|
final user = _auth.currentUser;
|
|
if (user == null) {
|
|
_activityErrorMessage = 'Pengguna tidak ditemukan. Silakan login ulang.';
|
|
_setActivityBusy(false);
|
|
return;
|
|
}// ketika pengguna ingin memuat data aktivitas mereka (seperti riwayat perjalanan dan statistik total), kita akan memeriksa apakah pengguna saat ini sudah login, jika belum login maka kita tidak akan melakukan apa-apa karena data aktivitas terkait dengan akun pengguna, tetapi jika sudah login maka kita akan memeriksa apakah data aktivitas sudah pernah dimuat selama sesi saat ini dan apakah pemuatan ulang dipaksa, jika data sudah dimuat dan pemuatan ulang tidak dipaksa maka kita tidak akan melakukan apa-apa untuk menghindari pemuatan ulang yang tidak perlu, tetapi jika data belum dimuat atau pemuatan ulang dipaksa maka kita akan mengatur status sibuk untuk aktivitas menjadi true untuk menunjukkan bahwa kita sedang dalam proses memuat data, lalu kita akan mencoba mengambil data aktivitas dari Firestore berdasarkan ID pengguna saat ini, termasuk total jarak tempuh mentah, total rekaman perjalanan,
|
|
|
|
|
|
// Pengambilan data dari firestore
|
|
try {
|
|
final userRef = _firestore.collection('users').doc(user.uid); // alamat dokumen pengguna di Firestore berdasarkan ID pengguna saat ini, yang akan digunakan untuk mengambil data aktivitas terkait dengan pengguna tersebut, seperti total jarak tempuh dan riwayat perjalanan.
|
|
final userSnapshot = await userRef.get(); // data pengguna dari userref ( masih snapshot )
|
|
final userData = userSnapshot.data(); // data pengguna dalam bentuk map yang berisi field-field seperti totalDistanceKmRaw dan totalRecordings, yang akan kita ekstrak untuk memperbarui status aktivitas di dalam aplikasi, serta untuk menampilkan informasi tersebut kepada pengguna di bagian aktivitas atau profil mereka.
|
|
|
|
_totalDistanceKmRaw = // dari userdata map
|
|
(userData?['totalDistanceKmRaw'] as num?)?.toDouble() ?? 0.0;
|
|
_totalRecordings = (userData?['totalRecordings'] as num?)?.toInt() ?? 0;
|
|
|
|
final sessionsSnapshot = await userRef // mengambil data sesi perjalanan terbaru dari koleksi 'sessions' yang merupakan subkoleksi di dalam dokumen pengguna, kita akan mengurutkan sesi berdasarkan waktu mulai (startedAt) secara menurun untuk mendapatkan sesi terbaru terlebih dahulu, dan membatasi jumlah sesi yang diambil hingga 20 untuk menghindari mengambil terlalu banyak data sekaligus, lalu kita akan memetakan setiap dokumen sesi menjadi model RideSessionModel yang terstruktur dan mudah digunakan dalam aplikasi, serta menyimpan daftar sesi perjalanan terbaru ini di dalam status aktivitas untuk ditampilkan kepada pengguna di bagian riwayat perjalanan mereka.
|
|
.collection('sessions')
|
|
.orderBy('startedAt', descending: true)
|
|
.limit(20)
|
|
.get();
|
|
|
|
_recentRideSessions = sessionsSnapshot.docs
|
|
.map(RideSessionModel.fromFirestore)
|
|
.toList(growable: false);
|
|
_hasLoadedActivityData = true;
|
|
notifyListeners();
|
|
} catch (_) {
|
|
_activityErrorMessage = 'Gagal memuat riwayat perjalanan. Coba lagi.';
|
|
} finally {
|
|
_setActivityBusy(false);
|
|
}
|
|
}
|
|
|
|
// Memulai rekam perjalanan
|
|
Future<void> startRideRecording() async {
|
|
if (!_isLoggedIn || // memeriksa apakah pengguna sudah login, apakah koneksi internet tersedia, apakah perangkat terhubung, dan apakah saat ini tidak sedang dalam proses perekaman, jika salah satu dari kondisi ini tidak terpenuhi maka kita tidak akan memulai perekaman karena perekaman perjalanan memerlukan pengguna yang terautentikasi, koneksi ke skateboard untuk mendapatkan data telemetri, dan tidak boleh ada perekaman yang sedang berlangsung untuk memastikan bahwa data yang direkam akurat dan terkait dengan sesi berkendara yang valid.
|
|
!_isInternetAvailable ||
|
|
!_isDeviceConnected ||
|
|
_recordingState != RecordingState.idle) {
|
|
return;
|
|
}
|
|
|
|
_activityErrorMessage = null;
|
|
_recordingBaselinePulse = _telemetryPulseTotal;
|
|
_recordingBaselineDistanceKm = _telemetryDistanceKmTotal;
|
|
_recordingStartedAt = DateTime.now();
|
|
_recordingDistanceKm = 0.0;
|
|
_recordingState = RecordingState.recording;
|
|
_startRecordingTicker();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> discardRideRecording() async {
|
|
if (_recordingState == RecordingState.idle) {
|
|
return;
|
|
}
|
|
|
|
_resetRecordingState(notify: true);
|
|
}
|
|
|
|
// Menghentikan rekam perjalanan dan menyimpan hasilnya ke Firestore
|
|
Future<bool> stopRideRecordingAndSave({
|
|
required String sessionName,
|
|
}) async {
|
|
final user = _auth.currentUser;
|
|
final startedAt = _recordingStartedAt;
|
|
|
|
if (user == null ||
|
|
startedAt == null ||
|
|
!_isLoggedIn ||
|
|
!_isInternetAvailable) {
|
|
_activityErrorMessage =
|
|
'Recording tidak bisa disimpan karena sesi pengguna tidak ditemukan.';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
|
|
_recordingState = RecordingState.stopping;
|
|
_setActivityBusy(true);
|
|
notifyListeners();
|
|
|
|
final endedAt = DateTime.now();
|
|
final durationSec = endedAt.difference(startedAt).inSeconds; // hitung durasi mulai dan berhenti dalam detik
|
|
int pulseDelta = _telemetryPulseTotal - _recordingBaselinePulse; // pulse stop - pulse start
|
|
if (pulseDelta < 0) {
|
|
pulseDelta = 0;
|
|
}
|
|
final distanceKmRaw = pulseDelta * _distancePerPulseKm; // menghitung jarak dalam KM
|
|
final userRef = _firestore.collection('users').doc(user.uid);
|
|
final sessionRef = userRef.collection('sessions').doc();
|
|
|
|
final normalizedTitle = sessionName.trim().isEmpty
|
|
? 'Recording Session' // jika tidak dinamai maka menjadi recording session
|
|
: sessionName.trim();
|
|
final session = RideSessionModel( // Format paket ride session yang akan dikirim ke firebase
|
|
id: sessionRef.id,
|
|
title: normalizedTitle,
|
|
startedAt: startedAt,
|
|
endedAt: endedAt,
|
|
durationSec: durationSec,
|
|
distanceKmRaw: distanceKmRaw,
|
|
);
|
|
|
|
try {
|
|
await _firestore.runTransaction((transaction) async {
|
|
final userSnapshot = await transaction.get(userRef);
|
|
final userData = userSnapshot.data();
|
|
final currentDistance =
|
|
(userData?['totalDistanceKmRaw'] as num?)?.toDouble() ?? 0.0;
|
|
final currentRecordings =
|
|
(userData?['totalRecordings'] as num?)?.toInt() ?? 0;
|
|
|
|
transaction.set(sessionRef, session.toFirestoreMap());
|
|
transaction.set(
|
|
userRef,
|
|
{
|
|
'email': user.email,
|
|
'totalDistanceKmRaw': currentDistance + distanceKmRaw,
|
|
'totalRecordings': currentRecordings + 1,
|
|
'updatedAt': FieldValue.serverTimestamp(),
|
|
},
|
|
SetOptions(merge: true),
|
|
);
|
|
});
|
|
|
|
_recentRideSessions = [session, ..._recentRideSessions];
|
|
_totalDistanceKmRaw += distanceKmRaw;
|
|
_totalRecordings += 1;
|
|
_hasLoadedActivityData = true;
|
|
_activityErrorMessage = null;
|
|
_resetRecordingState(notify: false);
|
|
notifyListeners();
|
|
return true;
|
|
} catch (_) {
|
|
_activityErrorMessage = 'Gagal menyimpan recording session. Coba lagi.';
|
|
_recordingState = RecordingState.recording;
|
|
notifyListeners();
|
|
return false;
|
|
} finally {
|
|
_setActivityBusy(false);
|
|
}
|
|
}
|
|
|
|
Future<void> deleteRideSession(RideSessionModel session) async {
|
|
final user = _auth.currentUser;
|
|
if (user == null) {
|
|
_activityErrorMessage =
|
|
'Riwayat perjalanan tidak bisa dihapus karena sesi pengguna tidak ditemukan.';
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
_setActivityBusy(true);
|
|
_activityErrorMessage = null;
|
|
|
|
final userRef = _firestore.collection('users').doc(user.uid);
|
|
final sessionRef = userRef.collection('sessions').doc(session.id);
|
|
|
|
try {
|
|
await _firestore.runTransaction((transaction) async {
|
|
final userSnapshot = await transaction.get(userRef);
|
|
final userData = userSnapshot.data();
|
|
final currentDistance =
|
|
(userData?['totalDistanceKmRaw'] as num?)?.toDouble() ?? 0.0;
|
|
final currentRecordings =
|
|
(userData?['totalRecordings'] as num?)?.toInt() ?? 0;
|
|
|
|
final nextDistance = (currentDistance - session.distanceKmRaw) < 0
|
|
? 0.0
|
|
: currentDistance - session.distanceKmRaw;
|
|
final nextRecordings = currentRecordings <= 0 ? 0 : currentRecordings - 1;
|
|
|
|
transaction.delete(sessionRef);
|
|
transaction.set(
|
|
userRef,
|
|
{
|
|
'totalDistanceKmRaw': nextDistance,
|
|
'totalRecordings': nextRecordings,
|
|
'updatedAt': FieldValue.serverTimestamp(),
|
|
},
|
|
SetOptions(merge: true),
|
|
);
|
|
});
|
|
|
|
_recentRideSessions = _recentRideSessions
|
|
.where((item) => item.id != session.id)
|
|
.toList(growable: false);
|
|
_totalDistanceKmRaw = (_totalDistanceKmRaw - session.distanceKmRaw) < 0
|
|
? 0.0
|
|
: (_totalDistanceKmRaw - session.distanceKmRaw);
|
|
_totalRecordings = _totalRecordings <= 0 ? 0 : _totalRecordings - 1;
|
|
notifyListeners();
|
|
} catch (_) {
|
|
_activityErrorMessage = 'Gagal menghapus recording session. Coba lagi.';
|
|
notifyListeners();
|
|
} finally {
|
|
_setActivityBusy(false);
|
|
}
|
|
}
|
|
|
|
void clearAuthError() {
|
|
_clearAuthError(notify: true);
|
|
}
|
|
|
|
void clearConnectionError() {
|
|
_clearConnectionError(notify: true);
|
|
}
|
|
|
|
void clearActivityError() {
|
|
_activityErrorMessage = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> _ensureUserProfile(User? user, {bool isNewUser = false}) async {
|
|
if (user == null) {
|
|
throw FirebaseException(
|
|
plugin: 'firebase_auth',
|
|
message: 'User tidak ditemukan setelah autentikasi.',
|
|
);
|
|
}
|
|
|
|
final docRef = _firestore.collection('users').doc(user.uid);
|
|
final snapshot = await docRef.get();
|
|
|
|
if (!snapshot.exists || isNewUser) {
|
|
await docRef.set({
|
|
'email': user.email,
|
|
'totalDistanceKmRaw': 0.0,
|
|
'totalRecordings': 0,
|
|
'createdAt': FieldValue.serverTimestamp(),
|
|
'updatedAt': FieldValue.serverTimestamp(),
|
|
}, SetOptions(merge: true));
|
|
return;
|
|
}
|
|
|
|
await docRef.set({
|
|
'email': user.email,
|
|
'updatedAt': FieldValue.serverTimestamp(),
|
|
}, SetOptions(merge: true));
|
|
}
|
|
|
|
void _hydrateSignedInState({required String email}) {
|
|
_isLoggedIn = true;
|
|
_isInternetAvailable = true;
|
|
_currentUserEmail = email;
|
|
_appMode = AppMode.signedInOnline;
|
|
_activityErrorMessage = null;
|
|
notifyListeners();
|
|
unawaited(loadActivityData(force: true));
|
|
}
|
|
|
|
Future<void> _safeSignOut() async {
|
|
if (_auth.currentUser == null) {
|
|
return;
|
|
}
|
|
|
|
await _auth.signOut();
|
|
}
|
|
|
|
Future<BluetoothPreparationResult> _prepareBluetoothForUse() async {
|
|
try {
|
|
final sdkInt = await _getAndroidSdkInt();
|
|
final permissionsGranted = await _requestScanPermissions(sdkInt);
|
|
if (!permissionsGranted) {
|
|
_connectionErrorMessage = sdkInt >= 31
|
|
? 'Izin Bluetooth belum diberikan. Izinkan Nearby devices lalu coba lagi.'
|
|
: 'Izin lokasi belum diberikan. Izinkan akses lokasi untuk memindai perangkat Bluetooth.';
|
|
return BluetoothPreparationResult.permissionDenied;
|
|
}
|
|
|
|
final bluetoothEnabled = await _ensureBluetoothEnabled();
|
|
if (!bluetoothEnabled) {
|
|
_connectionErrorMessage =
|
|
'Bluetooth belum dinyalakan. Aktifkan Bluetooth lalu coba lagi.';
|
|
return BluetoothPreparationResult.bluetoothEnableDeclined;
|
|
}
|
|
|
|
return BluetoothPreparationResult.ready;
|
|
} catch (_) {
|
|
_connectionErrorMessage =
|
|
'Persiapan Bluetooth gagal dilakukan. Coba lagi.';
|
|
return BluetoothPreparationResult.failed;
|
|
}
|
|
}
|
|
|
|
Future<int> _getAndroidSdkInt() async {
|
|
try {
|
|
return await _platformChannel.invokeMethod<int>('getSdkInt') ?? 30;
|
|
} catch (_) {
|
|
return 30;
|
|
}
|
|
}
|
|
|
|
Future<bool> _requestScanPermissions(int sdkInt) async {
|
|
if (sdkInt >= 31) {
|
|
final scanStatus = await Permission.bluetoothScan.request();
|
|
final connectStatus = await Permission.bluetoothConnect.request();
|
|
return scanStatus.isGranted && connectStatus.isGranted;
|
|
}
|
|
|
|
final locationStatus = await Permission.location.request();
|
|
return locationStatus.isGranted;
|
|
}
|
|
|
|
Future<bool> _ensureBluetoothEnabled() async {
|
|
try {
|
|
final isEnabled =
|
|
await _platformChannel.invokeMethod<bool>('isBluetoothEnabled') ?? false;
|
|
if (isEnabled) {
|
|
return true;
|
|
}
|
|
|
|
return await _platformChannel.invokeMethod<bool>('requestEnableBluetooth') ??
|
|
false;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
|
|
bool get _shouldCaptureHardwareButtons {
|
|
return _controlEnabled &&
|
|
_isDeviceConnected &&
|
|
_isControlSurfaceActive &&
|
|
_isAppInForeground &&
|
|
!_isBlockingDialogVisible;
|
|
}
|
|
|
|
Future<void> _cancelNativeBluetoothDiscovery() async {
|
|
try {
|
|
await _platformChannel.invokeMethod<bool>('cancelBluetoothDiscovery');
|
|
} catch (_) {
|
|
// Best effort only.
|
|
}
|
|
}
|
|
|
|
Future<void> _refreshHardwareButtonCapture({
|
|
required bool triggerFailsafeOnDisable,
|
|
}) async {
|
|
final shouldCapture = _shouldCaptureHardwareButtons;
|
|
await _setPlatformControlCaptureEnabled(shouldCapture);
|
|
|
|
if (shouldCapture) {
|
|
return;
|
|
}
|
|
|
|
final hadActiveControl = _isThrottleButtonPressed || _isBrakeButtonPressed;
|
|
_isThrottleButtonPressed = false;
|
|
_isBrakeButtonPressed = false;
|
|
|
|
final shouldSendFailsafe = triggerFailsafeOnDisable &&
|
|
_isDeviceConnected &&
|
|
(hadActiveControl || ((_speedKmh ?? 0) > 0.2));
|
|
if (shouldSendFailsafe) {
|
|
await _writeLine('ESTOP');
|
|
}
|
|
}
|
|
|
|
Future<void> _setPlatformControlCaptureEnabled(bool enabled) async {
|
|
try {
|
|
await _platformChannel.invokeMethod('setControlCaptureEnabled', enabled);
|
|
} catch (_) {
|
|
// Best effort only.
|
|
}
|
|
}
|
|
|
|
Future<dynamic> _handlePlatformCall(MethodCall call) async {
|
|
if (call.method != 'hardwareButtonEvent') {
|
|
return null;
|
|
}
|
|
|
|
final rawArgs = call.arguments;
|
|
if (rawArgs is! Map) {
|
|
return null;
|
|
}
|
|
|
|
final args = Map<String, dynamic>.from(
|
|
rawArgs.map((key, value) => MapEntry(key.toString(), value)),
|
|
);
|
|
|
|
await _handleHardwareButtonEvent(
|
|
key: (args['key'] as String?)?.trim().toLowerCase() ?? '',
|
|
action: (args['action'] as String?)?.trim().toLowerCase() ?? '',
|
|
);
|
|
return true;
|
|
}
|
|
|
|
Future<void> _handleHardwareButtonEvent({
|
|
required String key,
|
|
required String action,
|
|
}) async {
|
|
if (!_shouldCaptureHardwareButtons) {
|
|
return;
|
|
}
|
|
|
|
if (action == 'down') {
|
|
if (key == 'volume_up') {
|
|
if (_isBrakeButtonPressed || _isThrottleButtonPressed) {
|
|
return;
|
|
}
|
|
_isThrottleButtonPressed = true;
|
|
await _writeLine('THR:PRESS');
|
|
return;
|
|
}
|
|
|
|
if (key == 'volume_down') {
|
|
if (_isThrottleButtonPressed) {
|
|
_isThrottleButtonPressed = false;
|
|
await _writeLine('THR:RELEASE');
|
|
}
|
|
if (_isBrakeButtonPressed) {
|
|
return;
|
|
}
|
|
_isBrakeButtonPressed = true;
|
|
await _writeLine('BRK:PRESS');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (action == 'up') {
|
|
if (key == 'volume_up') {
|
|
if (!_isThrottleButtonPressed) {
|
|
return;
|
|
}
|
|
_isThrottleButtonPressed = false;
|
|
await _writeLine('THR:RELEASE');
|
|
return;
|
|
}
|
|
|
|
if (key == 'volume_down') {
|
|
if (!_isBrakeButtonPressed) {
|
|
return;
|
|
}
|
|
_isBrakeButtonPressed = false;
|
|
await _writeLine('BRK:RELEASE');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<DeviceHandshakeResult> _performHandshake() async {
|
|
final bool sent = await _writeLine('HELLO');
|
|
if (!sent) {
|
|
return DeviceHandshakeResult.failed;
|
|
}
|
|
|
|
final stopwatch = Stopwatch()..start();
|
|
while (stopwatch.elapsed < _handshakeTimeout) {
|
|
try {
|
|
final line = await FlutterBluetoothSerial.readLine('\n');
|
|
final normalized = line?.trim();
|
|
if (normalized == null || normalized.isEmpty) {
|
|
continue;
|
|
}
|
|
if (normalized == _expectedHandshakeResponse) {
|
|
return DeviceHandshakeResult.success;
|
|
}
|
|
if (normalized.startsWith('ERR:NOT_SKATEBOARD')) {
|
|
return DeviceHandshakeResult.invalidDevice;
|
|
}
|
|
if (normalized.startsWith('TEL:') || normalized.startsWith('PONG:')) {
|
|
continue;
|
|
}
|
|
} catch (_) {
|
|
return DeviceHandshakeResult.failed;
|
|
}
|
|
}
|
|
|
|
return DeviceHandshakeResult.failed;
|
|
}
|
|
|
|
void _markTransportConnected(BluetoothDeviceModel device) {
|
|
_selectedBluetoothDevice = device;
|
|
_isDeviceConnected = true;
|
|
_controlEnabled = true;
|
|
_deviceConnectionState = DeviceConnectionState.connected;
|
|
_rideMode = RideMode.normal;
|
|
_speedKmh = 0;
|
|
_telemetryPulseTotal = 0;
|
|
_telemetryDistanceKmTotal = 0.0;
|
|
_batteryPercent = null;
|
|
_batteryVoltage = null;
|
|
_pingMs = 0;
|
|
_pendingPingTimestamp = null;
|
|
final now = DateTime.now().millisecondsSinceEpoch;
|
|
_lastIncomingPacketTimestamp = now;
|
|
_lastTelemetryPacketTimestamp = now;
|
|
_lastPongPacketTimestamp = null;
|
|
_shouldShowConnectionLostDialog = false;
|
|
_connectionLostDialogMessage = null;
|
|
_isHandlingUnexpectedDisconnect = false;
|
|
_isThrottleButtonPressed = false;
|
|
_isBrakeButtonPressed = false;
|
|
|
|
if (_isLoggedIn && _isInternetAvailable) {
|
|
_appMode = AppMode.signedInOnline;
|
|
} else if (_isLoggedIn && !_isInternetAvailable) {
|
|
_appMode = AppMode.signedInOffline;
|
|
} else {
|
|
_appMode = AppMode.guestOffline;
|
|
}
|
|
|
|
notifyListeners();
|
|
unawaited(
|
|
_refreshHardwareButtonCapture(
|
|
triggerFailsafeOnDisable: false,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _startReadLoop() {
|
|
if (_isReadLoopActive) {
|
|
return;
|
|
}
|
|
|
|
_isReadLoopActive = true;
|
|
unawaited(
|
|
Future<void>(() async {
|
|
while (_isReadLoopActive && _isDeviceConnected) {
|
|
try {
|
|
final line = await FlutterBluetoothSerial.readLine('\n');
|
|
final normalized = line?.trim();
|
|
if (normalized == null || normalized.isEmpty) {
|
|
continue;
|
|
}
|
|
_handleIncomingLine(normalized);
|
|
} catch (_) {
|
|
await _handleUnexpectedTransportDisconnect();
|
|
break;
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
void _startPingTimer() {
|
|
_pingTimer?.cancel();
|
|
unawaited(_handlePingTick());
|
|
_pingTimer = Timer.periodic(_pingInterval, (_) {
|
|
unawaited(_handlePingTick());
|
|
});
|
|
}
|
|
|
|
void _startHealthTimer() {
|
|
_healthTimer?.cancel();
|
|
_healthTimer = Timer.periodic(_healthCheckInterval, (_) {
|
|
_runTransportHealthCheck();
|
|
});
|
|
}
|
|
|
|
void _runTransportHealthCheck() {
|
|
if (!_isDeviceConnected) {
|
|
return;
|
|
}
|
|
|
|
final now = DateTime.now().millisecondsSinceEpoch;
|
|
bool shouldNotify = false;
|
|
|
|
final lastTelemetry = _lastTelemetryPacketTimestamp;
|
|
if (lastTelemetry == null ||
|
|
(now - lastTelemetry) > _telemetryStaleTimeout.inMilliseconds) {
|
|
if ((_speedKmh ?? 0.0) != 0.0) {
|
|
_speedKmh = 0.0;
|
|
shouldNotify = true;
|
|
}
|
|
}
|
|
|
|
final lastPong = _lastPongPacketTimestamp;
|
|
if (lastPong != null &&
|
|
(now - lastPong) > _pingStaleTimeout.inMilliseconds) {
|
|
_pendingPingTimestamp = null;
|
|
}
|
|
|
|
final lastIncoming = _lastIncomingPacketTimestamp;
|
|
if (lastIncoming != null &&
|
|
(now - lastIncoming) > _transportSilenceTimeout.inMilliseconds) {
|
|
unawaited(_handleUnexpectedTransportDisconnect());
|
|
return;
|
|
}
|
|
|
|
if (shouldNotify) {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> _handlePingTick() async {
|
|
if (!_isDeviceConnected) {
|
|
return;
|
|
}
|
|
|
|
final now = DateTime.now().millisecondsSinceEpoch;
|
|
final pendingPing = _pendingPingTimestamp;
|
|
if (pendingPing != null &&
|
|
(now - pendingPing) < _pingStaleTimeout.inMilliseconds) {
|
|
return;
|
|
}
|
|
|
|
_pendingPingTimestamp = now;
|
|
final sent = await _writeLine('PING:$now');
|
|
if (!sent && _pendingPingTimestamp == now) {
|
|
_pendingPingTimestamp = null;
|
|
}
|
|
}
|
|
|
|
void _startRecordingTicker() {
|
|
_recordingTicker?.cancel();
|
|
_recordingTicker = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
if (_recordingState == RecordingState.recording) {
|
|
notifyListeners();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _handleIncomingLine(String line) {
|
|
final receivedAt = DateTime.now().millisecondsSinceEpoch;
|
|
_lastIncomingPacketTimestamp = receivedAt;
|
|
|
|
if (line == _expectedHandshakeResponse) {
|
|
return;
|
|
}
|
|
|
|
if (line.startsWith('PONG:')) {
|
|
final timestamp = int.tryParse(line.substring(5).trim());
|
|
_lastPongPacketTimestamp = receivedAt;
|
|
|
|
if (timestamp != null) {
|
|
final computedPing = receivedAt - timestamp;
|
|
if (computedPing >= 0 && computedPing <= 30000) {
|
|
_pingMs = computedPing;
|
|
_pendingPingTimestamp = null;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (line.startsWith('TEL:')) {
|
|
_lastTelemetryPacketTimestamp = receivedAt;
|
|
final payload = line.substring(4);
|
|
final fields = payload.split(';');
|
|
bool didUpdateTelemetry = false;
|
|
|
|
for (final field in fields) {
|
|
final parts = field.split('=');
|
|
if (parts.length != 2) {
|
|
continue;
|
|
}
|
|
final key = parts[0].trim().toUpperCase();
|
|
final value = parts[1].trim();
|
|
|
|
switch (key) {
|
|
case 'SPD':
|
|
final spd = double.tryParse(value);
|
|
if (spd != null) {
|
|
_speedKmh = spd;
|
|
didUpdateTelemetry = true;
|
|
}
|
|
break;
|
|
case 'DST_KM':
|
|
final distanceKm = double.tryParse(value);
|
|
if (distanceKm != null) {
|
|
_telemetryDistanceKmTotal = distanceKm;
|
|
didUpdateTelemetry = true;
|
|
}
|
|
break;
|
|
case 'PULSE':
|
|
final pulse = int.tryParse(value);
|
|
if (pulse != null) {
|
|
_telemetryPulseTotal = pulse;
|
|
didUpdateTelemetry = true;
|
|
}
|
|
break;
|
|
case 'BAT':
|
|
final bat = int.tryParse(value);
|
|
if (bat != null) {
|
|
_batteryPercent = bat.clamp(0, 100);
|
|
didUpdateTelemetry = true;
|
|
}
|
|
break;
|
|
case 'VBAT':
|
|
final voltage = double.tryParse(value);
|
|
if (voltage != null) {
|
|
_batteryVoltage = voltage;
|
|
didUpdateTelemetry = true;
|
|
}
|
|
break;
|
|
case 'STATE':
|
|
if (value.isNotEmpty) {
|
|
didUpdateTelemetry = true;
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (_recordingState == RecordingState.recording) {
|
|
int pulseDelta = _telemetryPulseTotal - _recordingBaselinePulse;
|
|
if (pulseDelta < 0) {
|
|
pulseDelta = 0;
|
|
}
|
|
final pulseDistanceKm = pulseDelta * _distancePerPulseKm;
|
|
final telemetryDeltaKm = (_telemetryDistanceKmTotal - _recordingBaselineDistanceKm) < 0
|
|
? 0.0
|
|
: (_telemetryDistanceKmTotal - _recordingBaselineDistanceKm);
|
|
_recordingDistanceKm = pulseDistanceKm > 0 ? pulseDistanceKm : telemetryDeltaKm;
|
|
}
|
|
|
|
if (didUpdateTelemetry) {
|
|
notifyListeners();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
Future<void> _handleUnexpectedTransportDisconnect() async {
|
|
await _markUnexpectedDisconnect(
|
|
'Koneksi ke skateboard terputus. Silakan hubungkan kembali dari halaman Connect Device.',
|
|
);
|
|
}
|
|
|
|
Future<void> _markUnexpectedDisconnect(String message) async {
|
|
if (_isHandlingUnexpectedDisconnect || !_isDeviceConnected) {
|
|
return;
|
|
}
|
|
|
|
_isHandlingUnexpectedDisconnect = true;
|
|
final wasRecording = _recordingState == RecordingState.recording ||
|
|
_recordingState == RecordingState.stopping;
|
|
|
|
await _disconnectBluetoothTransport(clearDevices: false, notify: false);
|
|
|
|
_deviceConnectionState = DeviceConnectionState.disconnected;
|
|
_shouldShowConnectionLostDialog = true;
|
|
_connectionLostDialogMessage = message;
|
|
if (wasRecording) {
|
|
_activityErrorMessage =
|
|
'Koneksi terputus. Recording dihentikan dan tidak disimpan.';
|
|
}
|
|
_isHandlingUnexpectedDisconnect = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<bool> _writeLine(String line) async {
|
|
try {
|
|
await FlutterBluetoothSerial.write('$line\n');
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> _disconnectBluetoothTransport({
|
|
required bool clearDevices,
|
|
required bool notify,
|
|
}) async {
|
|
_pingTimer?.cancel();
|
|
_pingTimer = null;
|
|
_healthTimer?.cancel();
|
|
_healthTimer = null;
|
|
_isReadLoopActive = false;
|
|
_pendingPingTimestamp = null;
|
|
_lastIncomingPacketTimestamp = null;
|
|
_lastTelemetryPacketTimestamp = null;
|
|
_lastPongPacketTimestamp = null;
|
|
|
|
try {
|
|
await FlutterBluetoothSerial.disconnect();
|
|
} catch (_) {
|
|
// Best effort disconnect.
|
|
}
|
|
|
|
_isDeviceConnected = false;
|
|
_controlEnabled = false;
|
|
_isThrottleButtonPressed = false;
|
|
_isBrakeButtonPressed = false;
|
|
_speedKmh = null;
|
|
_telemetryPulseTotal = 0;
|
|
_telemetryDistanceKmTotal = 0.0;
|
|
_batteryPercent = null;
|
|
_batteryVoltage = null;
|
|
_pingMs = null;
|
|
_rideMode = RideMode.normal;
|
|
_recordingState = RecordingState.idle;
|
|
_deviceConnectionState = DeviceConnectionState.disconnected;
|
|
_resetRecordingState(notify: false);
|
|
await _setPlatformControlCaptureEnabled(false);
|
|
|
|
if (clearDevices) {
|
|
_availableBluetoothDevices = const [];
|
|
_selectedBluetoothDevice = null;
|
|
}
|
|
|
|
if (notify) {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
void _resetToLoginState() {
|
|
_pingTimer?.cancel();
|
|
_pingTimer = null;
|
|
_healthTimer?.cancel();
|
|
_healthTimer = null;
|
|
_isReadLoopActive = false;
|
|
_pendingPingTimestamp = null;
|
|
_lastIncomingPacketTimestamp = null;
|
|
_lastTelemetryPacketTimestamp = null;
|
|
_lastPongPacketTimestamp = null;
|
|
_isThrottleButtonPressed = false;
|
|
_isBrakeButtonPressed = false;
|
|
unawaited(_setPlatformControlCaptureEnabled(false));
|
|
_isLoggedIn = false;
|
|
_isInternetAvailable = false;
|
|
_isDeviceConnected = false;
|
|
_controlEnabled = false;
|
|
_speedKmh = null;
|
|
_telemetryPulseTotal = 0;
|
|
_telemetryDistanceKmTotal = 0.0;
|
|
_batteryPercent = null;
|
|
_batteryVoltage = null;
|
|
_pingMs = null;
|
|
_rideMode = RideMode.normal;
|
|
_recordingState = RecordingState.idle;
|
|
_deviceConnectionState = DeviceConnectionState.initial;
|
|
_appMode = AppMode.guestOffline;
|
|
_currentUserEmail = null;
|
|
_authErrorMessage = null;
|
|
_authSuccessMessage = null;
|
|
_isAuthBusy = false;
|
|
_isBluetoothBusy = false;
|
|
_availableBluetoothDevices = const [];
|
|
_selectedBluetoothDevice = null;
|
|
_connectionErrorMessage = null;
|
|
_shouldShowConnectionLostDialog = false;
|
|
_connectionLostDialogMessage = null;
|
|
_isHandlingUnexpectedDisconnect = false;
|
|
_pendingPingTimestamp = null;
|
|
_lastIncomingPacketTimestamp = null;
|
|
_lastTelemetryPacketTimestamp = null;
|
|
_lastPongPacketTimestamp = null;
|
|
_resetActivityState(keepSummary: false, notify: false);
|
|
notifyListeners();
|
|
}
|
|
|
|
void _resetActivityState({
|
|
required bool keepSummary,
|
|
required bool notify,
|
|
}) {
|
|
_activityErrorMessage = null;
|
|
_isActivityBusy = false;
|
|
_hasLoadedActivityData = keepSummary && _hasLoadedActivityData;
|
|
|
|
if (!keepSummary) {
|
|
_totalDistanceKmRaw = 0.0;
|
|
_totalRecordings = 0;
|
|
_recentRideSessions = const [];
|
|
}
|
|
|
|
_resetRecordingState(notify: false);
|
|
|
|
if (notify) {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
void _resetRecordingState({required bool notify}) {
|
|
_recordingTicker?.cancel();
|
|
_recordingTicker = null;
|
|
_recordingStartedAt = null;
|
|
_recordingBaselinePulse = 0;
|
|
_recordingBaselineDistanceKm = 0.0;
|
|
_recordingDistanceKm = 0.0;
|
|
_recordingState = RecordingState.idle;
|
|
|
|
if (notify) {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_platformChannel.setMethodCallHandler(null);
|
|
super.dispose();
|
|
}
|
|
|
|
void _setAuthBusy(bool value) {
|
|
_isAuthBusy = value;
|
|
notifyListeners();
|
|
}
|
|
|
|
void _setActivityBusy(bool value) {
|
|
_isActivityBusy = value;
|
|
notifyListeners();
|
|
}
|
|
|
|
void _clearAuthError({required bool notify}) {
|
|
_authErrorMessage = null;
|
|
if (notify) {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
void _clearConnectionError({required bool notify}) {
|
|
_connectionErrorMessage = null;
|
|
if (notify) {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
String _mapFirebaseAuthError(FirebaseAuthException error) {
|
|
switch (error.code) {
|
|
case 'invalid-email':
|
|
return 'Format email tidak valid.';
|
|
case 'user-disabled':
|
|
return 'Akun ini telah dinonaktifkan.';
|
|
case 'user-not-found':
|
|
case 'wrong-password':
|
|
case 'invalid-credential':
|
|
return 'Email atau password salah.';
|
|
case 'email-already-in-use':
|
|
return 'Email ini sudah digunakan akun lain.';
|
|
case 'weak-password':
|
|
return 'Password terlalu lemah. Gunakan minimal 6 karakter.';
|
|
case 'network-request-failed':
|
|
return 'Koneksi internet bermasalah. Coba lagi.';
|
|
case 'too-many-requests':
|
|
return 'Terlalu banyak percobaan. Coba lagi beberapa saat.';
|
|
default:
|
|
return error.message ?? 'Autentikasi gagal. Coba lagi.';
|
|
}
|
|
}
|
|
}
|