4 Commits

Author SHA1 Message Date
dj99k 05af012479 Revert "first Commit"
This reverts commit f601964083.
2026-08-06 01:27:44 +09:00
dj99k 86868d2b8e first Commit
1. BLE입력되는 메세지는 \r\n  없음. - 관련 항목 삭제
    -. 국도모니터 BLE로 부터 데이터 수신 확인 완료.
2. UI 변경
3. 가변축 하중 분배율 추가
2026-08-06 01:21:22 +09:00
dj99k f601964083 first Commit
1. BLE입력되는 메세지는 \r\n  없음. - 관련 항목 삭제
    -. 국도모니터 BLE로 부터 데이터 수신 확인 완료.
2. UI 변경
3. 가변축 하중 분배율 추가
2026-08-06 01:21:18 +09:00
dj99k e23b30605f first Commit
Variable Axle Monitor App   BLE : OK
2026-08-04 22:13:35 +09:00
56 changed files with 502 additions and 374 deletions
+123
View File
@@ -0,0 +1,123 @@
<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
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+13
View File
@@ -0,0 +1,13 @@
<?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,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
+61
View File
@@ -0,0 +1,61 @@
<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>
+3 -1
View File
@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" project-jdk-name="temurin-25" project-jdk-type="JavaSDK" /> <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="ProjectType"> <component name="ProjectType">
<option name="id" value="Android" /> <option name="id" value="Android" />
</component> </component>
+11
View File
@@ -0,0 +1,11 @@
<?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:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/variable_axle_load_icon" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@drawable/variable_axle_load_icon" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.VariableAxleMonitor"> android:theme="@style/Theme.VariableAxleMonitor">
<activity <activity
@@ -4,6 +4,7 @@ import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
@@ -16,14 +17,21 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.* 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.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn 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.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -35,11 +43,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color 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.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -49,6 +53,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@@ -57,41 +62,12 @@ import androidx.navigation.compose.rememberNavController
import com.example.variableaxlemonitor.ui.theme.VariableAxleMonitorTheme import com.example.variableaxlemonitor.ui.theme.VariableAxleMonitorTheme
import com.example.variableaxlemonitor.viewmodel.AxleViewModel import com.example.variableaxlemonitor.viewmodel.AxleViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.math.cos
import kotlin.math.sin
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { 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 { VariableAxleMonitorTheme {
MainApp() MainApp()
} }
@@ -103,11 +79,44 @@ class MainActivity : ComponentActivity() {
fun MainApp() { fun MainApp() {
val navController = rememberNavController() val navController = rememberNavController()
val viewModel: AxleViewModel = viewModel() val viewModel: AxleViewModel = viewModel()
val context = LocalContext.current
NavHost(navController = navController, startDestination = "splash") { val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
composable("splash") { arrayOf(
SplashScreen(navController) 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
)
}
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") { composable("login") {
LoginScreen(navController, viewModel) LoginScreen(navController, viewModel)
} }
@@ -117,44 +126,19 @@ 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 @Composable
fun LoginScreen(navController: NavController, viewModel: AxleViewModel) { fun LoginScreen(navController: NavController, viewModel: AxleViewModel) {
var user by remember { mutableStateOf("") } var user by remember { mutableStateOf("") }
var pass by remember { mutableStateOf("") } var pass by remember { mutableStateOf("") }
val context = LocalContext.current
val loginSuccess by viewModel.loginSuccess val loginSuccess by viewModel.loginSuccess
LaunchedEffect(loginSuccess) { LaunchedEffect(loginSuccess) {
if (loginSuccess) { if (loginSuccess) {
navController.navigate("monitoring") { navController.navigate("monitoring")
popUpTo("login") { inclusive = true }
}
} }
} }
val context = LocalContext.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.loginError.collect { error -> viewModel.loginError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show() Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
@@ -162,19 +146,20 @@ fun LoginScreen(navController: NavController, viewModel: AxleViewModel) {
} }
Column( Column(
modifier = Modifier.fillMaxSize().padding(16.dp), modifier = Modifier
verticalArrangement = Arrangement.Center, .fillMaxSize()
horizontalAlignment = Alignment.CenterHorizontally .padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) { ) {
Text("Variable Axle Monitor", fontSize = 24.sp, fontWeight = FontWeight.Bold) Text("가변축 시스템", fontSize = 32.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField( OutlinedTextField(
value = user, value = user,
onValueChange = { user = it }, onValueChange = { user = it },
label = { Text("ID") }, label = { Text("User") },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField( OutlinedTextField(
value = pass, value = pass,
onValueChange = { pass = it }, onValueChange = { pass = it },
@@ -182,82 +167,63 @@ fun LoginScreen(navController: NavController, viewModel: AxleViewModel) {
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(16.dp))
Button( Button(
onClick = { viewModel.login(user, pass) }, onClick = { viewModel.login(user, pass) },
modifier = Modifier.fillMaxWidth().height(56.dp) modifier = Modifier.fillMaxWidth()
) { ) {
Text("Login") Text("로그인")
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = { (context as? Activity)?.finishAffinity() },
modifier = Modifier.fillMaxWidth()
) {
Text("앱 종료")
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("DefaultLocale")
@Composable @Composable
fun MonitoringScreen(viewModel: AxleViewModel) { fun MonitoringScreen(viewModel: AxleViewModel) {
val axleData by viewModel.axleData.collectAsState() val axleData by viewModel.axleData.collectAsState()
val isConnected by viewModel.isConnected.collectAsState() val isConnected by viewModel.isConnected.collectAsState()
val dataTrigger = viewModel.dataReceivedTrigger
var showDeviceDialog by remember { mutableStateOf(false) } 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 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( Scaffold(
topBar = { topBar = {
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
title = { title = {
Text( Text("가변축 시스템 모니터링", fontSize = 32.sp, fontWeight = FontWeight.Bold)
text = "가변축 모니터링",
fontSize = 32.sp,
fontWeight = FontWeight.Bold
)
}, },
actions = { actions = {
Box( IconButton(onClick = { showDeviceDialog = true }) {
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( Icon(
imageVector = Icons.Default.Bluetooth, imageVector = Icons.Default.Bluetooth,
contentDescription = "Bluetooth Status", 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)
) )
} }
} }
@@ -270,73 +236,68 @@ fun MonitoringScreen(viewModel: AxleViewModel) {
.fillMaxSize() .fillMaxSize()
.padding(8.dp) .padding(8.dp)
) { ) {
Column(modifier = Modifier.weight(1f)) { LazyVerticalGrid(
// Row 1: 압력센서, 속도 columns = GridCells.Fixed(2),
Row(modifier = Modifier.weight(1f)) { modifier = Modifier.weight(1f),
contentPadding = PaddingValues(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
// 1. 압력센서
item {
MonitoringCard( MonitoringCard(
title = "압력 센서", title = "압력 센서",
value = String.format(Locale.US, "%.1f kgf/cm²", axleData.pressure * 0.1f), value = String.format(Locale.US, "%.1f kgf/cm²", axleData.pressure * 0.1f),
iconRes = R.drawable.pressure_sensor_gauge, iconRes = R.drawable.pressure_sensor,
isBitmap = true, isBitmap = true
iconSize = 150.dp,
modifier = Modifier.weight(1f),
customIcon = { PressureGauge(pressure = axleData.pressure * 0.1f) }
) )
}
// 2. 속도
item {
MonitoringCard( MonitoringCard(
title = "속도(Speed)", title = "속도(Speed)",
value = "${axleData.speed} km/h", value = "${axleData.speed} km/h",
iconRes = R.drawable.speed_display, iconRes = R.drawable.speed_display,
isBitmap = true, isBitmap = true
modifier = Modifier.weight(1f),
customIcon = { SpeedGauge(speed = axleData.speed) }
) )
} }
// 3. 가변축 하중
// Row 2: 가변축 하중, 구동축 하중 item {
Row(modifier = Modifier.weight(1f)) {
MonitoringCard( MonitoringCard(
title = "가변축 하중", title = "가변축 하중",
value = String.format(Locale.US, "%.3f ton", axleData.variableAxleLoad * 0.001f), value = String.format(Locale.US, "%.3f ton", axleData.variableAxleLoad * 0.001f),
iconRes = R.drawable.axle_load_variable, iconRes = R.drawable.variable_axle_load,
isBitmap = true, isBitmap = true,
iconSize = 200.dp, iconSize = 60.dp
modifier = Modifier.weight(1f)
) )
}
// 4. 구동축 하중
item {
MonitoringCard( MonitoringCard(
title = "구동축 하중", title = "구동축 하중",
value = String.format(Locale.US, "%.3f ton", axleData.driveAxleLoad * 0.001f), value = String.format(Locale.US, "%.3f ton", axleData.driveAxleLoad * 0.001f),
iconRes = R.drawable.axle_load_driver, iconRes = R.drawable.ic_axle_drive
isBitmap = true,
iconSize = 200.dp,
modifier = Modifier.weight(1f)
) )
} }
// 5. 가변축 상태
// Row 3: 가변축 상태, 하중분배율 item {
Row(modifier = Modifier.weight(1f)) {
MonitoringCard( MonitoringCard(
title = "가변축 상태", title = "가변축 상태",
value = if (axleData.isDown) "가변축 Down" else "가변축 Up", value = if (axleData.isDown) "가변축 Down" else "가변축 Up",
iconRes = if (axleData.isDown) R.drawable.axle_down else R.drawable.axle_up, iconRes = if (axleData.isDown) R.drawable.ic_axle_down else R.drawable.ic_axle_up
isBitmap = true,
iconSize = 200.dp,
modifier = Modifier.weight(1f)
) )
val distributionRatio = if (axleData.driveAxleLoad + axleData.variableAxleLoad > 0) { }
(axleData.variableAxleLoad.toFloat() / (axleData.driveAxleLoad.toFloat()+axleData.variableAxleLoad.toFloat()) * 100f) // 6. 설정 및 종료
} else 0f item {
SystemCard(
MonitoringCard( isConnected = isConnected,
title = "가변축 하중 분배율", onBluetoothClick = { showDeviceDialog = true },
value = String.format(Locale.US, "%.1f %%", distributionRatio), onExitClick = {
iconRes = when { viewModel.disconnect()
distributionRatio < 50f -> R.drawable.load_sharing_ratio_d50 (context as? Activity)?.finishAffinity()
distributionRatio == 50f -> R.drawable.load_sharing_ratio_50
else -> R.drawable.load_sharing_ratio_u50
}, },
isBitmap = true, dataTrigger = viewModel.dataReceivedTrigger,
iconSize = 300.dp, modifier = Modifier.fillMaxWidth().aspectRatio(1.2f)
modifier = Modifier.weight(1f)
) )
} }
} }
@@ -345,190 +306,51 @@ fun MonitoringScreen(viewModel: AxleViewModel) {
Text( Text(
text = "마지막 데이터 수신: $lastUpdate", text = "마지막 데이터 수신: $lastUpdate",
modifier = Modifier.align(Alignment.End), modifier = Modifier.align(Alignment.End),
fontSize = 11.sp, fontSize = 12.sp,
color = Color.Gray color = Color.Gray
) )
} }
} }
if (showDeviceDialog) { if (showDeviceDialog) {
LaunchedEffect(Unit) { val devices by viewModel.foundDevices.collectAsState()
DisposableEffect(Unit) {
viewModel.startScan() viewModel.startScan()
onDispose {
viewModel.stopScan()
}
} }
val foundDevices by viewModel.foundDevices.collectAsState()
BluetoothDeviceDialog( BluetoothDeviceDialog(
devices = foundDevices, devices = devices,
onDeviceSelected = { device -> onDeviceSelected = {
viewModel.stopScan() viewModel.connectToDevice(it)
viewModel.connectToDevice(device)
showDeviceDialog = false showDeviceDialog = false
}, },
onDismiss = { onDismiss = { showDeviceDialog = false }
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 @Composable
fun MonitoringCard( fun MonitoringCard(
modifier: Modifier = Modifier.fillMaxSize(),
title: String, title: String,
value: String, value: String,
iconRes: Int = 0, iconRes: Int,
modifier: Modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.2f), // Slightly wider to accommodate text
isBitmap: Boolean = false, isBitmap: Boolean = false,
iconSize: Dp = 200.dp, iconSize: Dp = 50.dp, // Reduced default size
titleFontSize: TextUnit = 32.sp, titleFontSize: TextUnit = 18.sp, // Reduced font size
valueFontSize: TextUnit = 26.sp, valueFontSize: TextUnit = 22.sp, // Reduced font size
titleModifier: Modifier = Modifier, titleModifier: Modifier = Modifier,
valueModifier: Modifier = Modifier, valueModifier: Modifier = Modifier,
iconModifier: Modifier = Modifier, iconModifier: Modifier = Modifier
titlePaddingTop: Dp = 24.dp,
contentSpacing: Dp = 14.dp,
customIcon: @Composable (() -> Unit)? = null
) { ) {
Card( Card(
modifier = modifier.padding(4.dp), modifier = modifier,
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color.White), colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, Color.LightGray) border = BorderStroke(1.dp, Color.LightGray)
@@ -536,18 +358,33 @@ fun MonitoringCard(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(bottom = 8.dp), .padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) { ) {
Spacer(modifier = Modifier.height(titlePaddingTop))
Text( Text(
text = title, text = title,
fontSize = titleFontSize, fontSize = titleFontSize,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color(0xFF444444), color = Color.DarkGray,
modifier = titleModifier modifier = titleModifier
) )
Spacer(modifier = Modifier.height(contentSpacing)) Spacer(modifier = Modifier.height(4.dp))
if (isBitmap) {
Image(
painter = painterResource(id = iconRes),
contentDescription = title,
modifier = iconModifier.size(iconSize)
)
} else {
Icon(
painter = painterResource(id = iconRes),
contentDescription = title,
modifier = iconModifier.size(iconSize),
tint = MaterialTheme.colorScheme.secondary
)
}
Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = value, text = value,
fontSize = valueFontSize, fontSize = valueFontSize,
@@ -555,28 +392,75 @@ fun MonitoringCard(
color = Color.Blue, color = Color.Blue,
modifier = valueModifier modifier = valueModifier
) )
Box( }
modifier = Modifier.weight(1f).fillMaxWidth(), }
contentAlignment = Alignment.Center }
@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()
) { ) {
if (customIcon != null) { Icon(
customIcon() imageVector = Icons.Default.Bluetooth,
} else if (iconRes != 0) { contentDescription = "Bluetooth",
if (isBitmap) { modifier = Modifier
Image( .size(32.dp)
painter = painterResource(id = iconRes), .clickable { onBluetoothClick() },
contentDescription = title, tint = if (isConnected) Color.Green else Color.Red
modifier = iconModifier.size(iconSize) )
) Spacer(modifier = Modifier.width(8.dp))
} else { Box(
Icon( modifier = Modifier
painter = painterResource(id = iconRes), .size(16.dp)
contentDescription = title, .clip(CircleShape)
modifier = iconModifier.size(iconSize), .background(if (isBlinking) Color.Green else Color.Transparent)
tint = MaterialTheme.colorScheme.secondary )
) }
}
} 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("종료")
} }
} }
} }
@@ -600,19 +484,21 @@ fun BluetoothDeviceDialog(
items(devices) { device -> items(devices) { device ->
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
val deviceName = device.name ?: "알 수 없는 기기" val deviceName = device.name ?: "알 수 없는 기기"
Text( val deviceAddress = device.address
text = deviceName, Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onDeviceSelected(device) } .clickable { onDeviceSelected(device) }
.padding(vertical = 12.dp), .padding(vertical = 12.dp)
fontSize = 16.sp ) {
) Text(text = deviceName, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
Text(text = deviceAddress, fontSize = 12.sp, color = Color.Gray)
}
HorizontalDivider() HorizontalDivider()
} }
} }
if (devices.isEmpty()) { if (devices.isEmpty()) {
Text("검색된 기기가 없습니다.", modifier = Modifier.padding(vertical = 16.dp)) Text("주변 기기를 검색 중입니다...", modifier = Modifier.padding(vertical = 16.dp))
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
@@ -52,13 +52,13 @@ class BluetoothManager(private val context: Context) {
val foundDevices: StateFlow<List<BluetoothDevice>> = _foundDevices val foundDevices: StateFlow<List<BluetoothDevice>> = _foundDevices
private var gatt: BluetoothGatt? = null private var gatt: BluetoothGatt? = null
private val dataBuffer = StringBuilder()
private val scanCallback = object : ScanCallback() { private val scanCallback = object : ScanCallback() {
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
override fun onScanResult(callbackType: Int, result: ScanResult) { override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device val device = result.device
// BLE 기기만 필터링 (DEVICE_TYPE_LE 또는 DEVICE_TYPE_DUAL) if (device.name != null) {
if (device.name != null && (device.type == BluetoothDevice.DEVICE_TYPE_LE || device.type == BluetoothDevice.DEVICE_TYPE_DUAL)) {
val currentList = _foundDevices.value val currentList = _foundDevices.value
if (currentList.none { it.address == device.address }) { if (currentList.none { it.address == device.address }) {
_foundDevices.value = currentList + device _foundDevices.value = currentList + device
@@ -167,11 +167,42 @@ class BluetoothManager(private val context: Context) {
private fun processReceivedData(value: ByteArray?) { private fun processReceivedData(value: ByteArray?) {
if (value == null) return if (value == null) return
val received = String(value).trim() val received = String(value)
if (received.isNotEmpty()) { // Log.d(TAG, "Raw Data (String): $received")
Log.d(TAG, "Extracted line: '$received'")
scope.launch { synchronized(dataBuffer) {
parseData(received) 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"
scope.launch {
parseData(line)
}
}
}
} }
} }
} }
@@ -200,12 +231,7 @@ class BluetoothManager(private val context: Context) {
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
fun startScan() { fun startScan() {
Log.d(TAG, "Starting BLE scan...") Log.d(TAG, "Starting BLE scan...")
// 페어링된 기기 중 BLE 기기만 먼저 추가 _foundDevices.value = emptyList()
val pairedDevices = adapter?.bondedDevices ?: emptySet()
_foundDevices.value = pairedDevices.filter {
it.type == BluetoothDevice.DEVICE_TYPE_LE || it.type == BluetoothDevice.DEVICE_TYPE_DUAL
}
scanner?.startScan(scanCallback) scanner?.startScan(scanCallback)
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB