ironOS native ios app
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add android studio

+3056
+10
android/.gitignore
··· 1 + *.iml 2 + .gradle 3 + /local.properties 4 + /.idea 5 + .DS_Store 6 + /build 7 + /captures 8 + .externalNativeBuild 9 + .cxx 10 + local.properties
+74
android/app/build.gradle.kts
··· 1 + plugins { 2 + alias(libs.plugins.android.application) 3 + alias(libs.plugins.kotlin.android) 4 + alias(libs.plugins.kotlin.compose) 5 + alias(libs.plugins.hilt.android) 6 + alias(libs.plugins.ksp) 7 + } 8 + 9 + android { 10 + namespace = "com.tinkcil" 11 + compileSdk = 35 12 + 13 + defaultConfig { 14 + applicationId = "sh.dunkirk.tinkcil" 15 + minSdk = 26 16 + targetSdk = 35 17 + versionCode = 1 18 + versionName = "1.0" 19 + 20 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 21 + } 22 + 23 + buildTypes { 24 + release { 25 + isMinifyEnabled = true 26 + isShrinkResources = true 27 + proguardFiles( 28 + getDefaultProguardFile("proguard-android-optimize.txt"), 29 + "proguard-rules.pro" 30 + ) 31 + } 32 + } 33 + compileOptions { 34 + sourceCompatibility = JavaVersion.VERSION_17 35 + targetCompatibility = JavaVersion.VERSION_17 36 + } 37 + kotlinOptions { 38 + jvmTarget = "17" 39 + } 40 + buildFeatures { 41 + compose = true 42 + } 43 + } 44 + 45 + dependencies { 46 + // Core 47 + implementation(libs.androidx.core.ktx) 48 + implementation(libs.androidx.lifecycle.runtime.ktx) 49 + implementation(libs.androidx.lifecycle.viewmodel.compose) 50 + implementation(libs.androidx.lifecycle.runtime.compose) 51 + implementation(libs.androidx.activity.compose) 52 + implementation(libs.androidx.splashscreen) 53 + 54 + // Compose 55 + implementation(platform(libs.androidx.compose.bom)) 56 + implementation(libs.androidx.ui) 57 + implementation(libs.androidx.ui.graphics) 58 + implementation(libs.androidx.ui.tooling.preview) 59 + implementation(libs.androidx.material3) 60 + implementation(libs.androidx.material.icons.extended) 61 + implementation(libs.androidx.material3.windowsizeclass) 62 + debugImplementation(libs.androidx.ui.tooling) 63 + 64 + // Hilt 65 + implementation(libs.hilt.android) 66 + ksp(libs.hilt.android.compiler) 67 + implementation(libs.hilt.navigation.compose) 68 + 69 + // DataStore 70 + implementation(libs.androidx.datastore.preferences) 71 + 72 + // Coroutines 73 + implementation(libs.kotlinx.coroutines.android) 74 + }
+4
android/app/proguard-rules.pro
··· 1 + # Add project specific ProGuard rules here. 2 + 3 + # Keep data classes 4 + -keep class com.tinkcil.data.model.** { *; }
+43
android/app/src/main/AndroidManifest.xml
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <manifest xmlns:android="http://schemas.android.com/apk/res/android" 3 + xmlns:tools="http://schemas.android.com/tools"> 4 + 5 + <uses-permission android:name="android.permission.BLUETOOTH_SCAN" 6 + android:usesPermissionFlags="neverForLocation" 7 + tools:targetApi="s" /> 8 + <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> 9 + <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> 10 + <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> 11 + 12 + <!-- Legacy BLE permissions for API < 31 --> 13 + <uses-permission android:name="android.permission.BLUETOOTH" 14 + android:maxSdkVersion="30" /> 15 + <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" 16 + android:maxSdkVersion="30" /> 17 + 18 + <uses-feature android:name="android.hardware.bluetooth_le" 19 + android:required="true" /> 20 + 21 + <application 22 + android:name=".TinkcilApplication" 23 + android:allowBackup="true" 24 + android:icon="@mipmap/ic_launcher" 25 + android:label="@string/app_name" 26 + android:roundIcon="@mipmap/ic_launcher_round" 27 + android:supportsRtl="true" 28 + android:theme="@style/Theme.Tinkcil" 29 + tools:targetApi="35"> 30 + 31 + <activity 32 + android:name=".MainActivity" 33 + android:exported="true" 34 + android:theme="@style/Theme.Tinkcil"> 35 + <intent-filter> 36 + <action android:name="android.intent.action.MAIN" /> 37 + <category android:name="android.intent.category.LAUNCHER" /> 38 + </intent-filter> 39 + </activity> 40 + 41 + </application> 42 + 43 + </manifest>
+60
android/app/src/main/java/com/tinkcil/MainActivity.kt
··· 1 + package com.tinkcil 2 + 3 + import android.Manifest 4 + import android.content.pm.PackageManager 5 + import android.os.Build 6 + import android.os.Bundle 7 + import androidx.activity.ComponentActivity 8 + import androidx.activity.compose.setContent 9 + import androidx.activity.enableEdgeToEdge 10 + import androidx.activity.result.contract.ActivityResultContracts 11 + import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi 12 + import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass 13 + import androidx.core.content.ContextCompat 14 + import com.tinkcil.ui.screens.home.HomeScreen 15 + import com.tinkcil.ui.theme.TinkcilTheme 16 + import dagger.hilt.android.AndroidEntryPoint 17 + 18 + @AndroidEntryPoint 19 + class MainActivity : ComponentActivity() { 20 + 21 + private val permissionLauncher = registerForActivityResult( 22 + ActivityResultContracts.RequestMultiplePermissions() 23 + ) { /* Permissions handled - screen will react to BLE state */ } 24 + 25 + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) 26 + override fun onCreate(savedInstanceState: Bundle?) { 27 + super.onCreate(savedInstanceState) 28 + enableEdgeToEdge() 29 + requestBLEPermissions() 30 + 31 + setContent { 32 + TinkcilTheme { 33 + val windowSizeClass = calculateWindowSizeClass(this) 34 + HomeScreen(widthSizeClass = windowSizeClass.widthSizeClass) 35 + } 36 + } 37 + } 38 + 39 + private fun requestBLEPermissions() { 40 + val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 41 + arrayOf( 42 + Manifest.permission.BLUETOOTH_SCAN, 43 + Manifest.permission.BLUETOOTH_CONNECT 44 + ) 45 + } else { 46 + arrayOf( 47 + Manifest.permission.ACCESS_FINE_LOCATION, 48 + Manifest.permission.ACCESS_COARSE_LOCATION 49 + ) 50 + } 51 + 52 + val needed = permissions.filter { 53 + ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED 54 + } 55 + 56 + if (needed.isNotEmpty()) { 57 + permissionLauncher.launch(needed.toTypedArray()) 58 + } 59 + } 60 + }
+7
android/app/src/main/java/com/tinkcil/TinkcilApplication.kt
··· 1 + package com.tinkcil 2 + 3 + import android.app.Application 4 + import dagger.hilt.android.HiltAndroidApp 5 + 6 + @HiltAndroidApp 7 + class TinkcilApplication : Application()
+10
android/app/src/main/java/com/tinkcil/data/ble/BLEError.kt
··· 1 + package com.tinkcil.data.ble 2 + 3 + sealed class BLEError(val message: String) { 4 + data object NotConnected : BLEError("Not connected to device") 5 + data object CharacteristicNotFound : BLEError("Characteristic not found") 6 + data class ReadFailed(val detail: String) : BLEError("Read failed: $detail") 7 + data class WriteFailed(val detail: String) : BLEError("Write failed: $detail") 8 + data object Timeout : BLEError("Operation timed out") 9 + data object PermissionDenied : BLEError("Bluetooth permission denied") 10 + }
+522
android/app/src/main/java/com/tinkcil/data/ble/BLEManager.kt
··· 1 + package com.tinkcil.data.ble 2 + 3 + import android.annotation.SuppressLint 4 + import android.bluetooth.BluetoothAdapter 5 + import android.bluetooth.BluetoothDevice 6 + import android.bluetooth.BluetoothGatt 7 + import android.bluetooth.BluetoothGattCallback 8 + import android.bluetooth.BluetoothGattCharacteristic 9 + import android.bluetooth.BluetoothGattService 10 + import android.bluetooth.BluetoothManager 11 + import android.bluetooth.BluetoothProfile 12 + import android.bluetooth.le.ScanCallback 13 + import android.bluetooth.le.ScanResult 14 + import android.bluetooth.le.ScanSettings 15 + import android.content.Context 16 + import com.tinkcil.data.model.CircularBuffer 17 + import com.tinkcil.data.model.IronOSLiveData 18 + import com.tinkcil.data.model.OperatingMode 19 + import com.tinkcil.data.model.PowerSource 20 + import com.tinkcil.data.model.TemperaturePoint 21 + import dagger.hilt.android.qualifiers.ApplicationContext 22 + import kotlinx.coroutines.CoroutineScope 23 + import kotlinx.coroutines.Dispatchers 24 + import kotlinx.coroutines.Job 25 + import kotlinx.coroutines.SupervisorJob 26 + import kotlinx.coroutines.delay 27 + import kotlinx.coroutines.flow.MutableStateFlow 28 + import kotlinx.coroutines.flow.StateFlow 29 + import kotlinx.coroutines.flow.asStateFlow 30 + import kotlinx.coroutines.launch 31 + import kotlinx.coroutines.suspendCancellableCoroutine 32 + import kotlinx.coroutines.sync.Mutex 33 + import kotlinx.coroutines.sync.withLock 34 + import kotlinx.coroutines.withTimeoutOrNull 35 + import java.nio.ByteBuffer 36 + import java.nio.ByteOrder 37 + import java.util.UUID 38 + import javax.inject.Inject 39 + import javax.inject.Singleton 40 + import kotlin.coroutines.resume 41 + 42 + enum class ConnectionState { 43 + DISCONNECTED, SCANNING, CONNECTING, CONNECTED 44 + } 45 + 46 + @Singleton 47 + class BLEManager @Inject constructor( 48 + @ApplicationContext private val context: Context 49 + ) { 50 + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) 51 + private val bleScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) 52 + 53 + private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager 54 + private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter 55 + 56 + private var gatt: BluetoothGatt? = null 57 + private var pollingJob: Job? = null 58 + private var scanTimeoutJob: Job? = null 59 + private var demoJob: Job? = null 60 + private val operationMutex = Mutex() 61 + 62 + // Continuation for GATT connection 63 + private var connectionContinuation: ((Boolean) -> Unit)? = null 64 + private var serviceDiscoveryContinuation: ((Boolean) -> Unit)? = null 65 + 66 + // Read operation tracking 67 + private var pendingReadCharacteristic: UUID? = null 68 + private var pendingReadContinuation: ((ByteArray?) -> Unit)? = null 69 + private var pendingWriteContinuation: ((Boolean) -> Unit)? = null 70 + 71 + private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) 72 + val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow() 73 + 74 + private val _liveData = MutableStateFlow(IronOSLiveData()) 75 + val liveData: StateFlow<IronOSLiveData> = _liveData.asStateFlow() 76 + 77 + private val _deviceName = MutableStateFlow<String?>(null) 78 + val deviceName: StateFlow<String?> = _deviceName.asStateFlow() 79 + 80 + private val _firmwareVersion = MutableStateFlow<String?>(null) 81 + val firmwareVersion: StateFlow<String?> = _firmwareVersion.asStateFlow() 82 + 83 + private val _serialNumber = MutableStateFlow<String?>(null) 84 + val serialNumber: StateFlow<String?> = _serialNumber.asStateFlow() 85 + 86 + private val _lastError = MutableStateFlow<BLEError?>(null) 87 + val lastError: StateFlow<BLEError?> = _lastError.asStateFlow() 88 + 89 + private val _isDemo = MutableStateFlow(false) 90 + val isDemo: StateFlow<Boolean> = _isDemo.asStateFlow() 91 + 92 + private val _isSlowPolling = MutableStateFlow(false) 93 + 94 + val temperatureHistory = CircularBuffer<TemperaturePoint>(150) 95 + 96 + // Settings cache: index -> value 97 + private val _settingsCache = MutableStateFlow<Map<Int, Int>>(emptyMap()) 98 + val settingsCache: StateFlow<Map<Int, Int>> = _settingsCache.asStateFlow() 99 + 100 + private val gattCallback = object : BluetoothGattCallback() { 101 + @SuppressLint("MissingPermission") 102 + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { 103 + if (newState == BluetoothProfile.STATE_CONNECTED) { 104 + connectionContinuation?.invoke(true) 105 + connectionContinuation = null 106 + gatt.discoverServices() 107 + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { 108 + connectionContinuation?.invoke(false) 109 + connectionContinuation = null 110 + serviceDiscoveryContinuation?.invoke(false) 111 + serviceDiscoveryContinuation = null 112 + scope.launch { handleDisconnection() } 113 + } 114 + } 115 + 116 + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { 117 + val success = status == BluetoothGatt.GATT_SUCCESS 118 + serviceDiscoveryContinuation?.invoke(success) 119 + serviceDiscoveryContinuation = null 120 + } 121 + 122 + override fun onCharacteristicRead( 123 + gatt: BluetoothGatt, 124 + characteristic: BluetoothGattCharacteristic, 125 + value: ByteArray, 126 + status: Int 127 + ) { 128 + if (characteristic.uuid == pendingReadCharacteristic) { 129 + pendingReadContinuation?.invoke(if (status == BluetoothGatt.GATT_SUCCESS) value else null) 130 + pendingReadContinuation = null 131 + pendingReadCharacteristic = null 132 + } 133 + } 134 + 135 + @Deprecated("Deprecated in API 33") 136 + override fun onCharacteristicRead( 137 + gatt: BluetoothGatt, 138 + characteristic: BluetoothGattCharacteristic, 139 + status: Int 140 + ) { 141 + if (characteristic.uuid == pendingReadCharacteristic) { 142 + @Suppress("DEPRECATION") 143 + val value = characteristic.value 144 + pendingReadContinuation?.invoke(if (status == BluetoothGatt.GATT_SUCCESS) value else null) 145 + pendingReadContinuation = null 146 + pendingReadCharacteristic = null 147 + } 148 + } 149 + 150 + override fun onCharacteristicWrite( 151 + gatt: BluetoothGatt, 152 + characteristic: BluetoothGattCharacteristic, 153 + status: Int 154 + ) { 155 + pendingWriteContinuation?.invoke(status == BluetoothGatt.GATT_SUCCESS) 156 + pendingWriteContinuation = null 157 + } 158 + } 159 + 160 + @SuppressLint("MissingPermission") 161 + fun startScan() { 162 + if (_connectionState.value == ConnectionState.SCANNING || _connectionState.value == ConnectionState.CONNECTED) return 163 + 164 + val scanner = bluetoothAdapter?.bluetoothLeScanner ?: run { 165 + _lastError.value = BLEError.PermissionDenied 166 + return 167 + } 168 + 169 + _connectionState.value = ConnectionState.SCANNING 170 + 171 + val scanSettings = ScanSettings.Builder() 172 + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) 173 + .build() 174 + 175 + val scanCallback = object : ScanCallback() { 176 + override fun onScanResult(callbackType: Int, result: ScanResult) { 177 + val name = result.device.name ?: return 178 + if (name.startsWith("Pinecil-") || name.startsWith("PrattlePin-")) { 179 + scanner.stopScan(this) 180 + scanTimeoutJob?.cancel() 181 + scope.launch { connectToDevice(result.device, name) } 182 + } 183 + } 184 + 185 + override fun onScanFailed(errorCode: Int) { 186 + _connectionState.value = ConnectionState.DISCONNECTED 187 + _lastError.value = BLEError.ReadFailed("Scan failed: $errorCode") 188 + } 189 + } 190 + 191 + scanner.startScan(null, scanSettings, scanCallback) 192 + 193 + scanTimeoutJob = scope.launch { 194 + delay(10_000) 195 + try { 196 + scanner.stopScan(scanCallback) 197 + } catch (_: Exception) {} 198 + if (_connectionState.value == ConnectionState.SCANNING) { 199 + _connectionState.value = ConnectionState.DISCONNECTED 200 + } 201 + } 202 + } 203 + 204 + @SuppressLint("MissingPermission") 205 + private suspend fun connectToDevice(device: BluetoothDevice, name: String) { 206 + _connectionState.value = ConnectionState.CONNECTING 207 + _deviceName.value = name 208 + 209 + val connected = suspendCancellableCoroutine { cont -> 210 + connectionContinuation = { success -> cont.resume(success) } 211 + gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) 212 + } 213 + 214 + if (!connected) { 215 + _connectionState.value = ConnectionState.DISCONNECTED 216 + return 217 + } 218 + 219 + val servicesDiscovered = suspendCancellableCoroutine { cont -> 220 + serviceDiscoveryContinuation = { success -> cont.resume(success) } 221 + } 222 + 223 + if (!servicesDiscovered) { 224 + gatt?.disconnect() 225 + _connectionState.value = ConnectionState.DISCONNECTED 226 + return 227 + } 228 + 229 + _connectionState.value = ConnectionState.CONNECTED 230 + 231 + // Read device info 232 + bleScope.launch { 233 + readDeviceInfo() 234 + readAllSettings() 235 + } 236 + 237 + startPolling() 238 + } 239 + 240 + @SuppressLint("MissingPermission") 241 + private suspend fun readCharacteristic(serviceUuid: UUID, characteristicUuid: UUID): ByteArray? { 242 + val gatt = this.gatt ?: return null 243 + val service = gatt.getService(serviceUuid) ?: return null 244 + val characteristic = service.getCharacteristic(characteristicUuid) ?: return null 245 + 246 + return operationMutex.withLock { 247 + withTimeoutOrNull(5000L) { 248 + suspendCancellableCoroutine { cont -> 249 + pendingReadCharacteristic = characteristicUuid 250 + pendingReadContinuation = { data -> cont.resume(data) } 251 + if (!gatt.readCharacteristic(characteristic)) { 252 + pendingReadContinuation = null 253 + pendingReadCharacteristic = null 254 + cont.resume(null) 255 + } 256 + } 257 + } 258 + } 259 + } 260 + 261 + @SuppressLint("MissingPermission") 262 + private suspend fun writeCharacteristic(serviceUuid: UUID, characteristicUuid: UUID, data: ByteArray): Boolean { 263 + val gatt = this.gatt ?: return false 264 + val service = gatt.getService(serviceUuid) ?: return false 265 + val characteristic = service.getCharacteristic(characteristicUuid) ?: return false 266 + 267 + return operationMutex.withLock { 268 + withTimeoutOrNull(5000L) { 269 + suspendCancellableCoroutine { cont -> 270 + pendingWriteContinuation = { success -> cont.resume(success) } 271 + characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT 272 + @Suppress("DEPRECATION") 273 + characteristic.value = data 274 + @Suppress("DEPRECATION") 275 + if (!gatt.writeCharacteristic(characteristic)) { 276 + pendingWriteContinuation = null 277 + cont.resume(false) 278 + } 279 + } 280 + } ?: false 281 + } 282 + } 283 + 284 + private suspend fun readDeviceInfo() { 285 + val buildIdData = readCharacteristic(IronOSUUIDs.BULK_DATA_SERVICE, IronOSUUIDs.BUILD_ID) 286 + if (buildIdData != null) { 287 + _firmwareVersion.value = String(buildIdData, Charsets.UTF_8).trim() 288 + } 289 + 290 + val serialData = readCharacteristic(IronOSUUIDs.BULK_DATA_SERVICE, IronOSUUIDs.DEVICE_SERIAL) 291 + if (serialData != null && serialData.size >= 8) { 292 + val buffer = ByteBuffer.wrap(serialData).order(ByteOrder.LITTLE_ENDIAN) 293 + val serial = buffer.getLong() 294 + _serialNumber.value = "%016X".format(serial) 295 + } 296 + } 297 + 298 + private fun startPolling() { 299 + pollingJob?.cancel() 300 + pollingJob = bleScope.launch { 301 + while (true) { 302 + val interval = if (_isSlowPolling.value) 200L else 100L 303 + pollBulkData() 304 + delay(interval) 305 + } 306 + } 307 + } 308 + 309 + private suspend fun pollBulkData() { 310 + val data = readCharacteristic(IronOSUUIDs.BULK_DATA_SERVICE, IronOSUUIDs.BULK_LIVE_DATA) ?: return 311 + if (data.size < 56) return 312 + 313 + val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) 314 + val values = IntArray(14) { buffer.getInt() } 315 + 316 + val newData = IronOSLiveData( 317 + liveTemp = values[0].coerceIn(0, 600), 318 + setpoint = values[1], 319 + dcInput = values[2], 320 + handleTemp = values[3], 321 + powerLevel = values[4].coerceIn(0, 255), 322 + powerSource = PowerSource.fromRaw(values[5]), 323 + tipResistance = values[6], 324 + uptime = values[7], 325 + lastMovement = values[8], 326 + maxTemp = values[9], 327 + rawTip = values[10], 328 + hallSensor = values[11], 329 + operatingMode = OperatingMode.fromRaw(values[12]), 330 + estimatedWatts = values[13] 331 + ) 332 + 333 + _liveData.value = newData 334 + 335 + temperatureHistory.add( 336 + TemperaturePoint( 337 + timestamp = System.currentTimeMillis(), 338 + actualTemp = newData.liveTemp, 339 + setpoint = newData.setpoint 340 + ) 341 + ) 342 + } 343 + 344 + // --- Settings --- 345 + 346 + suspend fun readAllSettings() { 347 + val cache = mutableMapOf<Int, Int>() 348 + for (index in IronOSUUIDs.SETTING_INDICES) { 349 + val data = readCharacteristic( 350 + IronOSUUIDs.SETTINGS_SERVICE, 351 + IronOSUUIDs.settingCharacteristic(index) 352 + ) 353 + if (data != null && data.size >= 2) { 354 + val value = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).getShort().toInt() and 0xFFFF 355 + cache[index] = value 356 + } 357 + } 358 + _settingsCache.value = cache 359 + } 360 + 361 + suspend fun writeSetting(index: Int, value: Int) { 362 + if (_isDemo.value) { 363 + _settingsCache.value = _settingsCache.value.toMutableMap().also { it[index] = value } 364 + return 365 + } 366 + val data = ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort(value.toShort()).array() 367 + val success = writeCharacteristic( 368 + IronOSUUIDs.SETTINGS_SERVICE, 369 + IronOSUUIDs.settingCharacteristic(index), 370 + data 371 + ) 372 + if (success) { 373 + _settingsCache.value = _settingsCache.value.toMutableMap().also { it[index] = value } 374 + } else { 375 + _lastError.value = BLEError.WriteFailed("Setting $index") 376 + } 377 + } 378 + 379 + suspend fun saveSettingsToDevice() { 380 + if (_isDemo.value) return 381 + val data = ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort(1).array() 382 + val success = writeCharacteristic(IronOSUUIDs.SETTINGS_SERVICE, IronOSUUIDs.SAVE_SETTINGS, data) 383 + if (!success) { 384 + _lastError.value = BLEError.WriteFailed("Save settings") 385 + } 386 + } 387 + 388 + suspend fun writeSetpoint(temperature: Int) { 389 + if (_isDemo.value) { 390 + _settingsCache.value = _settingsCache.value.toMutableMap().also { it[0] = temperature } 391 + return 392 + } 393 + val data = ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort(temperature.toShort()).array() 394 + writeCharacteristic( 395 + IronOSUUIDs.SETTINGS_SERVICE, 396 + IronOSUUIDs.settingCharacteristic(0), 397 + data 398 + ) 399 + } 400 + 401 + fun setSlowPolling(slow: Boolean) { 402 + _isSlowPolling.value = slow 403 + } 404 + 405 + // --- Demo Mode --- 406 + 407 + fun startDemo() { 408 + _isDemo.value = true 409 + _connectionState.value = ConnectionState.CONNECTED 410 + _deviceName.value = "Pinecil-DEMO" 411 + _firmwareVersion.value = "v2.22" 412 + _serialNumber.value = "DEMO000000000000" 413 + temperatureHistory.clear() 414 + 415 + _settingsCache.value = mapOf( 416 + 0 to 320, 1 to 150, 2 to 1, 6 to 2, 7 to 6, 417 + 11 to 10, 13 to 0, 14 to 0, 17 to 0, 22 to 420, 418 + 24 to 65, 25 to 0, 26 to 10, 27 to 1, 28 to 7, 419 + 33 to 0, 34 to 51 420 + ) 421 + 422 + demoJob?.cancel() 423 + demoJob = scope.launch { 424 + var currentTemp = 25 425 + var tick = 0 426 + while (true) { 427 + val target = _settingsCache.value[0] ?: 320 428 + val delta = target - currentTemp 429 + 430 + val watts: Int 431 + val power: Int 432 + 433 + when { 434 + kotlin.math.abs(delta) <= 2 -> { 435 + currentTemp = target + (-1..1).random() 436 + watts = (200..400).random() 437 + power = (5..20).random() 438 + } 439 + delta > 0 -> { 440 + val step = (delta / 8).coerceIn(3, 5) 441 + currentTemp += step + (-1..1).random() 442 + watts = (470..530).random() 443 + power = (180..220).random() 444 + } 445 + else -> { 446 + val step = (-delta / 10).coerceIn(2, 4) 447 + currentTemp -= step + (-1..0).random() 448 + watts = 0 449 + power = 0 450 + } 451 + } 452 + 453 + currentTemp = currentTemp.coerceIn(20, 500) 454 + tick++ 455 + 456 + val mode = if (power > 0) OperatingMode.HEATING else OperatingMode.IDLE 457 + 458 + _liveData.value = IronOSLiveData( 459 + liveTemp = currentTemp, 460 + setpoint = target, 461 + dcInput = 200, 462 + handleTemp = 280 + (-5..5).random(), 463 + powerLevel = power.coerceIn(0, 255), 464 + powerSource = PowerSource.PD_TYPE1, 465 + tipResistance = 620 + (-10..10).random(), 466 + uptime = tick, 467 + lastMovement = (0..50).random(), 468 + maxTemp = 450, 469 + rawTip = (500..1500).random(), 470 + hallSensor = (400..600).random(), 471 + operatingMode = mode, 472 + estimatedWatts = watts 473 + ) 474 + 475 + temperatureHistory.add( 476 + TemperaturePoint( 477 + timestamp = System.currentTimeMillis(), 478 + actualTemp = currentTemp, 479 + setpoint = target 480 + ) 481 + ) 482 + 483 + delay(100) 484 + } 485 + } 486 + } 487 + 488 + // --- Disconnect --- 489 + 490 + @SuppressLint("MissingPermission") 491 + fun disconnect() { 492 + pollingJob?.cancel() 493 + pollingJob = null 494 + demoJob?.cancel() 495 + demoJob = null 496 + scanTimeoutJob?.cancel() 497 + 498 + try { 499 + gatt?.disconnect() 500 + gatt?.close() 501 + } catch (_: Exception) {} 502 + gatt = null 503 + 504 + _connectionState.value = ConnectionState.DISCONNECTED 505 + _isDemo.value = false 506 + _deviceName.value = null 507 + _firmwareVersion.value = null 508 + _serialNumber.value = null 509 + _liveData.value = IronOSLiveData() 510 + temperatureHistory.clear() 511 + } 512 + 513 + private fun handleDisconnection() { 514 + pollingJob?.cancel() 515 + pollingJob = null 516 + _connectionState.value = ConnectionState.DISCONNECTED 517 + } 518 + 519 + fun clearError() { 520 + _lastError.value = null 521 + } 522 + }
+26
android/app/src/main/java/com/tinkcil/data/ble/IronOSUUIDs.kt
··· 1 + package com.tinkcil.data.ble 2 + 3 + import java.util.UUID 4 + 5 + object IronOSUUIDs { 6 + // Services 7 + val BULK_DATA_SERVICE: UUID = UUID.fromString("9eae1000-9d0d-48c5-aa55-33e27f9bc533") 8 + val LIVE_DATA_SERVICE: UUID = UUID.fromString("d85ef000-168e-4a71-aa55-33e27f9bc533") 9 + val SETTINGS_SERVICE: UUID = UUID.fromString("f6d80000-5a10-4eba-aa55-33e27f9bc533") 10 + 11 + // Bulk data characteristics 12 + val BULK_LIVE_DATA: UUID = UUID.fromString("9eae1001-9d0d-48c5-aa55-33e27f9bc533") 13 + val BUILD_ID: UUID = UUID.fromString("9eae1003-9d0d-48c5-aa55-33e27f9bc533") 14 + val DEVICE_SERIAL: UUID = UUID.fromString("9eae1004-9d0d-48c5-aa55-33e27f9bc533") 15 + 16 + // Settings control 17 + val SAVE_SETTINGS: UUID = UUID.fromString("f6d7ffff-5a10-4eba-aa55-33e27f9bc533") 18 + val RESET_SETTINGS: UUID = UUID.fromString("f6d7fffe-5a10-4eba-aa55-33e27f9bc533") 19 + 20 + // Setting characteristic by index 21 + fun settingCharacteristic(index: Int): UUID = 22 + UUID.fromString("f6d7%04x-5a10-4eba-aa55-33e27f9bc533".format(index)) 23 + 24 + // All setting indices used by the app 25 + val SETTING_INDICES = intArrayOf(0, 1, 2, 6, 7, 11, 13, 14, 17, 22, 24, 25, 26, 27, 28, 33, 34) 26 + }
+34
android/app/src/main/java/com/tinkcil/data/model/CircularBuffer.kt
··· 1 + package com.tinkcil.data.model 2 + 3 + class CircularBuffer<T>(private val capacity: Int) { 4 + private val buffer = ArrayList<T>(capacity) 5 + private var writeIndex = 0 6 + 7 + val size: Int get() = buffer.size 8 + 9 + @Synchronized 10 + fun add(element: T) { 11 + if (buffer.size < capacity) { 12 + buffer.add(element) 13 + } else { 14 + buffer[writeIndex] = element 15 + } 16 + writeIndex = (writeIndex + 1) % capacity 17 + } 18 + 19 + @Synchronized 20 + fun toList(): List<T> { 21 + if (buffer.size < capacity) return ArrayList(buffer) 22 + val result = ArrayList<T>(capacity) 23 + for (i in 0 until capacity) { 24 + result.add(buffer[(writeIndex + i) % capacity]) 25 + } 26 + return result 27 + } 28 + 29 + @Synchronized 30 + fun clear() { 31 + buffer.clear() 32 + writeIndex = 0 33 + } 34 + }
+44
android/app/src/main/java/com/tinkcil/data/model/IronOSLiveData.kt
··· 1 + package com.tinkcil.data.model 2 + 3 + data class IronOSLiveData( 4 + val liveTemp: Int = 0, 5 + val setpoint: Int = 0, 6 + val dcInput: Int = 0, 7 + val handleTemp: Int = 0, 8 + val powerLevel: Int = 0, 9 + val powerSource: PowerSource = PowerSource.DC, 10 + val tipResistance: Int = 0, 11 + val uptime: Int = 0, 12 + val lastMovement: Int = 0, 13 + val maxTemp: Int = 0, 14 + val rawTip: Int = 0, 15 + val hallSensor: Int = 0, 16 + val operatingMode: OperatingMode = OperatingMode.IDLE, 17 + val estimatedWatts: Int = 0 18 + ) { 19 + val dcInputVolts: Float get() = dcInput / 10f 20 + val handleTempC: Float get() = handleTemp / 10f 21 + val powerPercent: Int get() = if (powerLevel > 0) (powerLevel * 100 / 255).coerceIn(0, 100) else 0 22 + val tipResistanceOhms: Float get() = tipResistance / 100f 23 + val uptimeSeconds: Int get() = uptime / 10 24 + val lastMovementSeconds: Int get() = lastMovement / 10 25 + val estimatedWattsFloat: Float get() = estimatedWatts / 10f 26 + 27 + val uptimeFormatted: String get() { 28 + val totalSeconds = uptimeSeconds 29 + val hours = totalSeconds / 3600 30 + val minutes = (totalSeconds % 3600) / 60 31 + val seconds = totalSeconds % 60 32 + return "%02d:%02d:%02d".format(hours, minutes, seconds) 33 + } 34 + 35 + val lastMovementFormatted: String get() { 36 + val seconds = lastMovementSeconds 37 + return when { 38 + seconds < 2 -> "just now" 39 + seconds < 60 -> "${seconds}s ago" 40 + seconds < 3600 -> "${seconds / 60}m ago" 41 + else -> "${seconds / 3600}h ${(seconds % 3600) / 60}m ago" 42 + } 43 + } 44 + }
+25
android/app/src/main/java/com/tinkcil/data/model/OperatingMode.kt
··· 1 + package com.tinkcil.data.model 2 + 3 + import androidx.compose.material.icons.Icons 4 + import androidx.compose.material.icons.filled.AcUnit 5 + import androidx.compose.material.icons.filled.LocalFireDepartment 6 + import androidx.compose.material.icons.filled.Nightlight 7 + import androidx.compose.material.icons.filled.Power 8 + import androidx.compose.material.icons.filled.Settings 9 + import androidx.compose.material.icons.filled.Warning 10 + import androidx.compose.ui.graphics.vector.ImageVector 11 + 12 + enum class OperatingMode(val raw: Int, val label: String, val icon: ImageVector, val isActive: Boolean = false) { 13 + IDLE(0, "Idle", Icons.Filled.Power), 14 + HEATING(1, "Heating", Icons.Filled.LocalFireDepartment, isActive = true), 15 + SLEEP(3, "Sleep", Icons.Filled.Nightlight), 16 + SETTINGS_MENU(4, "Settings", Icons.Filled.Settings), 17 + PROFILE(6, "Profile", Icons.Filled.LocalFireDepartment, isActive = true), 18 + ERROR(9, "ERROR", Icons.Filled.Warning), 19 + HIBERNATING(14, "Hibernate", Icons.Filled.AcUnit); 20 + 21 + companion object { 22 + fun fromRaw(value: Int): OperatingMode = 23 + entries.find { it.raw == value } ?: IDLE 24 + } 25 + }
+13
android/app/src/main/java/com/tinkcil/data/model/PowerSource.kt
··· 1 + package com.tinkcil.data.model 2 + 3 + enum class PowerSource(val raw: Int, val label: String) { 4 + DC(0, "DC"), 5 + QC(1, "QC"), 6 + PD_TYPE1(2, "PD"), 7 + PD_TYPE2(3, "PD"); 8 + 9 + companion object { 10 + fun fromRaw(value: Int): PowerSource = 11 + entries.find { it.raw == value } ?: DC 12 + } 13 + }
+7
android/app/src/main/java/com/tinkcil/data/model/TemperaturePoint.kt
··· 1 + package com.tinkcil.data.model 2 + 3 + data class TemperaturePoint( 4 + val timestamp: Long, 5 + val actualTemp: Int, 6 + val setpoint: Int 7 + )
+43
android/app/src/main/java/com/tinkcil/data/repository/SettingsRepository.kt
··· 1 + package com.tinkcil.data.repository 2 + 3 + import androidx.datastore.core.DataStore 4 + import androidx.datastore.preferences.core.Preferences 5 + import androidx.datastore.preferences.core.edit 6 + import androidx.datastore.preferences.core.intPreferencesKey 7 + import com.tinkcil.data.ble.IronOSUUIDs 8 + import kotlinx.coroutines.flow.first 9 + import kotlinx.coroutines.flow.map 10 + import javax.inject.Inject 11 + import javax.inject.Singleton 12 + 13 + @Singleton 14 + class SettingsRepository @Inject constructor( 15 + private val dataStore: DataStore<Preferences> 16 + ) { 17 + private fun keyForIndex(index: Int) = intPreferencesKey("setting_$index") 18 + 19 + suspend fun getCachedSettings(): Map<Int, Int> { 20 + return dataStore.data.map { prefs -> 21 + val result = mutableMapOf<Int, Int>() 22 + for (index in IronOSUUIDs.SETTING_INDICES) { 23 + val key = keyForIndex(index) 24 + prefs[key]?.let { result[index] = it } 25 + } 26 + result 27 + }.first() 28 + } 29 + 30 + suspend fun cacheSettings(settings: Map<Int, Int>) { 31 + dataStore.edit { prefs -> 32 + for ((index, value) in settings) { 33 + prefs[keyForIndex(index)] = value 34 + } 35 + } 36 + } 37 + 38 + suspend fun cacheSetting(index: Int, value: Int) { 39 + dataStore.edit { prefs -> 40 + prefs[keyForIndex(index)] = value 41 + } 42 + } 43 + }
+27
android/app/src/main/java/com/tinkcil/di/AppModule.kt
··· 1 + package com.tinkcil.di 2 + 3 + import android.content.Context 4 + import androidx.datastore.core.DataStore 5 + import androidx.datastore.preferences.core.Preferences 6 + import androidx.datastore.preferences.preferencesDataStore 7 + import dagger.Module 8 + import dagger.Provides 9 + import dagger.hilt.InstallIn 10 + import dagger.hilt.android.qualifiers.ApplicationContext 11 + import dagger.hilt.components.SingletonComponent 12 + import javax.inject.Singleton 13 + 14 + private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "tinkcil_settings") 15 + 16 + @Module 17 + @InstallIn(SingletonComponent::class) 18 + object AppModule { 19 + 20 + @Provides 21 + @Singleton 22 + fun provideDataStore( 23 + @ApplicationContext context: Context 24 + ): DataStore<Preferences> { 25 + return context.dataStore 26 + } 27 + }
+73
android/app/src/main/java/com/tinkcil/ui/components/DeviceDetailStats.kt
··· 1 + package com.tinkcil.ui.components 2 + 3 + import androidx.compose.foundation.layout.Arrangement 4 + import androidx.compose.foundation.layout.Column 5 + import androidx.compose.foundation.layout.Row 6 + import androidx.compose.foundation.layout.fillMaxWidth 7 + import androidx.compose.foundation.layout.padding 8 + import androidx.compose.material3.Button 9 + import androidx.compose.material3.MaterialTheme 10 + import androidx.compose.material3.Text 11 + import androidx.compose.runtime.Composable 12 + import androidx.compose.ui.Modifier 13 + import androidx.compose.ui.res.stringResource 14 + import androidx.compose.ui.unit.dp 15 + import com.tinkcil.R 16 + import com.tinkcil.data.model.IronOSLiveData 17 + 18 + @Composable 19 + fun DeviceDetailStats( 20 + liveData: IronOSLiveData, 21 + firmwareVersion: String?, 22 + onSettingsClick: () -> Unit, 23 + modifier: Modifier = Modifier 24 + ) { 25 + Column(modifier = modifier) { 26 + Row( 27 + modifier = Modifier.fillMaxWidth(), 28 + horizontalArrangement = Arrangement.spacedBy(24.dp) 29 + ) { 30 + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { 31 + DetailRow(stringResource(R.string.detail_handle), "%.1f°C".format(liveData.handleTempC)) 32 + DetailRow(stringResource(R.string.detail_tip_resistance), "%.2f Ω".format(liveData.tipResistanceOhms)) 33 + DetailRow(stringResource(R.string.detail_mode), liveData.operatingMode.label) 34 + } 35 + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { 36 + DetailRow(stringResource(R.string.detail_power), liveData.powerSource.label) 37 + DetailRow( 38 + stringResource(R.string.detail_firmware), 39 + firmwareVersion ?: stringResource(R.string.common_unknown) 40 + ) 41 + } 42 + } 43 + 44 + Button( 45 + onClick = onSettingsClick, 46 + modifier = Modifier 47 + .fillMaxWidth() 48 + .padding(top = 12.dp), 49 + shape = MaterialTheme.shapes.medium 50 + ) { 51 + Text(stringResource(R.string.settings_button)) 52 + } 53 + } 54 + } 55 + 56 + @Composable 57 + private fun DetailRow(label: String, value: String) { 58 + Row( 59 + modifier = Modifier.fillMaxWidth(), 60 + horizontalArrangement = Arrangement.SpaceBetween 61 + ) { 62 + Text( 63 + text = label, 64 + style = MaterialTheme.typography.bodySmall, 65 + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) 66 + ) 67 + Text( 68 + text = value, 69 + style = MaterialTheme.typography.bodySmall, 70 + color = MaterialTheme.colorScheme.onSurface 71 + ) 72 + } 73 + }
+112
android/app/src/main/java/com/tinkcil/ui/components/DiagnosticsContent.kt
··· 1 + package com.tinkcil.ui.components 2 + 3 + import androidx.compose.foundation.layout.Arrangement 4 + import androidx.compose.foundation.layout.Column 5 + import androidx.compose.foundation.layout.Row 6 + import androidx.compose.foundation.layout.Spacer 7 + import androidx.compose.foundation.layout.fillMaxWidth 8 + import androidx.compose.foundation.layout.height 9 + import androidx.compose.foundation.layout.padding 10 + import androidx.compose.foundation.rememberScrollState 11 + import androidx.compose.foundation.verticalScroll 12 + import androidx.compose.material3.Button 13 + import androidx.compose.material3.ButtonDefaults 14 + import androidx.compose.material3.HorizontalDivider 15 + import androidx.compose.material3.MaterialTheme 16 + import androidx.compose.material3.Text 17 + import androidx.compose.runtime.Composable 18 + import androidx.compose.ui.Modifier 19 + import androidx.compose.ui.res.stringResource 20 + import androidx.compose.ui.unit.dp 21 + import com.tinkcil.R 22 + import com.tinkcil.data.model.IronOSLiveData 23 + 24 + @Composable 25 + fun DiagnosticsContent( 26 + liveData: IronOSLiveData, 27 + deviceName: String?, 28 + firmwareVersion: String?, 29 + serialNumber: String?, 30 + onDisconnect: () -> Unit, 31 + modifier: Modifier = Modifier 32 + ) { 33 + Column( 34 + modifier = modifier 35 + .verticalScroll(rememberScrollState()) 36 + .padding(horizontal = 16.dp, vertical = 16.dp) 37 + ) { 38 + // Device Info 39 + DiagSectionHeader(stringResource(R.string.device_info_title)) 40 + DiagRow(stringResource(R.string.info_device_name), deviceName ?: stringResource(R.string.common_unknown)) 41 + DiagRow(stringResource(R.string.info_firmware), firmwareVersion ?: stringResource(R.string.common_unknown)) 42 + DiagRow(stringResource(R.string.info_serial_number), serialNumber ?: stringResource(R.string.common_unknown)) 43 + 44 + // Current Status 45 + DiagSectionHeader(stringResource(R.string.section_current_status)) 46 + DiagRow(stringResource(R.string.info_temperature), "${liveData.liveTemp}°C") 47 + DiagRow(stringResource(R.string.info_setpoint), "${liveData.setpoint}°C") 48 + DiagRow(stringResource(R.string.info_max_temperature), "${liveData.maxTemp}°C") 49 + DiagRow(stringResource(R.string.info_operating_mode), liveData.operatingMode.label) 50 + 51 + // Power 52 + DiagSectionHeader(stringResource(R.string.section_power)) 53 + DiagRow(stringResource(R.string.info_voltage), "%.1f V".format(liveData.dcInputVolts)) 54 + DiagRow(stringResource(R.string.info_wattage), "%.1f W".format(liveData.estimatedWattsFloat)) 55 + DiagRow(stringResource(R.string.info_power_level), "${liveData.powerPercent}%") 56 + DiagRow(stringResource(R.string.info_power_source), liveData.powerSource.label) 57 + 58 + // Diagnostics 59 + DiagSectionHeader(stringResource(R.string.section_diagnostics)) 60 + DiagRow(stringResource(R.string.info_handle_temp), "%.1f°C".format(liveData.handleTempC)) 61 + DiagRow(stringResource(R.string.info_tip_resistance), "%.2f Ω".format(liveData.tipResistanceOhms)) 62 + DiagRow(stringResource(R.string.info_raw_tip), "${liveData.rawTip} μV") 63 + DiagRow(stringResource(R.string.info_hall_sensor), "${liveData.hallSensor}") 64 + DiagRow(stringResource(R.string.info_uptime), liveData.uptimeFormatted) 65 + DiagRow(stringResource(R.string.info_last_movement), liveData.lastMovementFormatted) 66 + 67 + Spacer(modifier = Modifier.height(24.dp)) 68 + 69 + Button( 70 + onClick = onDisconnect, 71 + modifier = Modifier.fillMaxWidth(), 72 + shape = MaterialTheme.shapes.medium, 73 + colors = ButtonDefaults.buttonColors( 74 + containerColor = MaterialTheme.colorScheme.error 75 + ) 76 + ) { 77 + Text(stringResource(R.string.button_disconnect)) 78 + } 79 + } 80 + } 81 + 82 + @Composable 83 + private fun DiagSectionHeader(title: String) { 84 + Text( 85 + text = title, 86 + style = MaterialTheme.typography.titleSmall, 87 + color = MaterialTheme.colorScheme.primary, 88 + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) 89 + ) 90 + HorizontalDivider() 91 + } 92 + 93 + @Composable 94 + private fun DiagRow(label: String, value: String) { 95 + Row( 96 + modifier = Modifier 97 + .fillMaxWidth() 98 + .padding(vertical = 6.dp), 99 + horizontalArrangement = Arrangement.SpaceBetween 100 + ) { 101 + Text( 102 + text = label, 103 + style = MaterialTheme.typography.bodyMedium, 104 + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) 105 + ) 106 + Text( 107 + text = value, 108 + style = MaterialTheme.typography.bodyMedium, 109 + color = MaterialTheme.colorScheme.onSurface 110 + ) 111 + } 112 + }
+81
android/app/src/main/java/com/tinkcil/ui/components/ScanningOverlay.kt
··· 1 + package com.tinkcil.ui.components 2 + 3 + import androidx.compose.foundation.layout.Arrangement 4 + import androidx.compose.foundation.layout.Column 5 + import androidx.compose.foundation.layout.Spacer 6 + import androidx.compose.foundation.layout.fillMaxSize 7 + import androidx.compose.foundation.layout.height 8 + import androidx.compose.foundation.layout.size 9 + import androidx.compose.material3.Button 10 + import androidx.compose.material3.CircularProgressIndicator 11 + import androidx.compose.material3.MaterialTheme 12 + import androidx.compose.material3.OutlinedButton 13 + import androidx.compose.material3.Text 14 + import androidx.compose.runtime.Composable 15 + import androidx.compose.ui.Alignment 16 + import androidx.compose.ui.Modifier 17 + import androidx.compose.ui.res.stringResource 18 + import androidx.compose.ui.text.style.TextAlign 19 + import androidx.compose.ui.unit.dp 20 + import com.tinkcil.R 21 + import com.tinkcil.data.ble.ConnectionState 22 + 23 + @Composable 24 + fun ScanningOverlay( 25 + connectionState: ConnectionState, 26 + onScanAgain: () -> Unit, 27 + onTryDemo: () -> Unit, 28 + modifier: Modifier = Modifier 29 + ) { 30 + Column( 31 + modifier = modifier.fillMaxSize(), 32 + horizontalAlignment = Alignment.CenterHorizontally, 33 + verticalArrangement = Arrangement.Center 34 + ) { 35 + when (connectionState) { 36 + ConnectionState.SCANNING -> { 37 + CircularProgressIndicator( 38 + modifier = Modifier.size(48.dp), 39 + color = MaterialTheme.colorScheme.primary 40 + ) 41 + Spacer(modifier = Modifier.height(24.dp)) 42 + Text( 43 + text = stringResource(R.string.connection_looking_for_iron), 44 + style = MaterialTheme.typography.bodyLarge, 45 + color = MaterialTheme.colorScheme.onSurface, 46 + textAlign = TextAlign.Center 47 + ) 48 + } 49 + ConnectionState.CONNECTING -> { 50 + CircularProgressIndicator( 51 + modifier = Modifier.size(48.dp), 52 + color = MaterialTheme.colorScheme.primary 53 + ) 54 + Spacer(modifier = Modifier.height(24.dp)) 55 + Text( 56 + text = stringResource(R.string.connection_connecting), 57 + style = MaterialTheme.typography.bodyLarge, 58 + color = MaterialTheme.colorScheme.onSurface, 59 + textAlign = TextAlign.Center 60 + ) 61 + } 62 + ConnectionState.DISCONNECTED -> { 63 + Text( 64 + text = stringResource(R.string.connection_no_device_found), 65 + style = MaterialTheme.typography.headlineSmall, 66 + color = MaterialTheme.colorScheme.onSurface, 67 + textAlign = TextAlign.Center 68 + ) 69 + Spacer(modifier = Modifier.height(24.dp)) 70 + Button(onClick = onScanAgain) { 71 + Text(stringResource(R.string.connection_scan_again)) 72 + } 73 + Spacer(modifier = Modifier.height(12.dp)) 74 + OutlinedButton(onClick = onTryDemo) { 75 + Text(stringResource(R.string.try_demo)) 76 + } 77 + } 78 + else -> {} 79 + } 80 + } 81 + }
+283
android/app/src/main/java/com/tinkcil/ui/components/SettingsSheet.kt
··· 1 + package com.tinkcil.ui.components 2 + 3 + import androidx.compose.foundation.layout.Arrangement 4 + import androidx.compose.foundation.layout.Column 5 + import androidx.compose.foundation.layout.Row 6 + import androidx.compose.foundation.layout.Spacer 7 + import androidx.compose.foundation.layout.fillMaxWidth 8 + import androidx.compose.foundation.layout.height 9 + import androidx.compose.foundation.layout.padding 10 + import androidx.compose.foundation.rememberScrollState 11 + import androidx.compose.foundation.verticalScroll 12 + import androidx.compose.material3.Button 13 + import androidx.compose.material3.ExperimentalMaterial3Api 14 + import androidx.compose.material3.HorizontalDivider 15 + import androidx.compose.material3.MaterialTheme 16 + import androidx.compose.material3.ModalBottomSheet 17 + import androidx.compose.material3.Slider 18 + import androidx.compose.material3.Switch 19 + import androidx.compose.material3.Tab 20 + import androidx.compose.material3.TabRow 21 + import androidx.compose.material3.Text 22 + import androidx.compose.material3.rememberModalBottomSheetState 23 + import androidx.compose.runtime.Composable 24 + import androidx.compose.runtime.getValue 25 + import androidx.compose.runtime.mutableFloatStateOf 26 + import androidx.compose.runtime.mutableIntStateOf 27 + import androidx.compose.runtime.remember 28 + import androidx.compose.runtime.setValue 29 + import androidx.compose.ui.Alignment 30 + import androidx.compose.ui.Modifier 31 + import androidx.compose.ui.res.stringResource 32 + import androidx.compose.ui.unit.dp 33 + import com.tinkcil.R 34 + import com.tinkcil.data.model.IronOSLiveData 35 + import kotlin.math.roundToInt 36 + 37 + @OptIn(ExperimentalMaterial3Api::class) 38 + @Composable 39 + fun SettingsSheet( 40 + settingsCache: Map<Int, Int>, 41 + liveData: IronOSLiveData, 42 + deviceName: String?, 43 + firmwareVersion: String?, 44 + serialNumber: String?, 45 + onSettingChanged: (Int, Int) -> Unit, 46 + onSaveSettings: () -> Unit, 47 + onDisconnect: () -> Unit, 48 + onDismiss: () -> Unit 49 + ) { 50 + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) 51 + var selectedTab by remember { mutableIntStateOf(0) } 52 + 53 + ModalBottomSheet( 54 + onDismissRequest = onDismiss, 55 + sheetState = sheetState 56 + ) { 57 + Column(modifier = Modifier.padding(bottom = 32.dp)) { 58 + TabRow(selectedTabIndex = selectedTab) { 59 + Tab( 60 + selected = selectedTab == 0, 61 + onClick = { selectedTab = 0 }, 62 + text = { Text(stringResource(R.string.settings_tab)) } 63 + ) 64 + Tab( 65 + selected = selectedTab == 1, 66 + onClick = { selectedTab = 1 }, 67 + text = { Text(stringResource(R.string.info_tab)) } 68 + ) 69 + } 70 + 71 + when (selectedTab) { 72 + 0 -> SettingsContent( 73 + settingsCache = settingsCache, 74 + onSettingChanged = onSettingChanged, 75 + onSaveSettings = onSaveSettings 76 + ) 77 + 1 -> DiagnosticsContent( 78 + liveData = liveData, 79 + deviceName = deviceName, 80 + firmwareVersion = firmwareVersion, 81 + serialNumber = serialNumber, 82 + onDisconnect = onDisconnect 83 + ) 84 + } 85 + } 86 + } 87 + } 88 + 89 + @Composable 90 + private fun SettingsContent( 91 + settingsCache: Map<Int, Int>, 92 + onSettingChanged: (Int, Int) -> Unit, 93 + onSaveSettings: () -> Unit 94 + ) { 95 + Column( 96 + modifier = Modifier 97 + .verticalScroll(rememberScrollState()) 98 + .padding(horizontal = 16.dp, vertical = 16.dp) 99 + ) { 100 + // Temperature section 101 + SectionHeader(stringResource(R.string.section_temperature)) 102 + SettingSlider(stringResource(R.string.setting_soldering_temp), 0, 10, 450, "°C", settingsCache, onSettingChanged) 103 + SettingSlider(stringResource(R.string.setting_sleep_temp), 1, 10, 450, "°C", settingsCache, onSettingChanged) 104 + SettingSlider(stringResource(R.string.setting_boost_temp), 22, 10, 450, "°C", settingsCache, onSettingChanged) 105 + 106 + // Timers section 107 + SectionHeader(stringResource(R.string.section_timers)) 108 + SettingSlider(stringResource(R.string.setting_sleep_time), 2, 0, 15, " min", settingsCache, onSettingChanged) 109 + SettingSlider(stringResource(R.string.setting_shutdown_time), 11, 0, 60, " min", settingsCache, onSettingChanged) 110 + 111 + // Power section 112 + SectionHeader(stringResource(R.string.section_power)) 113 + SettingSlider(stringResource(R.string.setting_power_limit), 24, 0, 180, " W", settingsCache, onSettingChanged) 114 + 115 + // Display section 116 + SectionHeader(stringResource(R.string.section_display)) 117 + SettingPicker( 118 + stringResource(R.string.setting_orientation), 119 + 6, 120 + listOf( 121 + stringResource(R.string.option_right), 122 + stringResource(R.string.option_left), 123 + stringResource(R.string.option_auto) 124 + ), 125 + settingsCache, 126 + onSettingChanged 127 + ) 128 + SettingSlider(stringResource(R.string.setting_brightness), 34, 1, 101, "%", settingsCache, onSettingChanged) 129 + SettingToggle(stringResource(R.string.setting_invert_display), 33, settingsCache, onSettingChanged) 130 + SettingToggle(stringResource(R.string.setting_detailed_idle), 13, settingsCache, onSettingChanged) 131 + SettingToggle(stringResource(R.string.setting_detailed_soldering), 14, settingsCache, onSettingChanged) 132 + 133 + // Sensors section 134 + SectionHeader(stringResource(R.string.section_sensors)) 135 + SettingSlider(stringResource(R.string.setting_motion_sensitivity), 7, 0, 9, "", settingsCache, onSettingChanged) 136 + SettingSlider(stringResource(R.string.setting_hall_sensitivity), 28, 0, 9, "", settingsCache, onSettingChanged) 137 + 138 + // Controls section 139 + SectionHeader(stringResource(R.string.section_controls)) 140 + SettingPicker( 141 + stringResource(R.string.setting_locking_mode), 142 + 17, 143 + listOf( 144 + stringResource(R.string.option_off), 145 + stringResource(R.string.option_boost_only), 146 + stringResource(R.string.option_full) 147 + ), 148 + settingsCache, 149 + onSettingChanged 150 + ) 151 + SettingToggle(stringResource(R.string.setting_reverse_buttons), 25, settingsCache, onSettingChanged) 152 + SettingSlider(stringResource(R.string.setting_short_press_step), 27, 1, 25, "°C", settingsCache, onSettingChanged) 153 + SettingSlider(stringResource(R.string.setting_long_press_step), 26, 5, 50, "°C", settingsCache, onSettingChanged) 154 + 155 + Spacer(modifier = Modifier.height(16.dp)) 156 + 157 + Button( 158 + onClick = onSaveSettings, 159 + modifier = Modifier.fillMaxWidth(), 160 + shape = MaterialTheme.shapes.medium 161 + ) { 162 + Text(stringResource(R.string.button_save_to_device)) 163 + } 164 + 165 + Spacer(modifier = Modifier.height(8.dp)) 166 + 167 + Text( 168 + text = stringResource(R.string.settings_footer_message), 169 + style = MaterialTheme.typography.bodySmall, 170 + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), 171 + modifier = Modifier.padding(horizontal = 4.dp) 172 + ) 173 + } 174 + } 175 + 176 + @Composable 177 + private fun SectionHeader(title: String) { 178 + Text( 179 + text = title, 180 + style = MaterialTheme.typography.titleSmall, 181 + color = MaterialTheme.colorScheme.primary, 182 + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) 183 + ) 184 + HorizontalDivider() 185 + } 186 + 187 + @Composable 188 + private fun SettingSlider( 189 + label: String, 190 + index: Int, 191 + min: Int, 192 + max: Int, 193 + unit: String, 194 + settingsCache: Map<Int, Int>, 195 + onSettingChanged: (Int, Int) -> Unit 196 + ) { 197 + val currentValue = settingsCache[index] ?: min 198 + var sliderValue by remember(currentValue) { mutableFloatStateOf(currentValue.toFloat()) } 199 + 200 + Column(modifier = Modifier.padding(vertical = 8.dp)) { 201 + Row( 202 + modifier = Modifier.fillMaxWidth(), 203 + horizontalArrangement = Arrangement.SpaceBetween 204 + ) { 205 + Text(text = label, style = MaterialTheme.typography.bodyMedium) 206 + Text( 207 + text = "${sliderValue.roundToInt()}$unit", 208 + style = MaterialTheme.typography.bodyMedium, 209 + color = MaterialTheme.colorScheme.primary 210 + ) 211 + } 212 + Slider( 213 + value = sliderValue, 214 + onValueChange = { sliderValue = it }, 215 + onValueChangeFinished = { onSettingChanged(index, sliderValue.roundToInt()) }, 216 + valueRange = min.toFloat()..max.toFloat(), 217 + modifier = Modifier.fillMaxWidth() 218 + ) 219 + } 220 + } 221 + 222 + @Composable 223 + private fun SettingToggle( 224 + label: String, 225 + index: Int, 226 + settingsCache: Map<Int, Int>, 227 + onSettingChanged: (Int, Int) -> Unit 228 + ) { 229 + val currentValue = settingsCache[index] ?: 0 230 + 231 + Row( 232 + modifier = Modifier 233 + .fillMaxWidth() 234 + .padding(vertical = 8.dp), 235 + horizontalArrangement = Arrangement.SpaceBetween, 236 + verticalAlignment = Alignment.CenterVertically 237 + ) { 238 + Text(text = label, style = MaterialTheme.typography.bodyMedium) 239 + Switch( 240 + checked = currentValue != 0, 241 + onCheckedChange = { checked -> 242 + onSettingChanged(index, if (checked) 1 else 0) 243 + } 244 + ) 245 + } 246 + } 247 + 248 + @Composable 249 + private fun SettingPicker( 250 + label: String, 251 + index: Int, 252 + options: List<String>, 253 + settingsCache: Map<Int, Int>, 254 + onSettingChanged: (Int, Int) -> Unit 255 + ) { 256 + val currentValue = (settingsCache[index] ?: 0).coerceIn(0, options.size - 1) 257 + 258 + Column(modifier = Modifier.padding(vertical = 8.dp)) { 259 + Text(text = label, style = MaterialTheme.typography.bodyMedium) 260 + Row( 261 + modifier = Modifier 262 + .fillMaxWidth() 263 + .padding(top = 4.dp), 264 + horizontalArrangement = Arrangement.spacedBy(8.dp) 265 + ) { 266 + options.forEachIndexed { optionIndex, optionLabel -> 267 + val isSelected = optionIndex == currentValue 268 + Button( 269 + onClick = { onSettingChanged(index, optionIndex) }, 270 + modifier = Modifier.weight(1f), 271 + shape = MaterialTheme.shapes.small, 272 + colors = if (isSelected) { 273 + androidx.compose.material3.ButtonDefaults.buttonColors() 274 + } else { 275 + androidx.compose.material3.ButtonDefaults.outlinedButtonColors() 276 + } 277 + ) { 278 + Text(optionLabel, style = MaterialTheme.typography.labelMedium) 279 + } 280 + } 281 + } 282 + } 283 + }
+89
android/app/src/main/java/com/tinkcil/ui/components/SliderPanel.kt
··· 1 + package com.tinkcil.ui.components 2 + 3 + import androidx.compose.foundation.layout.Arrangement 4 + import androidx.compose.foundation.layout.Column 5 + import androidx.compose.foundation.layout.Row 6 + import androidx.compose.foundation.layout.fillMaxWidth 7 + import androidx.compose.foundation.layout.padding 8 + import androidx.compose.material3.MaterialTheme 9 + import androidx.compose.material3.Slider 10 + import androidx.compose.material3.SliderDefaults 11 + import androidx.compose.material3.Surface 12 + import androidx.compose.material3.Text 13 + import androidx.compose.runtime.Composable 14 + import androidx.compose.runtime.getValue 15 + import androidx.compose.runtime.mutableFloatStateOf 16 + import androidx.compose.runtime.remember 17 + import androidx.compose.runtime.setValue 18 + import androidx.compose.ui.Alignment 19 + import androidx.compose.ui.Modifier 20 + import androidx.compose.ui.res.stringResource 21 + import androidx.compose.ui.semantics.contentDescription 22 + import androidx.compose.ui.semantics.semantics 23 + import androidx.compose.ui.unit.dp 24 + import com.tinkcil.R 25 + import kotlin.math.roundToInt 26 + 27 + @Composable 28 + fun SliderPanel( 29 + targetTemp: Int, 30 + onTargetChanged: (Int) -> Unit, 31 + onSliderStart: () -> Unit, 32 + onSliderEnd: () -> Unit, 33 + modifier: Modifier = Modifier 34 + ) { 35 + var sliderValue by remember(targetTemp) { mutableFloatStateOf(targetTemp.toFloat()) } 36 + 37 + Surface( 38 + modifier = modifier.fillMaxWidth(), 39 + shape = MaterialTheme.shapes.large, 40 + color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.85f), 41 + tonalElevation = 2.dp 42 + ) { 43 + Column( 44 + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), 45 + horizontalAlignment = Alignment.CenterHorizontally 46 + ) { 47 + Row( 48 + modifier = Modifier.fillMaxWidth(), 49 + horizontalArrangement = Arrangement.SpaceBetween, 50 + verticalAlignment = Alignment.Bottom 51 + ) { 52 + Text( 53 + text = stringResource(R.string.target_temperature), 54 + style = MaterialTheme.typography.titleSmall, 55 + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) 56 + ) 57 + Text( 58 + text = "${sliderValue.roundToInt()}°C", 59 + style = MaterialTheme.typography.headlineSmall, 60 + color = MaterialTheme.colorScheme.onSurface 61 + ) 62 + } 63 + 64 + Slider( 65 + value = sliderValue, 66 + onValueChange = { newValue -> 67 + if (sliderValue == targetTemp.toFloat()) { 68 + onSliderStart() 69 + } 70 + val stepped = (newValue / 5).roundToInt() * 5f 71 + sliderValue = stepped 72 + }, 73 + onValueChangeFinished = { 74 + onTargetChanged(sliderValue.roundToInt()) 75 + onSliderEnd() 76 + }, 77 + valueRange = 10f..450f, 78 + steps = 87, // (450-10)/5 - 1 = 87 steps 79 + colors = SliderDefaults.colors( 80 + thumbColor = MaterialTheme.colorScheme.primary, 81 + activeTrackColor = MaterialTheme.colorScheme.primary 82 + ), 83 + modifier = Modifier 84 + .fillMaxWidth() 85 + .semantics { contentDescription = "Temperature slider" } 86 + ) 87 + } 88 + } 89 + }
+55
android/app/src/main/java/com/tinkcil/ui/components/TemperatureDisplay.kt
··· 1 + package com.tinkcil.ui.components 2 + 3 + import androidx.compose.animation.animateColorAsState 4 + import androidx.compose.animation.core.tween 5 + import androidx.compose.foundation.layout.Arrangement 6 + import androidx.compose.foundation.layout.Column 7 + import androidx.compose.foundation.layout.padding 8 + import androidx.compose.material3.MaterialTheme 9 + import androidx.compose.material3.Text 10 + import androidx.compose.runtime.Composable 11 + import androidx.compose.runtime.getValue 12 + import androidx.compose.ui.Alignment 13 + import androidx.compose.ui.Modifier 14 + import androidx.compose.ui.text.style.TextAlign 15 + import androidx.compose.ui.unit.dp 16 + import com.tinkcil.data.model.OperatingMode 17 + import com.tinkcil.ui.theme.TemperatureTypography 18 + 19 + @Composable 20 + fun TemperatureDisplay( 21 + currentTemp: Int, 22 + setpoint: Int, 23 + maxTemp: Int, 24 + operatingMode: OperatingMode, 25 + isCompact: Boolean = false, 26 + modifier: Modifier = Modifier 27 + ) { 28 + val tempColor by animateColorAsState( 29 + targetValue = temperatureColor(currentTemp, maxTemp), 30 + animationSpec = tween(300), 31 + label = "tempColor" 32 + ) 33 + 34 + Column( 35 + modifier = modifier.padding(vertical = 8.dp), 36 + horizontalAlignment = Alignment.CenterHorizontally, 37 + verticalArrangement = Arrangement.Center 38 + ) { 39 + Text( 40 + text = "$currentTemp°", 41 + style = if (isCompact) TemperatureTypography.medium else TemperatureTypography.large, 42 + color = tempColor, 43 + textAlign = TextAlign.Center 44 + ) 45 + 46 + if (operatingMode.isActive && setpoint != currentTemp) { 47 + Text( 48 + text = "→ $setpoint°", 49 + style = MaterialTheme.typography.titleMedium, 50 + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), 51 + textAlign = TextAlign.Center 52 + ) 53 + } 54 + } 55 + }
+170
android/app/src/main/java/com/tinkcil/ui/components/TemperatureGraph.kt
··· 1 + package com.tinkcil.ui.components 2 + 3 + import androidx.compose.foundation.Canvas 4 + import androidx.compose.foundation.layout.fillMaxSize 5 + import androidx.compose.material3.MaterialTheme 6 + import androidx.compose.runtime.Composable 7 + import androidx.compose.runtime.LaunchedEffect 8 + import androidx.compose.runtime.getValue 9 + import androidx.compose.runtime.mutableLongStateOf 10 + import androidx.compose.runtime.remember 11 + import androidx.compose.runtime.setValue 12 + import androidx.compose.ui.Modifier 13 + import androidx.compose.ui.geometry.Offset 14 + import androidx.compose.ui.graphics.Color 15 + import androidx.compose.ui.graphics.Path 16 + import androidx.compose.ui.graphics.StrokeCap 17 + import androidx.compose.ui.graphics.StrokeJoin 18 + import androidx.compose.ui.graphics.drawscope.DrawScope 19 + import androidx.compose.ui.graphics.drawscope.Stroke 20 + import androidx.compose.ui.unit.dp 21 + import com.tinkcil.data.model.TemperaturePoint 22 + import kotlinx.coroutines.delay 23 + 24 + @Composable 25 + fun TemperatureGraph( 26 + points: List<TemperaturePoint>, 27 + currentTemp: Int, 28 + maxTemp: Int, 29 + showAxes: Boolean = false, 30 + windowSeconds: Float = 6f, 31 + modifier: Modifier = Modifier 32 + ) { 33 + val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant 34 + val onSurface = MaterialTheme.colorScheme.onSurface 35 + 36 + // Trigger recomposition every 100ms for real-time animation 37 + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } 38 + LaunchedEffect(Unit) { 39 + while (true) { 40 + now = System.currentTimeMillis() 41 + delay(100) 42 + } 43 + } 44 + 45 + Canvas(modifier = modifier.fillMaxSize()) { 46 + val w = size.width 47 + val h = size.height 48 + val yMax = 500f 49 + val windowMs = (windowSeconds * 1000).toLong() 50 + val endTime = now + 1000L 51 + val startTime = endTime - windowMs 52 + 53 + if (showAxes) { 54 + drawAxes(w, h, yMax, surfaceVariant, onSurface) 55 + } 56 + 57 + if (points.size < 2) return@Canvas 58 + 59 + val tempColor = temperatureColor(currentTemp, maxTemp) 60 + 61 + // Draw setpoint line (thin, semi-transparent) 62 + drawTemperatureLine( 63 + points = points, 64 + startTime = startTime, 65 + endTime = endTime, 66 + yMax = yMax, 67 + w = w, 68 + h = h, 69 + color = onSurface.copy(alpha = 0.3f), 70 + strokeWidth = 1.5.dp.toPx(), 71 + getValue = { it.setpoint.toFloat() } 72 + ) 73 + 74 + // Draw actual temperature line (thick, colored) 75 + drawTemperatureLine( 76 + points = points, 77 + startTime = startTime, 78 + endTime = endTime, 79 + yMax = yMax, 80 + w = w, 81 + h = h, 82 + color = tempColor, 83 + strokeWidth = 2.5.dp.toPx(), 84 + getValue = { it.actualTemp.toFloat() } 85 + ) 86 + } 87 + } 88 + 89 + private fun DrawScope.drawTemperatureLine( 90 + points: List<TemperaturePoint>, 91 + startTime: Long, 92 + endTime: Long, 93 + yMax: Float, 94 + w: Float, 95 + h: Float, 96 + color: Color, 97 + strokeWidth: Float, 98 + getValue: (TemperaturePoint) -> Float 99 + ) { 100 + val visiblePoints = points.filter { it.timestamp in startTime..endTime } 101 + if (visiblePoints.size < 2) return 102 + 103 + val path = Path() 104 + var first = true 105 + 106 + for (point in visiblePoints) { 107 + val x = ((point.timestamp - startTime).toFloat() / (endTime - startTime)) * w 108 + val y = h - (getValue(point) / yMax) * h 109 + 110 + if (first) { 111 + path.moveTo(x, y) 112 + first = false 113 + } else { 114 + path.lineTo(x, y) 115 + } 116 + } 117 + 118 + drawPath( 119 + path = path, 120 + color = color, 121 + style = Stroke( 122 + width = strokeWidth, 123 + cap = StrokeCap.Round, 124 + join = StrokeJoin.Round 125 + ) 126 + ) 127 + } 128 + 129 + private fun DrawScope.drawAxes(w: Float, h: Float, yMax: Float, lineColor: Color, textColor: Color) { 130 + val steps = listOf(0f, 100f, 200f, 300f, 400f, 500f) 131 + for (value in steps) { 132 + val y = h - (value / yMax) * h 133 + drawLine( 134 + color = lineColor.copy(alpha = 0.3f), 135 + start = Offset(0f, y), 136 + end = Offset(w, y), 137 + strokeWidth = 0.5.dp.toPx() 138 + ) 139 + } 140 + } 141 + 142 + fun temperatureColor(temp: Int, maxTemp: Int): Color { 143 + val progress = if (maxTemp > 0) (temp.toFloat() / maxTemp).coerceIn(0f, 1f) else 0f 144 + return when { 145 + progress < 0.33f -> { 146 + val t = progress / 0.33f 147 + Color( 148 + red = 0.1f * t, 149 + green = 0.5f + 0.3f * t, 150 + blue = 1f - 0.2f * t 151 + ) 152 + } 153 + progress < 0.66f -> { 154 + val t = (progress - 0.33f) / 0.33f 155 + Color( 156 + red = 0.1f + 0.9f * t, 157 + green = 0.8f - 0.2f * t, 158 + blue = 0.8f - 0.8f * t 159 + ) 160 + } 161 + else -> { 162 + val t = (progress - 0.66f) / 0.34f 163 + Color( 164 + red = 1f, 165 + green = 0.6f - 0.4f * t, 166 + blue = 0.1f - 0.1f * t 167 + ) 168 + } 169 + } 170 + }
+116
android/app/src/main/java/com/tinkcil/ui/components/TopStatsBar.kt
··· 1 + package com.tinkcil.ui.components 2 + 3 + import androidx.compose.animation.AnimatedVisibility 4 + import androidx.compose.animation.expandVertically 5 + import androidx.compose.animation.shrinkVertically 6 + import androidx.compose.foundation.clickable 7 + import androidx.compose.foundation.layout.Arrangement 8 + import androidx.compose.foundation.layout.Column 9 + import androidx.compose.foundation.layout.Row 10 + import androidx.compose.foundation.layout.Spacer 11 + import androidx.compose.foundation.layout.fillMaxWidth 12 + import androidx.compose.foundation.layout.padding 13 + import androidx.compose.foundation.layout.size 14 + import androidx.compose.foundation.layout.width 15 + import androidx.compose.material.icons.Icons 16 + import androidx.compose.material.icons.filled.ExpandLess 17 + import androidx.compose.material.icons.filled.ExpandMore 18 + import androidx.compose.material3.Icon 19 + import androidx.compose.material3.MaterialTheme 20 + import androidx.compose.material3.Surface 21 + import androidx.compose.material3.Text 22 + import androidx.compose.runtime.Composable 23 + import androidx.compose.ui.Alignment 24 + import androidx.compose.ui.Modifier 25 + import androidx.compose.ui.res.stringResource 26 + import androidx.compose.ui.semantics.contentDescription 27 + import androidx.compose.ui.semantics.semantics 28 + import androidx.compose.ui.unit.dp 29 + import com.tinkcil.R 30 + import com.tinkcil.data.model.IronOSLiveData 31 + import com.tinkcil.data.model.OperatingMode 32 + 33 + @Composable 34 + fun TopStatsBar( 35 + deviceName: String, 36 + liveData: IronOSLiveData, 37 + firmwareVersion: String?, 38 + isExpanded: Boolean, 39 + onExpandToggle: () -> Unit, 40 + onSettingsClick: () -> Unit, 41 + modifier: Modifier = Modifier 42 + ) { 43 + Surface( 44 + modifier = modifier.fillMaxWidth(), 45 + shape = MaterialTheme.shapes.large, 46 + color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.85f), 47 + tonalElevation = 2.dp 48 + ) { 49 + Column( 50 + modifier = Modifier 51 + .clickable { onExpandToggle() } 52 + .padding(horizontal = 16.dp, vertical = 12.dp) 53 + ) { 54 + Row( 55 + modifier = Modifier.fillMaxWidth(), 56 + horizontalArrangement = Arrangement.SpaceBetween, 57 + verticalAlignment = Alignment.CenterVertically 58 + ) { 59 + Text( 60 + text = deviceName, 61 + style = MaterialTheme.typography.titleSmall, 62 + color = MaterialTheme.colorScheme.onSurface 63 + ) 64 + 65 + Row( 66 + horizontalArrangement = Arrangement.spacedBy(12.dp), 67 + verticalAlignment = Alignment.CenterVertically 68 + ) { 69 + StatItem("%.1f W".format(liveData.estimatedWattsFloat)) 70 + StatItem("%.1f V".format(liveData.dcInputVolts)) 71 + StatItem("${liveData.powerPercent}%") 72 + 73 + Icon( 74 + imageVector = liveData.operatingMode.icon, 75 + contentDescription = liveData.operatingMode.label, 76 + tint = if (liveData.operatingMode.isActive) 77 + MaterialTheme.colorScheme.tertiary 78 + else 79 + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), 80 + modifier = Modifier.size(18.dp) 81 + ) 82 + 83 + Icon( 84 + imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, 85 + contentDescription = if (isExpanded) "Collapse" else "Expand", 86 + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), 87 + modifier = Modifier.size(18.dp) 88 + ) 89 + } 90 + } 91 + 92 + AnimatedVisibility( 93 + visible = isExpanded, 94 + enter = expandVertically(), 95 + exit = shrinkVertically() 96 + ) { 97 + DeviceDetailStats( 98 + liveData = liveData, 99 + firmwareVersion = firmwareVersion, 100 + onSettingsClick = onSettingsClick, 101 + modifier = Modifier.padding(top = 12.dp) 102 + ) 103 + } 104 + } 105 + } 106 + } 107 + 108 + @Composable 109 + private fun StatItem(text: String) { 110 + Text( 111 + text = text, 112 + style = MaterialTheme.typography.labelMedium, 113 + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), 114 + modifier = Modifier.semantics { contentDescription = text } 115 + ) 116 + }
+213
android/app/src/main/java/com/tinkcil/ui/screens/home/HomeScreen.kt
··· 1 + package com.tinkcil.ui.screens.home 2 + 3 + import androidx.compose.foundation.layout.Arrangement 4 + import androidx.compose.foundation.layout.Box 5 + import androidx.compose.foundation.layout.Column 6 + import androidx.compose.foundation.layout.Row 7 + import androidx.compose.foundation.layout.fillMaxHeight 8 + import androidx.compose.foundation.layout.fillMaxSize 9 + import androidx.compose.foundation.layout.fillMaxWidth 10 + import androidx.compose.foundation.layout.padding 11 + import androidx.compose.foundation.layout.width 12 + import androidx.compose.foundation.rememberScrollState 13 + import androidx.compose.foundation.verticalScroll 14 + import androidx.compose.material3.AlertDialog 15 + import androidx.compose.material3.HorizontalDivider 16 + import androidx.compose.material3.Scaffold 17 + import androidx.compose.material3.Text 18 + import androidx.compose.material3.TextButton 19 + import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass 20 + import androidx.compose.runtime.Composable 21 + import androidx.compose.runtime.LaunchedEffect 22 + import androidx.compose.runtime.getValue 23 + import androidx.compose.ui.Alignment 24 + import androidx.compose.ui.Modifier 25 + import androidx.compose.ui.res.stringResource 26 + import androidx.compose.ui.unit.dp 27 + import androidx.hilt.navigation.compose.hiltViewModel 28 + import androidx.lifecycle.compose.collectAsStateWithLifecycle 29 + import com.tinkcil.R 30 + import com.tinkcil.data.ble.ConnectionState 31 + import com.tinkcil.ui.components.ScanningOverlay 32 + import com.tinkcil.ui.components.SettingsSheet 33 + import com.tinkcil.ui.components.SliderPanel 34 + import com.tinkcil.ui.components.TemperatureDisplay 35 + import com.tinkcil.ui.components.TemperatureGraph 36 + import com.tinkcil.ui.components.TopStatsBar 37 + 38 + @Composable 39 + fun HomeScreen( 40 + widthSizeClass: WindowWidthSizeClass, 41 + viewModel: HomeViewModel = hiltViewModel() 42 + ) { 43 + val uiState by viewModel.uiState.collectAsStateWithLifecycle() 44 + 45 + LaunchedEffect(Unit) { 46 + viewModel.startScan() 47 + } 48 + 49 + Scaffold { innerPadding -> 50 + Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { 51 + if (uiState.connectionState == ConnectionState.CONNECTED) { 52 + if (widthSizeClass == WindowWidthSizeClass.Expanded) { 53 + TabletLayout(uiState = uiState, viewModel = viewModel) 54 + } else { 55 + PhoneLayout(uiState = uiState, viewModel = viewModel) 56 + } 57 + 58 + if (uiState.isSettingsSheetVisible) { 59 + SettingsSheet( 60 + settingsCache = uiState.settingsCache, 61 + liveData = uiState.liveData, 62 + deviceName = uiState.deviceName, 63 + firmwareVersion = uiState.firmwareVersion, 64 + serialNumber = uiState.serialNumber, 65 + onSettingChanged = viewModel::writeSetting, 66 + onSaveSettings = viewModel::saveSettings, 67 + onDisconnect = viewModel::disconnect, 68 + onDismiss = viewModel::hideSettings 69 + ) 70 + } 71 + } else { 72 + ScanningOverlay( 73 + connectionState = uiState.connectionState, 74 + onScanAgain = viewModel::startScan, 75 + onTryDemo = viewModel::startDemo 76 + ) 77 + } 78 + 79 + // Error dialog 80 + uiState.lastError?.let { error -> 81 + AlertDialog( 82 + onDismissRequest = viewModel::clearError, 83 + title = { Text(stringResource(R.string.bluetooth_error_title)) }, 84 + text = { Text(error.message) }, 85 + confirmButton = { 86 + TextButton(onClick = viewModel::clearError) { 87 + Text(stringResource(R.string.button_ok)) 88 + } 89 + } 90 + ) 91 + } 92 + } 93 + } 94 + } 95 + 96 + @Composable 97 + private fun PhoneLayout( 98 + uiState: HomeUiState, 99 + viewModel: HomeViewModel 100 + ) { 101 + Box(modifier = Modifier.fillMaxSize()) { 102 + // Background graph 103 + TemperatureGraph( 104 + points = uiState.temperatureHistory, 105 + currentTemp = uiState.liveData.liveTemp, 106 + maxTemp = uiState.liveData.maxTemp, 107 + showAxes = false, 108 + windowSeconds = 6f, 109 + modifier = Modifier 110 + .fillMaxSize() 111 + .padding(top = 80.dp, bottom = 140.dp) 112 + ) 113 + 114 + // Top stats bar 115 + TopStatsBar( 116 + deviceName = uiState.deviceName ?: "", 117 + liveData = uiState.liveData, 118 + firmwareVersion = uiState.firmwareVersion, 119 + isExpanded = uiState.isTopBarExpanded, 120 + onExpandToggle = viewModel::toggleTopBar, 121 + onSettingsClick = viewModel::showSettings, 122 + modifier = Modifier 123 + .align(Alignment.TopCenter) 124 + .padding(horizontal = 16.dp, vertical = 8.dp) 125 + ) 126 + 127 + // Center temperature display 128 + TemperatureDisplay( 129 + currentTemp = uiState.liveData.liveTemp, 130 + setpoint = uiState.liveData.setpoint, 131 + maxTemp = uiState.liveData.maxTemp, 132 + operatingMode = uiState.liveData.operatingMode, 133 + modifier = Modifier.align(Alignment.Center) 134 + ) 135 + 136 + // Bottom slider 137 + SliderPanel( 138 + targetTemp = uiState.settingsCache[0] ?: uiState.liveData.setpoint, 139 + onTargetChanged = viewModel::setTargetTemperature, 140 + onSliderStart = viewModel::onSliderStart, 141 + onSliderEnd = viewModel::onSliderEnd, 142 + modifier = Modifier 143 + .align(Alignment.BottomCenter) 144 + .padding(horizontal = 16.dp, vertical = 16.dp) 145 + ) 146 + } 147 + } 148 + 149 + @Composable 150 + private fun TabletLayout( 151 + uiState: HomeUiState, 152 + viewModel: HomeViewModel 153 + ) { 154 + Row(modifier = Modifier.fillMaxSize()) { 155 + // Left: Graph panel 156 + Box( 157 + modifier = Modifier 158 + .weight(1f) 159 + .fillMaxHeight() 160 + .padding(16.dp) 161 + ) { 162 + TemperatureGraph( 163 + points = uiState.temperatureHistory, 164 + currentTemp = uiState.liveData.liveTemp, 165 + maxTemp = uiState.liveData.maxTemp, 166 + showAxes = true, 167 + windowSeconds = 15f, 168 + modifier = Modifier.fillMaxSize() 169 + ) 170 + } 171 + 172 + // Divider 173 + HorizontalDivider( 174 + modifier = Modifier 175 + .fillMaxHeight() 176 + .width(1.dp) 177 + ) 178 + 179 + // Right: Control panel 180 + Column( 181 + modifier = Modifier 182 + .width(420.dp) 183 + .fillMaxHeight() 184 + .verticalScroll(rememberScrollState()) 185 + .padding(16.dp), 186 + verticalArrangement = Arrangement.spacedBy(16.dp) 187 + ) { 188 + TopStatsBar( 189 + deviceName = uiState.deviceName ?: "", 190 + liveData = uiState.liveData, 191 + firmwareVersion = uiState.firmwareVersion, 192 + isExpanded = uiState.isTopBarExpanded, 193 + onExpandToggle = viewModel::toggleTopBar, 194 + onSettingsClick = viewModel::showSettings 195 + ) 196 + 197 + TemperatureDisplay( 198 + currentTemp = uiState.liveData.liveTemp, 199 + setpoint = uiState.liveData.setpoint, 200 + maxTemp = uiState.liveData.maxTemp, 201 + operatingMode = uiState.liveData.operatingMode, 202 + isCompact = true 203 + ) 204 + 205 + SliderPanel( 206 + targetTemp = uiState.settingsCache[0] ?: uiState.liveData.setpoint, 207 + onTargetChanged = viewModel::setTargetTemperature, 208 + onSliderStart = viewModel::onSliderStart, 209 + onSliderEnd = viewModel::onSliderEnd 210 + ) 211 + } 212 + } 213 + }
+20
android/app/src/main/java/com/tinkcil/ui/screens/home/HomeUiState.kt
··· 1 + package com.tinkcil.ui.screens.home 2 + 3 + import com.tinkcil.data.ble.BLEError 4 + import com.tinkcil.data.ble.ConnectionState 5 + import com.tinkcil.data.model.IronOSLiveData 6 + import com.tinkcil.data.model.TemperaturePoint 7 + 8 + data class HomeUiState( 9 + val connectionState: ConnectionState = ConnectionState.DISCONNECTED, 10 + val liveData: IronOSLiveData = IronOSLiveData(), 11 + val deviceName: String? = null, 12 + val firmwareVersion: String? = null, 13 + val serialNumber: String? = null, 14 + val isDemo: Boolean = false, 15 + val settingsCache: Map<Int, Int> = emptyMap(), 16 + val temperatureHistory: List<TemperaturePoint> = emptyList(), 17 + val lastError: BLEError? = null, 18 + val isTopBarExpanded: Boolean = false, 19 + val isSettingsSheetVisible: Boolean = false 20 + )
+142
android/app/src/main/java/com/tinkcil/ui/screens/home/HomeViewModel.kt
··· 1 + package com.tinkcil.ui.screens.home 2 + 3 + import androidx.lifecycle.ViewModel 4 + import androidx.lifecycle.viewModelScope 5 + import com.tinkcil.data.ble.BLEManager 6 + import com.tinkcil.data.repository.SettingsRepository 7 + import dagger.hilt.android.lifecycle.HiltViewModel 8 + import kotlinx.coroutines.flow.MutableStateFlow 9 + import kotlinx.coroutines.flow.StateFlow 10 + import kotlinx.coroutines.flow.asStateFlow 11 + import kotlinx.coroutines.flow.combine 12 + import kotlinx.coroutines.flow.update 13 + import kotlinx.coroutines.launch 14 + import javax.inject.Inject 15 + 16 + @HiltViewModel 17 + class HomeViewModel @Inject constructor( 18 + private val bleManager: BLEManager, 19 + private val settingsRepository: SettingsRepository 20 + ) : ViewModel() { 21 + 22 + private val _uiState = MutableStateFlow(HomeUiState()) 23 + val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow() 24 + 25 + private var lastSentSetpoint = 0 26 + 27 + init { 28 + observeBLEState() 29 + loadCachedSettings() 30 + } 31 + 32 + private fun observeBLEState() { 33 + viewModelScope.launch { 34 + combine( 35 + bleManager.connectionState, 36 + bleManager.liveData, 37 + bleManager.deviceName, 38 + bleManager.firmwareVersion, 39 + bleManager.serialNumber 40 + ) { connState, live, name, firmware, serial -> 41 + Tuple5(connState, live, name, firmware, serial) 42 + }.combine( 43 + combine( 44 + bleManager.isDemo, 45 + bleManager.settingsCache, 46 + bleManager.lastError 47 + ) { demo, settings, error -> 48 + Triple(demo, settings, error) 49 + } 50 + ) { (connState, live, name, firmware, serial), (demo, settings, error) -> 51 + HomeUiState( 52 + connectionState = connState, 53 + liveData = live, 54 + deviceName = name, 55 + firmwareVersion = firmware, 56 + serialNumber = serial, 57 + isDemo = demo, 58 + settingsCache = settings, 59 + lastError = error, 60 + temperatureHistory = bleManager.temperatureHistory.toList(), 61 + isTopBarExpanded = _uiState.value.isTopBarExpanded, 62 + isSettingsSheetVisible = _uiState.value.isSettingsSheetVisible 63 + ) 64 + }.collect { state -> 65 + _uiState.value = state 66 + if (state.settingsCache.isNotEmpty()) { 67 + settingsRepository.cacheSettings(state.settingsCache) 68 + } 69 + } 70 + } 71 + } 72 + 73 + private fun loadCachedSettings() { 74 + viewModelScope.launch { 75 + val cached = settingsRepository.getCachedSettings() 76 + if (cached.isNotEmpty()) { 77 + // Pre-populate from cache (will be overwritten by device reads) 78 + } 79 + } 80 + } 81 + 82 + fun startScan() { 83 + bleManager.startScan() 84 + } 85 + 86 + fun startDemo() { 87 + bleManager.startDemo() 88 + } 89 + 90 + fun disconnect() { 91 + bleManager.disconnect() 92 + _uiState.update { it.copy(isSettingsSheetVisible = false, isTopBarExpanded = false) } 93 + } 94 + 95 + fun setTargetTemperature(temp: Int) { 96 + if (kotlin.math.abs(temp - lastSentSetpoint) >= 5) { 97 + lastSentSetpoint = temp 98 + viewModelScope.launch { 99 + bleManager.writeSetpoint(temp) 100 + } 101 + } 102 + } 103 + 104 + fun onSliderStart() { 105 + bleManager.setSlowPolling(true) 106 + } 107 + 108 + fun onSliderEnd() { 109 + bleManager.setSlowPolling(false) 110 + } 111 + 112 + fun writeSetting(index: Int, value: Int) { 113 + viewModelScope.launch { 114 + bleManager.writeSetting(index, value) 115 + settingsRepository.cacheSetting(index, value) 116 + } 117 + } 118 + 119 + fun saveSettings() { 120 + viewModelScope.launch { 121 + bleManager.saveSettingsToDevice() 122 + } 123 + } 124 + 125 + fun toggleTopBar() { 126 + _uiState.update { it.copy(isTopBarExpanded = !it.isTopBarExpanded) } 127 + } 128 + 129 + fun showSettings() { 130 + _uiState.update { it.copy(isSettingsSheetVisible = true) } 131 + } 132 + 133 + fun hideSettings() { 134 + _uiState.update { it.copy(isSettingsSheetVisible = false) } 135 + } 136 + 137 + fun clearError() { 138 + bleManager.clearError() 139 + } 140 + } 141 + 142 + private data class Tuple5<A, B, C, D, E>(val a: A, val b: B, val c: C, val d: D, val e: E)
+45
android/app/src/main/java/com/tinkcil/ui/theme/Theme.kt
··· 1 + package com.tinkcil.ui.theme 2 + 3 + import android.os.Build 4 + import androidx.compose.foundation.isSystemInDarkTheme 5 + import androidx.compose.foundation.shape.RoundedCornerShape 6 + import androidx.compose.material3.MaterialTheme 7 + import androidx.compose.material3.Shapes 8 + import androidx.compose.material3.darkColorScheme 9 + import androidx.compose.material3.dynamicDarkColorScheme 10 + import androidx.compose.material3.dynamicLightColorScheme 11 + import androidx.compose.material3.lightColorScheme 12 + import androidx.compose.runtime.Composable 13 + import androidx.compose.ui.platform.LocalContext 14 + import androidx.compose.ui.unit.dp 15 + 16 + val ExpressiveShapes = Shapes( 17 + extraSmall = RoundedCornerShape(8.dp), 18 + small = RoundedCornerShape(12.dp), 19 + medium = RoundedCornerShape(16.dp), 20 + large = RoundedCornerShape(24.dp), 21 + extraLarge = RoundedCornerShape(28.dp) 22 + ) 23 + 24 + private val LightColorScheme = lightColorScheme() 25 + private val DarkColorScheme = darkColorScheme() 26 + 27 + @Composable 28 + fun TinkcilTheme( 29 + darkTheme: Boolean = isSystemInDarkTheme(), 30 + content: @Composable () -> Unit 31 + ) { 32 + val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 33 + val context = LocalContext.current 34 + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 35 + } else { 36 + if (darkTheme) DarkColorScheme else LightColorScheme 37 + } 38 + 39 + MaterialTheme( 40 + colorScheme = colorScheme, 41 + typography = Typography, 42 + shapes = ExpressiveShapes, 43 + content = content 44 + ) 45 + }
+141
android/app/src/main/java/com/tinkcil/ui/theme/Type.kt
··· 1 + package com.tinkcil.ui.theme 2 + 3 + import androidx.compose.material3.Typography 4 + import androidx.compose.ui.text.TextStyle 5 + import androidx.compose.ui.text.font.FontFamily 6 + import androidx.compose.ui.text.font.FontWeight 7 + import androidx.compose.ui.unit.sp 8 + 9 + val Typography = Typography( 10 + displayLarge = TextStyle( 11 + fontFamily = FontFamily.Default, 12 + fontWeight = FontWeight.Bold, 13 + fontSize = 57.sp, 14 + lineHeight = 64.sp, 15 + letterSpacing = (-0.25).sp 16 + ), 17 + displayMedium = TextStyle( 18 + fontFamily = FontFamily.Default, 19 + fontWeight = FontWeight.Bold, 20 + fontSize = 45.sp, 21 + lineHeight = 52.sp, 22 + letterSpacing = 0.sp 23 + ), 24 + displaySmall = TextStyle( 25 + fontFamily = FontFamily.Default, 26 + fontWeight = FontWeight.Bold, 27 + fontSize = 36.sp, 28 + lineHeight = 44.sp, 29 + letterSpacing = 0.sp 30 + ), 31 + headlineLarge = TextStyle( 32 + fontFamily = FontFamily.Default, 33 + fontWeight = FontWeight.Bold, 34 + fontSize = 32.sp, 35 + lineHeight = 40.sp, 36 + letterSpacing = 0.sp 37 + ), 38 + headlineMedium = TextStyle( 39 + fontFamily = FontFamily.Default, 40 + fontWeight = FontWeight.SemiBold, 41 + fontSize = 28.sp, 42 + lineHeight = 36.sp, 43 + letterSpacing = 0.sp 44 + ), 45 + headlineSmall = TextStyle( 46 + fontFamily = FontFamily.Default, 47 + fontWeight = FontWeight.SemiBold, 48 + fontSize = 24.sp, 49 + lineHeight = 32.sp, 50 + letterSpacing = 0.sp 51 + ), 52 + titleLarge = TextStyle( 53 + fontFamily = FontFamily.Default, 54 + fontWeight = FontWeight.SemiBold, 55 + fontSize = 22.sp, 56 + lineHeight = 28.sp, 57 + letterSpacing = 0.sp 58 + ), 59 + titleMedium = TextStyle( 60 + fontFamily = FontFamily.Default, 61 + fontWeight = FontWeight.Medium, 62 + fontSize = 16.sp, 63 + lineHeight = 24.sp, 64 + letterSpacing = 0.15.sp 65 + ), 66 + titleSmall = TextStyle( 67 + fontFamily = FontFamily.Default, 68 + fontWeight = FontWeight.Medium, 69 + fontSize = 14.sp, 70 + lineHeight = 20.sp, 71 + letterSpacing = 0.1.sp 72 + ), 73 + bodyLarge = TextStyle( 74 + fontFamily = FontFamily.Default, 75 + fontWeight = FontWeight.Normal, 76 + fontSize = 16.sp, 77 + lineHeight = 24.sp, 78 + letterSpacing = 0.5.sp 79 + ), 80 + bodyMedium = TextStyle( 81 + fontFamily = FontFamily.Default, 82 + fontWeight = FontWeight.Normal, 83 + fontSize = 14.sp, 84 + lineHeight = 20.sp, 85 + letterSpacing = 0.25.sp 86 + ), 87 + bodySmall = TextStyle( 88 + fontFamily = FontFamily.Default, 89 + fontWeight = FontWeight.Normal, 90 + fontSize = 12.sp, 91 + lineHeight = 16.sp, 92 + letterSpacing = 0.4.sp 93 + ), 94 + labelLarge = TextStyle( 95 + fontFamily = FontFamily.Default, 96 + fontWeight = FontWeight.Medium, 97 + fontSize = 14.sp, 98 + lineHeight = 20.sp, 99 + letterSpacing = 0.1.sp 100 + ), 101 + labelMedium = TextStyle( 102 + fontFamily = FontFamily.Default, 103 + fontWeight = FontWeight.Medium, 104 + fontSize = 12.sp, 105 + lineHeight = 16.sp, 106 + letterSpacing = 0.5.sp 107 + ), 108 + labelSmall = TextStyle( 109 + fontFamily = FontFamily.Default, 110 + fontWeight = FontWeight.Medium, 111 + fontSize = 11.sp, 112 + lineHeight = 16.sp, 113 + letterSpacing = 0.5.sp 114 + ) 115 + ) 116 + 117 + object TemperatureTypography { 118 + val large = TextStyle( 119 + fontFamily = FontFamily.Default, 120 + fontWeight = FontWeight.Bold, 121 + fontSize = 96.sp, 122 + lineHeight = 104.sp, 123 + letterSpacing = (-2).sp 124 + ) 125 + 126 + val medium = TextStyle( 127 + fontFamily = FontFamily.Default, 128 + fontWeight = FontWeight.Bold, 129 + fontSize = 64.sp, 130 + lineHeight = 72.sp, 131 + letterSpacing = (-1).sp 132 + ) 133 + 134 + val small = TextStyle( 135 + fontFamily = FontFamily.Default, 136 + fontWeight = FontWeight.Bold, 137 + fontSize = 48.sp, 138 + lineHeight = 56.sp, 139 + letterSpacing = (-0.5).sp 140 + ) 141 + }
+50
android/app/src/main/java/com/tinkcil/util/Haptics.kt
··· 1 + package com.tinkcil.util 2 + 3 + import android.content.Context 4 + import android.os.Build 5 + import android.os.VibrationEffect 6 + import android.os.Vibrator 7 + import android.os.VibratorManager 8 + 9 + object Haptics { 10 + 11 + private fun vibrator(context: Context): Vibrator { 12 + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 13 + val manager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager 14 + manager.defaultVibrator 15 + } else { 16 + @Suppress("DEPRECATION") 17 + context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator 18 + } 19 + } 20 + 21 + fun light(context: Context) { 22 + vibrate(context, 10, VibrationEffect.EFFECT_TICK) 23 + } 24 + 25 + fun selection(context: Context) { 26 + vibrate(context, 15, VibrationEffect.EFFECT_CLICK) 27 + } 28 + 29 + fun success(context: Context) { 30 + vibrate(context, 30, VibrationEffect.EFFECT_HEAVY_CLICK) 31 + } 32 + 33 + fun warning(context: Context) { 34 + vibrate(context, 50, VibrationEffect.EFFECT_DOUBLE_CLICK) 35 + } 36 + 37 + fun error(context: Context) { 38 + vibrate(context, 100, VibrationEffect.EFFECT_DOUBLE_CLICK) 39 + } 40 + 41 + private fun vibrate(context: Context, durationMs: Long, effectId: Int) { 42 + val v = vibrator(context) 43 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 44 + v.vibrate(VibrationEffect.createPredefined(effectId)) 45 + } else { 46 + @Suppress("DEPRECATION") 47 + v.vibrate(durationMs) 48 + } 49 + } 50 + }
+10
android/app/src/main/res/drawable/ic_launcher_background.xml
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <vector xmlns:android="http://schemas.android.com/apk/res/android" 3 + android:width="108dp" 4 + android:height="108dp" 5 + android:viewportWidth="108" 6 + android:viewportHeight="108"> 7 + <path 8 + android:fillColor="#1B1B1F" 9 + android:pathData="M0,0h108v108h-108z" /> 10 + </vector>
+18
android/app/src/main/res/drawable/ic_launcher_foreground.xml
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <vector xmlns:android="http://schemas.android.com/apk/res/android" 3 + android:width="108dp" 4 + android:height="108dp" 5 + android:viewportWidth="108" 6 + android:viewportHeight="108"> 7 + <!-- Soldering iron tip shape --> 8 + <path 9 + android:fillColor="#E8DEF8" 10 + android:pathData="M54,28 L62,54 L54,80 L46,54 Z" /> 11 + <path 12 + android:fillColor="#D0BCFF" 13 + android:pathData="M48,54 L54,36 L60,54 L54,72 Z" /> 14 + <!-- Tip point --> 15 + <path 16 + android:fillColor="#FF8A65" 17 + android:pathData="M51,68 L54,78 L57,68 Z" /> 18 + </vector>
+5
android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> 3 + <background android:drawable="@drawable/ic_launcher_background" /> 4 + <foreground android:drawable="@drawable/ic_launcher_foreground" /> 5 + </adaptive-icon>
+5
android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> 3 + <background android:drawable="@drawable/ic_launcher_background" /> 4 + <foreground android:drawable="@drawable/ic_launcher_foreground" /> 5 + </adaptive-icon>
+101
android/app/src/main/res/values/strings.xml
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <resources> 3 + <string name="app_name">Tinkcil</string> 4 + 5 + <!-- Connection --> 6 + <string name="connection_scanning">Scanning for device</string> 7 + <string name="connection_connecting">Connecting to device</string> 8 + <string name="connection_looking_for_iron">Looking for soldering iron…</string> 9 + <string name="connection_no_device_found">No device found</string> 10 + <string name="connection_scan_again">Scan Again</string> 11 + <string name="bluetooth_error_title">Bluetooth Error</string> 12 + <string name="try_demo">Try Demo</string> 13 + 14 + <!-- Buttons --> 15 + <string name="button_ok">OK</string> 16 + <string name="button_done">Done</string> 17 + <string name="button_disconnect">Disconnect</string> 18 + <string name="button_save_to_device">Save to Device</string> 19 + <string name="settings_button">Settings</string> 20 + 21 + <!-- Tabs --> 22 + <string name="settings_tab">Settings</string> 23 + <string name="info_tab">Device Info</string> 24 + 25 + <!-- Sections --> 26 + <string name="section_temperature">Temperature</string> 27 + <string name="section_timers">Timers</string> 28 + <string name="section_power">Power</string> 29 + <string name="section_display">Display</string> 30 + <string name="section_sensors">Sensors</string> 31 + <string name="section_controls">Controls</string> 32 + <string name="section_current_status">Current Status</string> 33 + <string name="section_diagnostics">Diagnostics</string> 34 + 35 + <!-- Temperature Settings --> 36 + <string name="setting_soldering_temp">Soldering Temperature</string> 37 + <string name="setting_sleep_temp">Sleep Temperature</string> 38 + <string name="setting_boost_temp">Boost Temperature</string> 39 + 40 + <!-- Timer Settings --> 41 + <string name="setting_sleep_time">Sleep Time</string> 42 + <string name="setting_shutdown_time">Shutdown Time</string> 43 + 44 + <!-- Power Settings --> 45 + <string name="setting_power_limit">Power Limit</string> 46 + 47 + <!-- Display Settings --> 48 + <string name="setting_orientation">Orientation</string> 49 + <string name="option_right">Right</string> 50 + <string name="option_left">Left</string> 51 + <string name="option_auto">Auto</string> 52 + <string name="setting_brightness">Brightness</string> 53 + <string name="setting_invert_display">Invert Display</string> 54 + <string name="setting_detailed_idle">Detailed Idle Screen</string> 55 + <string name="setting_detailed_soldering">Detailed Soldering Screen</string> 56 + 57 + <!-- Sensor Settings --> 58 + <string name="setting_motion_sensitivity">Motion Sensitivity</string> 59 + <string name="setting_hall_sensitivity">Hall Sensitivity</string> 60 + 61 + <!-- Control Settings --> 62 + <string name="setting_locking_mode">Locking Mode</string> 63 + <string name="option_off">Off</string> 64 + <string name="option_boost_only">Boost Only</string> 65 + <string name="option_full">Full</string> 66 + <string name="setting_reverse_buttons">Reverse Buttons</string> 67 + <string name="setting_short_press_step">Short Press Step</string> 68 + <string name="setting_long_press_step">Long Press Step</string> 69 + 70 + <!-- Detail Stats --> 71 + <string name="detail_handle">Handle</string> 72 + <string name="detail_tip_resistance">Tip Resistance</string> 73 + <string name="detail_mode">Mode</string> 74 + <string name="detail_power">Power</string> 75 + <string name="detail_firmware">Firmware</string> 76 + 77 + <!-- Device Info --> 78 + <string name="device_info_title">Device Information</string> 79 + <string name="info_device_name">Device Name</string> 80 + <string name="info_firmware">Firmware</string> 81 + <string name="info_serial_number">Serial Number</string> 82 + <string name="info_temperature">Temperature</string> 83 + <string name="info_setpoint">Setpoint</string> 84 + <string name="info_max_temperature">Max Temperature</string> 85 + <string name="info_operating_mode">Operating Mode</string> 86 + <string name="info_voltage">Voltage</string> 87 + <string name="info_wattage">Wattage</string> 88 + <string name="info_power_level">Power Level</string> 89 + <string name="info_power_source">Power Source</string> 90 + <string name="info_handle_temp">Handle Temperature</string> 91 + <string name="info_tip_resistance">Tip Resistance</string> 92 + <string name="info_raw_tip">Raw Tip</string> 93 + <string name="info_hall_sensor">Hall Sensor</string> 94 + <string name="info_uptime">Uptime</string> 95 + <string name="info_last_movement">Last Movement</string> 96 + 97 + <!-- Misc --> 98 + <string name="common_unknown">Unknown</string> 99 + <string name="target_temperature">Target Temperature</string> 100 + <string name="settings_footer_message">Settings are written to the device immediately. Tap Save to persist them across reboots.</string> 101 + </resources>
+7
android/app/src/main/res/values/themes.xml
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <resources> 3 + <style name="Theme.Tinkcil" parent="android:Theme.Material.Light.NoActionBar"> 4 + <item name="android:statusBarColor">@android:color/transparent</item> 5 + <item name="android:navigationBarColor">@android:color/transparent</item> 6 + </style> 7 + </resources>
+7
android/build.gradle.kts
··· 1 + plugins { 2 + alias(libs.plugins.android.application) apply false 3 + alias(libs.plugins.kotlin.android) apply false 4 + alias(libs.plugins.kotlin.compose) apply false 5 + alias(libs.plugins.hilt.android) apply false 6 + alias(libs.plugins.ksp) apply false 7 + }
+27
android/gradle.properties
··· 1 + # Project-wide Gradle settings. 2 + org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 3 + org.gradle.parallel=true 4 + org.gradle.caching=true 5 + org.gradle.configuration-cache=true 6 + 7 + # AndroidX package structure 8 + android.useAndroidX=true 9 + 10 + # Kotlin code style 11 + kotlin.code.style=official 12 + 13 + # Enables namespacing of each library's R class 14 + android.nonTransitiveRClass=true 15 + 16 + # Suppress warnings for Kotlin options 17 + kotlin.options.suppressFreeCompilerArgsModificationWarning=true 18 + android.defaults.buildfeatures.resvalues=true 19 + android.sdk.defaultTargetSdkToCompileSdkIfUnset=false 20 + android.enableAppCompileTimeRClass=false 21 + android.usesSdkInManifest.disallowed=false 22 + android.uniquePackageNames=false 23 + android.dependency.useConstraints=true 24 + android.r8.strictFullModeForKeepRules=false 25 + android.r8.optimizedResourceShrinking=false 26 + android.builtInKotlin=false 27 + android.newDsl=false
+51
android/gradle/libs.versions.toml
··· 1 + [versions] 2 + agp = "9.0.0" 3 + kotlin = "2.2.10" 4 + ksp = "2.3.2" 5 + coreKtx = "1.15.0" 6 + lifecycleRuntimeKtx = "2.8.7" 7 + activityCompose = "1.9.3" 8 + composeBom = "2025.01.00" 9 + material3 = "1.3.1" 10 + hilt = "2.54" 11 + hiltNavigationCompose = "1.2.0" 12 + datastore = "1.1.2" 13 + coroutines = "1.9.0" 14 + splashscreen = "1.0.1" 15 + material3WindowSizeClass = "1.3.1" 16 + 17 + [libraries] 18 + androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 19 + androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } 20 + androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } 21 + androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } 22 + androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } 23 + androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } 24 + androidx-ui = { group = "androidx.compose.ui", name = "ui" } 25 + androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } 26 + androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } 27 + androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } 28 + androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } 29 + androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } 30 + androidx-material3-windowsizeclass = { group = "androidx.compose.material3", name = "material3-window-size-class", version.ref = "material3WindowSizeClass" } 31 + 32 + # Hilt 33 + hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } 34 + hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } 35 + hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } 36 + 37 + # DataStore 38 + androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } 39 + 40 + # Coroutines 41 + kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } 42 + 43 + # Splash screen 44 + androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } 45 + 46 + [plugins] 47 + android-application = { id = "com.android.application", version.ref = "agp" } 48 + kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 49 + kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 50 + hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } 51 + ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
android/gradle/wrapper/gradle-wrapper.jar

