1 Commits

Author SHA1 Message Date
dj99k cd93cf007f 1st commit 2026-08-10
variableAxleModitor
2026-08-10 23:13:43 +09:00
56 changed files with 378 additions and 506 deletions
-123
View File
@@ -1,123 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>
-5
View File
@@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
-13
View File
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
-1
View File
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
-61
View File
@@ -1,61 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>
+1 -3
View File
@@ -1,9 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" project-jdk-name="temurin-25" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" project-jdk-name="temurin-25" project-jdk-type="JavaSDK" />
<component name="ProjectType">
<option name="id" value="Android" />
</component>
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PlanningModeManager">
<option name="approvalStates">
<map>
<entry key="b43a2b32-8f18-49df-9a9e-f3eeb7b4a739" value="false" />
<entry key="c04a9953-a5f1-4945-b3ad-be57a23dba29" value="false" />
</map>
</option>
</component>
</project>
+2 -2
View File
@@ -13,9 +13,9 @@
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:icon="@drawable/variable_axle_load_icon"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:roundIcon="@drawable/variable_axle_load_icon"
android:supportsRtl="true"
android:theme="@style/Theme.VariableAxleMonitor">
<activity
@@ -4,7 +4,6 @@ import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.bluetooth.BluetoothDevice
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
@@ -17,21 +16,14 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -43,7 +35,11 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
@@ -53,7 +49,6 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.window.Dialog
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
@@ -62,12 +57,41 @@ import androidx.navigation.compose.rememberNavController
import com.example.variableaxlemonitor.ui.theme.VariableAxleMonitorTheme
import com.example.variableaxlemonitor.viewmodel.AxleViewModel
import kotlinx.coroutines.delay
import kotlin.math.cos
import kotlin.math.sin
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
)
} else {
listOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN,
Manifest.permission.ACCESS_FINE_LOCATION
)
}
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { results ->
if (results.values.all { it }) {
Log.d("MainActivity", "All permissions granted")
} else {
Toast.makeText(this, "블루투스 권한이 필요합니다.", Toast.LENGTH_SHORT).show()
}
}
LaunchedEffect(Unit) {
launcher.launch(permissions.toTypedArray())
}
VariableAxleMonitorTheme {
MainApp()
}
@@ -79,44 +103,11 @@ class MainActivity : ComponentActivity() {
fun MainApp() {
val navController = rememberNavController()
val viewModel: AxleViewModel = viewModel()
val context = LocalContext.current
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
} else {
arrayOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
NavHost(navController = navController, startDestination = "splash") {
composable("splash") {
SplashScreen(navController)
}
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { results ->
if (results.values.all { it }) {
// Permissions granted
} else {
Toast.makeText(context, "Permissions required for Bluetooth", Toast.LENGTH_SHORT).show()
}
}
LaunchedEffect(Unit) {
val missing = permissions.filter {
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isNotEmpty()) {
launcher.launch(missing.toTypedArray())
}
}
NavHost(navController = navController, startDestination = "login") {
composable("login") {
LoginScreen(navController, viewModel)
}
@@ -126,19 +117,44 @@ fun MainApp() {
}
}
@Composable
fun SplashScreen(navController: NavController) {
LaunchedEffect(Unit) {
delay(2000) // 2초 대기
navController.navigate("login") {
popUpTo("splash") { inclusive = true }
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = R.drawable.variable_axle_load_loading),
contentDescription = "App Title",
modifier = Modifier.fillMaxWidth(0.8f)
)
}
}
@Composable
fun LoginScreen(navController: NavController, viewModel: AxleViewModel) {
var user by remember { mutableStateOf("") }
var pass by remember { mutableStateOf("") }
val context = LocalContext.current
val loginSuccess by viewModel.loginSuccess
LaunchedEffect(loginSuccess) {
if (loginSuccess) {
navController.navigate("monitoring")
navController.navigate("monitoring") {
popUpTo("login") { inclusive = true }
}
}
}
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.loginError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
@@ -146,20 +162,19 @@ fun LoginScreen(navController: NavController, viewModel: AxleViewModel) {
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("가변축 시스템", fontSize = 32.sp, fontWeight = FontWeight.Bold)
Text("Variable Axle Monitor", fontSize = 24.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField(
value = user,
onValueChange = { user = it },
label = { Text("User") },
label = { Text("ID") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = pass,
onValueChange = { pass = it },
@@ -167,63 +182,82 @@ fun LoginScreen(navController: NavController, viewModel: AxleViewModel) {
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = { viewModel.login(user, pass) },
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth().height(56.dp)
) {
Text("로그인")
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = { (context as? Activity)?.finishAffinity() },
modifier = Modifier.fillMaxWidth()
) {
Text("앱 종료")
Text("Login")
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("DefaultLocale")
@Composable
fun MonitoringScreen(viewModel: AxleViewModel) {
val axleData by viewModel.axleData.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val dataTrigger = viewModel.dataReceivedTrigger
var showDeviceDialog by remember { mutableStateOf(false) }
val lastUpdate by remember(axleData.timestamp) {
derivedStateOf {
if (axleData.timestamp == 0L) "--:--:--"
else SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(axleData.timestamp))
}
}
var isBlinking by remember { mutableStateOf(false) }
LaunchedEffect(dataTrigger) {
dataTrigger.collect {
isBlinking = true
delay(200)
isBlinking = false
}
}
val context = LocalContext.current
var lastUpdate by remember { mutableStateOf("-") }
var receiveCount by remember { mutableIntStateOf(0) }
val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
LaunchedEffect(viewModel) {
Log.d("MonitoringScreen", "ViewModel Instance: ${System.identityHashCode(viewModel)}")
}
LaunchedEffect(Unit) {
viewModel.dataReceivedTrigger.collect {
receiveCount++
lastUpdate = timeFormatter.format(Date())
Log.d("MonitoringScreen", "Data Received Trigger #$receiveCount at $lastUpdate")
}
}
LaunchedEffect(axleData) {
Log.d("MonitoringScreen", "UI Update: $axleData")
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Text("가변축 시스템 모니터링", fontSize = 32.sp, fontWeight = FontWeight.Bold)
Text(
text = "가변축 모니터링",
fontSize = 32.sp,
fontWeight = FontWeight.Bold
)
},
actions = {
IconButton(onClick = { showDeviceDialog = true }) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(if (isBlinking) Color.Green else Color.LightGray.copy(alpha = 0.3f))
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = { showDeviceDialog = true },
modifier = Modifier.size(64.dp)
) {
Icon(
imageVector = Icons.Default.Bluetooth,
contentDescription = "Bluetooth Status",
tint = if (isConnected) Color.Green else Color.Red
tint = if (isConnected) Color.Green else Color.Red,
modifier = Modifier.size(48.dp)
)
}
IconButton(
onClick = {
viewModel.disconnect()
(context as? Activity)?.finishAffinity()
},
modifier = Modifier.size(64.dp)
) {
Icon(
imageVector = Icons.Default.ExitToApp,
contentDescription = "Exit App",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(48.dp)
)
}
}
@@ -236,68 +270,73 @@ fun MonitoringScreen(viewModel: AxleViewModel) {
.fillMaxSize()
.padding(8.dp)
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
// 1. 압력센서
item {
Column(modifier = Modifier.weight(1f)) {
// Row 1: 압력센서, 속도
Row(modifier = Modifier.weight(1f)) {
MonitoringCard(
title = "압력 센서",
value = String.format(Locale.US, "%.1f kgf/cm²", axleData.pressure * 0.1f),
iconRes = R.drawable.pressure_sensor,
isBitmap = true
iconRes = R.drawable.pressure_sensor_gauge,
isBitmap = true,
iconSize = 150.dp,
modifier = Modifier.weight(1f),
customIcon = { PressureGauge(pressure = axleData.pressure * 0.1f) }
)
}
// 2. 속도
item {
MonitoringCard(
title = "속도(Speed)",
value = "${axleData.speed} km/h",
iconRes = R.drawable.speed_display,
isBitmap = true
isBitmap = true,
modifier = Modifier.weight(1f),
customIcon = { SpeedGauge(speed = axleData.speed) }
)
}
// 3. 가변축 하중
item {
// Row 2: 가변축 하중, 구동축 하중
Row(modifier = Modifier.weight(1f)) {
MonitoringCard(
title = "가변축 하중",
value = String.format(Locale.US, "%.3f ton", axleData.variableAxleLoad * 0.001f),
iconRes = R.drawable.variable_axle_load,
iconRes = R.drawable.axle_load_variable,
isBitmap = true,
iconSize = 60.dp
iconSize = 200.dp,
modifier = Modifier.weight(1f)
)
}
// 4. 구동축 하중
item {
MonitoringCard(
title = "구동축 하중",
value = String.format(Locale.US, "%.3f ton", axleData.driveAxleLoad * 0.001f),
iconRes = R.drawable.ic_axle_drive
iconRes = R.drawable.axle_load_driver,
isBitmap = true,
iconSize = 200.dp,
modifier = Modifier.weight(1f)
)
}
// 5. 가변축 상태
item {
// Row 3: 가변축 상태, 하중분배율
Row(modifier = Modifier.weight(1f)) {
MonitoringCard(
title = "가변축 상태",
value = if (axleData.isDown) "가변축 Down" else "가변축 Up",
iconRes = if (axleData.isDown) R.drawable.ic_axle_down else R.drawable.ic_axle_up
iconRes = if (axleData.isDown) R.drawable.axle_down else R.drawable.axle_up,
isBitmap = true,
iconSize = 200.dp,
modifier = Modifier.weight(1f)
)
}
// 6. 설정 및 종료
item {
SystemCard(
isConnected = isConnected,
onBluetoothClick = { showDeviceDialog = true },
onExitClick = {
viewModel.disconnect()
(context as? Activity)?.finishAffinity()
val distributionRatio = if (axleData.driveAxleLoad + axleData.variableAxleLoad > 0) {
(axleData.variableAxleLoad.toFloat() / (axleData.driveAxleLoad.toFloat()+axleData.variableAxleLoad.toFloat()) * 100f)
} else 0f
MonitoringCard(
title = "가변축 하중 분배율",
value = String.format(Locale.US, "%.1f %%", distributionRatio),
iconRes = when {
distributionRatio < 50f -> R.drawable.load_sharing_ratio_d50
distributionRatio == 50f -> R.drawable.load_sharing_ratio_50
else -> R.drawable.load_sharing_ratio_u50
},
dataTrigger = viewModel.dataReceivedTrigger,
modifier = Modifier.fillMaxWidth().aspectRatio(1.2f)
isBitmap = true,
iconSize = 300.dp,
modifier = Modifier.weight(1f)
)
}
}
@@ -306,51 +345,190 @@ fun MonitoringScreen(viewModel: AxleViewModel) {
Text(
text = "마지막 데이터 수신: $lastUpdate",
modifier = Modifier.align(Alignment.End),
fontSize = 12.sp,
fontSize = 11.sp,
color = Color.Gray
)
}
}
if (showDeviceDialog) {
val devices by viewModel.foundDevices.collectAsState()
DisposableEffect(Unit) {
LaunchedEffect(Unit) {
viewModel.startScan()
onDispose {
viewModel.stopScan()
}
}
val foundDevices by viewModel.foundDevices.collectAsState()
BluetoothDeviceDialog(
devices = devices,
onDeviceSelected = {
viewModel.connectToDevice(it)
devices = foundDevices,
onDeviceSelected = { device ->
viewModel.stopScan()
viewModel.connectToDevice(device)
showDeviceDialog = false
},
onDismiss = { showDeviceDialog = false }
onDismiss = {
viewModel.stopScan()
showDeviceDialog = false
}
)
}
}
@Composable
fun PressureGauge(
pressure: Float,
modifier: Modifier = Modifier
) {
val maxPressure = 12f
// 사용자의 요청에 따라 0은 135도, 12은 -135도로 설정
val startAngle = 136f //135f
val sweepAngle = 270f //270f
val animatedPressure by animateFloatAsState(
targetValue = pressure.coerceIn(0f, maxPressure),
animationSpec = tween(durationMillis = 500),
label = "PressureNeedle"
)
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.fillMaxSize(0.6f) // 게이지 이미지를 부모 영역의 80%로 제한
.aspectRatio(1f),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = R.drawable.pressure_sensor_gauge),
contentDescription = null,
modifier = Modifier.fillMaxSize()
)
val angleDeg = startAngle + (animatedPressure / maxPressure) * sweepAngle
Image(
painter = painterResource(id = R.drawable.pressure_arrow2),
contentDescription = "Pressure Needle",
modifier = Modifier
.fillMaxSize(0.7f) //0.7
.graphicsLayer(
rotationZ = angleDeg + 90f,
transformOrigin = TransformOrigin(0.5f, 0.5f)
)
)
}
}
}
@Composable
fun SpeedGauge(
speed: Int,
modifier: Modifier = Modifier
) {
val maxSpeed = 160f
// 사용자의 피드백에 따라 수평 왼쪽(180도)을 0으로, 수평 오른쪽(0도)을 160으로 설정
val startAngle = 180f
val sweepAngle = 180f
val animatedSpeed by animateFloatAsState(
targetValue = speed.coerceIn(0, maxSpeed.toInt()).toFloat(),
animationSpec = tween(durationMillis = 500),
label = "SpeedNeedle"
)
Box(
modifier = modifier
.aspectRatio(1f)
.fillMaxSize(0.8f), // 전체 사이즈 0.8배 축소
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = R.drawable.speed_display),
contentDescription = null,
modifier = Modifier.fillMaxSize()
)
Canvas(modifier = Modifier.fillMaxSize()) {
// 바늘의 회전 중심축 위치를 사용자가 확인한 0.7f 지점으로 고정
val center = Offset(size.width / 2, size.height * 0.72f)
val radius = size.minDimension / 2
// 시계 방향으로 회전하므로 각도 계산
val angleDeg = startAngle + (animatedSpeed / maxSpeed) * sweepAngle
val angleRad = Math.toRadians(angleDeg.toDouble())
// 바늘의 길이 (게이지 크기에 맞춰 조정)
val needleLength = radius * 0.8f
// 바늘(화살표) 그리기 - 삼각형 형태
val needlePath = androidx.compose.ui.graphics.Path().apply {
val endX = center.x + needleLength * cos(angleRad).toFloat()
val endY = center.y + needleLength * sin(angleRad).toFloat()
// 바늘 뿌리 부분의 각도 (바늘 방향과 수직)
val baseAngleRad = Math.toRadians((angleDeg + 90).toDouble())
val baseWidth = 8.dp.toPx() // 뿌리 너비
val baseLeftX = center.x + baseWidth * cos(baseAngleRad).toFloat()
val baseLeftY = center.y + baseWidth * sin(baseAngleRad).toFloat()
val baseRightX = center.x - baseWidth * cos(baseAngleRad).toFloat()
val baseRightY = center.y - baseWidth * sin(baseAngleRad).toFloat()
moveTo(baseLeftX, baseLeftY)
lineTo(endX, endY)
lineTo(baseRightX, baseRightY)
close()
}
// 바늘 그림자
drawPath(
path = needlePath,
color = Color.Black.copy(alpha = 0.2f),
style = androidx.compose.ui.graphics.drawscope.Fill
)
// 바늘 본체 (빨간색)
drawPath(
path = needlePath,
color = Color.Red,
style = androidx.compose.ui.graphics.drawscope.Fill
)
// 중앙 검은 점(캡) 그리기
drawCircle(
color = Color.DarkGray,
radius = 10.dp.toPx(),
center = center
)
drawCircle(
color = Color.Black,
radius = 6.dp.toPx(),
center = center
)
}
}
}
@Composable
fun MonitoringCard(
modifier: Modifier = Modifier.fillMaxSize(),
title: String,
value: String,
iconRes: Int,
modifier: Modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.2f), // Slightly wider to accommodate text
iconRes: Int = 0,
isBitmap: Boolean = false,
iconSize: Dp = 50.dp, // Reduced default size
titleFontSize: TextUnit = 18.sp, // Reduced font size
valueFontSize: TextUnit = 22.sp, // Reduced font size
iconSize: Dp = 200.dp,
titleFontSize: TextUnit = 32.sp,
valueFontSize: TextUnit = 26.sp,
titleModifier: Modifier = Modifier,
valueModifier: Modifier = Modifier,
iconModifier: Modifier = Modifier
iconModifier: Modifier = Modifier,
titlePaddingTop: Dp = 24.dp,
contentSpacing: Dp = 14.dp,
customIcon: @Composable (() -> Unit)? = null
) {
Card(
modifier = modifier,
modifier = modifier.padding(4.dp),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, Color.LightGray)
@@ -358,18 +536,32 @@ fun MonitoringCard(
Column(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
.padding(bottom = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(titlePaddingTop))
Text(
text = title,
fontSize = titleFontSize,
fontWeight = FontWeight.Bold,
color = Color.DarkGray,
color = Color(0xFF444444),
modifier = titleModifier
)
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(contentSpacing))
Text(
text = value,
fontSize = valueFontSize,
fontWeight = FontWeight.ExtraBold,
color = Color.Blue,
modifier = valueModifier
)
Box(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center
) {
if (customIcon != null) {
customIcon()
} else if (iconRes != 0) {
if (isBitmap) {
Image(
painter = painterResource(id = iconRes),
@@ -384,84 +576,8 @@ fun MonitoringCard(
tint = MaterialTheme.colorScheme.secondary
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
fontSize = valueFontSize,
fontWeight = FontWeight.ExtraBold,
color = Color.Blue,
modifier = valueModifier
)
}
}
}
@Composable
fun SystemCard(
isConnected: Boolean,
onBluetoothClick: () -> Unit,
onExitClick: () -> Unit,
dataTrigger: kotlinx.coroutines.flow.SharedFlow<Unit>,
modifier: Modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
topSectionModifier: Modifier = Modifier,
bottomSectionModifier: Modifier = Modifier
) {
var isBlinking by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
dataTrigger.collect {
isBlinking = true
delay(200)
isBlinking = false
}
}
Card(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, Color.DarkGray)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = topSectionModifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Bluetooth,
contentDescription = "Bluetooth",
modifier = Modifier
.size(32.dp)
.clickable { onBluetoothClick() },
tint = if (isConnected) Color.Green else Color.Red
)
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.size(16.dp)
.clip(CircleShape)
.background(if (isBlinking) Color.Green else Color.Transparent)
)
}
Button(
onClick = onExitClick,
modifier = bottomSectionModifier,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
) {
Icon(Icons.Default.ExitToApp, contentDescription = null)
Spacer(modifier = Modifier.width(4.dp))
Text("종료")
}
}
}
}
@@ -484,21 +600,19 @@ fun BluetoothDeviceDialog(
items(devices) { device ->
@SuppressLint("MissingPermission")
val deviceName = device.name ?: "알 수 없는 기기"
val deviceAddress = device.address
Column(
Text(
text = deviceName,
modifier = Modifier
.fillMaxWidth()
.clickable { onDeviceSelected(device) }
.padding(vertical = 12.dp)
) {
Text(text = deviceName, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
Text(text = deviceAddress, fontSize = 12.sp, color = Color.Gray)
}
.padding(vertical = 12.dp),
fontSize = 16.sp
)
HorizontalDivider()
}
}
if (devices.isEmpty()) {
Text("주변 기기를 검색 중입니다...", modifier = Modifier.padding(vertical = 16.dp))
Text("검색된 기기가 없습니다.", modifier = Modifier.padding(vertical = 16.dp))
}
Spacer(modifier = Modifier.height(16.dp))
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
@@ -52,13 +52,13 @@ class BluetoothManager(private val context: Context) {
val foundDevices: StateFlow<List<BluetoothDevice>> = _foundDevices
private var gatt: BluetoothGatt? = null
private val dataBuffer = StringBuilder()
private val scanCallback = object : ScanCallback() {
@SuppressLint("MissingPermission")
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device
if (device.name != null) {
// BLE 기기만 필터링 (DEVICE_TYPE_LE 또는 DEVICE_TYPE_DUAL)
if (device.name != null && (device.type == BluetoothDevice.DEVICE_TYPE_LE || device.type == BluetoothDevice.DEVICE_TYPE_DUAL)) {
val currentList = _foundDevices.value
if (currentList.none { it.address == device.address }) {
_foundDevices.value = currentList + device
@@ -167,42 +167,11 @@ class BluetoothManager(private val context: Context) {
private fun processReceivedData(value: ByteArray?) {
if (value == null) return
val received = String(value)
// Log.d(TAG, "Raw Data (String): $received")
synchronized(dataBuffer) {
dataBuffer.append(received)
// Check for various delimiters including literal strings like "\r\n" or "\r\"
val delimiters = listOf("\r\n", "\\r\\n", "\\r\\", "\n", "\r")
var foundDelimiter = true
while (foundDelimiter) {
foundDelimiter = false
var earliestIndex = Int.MAX_VALUE
var selectedDelimiter = ""
for (delim in delimiters) {
val index = dataBuffer.indexOf(delim)
if (index != -1 && index < earliestIndex) {
earliestIndex = index
selectedDelimiter = delim
foundDelimiter = true
}
}
if (foundDelimiter) {
val line = dataBuffer.substring(0, earliestIndex).trim()
dataBuffer.delete(0, earliestIndex + selectedDelimiter.length)
if (line.isNotEmpty()) {
Log.d(TAG, "Extracted line: '$line'")
// _lastMessage.value = "데이터 수집 중: $line"
val received = String(value).trim()
if (received.isNotEmpty()) {
Log.d(TAG, "Extracted line: '$received'")
scope.launch {
parseData(line)
}
}
}
parseData(received)
}
}
}
@@ -231,7 +200,12 @@ class BluetoothManager(private val context: Context) {
@SuppressLint("MissingPermission")
fun startScan() {
Log.d(TAG, "Starting BLE scan...")
_foundDevices.value = emptyList()
// 페어링된 기기 중 BLE 기기만 먼저 추가
val pairedDevices = adapter?.bondedDevices ?: emptySet()
_foundDevices.value = pairedDevices.filter {
it.type == BluetoothDevice.DEVICE_TYPE_LE || it.type == BluetoothDevice.DEVICE_TYPE_DUAL
}
scanner?.startScan(scanCallback)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 561 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB