diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt index e9eda2b..1673ca0 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt @@ -17,6 +17,9 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.BluetoothConnected +import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -34,8 +37,13 @@ import com.example.medilightv2android.AppState import com.example.medilightv2android.ble.BleDevice import com.example.medilightv2android.ble.BleManager import com.example.medilightv2android.models.SensorMode +import com.example.medilightv2android.services.KnownDevice +import com.example.medilightv2android.services.KnownDeviceStore import com.example.medilightv2android.ui.components.BladdyIdleRiveView import com.example.medilightv2android.ui.theme.* +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale @Composable fun DeviceScanView(appState: AppState) { @@ -44,8 +52,12 @@ fun DeviceScanView(appState: AppState) { var connectingDeviceAddress by remember { mutableStateOf(null) } var permissionsGranted by remember { mutableStateOf(false) } - val filteredDevices = bleManager.discoveredDevices + var knownDevices by remember { mutableStateOf(KnownDeviceStore.getAll(context)) } + var pendingDelete by remember { mutableStateOf(null) } + val knownAddresses = knownDevices.map { it.address }.toSet() + val newDevices = bleManager.discoveredDevices .filter { it.name.startsWith("2025") || it.name.startsWith("VBT") } + .filter { it.address !in knownAddresses } .sortedBy { it.name } val isConnected by bleManager.isConnected @@ -159,10 +171,14 @@ fun DeviceScanView(appState: AppState) { ) { Text("Connect Device", fontSize = 24.sp, fontWeight = FontWeight.Bold) Spacer(modifier = Modifier.height(8.dp)) - Text("Searching for nearby devices...", fontSize = 14.sp, color = MlSecondaryText) + Text( + if (knownDevices.isEmpty()) "Searching for nearby devices..." + else "Tap a saved device or scan for new", + fontSize = 14.sp, color = MlSecondaryText + ) } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(16.dp)) // Bluetooth off warning if (!bluetoothEnabled) { @@ -227,8 +243,9 @@ fun DeviceScanView(appState: AppState) { Spacer(modifier = Modifier.height(8.dp)) } - // Device list - if (filteredDevices.isEmpty() && isScanning) { + // 디바이스 목록 — Known + New 두 섹션 + val emptyAll = knownDevices.isEmpty() && newDevices.isEmpty() + if (emptyAll && isScanning) { Column( modifier = Modifier.weight(1f).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, @@ -238,7 +255,7 @@ fun DeviceScanView(appState: AppState) { Spacer(modifier = Modifier.height(16.dp)) Text("Scanning...", color = MlSecondaryText) } - } else if (filteredDevices.isEmpty()) { + } else if (emptyAll) { Column( modifier = Modifier .weight(1f) @@ -259,7 +276,6 @@ fun DeviceScanView(appState: AppState) { TroubleshootItem("4.", "Try restarting the device") } } - // DEV: BLE 없이 측정 모드 진입 — 디자인 작업용 if (appState.isDevMode) { Spacer(modifier = Modifier.height(16.dp)) Button( @@ -278,23 +294,65 @@ fun DeviceScanView(appState: AppState) { } else { LazyColumn( modifier = Modifier.weight(1f), - contentPadding = PaddingValues(horizontal = 24.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) ) { - items(filteredDevices, key = { it.address }) { device -> - DeviceRow( - device = device, - isConnecting = connectingDeviceAddress == device.address, - enabled = connectingDeviceAddress == null, - onClick = { - connectingDeviceAddress = device.address - bleManager.connect(device) - } - ) + if (knownDevices.isNotEmpty()) { + item("known_header") { + SectionHeader("Saved Devices", knownDevices.size) + } + items(knownDevices, key = { "k_${it.address}" }) { known -> + KnownDeviceRow( + device = known, + isConnecting = connectingDeviceAddress == known.address, + enabled = connectingDeviceAddress == null, + onConnect = { + connectingDeviceAddress = known.address + bleManager.connectByAddress(known.address, known.name) + }, + onDelete = { pendingDelete = known } + ) + } + } + if (newDevices.isNotEmpty()) { + item("new_header") { + Spacer(modifier = Modifier.height(8.dp)) + SectionHeader("New Devices", newDevices.size) + } + items(newDevices, key = { "n_${it.address}" }) { device -> + DeviceRow( + device = device, + isConnecting = connectingDeviceAddress == device.address, + enabled = connectingDeviceAddress == null, + onClick = { + connectingDeviceAddress = device.address + bleManager.connect(device) + } + ) + } } } } + // Delete Known Device 확인 다이얼로그 + pendingDelete?.let { target -> + AlertDialog( + onDismissRequest = { pendingDelete = null }, + title = { Text("페어링 해제") }, + text = { Text("${target.name} 의 페어링을 해제합니다. 다시 사용하려면 PIN 재입력이 필요합니다.") }, + confirmButton = { + TextButton(onClick = { + bleManager.unbondAndRemoveAddress(target.address) + knownDevices = KnownDeviceStore.getAll(context) + pendingDelete = null + }) { Text("해제", color = Color.Red) } + }, + dismissButton = { + TextButton(onClick = { pendingDelete = null }) { Text("취소") } + } + ) + } + // Scan button Button( onClick = { @@ -339,6 +397,73 @@ fun DeviceScanView(appState: AppState) { } } +@Composable +private fun SectionHeader(label: String, count: Int) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText) + Spacer(modifier = Modifier.width(6.dp)) + Text("$count", fontSize = 12.sp, color = MlSecondaryText.copy(alpha = 0.6f)) + } +} + +@Composable +private fun KnownDeviceRow( + device: KnownDevice, + isConnecting: Boolean, + enabled: Boolean, + onConnect: () -> Unit, + onDelete: () -> Unit +) { + val lastConnectedLabel = remember(device.lastConnectedAt) { + if (device.lastConnectedAt <= 0) "—" + else SimpleDateFormat("M/d HH:mm", Locale.getDefault()).format(Date(device.lastConnectedAt)) + } + Card( + modifier = Modifier + .fillMaxWidth() + .shadow(4.dp, RoundedCornerShape(14.dp), spotColor = Color.Black.copy(alpha = 0.04f)) + .clickable(enabled = enabled, onClick = onConnect), + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors(containerColor = MlPrimary.copy(alpha = 0.06f)) + ) { + Row( + modifier = Modifier.padding(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MlPrimary.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Default.BluetoothConnected, null, tint = MlPrimary, modifier = Modifier.size(20.dp)) + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(device.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + Text("Last: $lastConnectedLabel", fontSize = 11.sp, color = MlSecondaryText) + } + if (isConnecting) { + CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) + } else { + IconButton( + onClick = onDelete, + enabled = enabled, + modifier = Modifier.size(36.dp) + ) { + Icon(Icons.Default.Delete, "Delete pairing", + tint = Color.Red.copy(alpha = 0.7f), + modifier = Modifier.size(18.dp)) + } + } + } + } +} + @Composable private fun DeviceRow(device: BleDevice, isConnecting: Boolean, enabled: Boolean, onClick: () -> Unit) { Card(