TKK_E32221292/Aplikasi Android/lib/app/app_session_view_model.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.';
}
}
}