Initial public release.
This commit is contained in:
parent
ed31acd60f
commit
58d87b11b7
249 changed files with 15831 additions and 4 deletions
49
subapp-mine/README.md
Normal file
49
subapp-mine/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# KTag Mine Subapp
|
||||
|
||||
A Jetpack Compose Android application that acts as a proximity-triggered "mine" for KTag laser tag devices. When an enemy device comes within range, the mine automatically tags it with configurable damage.
|
||||
|
||||
## How It Works
|
||||
|
||||
The Mine scans for nearby KTag devices broadcasting Status packets via BLE. When a device matching the target team enters the configured RSSI threshold, the mine:
|
||||
|
||||
1. Triggers a "BOOM!" explosion animation with haptic feedback
|
||||
2. Broadcasts a Tag packet to damage the target device
|
||||
3. Enters a rearm countdown period
|
||||
4. Returns to armed state, ready to tag again
|
||||
|
||||
## State Machine
|
||||
|
||||
The mine operates using a three-state machine:
|
||||
|
||||
| State | Description | Display |
|
||||
|-------|-------------|---------|
|
||||
| **Armed** | Ready to tag incoming devices | "ARMED" watermark |
|
||||
| **Tagging** | Explosion animation playing | BOOM! animation |
|
||||
| **Rearming** | Countdown before re-arming | Countdown number |
|
||||
|
||||
## Settings
|
||||
|
||||
Access settings via the gear icon in the app bar.
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| **Minimum RSSI** | Signal strength threshold for triggering (-120 = far, 0 = close) | -60 dBm |
|
||||
| **Damage Applied** | Amount of damage dealt to tagged devices | 100 |
|
||||
| **Target Team** | Which team(s) to target (Red, Blue, or All) | All |
|
||||
| **Time to Rearm** | Seconds before mine can tag again after detonation | 5 seconds |
|
||||
|
||||
## UI Elements
|
||||
|
||||
- **Status Bar**: Shows current target team, damage, and RSSI threshold
|
||||
- **Device List**: Displays detected KTag devices with:
|
||||
- Device name and BLE address
|
||||
- Current RSSI signal strength
|
||||
- Status: IN RANGE, OUT OF RANGE, TAGGED, or TAGGED OUT
|
||||
- **Watermark**: Large centered text showing current state (ARMED or countdown)
|
||||
- **Explosion Animation**: Full-screen animation with expanding rings and "BOOM!" text
|
||||
|
||||
## Permissions
|
||||
|
||||
- `BLUETOOTH_SCAN` / `BLUETOOTH_CONNECT` - BLE operations
|
||||
- `ACCESS_COARSE_LOCATION` - Required for BLE scanning on older Android versions
|
||||
- `VIBRATE` - Haptic feedback on explosion
|
||||
46
subapp-mine/build.gradle.kts
Normal file
46
subapp-mine/build.gradle.kts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "club.clubk.ktag.apps.mine"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core"))
|
||||
implementation(project(":shared-services"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.preference)
|
||||
implementation(libs.androidx.startup)
|
||||
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
39
subapp-mine/src/main/AndroidManifest.xml
Normal file
39
subapp-mine/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Request legacy Bluetooth permissions on older devices. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".MineActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".MineSettingsActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/action_settings"
|
||||
android:parentActivityName=".MineActivity"
|
||||
android:theme="@style/Theme.AppCompat.Light.DarkActionBar">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value=".MineActivity"/>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="club.clubk.ktag.apps.mine.MineInitializer"
|
||||
android:value="androidx.startup" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package club.clubk.ktag.apps.mine
|
||||
|
||||
/**
|
||||
* Team targeting settings.
|
||||
*/
|
||||
enum class TargetTeamSetting(val value: Int) {
|
||||
RED(1),
|
||||
BLUE(2),
|
||||
ALL(3);
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int): TargetTeamSetting =
|
||||
entries.find { it.value == value } ?: ALL
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
package club.clubk.ktag.apps.mine
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import club.clubk.ktag.apps.mine.ui.MineScreen
|
||||
import club.clubk.ktag.apps.mine.ui.theme.MineTheme
|
||||
|
||||
private const val TAG = "KTag Mine"
|
||||
|
||||
class MineActivity : ComponentActivity() {
|
||||
|
||||
private val viewModel: MineViewModel by viewModels()
|
||||
|
||||
private val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH_ADVERTISE,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { permissions ->
|
||||
val allGranted = permissions.entries.all { it.value }
|
||||
Log.d(TAG, "Permission results: ${permissions.map { "${it.key}: ${it.value}" }}")
|
||||
|
||||
if (allGranted) {
|
||||
Log.i(TAG, "All permissions granted")
|
||||
showMainContent()
|
||||
} else {
|
||||
val deniedPermissions = permissions.filter { !it.value }.keys
|
||||
Log.w(TAG, "Some permissions denied: $deniedPermissions")
|
||||
showPermissionDeniedContent(deniedPermissions.toList())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Log.d(TAG, "onCreate called")
|
||||
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
|
||||
checkAndRequestPermissions()
|
||||
}
|
||||
|
||||
private fun checkAndRequestPermissions() {
|
||||
Log.d(TAG, "Checking permissions: ${requiredPermissions.joinToString()}")
|
||||
if (hasRequiredPermissions()) {
|
||||
Log.i(TAG, "All required permissions already granted")
|
||||
showMainContent()
|
||||
} else {
|
||||
Log.i(TAG, "Requesting permissions")
|
||||
requestPermissions()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasRequiredPermissions(): Boolean {
|
||||
return requiredPermissions.all { permission ->
|
||||
ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestPermissions() {
|
||||
Log.d(TAG, "Launching permission request")
|
||||
permissionLauncher.launch(requiredPermissions)
|
||||
}
|
||||
|
||||
private fun showMainContent() {
|
||||
setContent {
|
||||
MineTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
MineMainScreen(
|
||||
viewModel = viewModel,
|
||||
context = this,
|
||||
onSettingsClick = { openSettings() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showPermissionDeniedContent(deniedPermissions: List<String>) {
|
||||
setContent {
|
||||
MineTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
PermissionDeniedScreen(
|
||||
deniedPermissions = deniedPermissions,
|
||||
onRequestPermissions = { requestPermissions() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openSettings() {
|
||||
val intent = MineSettingsActivity.createIntent(this)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (hasRequiredPermissions()) {
|
||||
viewModel.loadPreferences(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (hasRequiredPermissions()) {
|
||||
viewModel.startScanning()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
viewModel.stopScanning()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
viewModel.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MineMainScreen(
|
||||
viewModel: MineViewModel,
|
||||
context: Context,
|
||||
onSettingsClick: () -> Unit
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
|
||||
// Initialize ViewModel and handle BLE scanning lifecycle
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.initialize(context)
|
||||
viewModel.startScanning()
|
||||
onDispose {
|
||||
viewModel.stopScanning()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.app_name)) },
|
||||
actions = {
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "Menu")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_settings)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onSettingsClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
MineScreen(
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PermissionDeniedScreen(
|
||||
deniedPermissions: List<String>,
|
||||
onRequestPermissions: () -> Unit
|
||||
) {
|
||||
Log.d(TAG, "Showing permission denied screen for permissions: $deniedPermissions")
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "KTag Mine needs the following permissions:",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
deniedPermissions.forEach { permission ->
|
||||
Text(
|
||||
text = "\u2022 ${permission.split(".").last()}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
Log.d(TAG, "Permission request button clicked")
|
||||
onRequestPermissions()
|
||||
},
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
) {
|
||||
Text("Grant Permissions")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package club.clubk.ktag.apps.mine
|
||||
|
||||
import android.content.Context
|
||||
import androidx.startup.Initializer
|
||||
import club.clubk.ktag.apps.core.SubAppRegistry
|
||||
|
||||
class MineInitializer : Initializer<Unit> {
|
||||
override fun create(context: Context) {
|
||||
SubAppRegistry.register(MineSubApp())
|
||||
}
|
||||
|
||||
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package club.clubk.ktag.apps.mine
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import club.clubk.ktag.apps.sharedservices.BaseSettingsActivity
|
||||
|
||||
class MineSettingsActivity : BaseSettingsActivity() {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun createIntent(context: Context): Intent {
|
||||
return BaseSettingsActivity.createIntent(context, R.xml.mine_settings_pref, MineSettingsActivity::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package club.clubk.ktag.apps.mine
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import club.clubk.ktag.apps.sharedservices.SettingsSubApp
|
||||
|
||||
class MineSubApp : SettingsSubApp {
|
||||
override val id = "mine"
|
||||
override val name = "Mine"
|
||||
override val icon = R.drawable.ic_mine
|
||||
override val settingsPreferencesResId = R.xml.mine_settings_pref
|
||||
override val usesMqtt = false
|
||||
|
||||
override fun createIntent(context: Context): Intent {
|
||||
return Intent(context, MineActivity::class.java)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,368 @@
|
|||
package club.clubk.ktag.apps.mine
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.le.AdvertiseData
|
||||
import android.bluetooth.le.AdvertiseSettings
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import club.clubk.ktag.apps.core.DeviceModel
|
||||
import club.clubk.ktag.apps.core.HexUtils
|
||||
import club.clubk.ktag.apps.core.R
|
||||
import club.clubk.ktag.apps.core.ble.Packet
|
||||
import club.clubk.ktag.apps.core.ui.ColorUtils
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagBlue
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagPurple
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagRed
|
||||
import club.clubk.ktag.apps.sharedservices.BleViewModel
|
||||
import club.clubk.ktag.apps.sharedservices.DeviceInfo
|
||||
import club.clubk.ktag.apps.sharedservices.DevicePreferenceKeys
|
||||
import club.clubk.ktag.apps.sharedservices.MinePreferenceKeys
|
||||
import club.clubk.ktag.apps.sharedservices.getIntPref
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* State machine for the Mine's arming cycle.
|
||||
*/
|
||||
sealed class MineState {
|
||||
/** Mine is armed and ready to tag devices */
|
||||
data object Armed : MineState()
|
||||
|
||||
/** Mine is currently showing explosion animation */
|
||||
data object Tagging : MineState()
|
||||
|
||||
/** Mine is rearming with countdown */
|
||||
data class Rearming(val countdownSeconds: Int) : MineState()
|
||||
}
|
||||
|
||||
class MineViewModel : BleViewModel() {
|
||||
|
||||
override val logTag = "KTag Mine"
|
||||
|
||||
companion object {
|
||||
private const val CLEANUP_INTERVAL_MS = 500L
|
||||
private const val RSSI_HYSTERESIS = 20
|
||||
private const val TAG_DURATION_MS = 3000L
|
||||
private const val DEFAULT_DAMAGE = 100
|
||||
private var tagEventNumber: Byte = 0
|
||||
}
|
||||
|
||||
// Device list
|
||||
private val _devices = mutableStateListOf<DeviceModel>()
|
||||
val devices: List<DeviceModel> get() = _devices
|
||||
|
||||
// Mine state machine
|
||||
var mineState by mutableStateOf<MineState>(MineState.Armed)
|
||||
private set
|
||||
|
||||
var rearmTimeSeconds by mutableStateOf(5)
|
||||
private set
|
||||
|
||||
private var rearmJob: Job? = null
|
||||
|
||||
fun onExplosionAnimationComplete() {
|
||||
transitionToRearming()
|
||||
}
|
||||
|
||||
private fun transitionToRearming() {
|
||||
rearmJob?.cancel()
|
||||
mineState = MineState.Rearming(rearmTimeSeconds)
|
||||
|
||||
rearmJob = viewModelScope.launch {
|
||||
var countdown = rearmTimeSeconds
|
||||
while (countdown > 0) {
|
||||
delay(1000L)
|
||||
countdown--
|
||||
mineState = MineState.Rearming(countdown)
|
||||
}
|
||||
resetTaggedDevices()
|
||||
mineState = MineState.Armed
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetTaggedDevices() {
|
||||
val iterator = _devices.listIterator()
|
||||
while (iterator.hasNext()) {
|
||||
val device = iterator.next()
|
||||
if (device.hasBeenTagged) {
|
||||
iterator.set(device.withReset())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settings
|
||||
var targetTeamSetting by mutableStateOf(TargetTeamSetting.ALL)
|
||||
private set
|
||||
|
||||
var minimumRssi by mutableStateOf(-60)
|
||||
private set
|
||||
|
||||
var damage by mutableStateOf(DEFAULT_DAMAGE)
|
||||
private set
|
||||
|
||||
private var deviceTtlMs = DeviceModel.DEFAULT_TTL_MS
|
||||
|
||||
val backgroundColorForTargetTeam: Color
|
||||
get() = when (targetTeamSetting) {
|
||||
TargetTeamSetting.RED -> ColorUtils.makePastel(KTagRed)
|
||||
TargetTeamSetting.BLUE -> ColorUtils.makePastel(KTagBlue)
|
||||
TargetTeamSetting.ALL -> ColorUtils.makePastel(KTagPurple)
|
||||
}
|
||||
|
||||
// Cleanup job
|
||||
private var cleanupJob: Job? = null
|
||||
|
||||
override fun onStatusPacket(result: ScanResult) {
|
||||
val status = Packet.Status(result)
|
||||
addOrRefreshDevice(
|
||||
bleAddress = result.device.address,
|
||||
rssi = result.rssi,
|
||||
color = Color(status.primary_color),
|
||||
health = status.health,
|
||||
teamId = status.team_ID
|
||||
)
|
||||
}
|
||||
|
||||
override fun updateDeviceInfoFromRepository(infoMap: Map<String, DeviceInfo>) {
|
||||
applyDeviceInfoUpdate(_devices, infoMap)
|
||||
}
|
||||
|
||||
fun initialize(context: Context) {
|
||||
initBluetooth(context)
|
||||
loadPreferences(context)
|
||||
initDeviceInfo(context)
|
||||
}
|
||||
|
||||
fun loadPreferences(context: Context) {
|
||||
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
minimumRssi = sharedPrefs.getIntPref(MinePreferenceKeys.MIN_RSSI, MinePreferenceKeys.DEFAULT_MIN_RSSI)
|
||||
targetTeamSetting = TargetTeamSetting.fromInt(
|
||||
sharedPrefs.getIntPref(MinePreferenceKeys.TARGET_TEAM, MinePreferenceKeys.DEFAULT_TARGET_TEAM)
|
||||
)
|
||||
damage = sharedPrefs.getIntPref(MinePreferenceKeys.DAMAGE, MinePreferenceKeys.DEFAULT_DAMAGE)
|
||||
rearmTimeSeconds = sharedPrefs.getIntPref(MinePreferenceKeys.REARM_TIME, MinePreferenceKeys.DEFAULT_REARM_TIME)
|
||||
deviceTtlMs = sharedPrefs.getIntPref(DevicePreferenceKeys.DEVICE_TTL, DevicePreferenceKeys.DEFAULT_DEVICE_TTL) * 1000
|
||||
Log.d(logTag, "minimumRSSI: $minimumRssi, targetTeamSetting: $targetTeamSetting, damage: $damage, rearmTime: $rearmTimeSeconds")
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun startScanning() {
|
||||
startBleScanning()
|
||||
|
||||
cleanupJob?.cancel()
|
||||
cleanupJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
delay(CLEANUP_INTERVAL_MS)
|
||||
cleanupExpiredDevices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun stopScanning() {
|
||||
stopBleScanning()
|
||||
cleanupJob?.cancel()
|
||||
cleanupJob = null
|
||||
stopAdvertising()
|
||||
}
|
||||
|
||||
private fun cleanupExpiredDevices() {
|
||||
val iterator = _devices.listIterator()
|
||||
while (iterator.hasNext()) {
|
||||
val device = iterator.next()
|
||||
val updated = device.withDecrementedTtl(CLEANUP_INTERVAL_MS.toInt())
|
||||
if (updated.isExpired) {
|
||||
iterator.remove()
|
||||
} else {
|
||||
iterator.set(updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addOrRefreshDevice(
|
||||
bleAddress: String,
|
||||
rssi: Int,
|
||||
color: Color,
|
||||
health: Int,
|
||||
teamId: Byte
|
||||
) {
|
||||
Log.d(logTag, "RSSI $rssi (min $minimumRssi), health: $health, teamId: $teamId")
|
||||
|
||||
val matchesTeam = when (targetTeamSetting) {
|
||||
TargetTeamSetting.ALL -> true
|
||||
TargetTeamSetting.RED -> teamId == 1.toByte()
|
||||
TargetTeamSetting.BLUE -> teamId == 2.toByte()
|
||||
}
|
||||
|
||||
val existingIndex = _devices.indexOfFirst { it.bleAddress == bleAddress }
|
||||
|
||||
if (!matchesTeam) {
|
||||
if (existingIndex != -1) {
|
||||
Log.d(logTag, "Removing device (wrong team): $bleAddress")
|
||||
_devices.removeAt(existingIndex)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (health <= 0) {
|
||||
if (existingIndex != -1) {
|
||||
val existingDevice = _devices[existingIndex]
|
||||
if (!existingDevice.isTaggedOut) {
|
||||
Log.d(logTag, "Device tagged out, showing status: $bleAddress")
|
||||
_devices[existingIndex] = existingDevice.withTaggedOut(deviceTtlMs)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val isInRange = rssi >= minimumRssi
|
||||
|
||||
if (existingIndex == -1) {
|
||||
val deviceName = deviceInfoRepository?.getName(bleAddress) ?: "KTag Device"
|
||||
val info = deviceInfoRepository?.getInfo(bleAddress)
|
||||
val newDevice = DeviceModel(
|
||||
name = deviceName,
|
||||
version = if (info != null) String.format("SystemK v%d.%02d", info.majorVersion, info.minorVersion) else "",
|
||||
deviceType = info?.deviceTypeName ?: "",
|
||||
id = 1,
|
||||
image = R.drawable.ktag_shield_gray,
|
||||
bleAddress = bleAddress,
|
||||
rssi = rssi,
|
||||
color = color,
|
||||
health = health,
|
||||
inRange = rssi >= minimumRssi
|
||||
)
|
||||
_devices.add(newDevice)
|
||||
Log.d(logTag, "Adding device: $bleAddress, inRange: $isInRange")
|
||||
|
||||
if (isInRange) {
|
||||
tagDevice(bleAddress)
|
||||
_devices[_devices.size - 1] = _devices.last().withTagged()
|
||||
}
|
||||
} else {
|
||||
val existingDevice = _devices[existingIndex]
|
||||
|
||||
if (existingDevice.isTaggedOut) {
|
||||
return
|
||||
}
|
||||
|
||||
val wasInRange = existingDevice.inRange
|
||||
val updatedDevice = existingDevice
|
||||
.withResetTtl(deviceTtlMs)
|
||||
.withRssi(rssi, minimumRssi)
|
||||
|
||||
_devices[existingIndex] = updatedDevice
|
||||
|
||||
if (isInRange && !wasInRange && !existingDevice.hasBeenTagged) {
|
||||
tagDevice(bleAddress)
|
||||
_devices[existingIndex] = _devices[existingIndex].withTagged()
|
||||
Log.d(logTag, "Device came into range, tagging: $bleAddress")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun tagDevice(bleAddress: String) {
|
||||
if (mineState != MineState.Armed) {
|
||||
Log.d(logTag, "Mine not ready - state: $mineState")
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(logTag, "Tagging $bleAddress with damage $damage")
|
||||
|
||||
mineState = MineState.Tagging
|
||||
|
||||
val settings = AdvertiseSettings.Builder()
|
||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
|
||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
|
||||
.setConnectable(false)
|
||||
.build()
|
||||
|
||||
val tagBytes = createTagPacketWithDamage(
|
||||
HexUtils.hexStringToByteArray(bleAddress, ':'),
|
||||
damage
|
||||
)
|
||||
val adData = AdvertiseData.Builder()
|
||||
.addManufacturerData(0xFFFF, tagBytes)
|
||||
.build()
|
||||
|
||||
stopAdvertising()
|
||||
|
||||
btAdvertiser?.startAdvertising(settings, adData, advertisingCallback)
|
||||
|
||||
advertisingJob = viewModelScope.launch {
|
||||
delay(TAG_DURATION_MS)
|
||||
stopAdvertising()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Tag packet with configurable damage.
|
||||
* Positive damage deals damage, negative damage heals.
|
||||
*/
|
||||
private fun createTagPacketWithDamage(targetBdAddr: ByteArray, damageAmount: Int): ByteArray {
|
||||
val reversedAddr = ByteArray(6)
|
||||
for (i in 0 until 6) {
|
||||
reversedAddr[i] = targetBdAddr[5 - i]
|
||||
}
|
||||
|
||||
val damageLsb = (damageAmount and 0xFF).toByte()
|
||||
val damageMsb = ((damageAmount shr 8) and 0xFF).toByte()
|
||||
|
||||
return byteArrayOf(
|
||||
'K'.code.toByte(),
|
||||
'T'.code.toByte(),
|
||||
'a'.code.toByte(),
|
||||
'g'.code.toByte(),
|
||||
0x03, // PACKET_TYPE_TAG
|
||||
tagEventNumber++, // Event number
|
||||
4, // Tx Power Level (dBm)
|
||||
0x03, // Protocol
|
||||
0x00, // Team ID
|
||||
0xFF.toByte(), // Player ID
|
||||
damageLsb, // Damage (lsb)
|
||||
damageMsb, // Damage (msb)
|
||||
0xFF.toByte(), // Primary Color RED
|
||||
0xFF.toByte(), // Primary Color GREEN
|
||||
0xFF.toByte(), // Primary Color BLUE
|
||||
0xFE.toByte(), // Primary Color BRIGHTNESS
|
||||
reversedAddr[0],
|
||||
reversedAddr[1],
|
||||
reversedAddr[2],
|
||||
reversedAddr[3],
|
||||
reversedAddr[4],
|
||||
reversedAddr[5],
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte()
|
||||
)
|
||||
}
|
||||
|
||||
fun onDeviceClicked(device: DeviceModel) {
|
||||
tagDevice(device.bleAddress)
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
stopScanning()
|
||||
stopAdvertising()
|
||||
rearmJob?.cancel()
|
||||
rearmJob = null
|
||||
cleanupDeviceInfo()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
package club.clubk.ktag.apps.mine.ui
|
||||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import club.clubk.ktag.apps.core.DeviceModel
|
||||
import club.clubk.ktag.apps.mine.MineState
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagDarkGray
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagGreen
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagRed
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagYellow
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun DeviceCard(
|
||||
device: DeviceModel,
|
||||
mineState: MineState,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val view = LocalView.current
|
||||
|
||||
// Only show tagged status when mine is armed
|
||||
val showTaggedStatus = mineState is MineState.Armed && device.hasBeenTagged
|
||||
|
||||
val cardColor = KTagDarkGray
|
||||
val borderColor = when {
|
||||
device.isTaggedOut -> KTagRed
|
||||
showTaggedStatus -> KTagRed
|
||||
device.inRange -> KTagGreen
|
||||
else -> Color.Gray
|
||||
}
|
||||
val statusText = when {
|
||||
device.isTaggedOut -> "TAGGED OUT!"
|
||||
showTaggedStatus -> "TAGGED"
|
||||
device.inRange -> "IN RANGE"
|
||||
else -> "OUT OF RANGE"
|
||||
}
|
||||
val statusColor = when {
|
||||
device.isTaggedOut -> KTagRed
|
||||
showTaggedStatus -> KTagRed
|
||||
device.inRange -> KTagGreen
|
||||
else -> Color.Gray
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.border(2.dp, borderColor, RoundedCornerShape(10.dp))
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||
onLongClick()
|
||||
}
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 5.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = cardColor)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Device icon with color background
|
||||
Image(
|
||||
painter = painterResource(id = device.image),
|
||||
contentDescription = "Device icon",
|
||||
modifier = Modifier
|
||||
.size(75.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(device.color)
|
||||
)
|
||||
|
||||
// Device info
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = device.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = KTagYellow,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (device.deviceType.isNotEmpty()) {
|
||||
Text(
|
||||
text = device.deviceType,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = KTagYellow,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = device.bleAddress,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = KTagYellow,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
Text(
|
||||
text = "RSSI: ${device.rssi} dBm",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = KTagYellow,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = statusColor,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
package club.clubk.ktag.apps.mine.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.sp
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagRed
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagYellow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun MineExplosionAnimation(
|
||||
onAnimationComplete: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val flashAlpha = remember { Animatable(0f) }
|
||||
val ring1Progress = remember { Animatable(0f) }
|
||||
val ring2Progress = remember { Animatable(0f) }
|
||||
val ring3Progress = remember { Animatable(0f) }
|
||||
val textScale = remember { Animatable(0f) }
|
||||
val textAlpha = remember { Animatable(0f) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Haptic feedback for explosion
|
||||
triggerExplosionHaptic(context)
|
||||
|
||||
// Initial flash
|
||||
launch {
|
||||
flashAlpha.animateTo(0.8f, tween(100))
|
||||
flashAlpha.animateTo(0f, tween(400))
|
||||
}
|
||||
|
||||
// Expanding rings with staggered start
|
||||
launch {
|
||||
ring1Progress.animateTo(1f, tween(500, easing = FastOutSlowInEasing))
|
||||
}
|
||||
launch {
|
||||
delay(100)
|
||||
ring2Progress.animateTo(1f, tween(500, easing = FastOutSlowInEasing))
|
||||
}
|
||||
launch {
|
||||
delay(200)
|
||||
ring3Progress.animateTo(1f, tween(500, easing = FastOutSlowInEasing))
|
||||
}
|
||||
|
||||
// Text animation
|
||||
launch {
|
||||
delay(150)
|
||||
textAlpha.snapTo(1f)
|
||||
textScale.animateTo(1.2f, tween(200, easing = FastOutSlowInEasing))
|
||||
textScale.animateTo(1f, tween(100))
|
||||
}
|
||||
|
||||
// Wait for animations to complete
|
||||
delay(1200)
|
||||
|
||||
// Fade out
|
||||
textAlpha.animateTo(0f, tween(300))
|
||||
|
||||
onAnimationComplete()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Flash overlay
|
||||
if (flashAlpha.value > 0f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(KTagYellow.copy(alpha = flashAlpha.value))
|
||||
)
|
||||
}
|
||||
|
||||
// Explosion rings
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val center = Offset(size.width / 2, size.height / 2)
|
||||
val maxRadius = size.maxDimension * 0.7f
|
||||
|
||||
// Ring 1 - Yellow/Orange
|
||||
if (ring1Progress.value > 0f) {
|
||||
val radius = maxRadius * ring1Progress.value
|
||||
val alpha = (1f - ring1Progress.value).coerceIn(0f, 1f)
|
||||
val strokeWidth = 40f * (1f - ring1Progress.value * 0.5f)
|
||||
drawCircle(
|
||||
color = KTagYellow.copy(alpha = alpha),
|
||||
radius = radius,
|
||||
center = center,
|
||||
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
|
||||
)
|
||||
}
|
||||
|
||||
// Ring 2 - Red/Orange
|
||||
if (ring2Progress.value > 0f) {
|
||||
val radius = maxRadius * ring2Progress.value * 0.85f
|
||||
val alpha = (1f - ring2Progress.value).coerceIn(0f, 1f)
|
||||
val strokeWidth = 50f * (1f - ring2Progress.value * 0.5f)
|
||||
drawCircle(
|
||||
color = KTagRed.copy(alpha = alpha),
|
||||
radius = radius,
|
||||
center = center,
|
||||
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
|
||||
)
|
||||
}
|
||||
|
||||
// Ring 3 - Dark Red
|
||||
if (ring3Progress.value > 0f) {
|
||||
val radius = maxRadius * ring3Progress.value * 0.7f
|
||||
val alpha = (1f - ring3Progress.value).coerceIn(0f, 1f)
|
||||
val strokeWidth = 60f * (1f - ring3Progress.value * 0.5f)
|
||||
drawCircle(
|
||||
color = Color(0xFFAA0000).copy(alpha = alpha),
|
||||
radius = radius,
|
||||
center = center,
|
||||
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// "BOOM!" text
|
||||
if (textAlpha.value > 0f) {
|
||||
Text(
|
||||
text = "BOOM!",
|
||||
fontSize = (72 * textScale.value).sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = KTagRed.copy(alpha = textAlpha.value),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerExplosionHaptic(context: Context) {
|
||||
val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||
vibratorManager.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// Strong explosion vibration pattern: initial burst + aftershock
|
||||
val timings = longArrayOf(0, 100, 50, 150)
|
||||
val amplitudes = intArrayOf(0, 255, 0, 180)
|
||||
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(longArrayOf(0, 100, 50, 150), -1)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
package club.clubk.ktag.apps.mine.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagBlue
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagPurple
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagRed
|
||||
import club.clubk.ktag.apps.core.DeviceModel
|
||||
import club.clubk.ktag.apps.core.ui.RenameDeviceDialog
|
||||
import club.clubk.ktag.apps.mine.MineState
|
||||
import club.clubk.ktag.apps.mine.MineViewModel
|
||||
import club.clubk.ktag.apps.mine.TargetTeamSetting
|
||||
|
||||
@Composable
|
||||
fun MineScreen(
|
||||
viewModel: MineViewModel,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val devices = viewModel.devices
|
||||
val targetTeam = viewModel.targetTeamSetting
|
||||
val damage = viewModel.damage
|
||||
val minRssi = viewModel.minimumRssi
|
||||
val mineState = viewModel.mineState
|
||||
|
||||
// Rename dialog state
|
||||
var deviceToRename by remember { mutableStateOf<DeviceModel?>(null) }
|
||||
|
||||
val teamColor = when (targetTeam) {
|
||||
TargetTeamSetting.RED -> KTagRed
|
||||
TargetTeamSetting.BLUE -> KTagBlue
|
||||
TargetTeamSetting.ALL -> KTagPurple
|
||||
}
|
||||
|
||||
// Rename dialog
|
||||
deviceToRename?.let { device ->
|
||||
RenameDeviceDialog(
|
||||
macAddress = device.bleAddress,
|
||||
currentName = device.name,
|
||||
onDismiss = { deviceToRename = null },
|
||||
onSave = { newName ->
|
||||
viewModel.renameDevice(device.bleAddress, newName)
|
||||
deviceToRename = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
// Main content
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
// Status bar showing current settings
|
||||
MineStatusBar(
|
||||
targetTeam = targetTeam,
|
||||
damage = damage,
|
||||
minRssi = minRssi
|
||||
)
|
||||
|
||||
// Device list area with "ARMED" watermark
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.background(Color.Black)
|
||||
) {
|
||||
// Watermark text based on mine state
|
||||
when (mineState) {
|
||||
is MineState.Armed -> {
|
||||
Text(
|
||||
text = "ARMED",
|
||||
fontSize = 64.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = teamColor.copy(alpha = 0.3f),
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
is MineState.Rearming -> {
|
||||
Text(
|
||||
text = mineState.countdownSeconds.toString(),
|
||||
fontSize = 120.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = teamColor.copy(alpha = 0.3f),
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
is MineState.Tagging -> {
|
||||
// No watermark during explosion
|
||||
}
|
||||
}
|
||||
|
||||
// Device list
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(
|
||||
items = devices,
|
||||
key = { it.bleAddress }
|
||||
) { device ->
|
||||
DeviceCard(
|
||||
device = device,
|
||||
mineState = mineState,
|
||||
onClick = { viewModel.onDeviceClicked(device) },
|
||||
onLongClick = { deviceToRename = device }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Explosion animation overlay
|
||||
if (mineState is MineState.Tagging) {
|
||||
MineExplosionAnimation(
|
||||
onAnimationComplete = { viewModel.onExplosionAnimationComplete() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MineStatusBar(
|
||||
targetTeam: TargetTeamSetting,
|
||||
damage: Int,
|
||||
minRssi: Int,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val teamText = when (targetTeam) {
|
||||
TargetTeamSetting.RED -> "Red Team"
|
||||
TargetTeamSetting.BLUE -> "Blue Team"
|
||||
TargetTeamSetting.ALL -> "All Teams"
|
||||
}
|
||||
|
||||
val teamColor = when (targetTeam) {
|
||||
TargetTeamSetting.RED -> KTagRed
|
||||
TargetTeamSetting.BLUE -> KTagBlue
|
||||
TargetTeamSetting.ALL -> KTagPurple
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xFF1A1A1A))
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Targeting: $teamText",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = teamColor
|
||||
)
|
||||
Text(
|
||||
text = "Damage: $damage | Min RSSI: $minRssi dBm",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package club.clubk.ktag.apps.mine.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagBlue
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagPurple
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagRed
|
||||
|
||||
// Mine color aliases for clarity in module-specific code
|
||||
val MineGrey = Color(0xFFE0E0E0)
|
||||
val MineWhite = Color(0xFFFEFEFE)
|
||||
val MineRed = KTagRed
|
||||
val MineBlue = KTagBlue
|
||||
val MinePurple = KTagPurple
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package club.clubk.ktag.apps.mine.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagDarkGray
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagRed
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagPurple
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagYellow
|
||||
import club.clubk.ktag.apps.core.ui.theme.Typography
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = KTagDarkGray,
|
||||
onPrimary = Color.White,
|
||||
secondary = KTagYellow,
|
||||
onSecondary = KTagDarkGray,
|
||||
tertiary = KTagPurple,
|
||||
background = Color.Black,
|
||||
surface = KTagDarkGray
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = KTagDarkGray,
|
||||
onPrimary = Color.White,
|
||||
secondary = KTagYellow,
|
||||
onSecondary = KTagDarkGray,
|
||||
tertiary = KTagPurple,
|
||||
background = Color.Black,
|
||||
surface = KTagDarkGray
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MineTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.primary.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
BIN
subapp-mine/src/main/res/drawable/ic_mine.png
Normal file
BIN
subapp-mine/src/main/res/drawable/ic_mine.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 694 KiB |
13
subapp-mine/src/main/res/values/arrays.xml
Normal file
13
subapp-mine/src/main/res/values/arrays.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="pref_target_team_list_titles">
|
||||
<item>Red</item>
|
||||
<item>Blue</item>
|
||||
<item>All</item>
|
||||
</string-array>
|
||||
<string-array name="pref_target_team_list_values">
|
||||
<item>1</item>
|
||||
<item>2</item>
|
||||
<item>3</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
5
subapp-mine/src/main/res/values/strings.xml
Normal file
5
subapp-mine/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<resources>
|
||||
<string name="app_name">KTag Mine</string>
|
||||
<string name="hello_world">Hello world!</string>
|
||||
<string name="action_settings">Settings</string>
|
||||
</resources>
|
||||
8
subapp-mine/src/main/res/values/styles.xml
Normal file
8
subapp-mine/src/main/res/values/styles.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
37
subapp-mine/src/main/res/xml/mine_settings_pref.xml
Normal file
37
subapp-mine/src/main/res/xml/mine_settings_pref.xml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<PreferenceCategory android:title="Mine Settings">
|
||||
|
||||
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
|
||||
app:customHint='"-120" (far) to "0" (nearby); try "-60"'
|
||||
android:inputType="numberSigned"
|
||||
android:key="mine_min_rssi"
|
||||
android:summary="%s dBm"
|
||||
android:title="Minimum RSSI (dBm) for Tagging" />
|
||||
|
||||
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
|
||||
app:customHint="Damage to apply (e.g. 100)"
|
||||
android:inputType="number"
|
||||
android:key="mine_damage"
|
||||
android:summary="%s"
|
||||
android:title="Damage Applied" />
|
||||
|
||||
<ListPreference
|
||||
android:entries="@array/pref_target_team_list_titles"
|
||||
android:entryValues="@array/pref_target_team_list_values"
|
||||
android:defaultValue="3"
|
||||
android:key="mine_target_team"
|
||||
android:title="Target Team"
|
||||
android:summary="Target %s" />
|
||||
|
||||
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
|
||||
app:customHint="Seconds before mine rearms (e.g. 5)"
|
||||
android:inputType="number"
|
||||
android:key="mine_rearm_time"
|
||||
android:summary="%s seconds"
|
||||
android:title="Time to Rearm" />
|
||||
|
||||
</PreferenceCategory>
|
||||
|
||||
</PreferenceScreen>
|
||||
Loading…
Add table
Add a link
Reference in a new issue