This is a binary file and will not be displayed.

+7
android/gradle/wrapper/gradle-wrapper.properties
··· 1 + distributionBase=GRADLE_USER_HOME 2 + distributionPath=wrapper/dists 3 + distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip 4 + networkTimeout=10000 5 + validateDistributionUrl=true 6 + zipStoreBase=GRADLE_USER_HOME 7 + zipStorePath=wrapper/dists
+170
android/gradlew
··· 1 + #!/bin/sh 2 + 3 + # 4 + # Copyright 2015-2024 the original author or authors. 5 + # 6 + # Licensed under the Apache License, Version 2.0 (the "License"); 7 + # you may not use this file except in compliance with the License. 8 + # You may obtain a copy of the License at 9 + # 10 + # https://www.apache.org/licenses/LICENSE-2.0 11 + # 12 + 13 + ############################################################################## 14 + # 15 + # Gradle start up script for POSIX generated by Gradle. 16 + # 17 + ############################################################################## 18 + 19 + # Attempt to set APP_HOME 20 + 21 + # Resolve links: $0 may be a link 22 + app_path=$0 23 + 24 + # Need this for daisy-chained symlinks. 25 + while 26 + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 27 + [ -h "$app_path" ] 28 + do 29 + ls=$( ls -ld "$app_path" ) 30 + link=${ls#*' -> '} 31 + case $link in #( 32 + /*) app_path=$link ;; #( 33 + *) app_path=$APP_HOME$link ;; 34 + esac 35 + done 36 + 37 + APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 38 + 39 + # Use the maximum available, or set MAX_FD != -1 to use that value. 40 + MAX_FD=maximum 41 + 42 + warn () { 43 + echo "$*" 44 + } >&2 45 + 46 + die () { 47 + echo 48 + echo "$*" 49 + echo 50 + exit 1 51 + } >&2 52 + 53 + # OS specific support (must be 'true' or 'false'). 54 + cygwin=false 55 + msys=false 56 + darwin=false 57 + nonstop=false 58 + case "$( uname )" in #( 59 + CYGWIN* ) cygwin=true ;; #( 60 + Darwin* ) darwin=true ;; #( 61 + MSYS* | MINGW* ) msys=true ;; #( 62 + NONSTOP* ) nonstop=true ;; 63 + esac 64 + 65 + CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 66 + 67 + 68 + # Determine the Java command to use to start the JVM. 69 + if [ -n "$JAVA_HOME" ] ; then 70 + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 71 + # IBM's JDK on AIX uses strange locations for the executables 72 + JAVACMD=$JAVA_HOME/jre/sh/java 73 + else 74 + JAVACMD=$JAVA_HOME/bin/java 75 + fi 76 + if [ ! -x "$JAVACMD" ] ; then 77 + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 78 + 79 + Please set the JAVA_HOME variable in your environment to match the 80 + location of your Java installation." 81 + fi 82 + else 83 + JAVACMD=java 84 + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 85 + 86 + Please set the JAVA_HOME variable in your environment to match the 87 + location of your Java installation." 88 + fi 89 + 90 + # Increase the maximum file descriptors if we can. 91 + if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 92 + case $MAX_FD in #( 93 + max*) 94 + MAX_FD=$( ulimit -H -n ) || 95 + warn "Could not query maximum file descriptor limit" 96 + esac 97 + case $MAX_FD in #( 98 + '' | soft) :;; #( 99 + *) 100 + ulimit -n "$MAX_FD" || 101 + warn "Could not set maximum file descriptor limit to $MAX_FD" 102 + esac 103 + fi 104 + 105 + # Collect all arguments for the java command, stacking in reverse order: 106 + # * args from the command line 107 + # * the main class name 108 + # * -classpath 109 + # * -D...appname settings 110 + # * --module-path (only if needed) 111 + # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 112 + 113 + # For Cygwin or MSYS, switch paths to Windows format before running java 114 + if "$cygwin" || "$msys" ; then 115 + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 116 + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 117 + 118 + JAVACMD=$( cygpath --unix "$JAVACMD" ) 119 + 120 + # Now convert the arguments - kludge to limit ourselves to /bin/sh 121 + for arg do 122 + if 123 + case $arg in #( 124 + -*) false ;; # don't mess with options #( 125 + /?*) t=${arg#)}; t=/${t%%/*} # looks like a POSIX filepath 126 + [ -e "$t" ] ;; #( 127 + *) false ;; 128 + esac 129 + then 130 + arg=$( cygpath --path --ignore --mixed "$arg" ) 131 + fi 132 + # Roll the args list around exactly as many times as the number of 133 + # temporary files were generated. 134 + set -- "$@" "$arg" 135 + shift 136 + done 137 + fi 138 + 139 + 140 + # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 141 + DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 142 + 143 + # Collect all arguments for the java command: 144 + # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not:// having any whitespace:// * example:// having any whitespace 145 + set -- \ 146 + "-Dorg.gradle.appname=$APP_BASE_NAME" \ 147 + -classpath "$CLASSPATH" \ 148 + org.gradle.wrapper.GradleWrapperMain \ 149 + "$@" 150 + 151 + # Stop when "xargs" is not available. 152 + if ! command -v xargs >/dev/null 2>&1 153 + then 154 + die "xargs is not available" 155 + fi 156 + 157 + # Use "xargs" to parse quoted args. 158 + # 159 + # With -n:// * "arg// "arg one two three" "arg" 160 + # ... would result in:// * "arg" "one two three" "arg" 161 + # 162 + # We need to respect:// * and then eval, for any shell// * The output is handled by the function:// *:// * The output is then read by xargs. 163 + eval "set -- $( 164 + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 165 + xargs -n1 | 166 + sed ' s~[`528"]~\\&~g; ' | 167 + tr '\n' ' ' 168 + )" '"$@"' 169 + 170 + exec "$JAVACMD" "$@"
+86
android/gradlew.bat
··· 1 + @rem 2 + @rem Copyright 2015-2024 the original author or authors. 3 + @rem 4 + @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 + @rem you may not use this file except in compliance with the License. 6 + @rem You may obtain a copy of the License at 7 + @rem 8 + @rem https://www.apache.org/licenses/LICENSE-2.0 9 + @rem 10 + 11 + @if "%DEBUG%"=="" @echo off 12 + @rem ########################################################################## 13 + @rem 14 + @rem Gradle startup script for Windows 15 + @rem 16 + @rem ########################################################################## 17 + 18 + @rem Set local scope for the variables with windows NT shell 19 + if "%OS%"=="Windows_NT" setlocal 20 + 21 + set DIRNAME=%~dp0 22 + if "%DIRNAME%"=="" set DIRNAME=. 23 + @rem This is normally unused 24 + set APP_BASE_NAME=%~n0 25 + set APP_HOME=%DIRNAME% 26 + 27 + @rem Resolve any "." and ".." in APP_HOME to make it shorter. 28 + for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 29 + 30 + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 + set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 32 + 33 + @rem Find java.exe 34 + if defined JAVA_HOME goto findJavaFromJavaHome 35 + 36 + set JAVA_EXE=java.exe 37 + %JAVA_EXE% -version >NUL 2>&1 38 + if %ERRORLEVEL% equ 0 goto execute 39 + 40 + echo. 1>&2 41 + echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 42 + echo. 1>&2 43 + echo Please set the JAVA_HOME variable in your environment to match the 1>&2 44 + echo location of your Java installation. 1>&2 45 + 46 + goto fail 47 + 48 + :findJavaFromJavaHome 49 + set JAVA_HOME=%JAVA_HOME:"=% 50 + set JAVA_EXE=%JAVA_HOME%/bin/java.exe 51 + 52 + if exist "%JAVA_EXE%" goto execute 53 + 54 + echo. 1>&2 55 + echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 56 + echo. 1>&2 57 + echo Please set the JAVA_HOME variable in your environment to match the 1>&2 58 + echo location of your Java installation. 1>&2 59 + 60 + goto fail 61 + 62 + :execute 63 + @rem Setup the command line 64 + 65 + set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 66 + 67 + 68 + @rem Execute Gradle 69 + "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 70 + 71 + :end 72 + @rem End local scope for the variables with windows NT shell 73 + if %ERRORLEVEL% equ 0 goto mainEnd 74 + 75 + :fail 76 + rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 + rem the _cmd.exe /c_ return code! 78 + set EXIT_CODE=%ERRORLEVEL% 79 + if %EXIT_CODE% equ 0 set EXIT_CODE=1 80 + if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 81 + exit /b %EXIT_CODE% 82 + 83 + :mainEnd 84 + if "%OS%"=="Windows_NT" endlocal 85 + 86 + :omega
+23
android/settings.gradle.kts
··· 1 + pluginManagement { 2 + repositories { 3 + google { 4 + content { 5 + includeGroupByRegex("com\\.android.*") 6 + includeGroupByRegex("com\\.google.*") 7 + includeGroupByRegex("androidx.*") 8 + } 9 + } 10 + mavenCentral() 11 + gradlePluginPortal() 12 + } 13 + } 14 + dependencyResolutionManagement { 15 + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 16 + repositories { 17 + google() 18 + mavenCentral() 19 + } 20 + } 21 + 22 + rootProject.name = "Tinkcil" 23 + include(":app")