commit 611429c2231456cb96918e71258b7fa7aa74efad Author: Domagoj Zecevic Date: Wed Feb 11 16:37:51 2026 +0100 First working version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..4505178 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,69 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.dzecevic.babymonitor" + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId = "com.dzecevic.babymonitor" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) + // CameraX + val camerax_version = "1.3.4" + implementation("androidx.camera:camera-core:$camerax_version") + implementation("androidx.camera:camera-camera2:$camerax_version") + implementation("androidx.camera:camera-lifecycle:$camerax_version") + implementation("androidx.camera:camera-view:$camerax_version") + +// HTTP server + implementation("org.nanohttpd:nanohttpd:2.3.1") + + implementation("androidx.lifecycle:lifecycle-service:2.7.0") +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/dzecevic/babymonitor/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/dzecevic/babymonitor/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..05a62eb --- /dev/null +++ b/app/src/androidTest/java/com/dzecevic/babymonitor/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.dzecevic.babymonitor + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.dzecevic.babymonitor", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..41a85c3 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/dzecevic/babymonitor/FrameStore.kt b/app/src/main/java/com/dzecevic/babymonitor/FrameStore.kt new file mode 100644 index 0000000..70087d5 --- /dev/null +++ b/app/src/main/java/com/dzecevic/babymonitor/FrameStore.kt @@ -0,0 +1,12 @@ +package com.dzecevic.babymonitor + +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.atomic.AtomicInteger + +object FrameStore { + val latestJpeg = AtomicReference(null) + val frameCount = AtomicInteger(0) + + @Volatile + var isStreaming: Boolean = false +} diff --git a/app/src/main/java/com/dzecevic/babymonitor/MainActivity.kt b/app/src/main/java/com/dzecevic/babymonitor/MainActivity.kt new file mode 100644 index 0000000..895e7a4 --- /dev/null +++ b/app/src/main/java/com/dzecevic/babymonitor/MainActivity.kt @@ -0,0 +1,307 @@ +package com.dzecevic.babymonitor + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.* +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import java.net.Inet4Address +import java.net.NetworkInterface +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.foundation.layout.statusBarsPadding +import android.content.Intent +import com.dzecevic.babymonitor.FrameStore +import androidx.compose.ui.Alignment + + +class MainActivity : ComponentActivity() { + + private val latestJpeg = AtomicReference(null) + private val serverPort = 8080 + + private var cameraProvider: ProcessCameraProvider? = null + private lateinit var cameraExecutor: ExecutorService + private var lensFacing = CameraSelector.LENS_FACING_BACK + + private val frameCount = AtomicInteger(0) + + private var rebindToken by mutableStateOf(0) + + private val requestPermission = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (!granted) Toast.makeText(this, "Camera permission required", Toast.LENGTH_LONG).show() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + cameraExecutor = Executors.newSingleThreadExecutor() + + // request permission if needed + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED + ) { + requestPermission.launch(Manifest.permission.CAMERA) + } + + setContent { + BabyMonitorScreen( + onStartServer = { ip -> + val intent = Intent(this, StreamService::class.java) + ContextCompat.startForegroundService(this, intent) + + Toast.makeText(this, "Open: http://$ip:$serverPort/", Toast.LENGTH_LONG).show() + }, + + onStopServer = { + stopService(Intent(this, StreamService::class.java)) + }, + + isServerRunning = { false }, + + onSwitchCamera = { + lensFacing = + if (lensFacing == CameraSelector.LENS_FACING_BACK) CameraSelector.LENS_FACING_FRONT + else CameraSelector.LENS_FACING_BACK + rebindToken++ + }, + + rebindToken = rebindToken, + + bindCamera = { previewView -> + bindCameraUseCases(previewView) + }, + + getIp = { getLocalWifiIp() }, + + getFrames = { frameCount.get() } + ) + } + } + + override fun onDestroy() { + super.onDestroy() + cameraExecutor.shutdown() + } + + private fun bindCameraUseCases(previewView: PreviewView) { + if (FrameStore.isStreaming) { + return + } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED + ) return + + val providerFuture = ProcessCameraProvider.getInstance(this) + providerFuture.addListener({ + val provider = providerFuture.get() + cameraProvider = provider + provider.unbindAll() + + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) + .build() + + analysis.setAnalyzer(cameraExecutor) { image -> + try { + val rotation = image.imageInfo.rotationDegrees + val isFront = (lensFacing == CameraSelector.LENS_FACING_FRONT) + + val jpg = YuvToJpegConverter.toJpeg( + image = image, + quality = 70, + rotationDegrees = rotation, + mirrorHorizontally = isFront + ) + FrameStore.latestJpeg.set(jpg) + FrameStore.frameCount.incrementAndGet() + } catch (_: Exception) { + } finally { + image.close() + } + } + + val selector = CameraSelector.Builder() + .requireLensFacing(lensFacing) + .build() + + try { + provider.bindToLifecycle(this, selector, preview, analysis) + } catch (e: Exception) { + Toast.makeText(this, "Camera bind failed: ${e.message}", Toast.LENGTH_LONG).show() + } + }, ContextCompat.getMainExecutor(this)) + } + + private fun getLocalWifiIp(): String? { + if (!isOnWifi()) return null + + // Prefer wlan interface addresses + NetworkInterface.getNetworkInterfaces().toList().forEach { nif -> + nif.inetAddresses.toList().forEach { addr -> + if (!addr.isLoopbackAddress && addr is Inet4Address) { + if (nif.name.startsWith("wlan") || nif.name.startsWith("wifi")) { + return addr.hostAddress + } + } + } + } + + // Fallback to WifiManager + val wm = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + val ipInt = wm.connectionInfo.ipAddress + if (ipInt == 0) return null + return "${ipInt and 0xFF}.${(ipInt shr 8) and 0xFF}.${(ipInt shr 16) and 0xFF}.${(ipInt shr 24) and 0xFF}" + } + + private fun isOnWifi(): Boolean { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val net = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(net) ?: return false + return caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + } +} + +@Composable +private fun BabyMonitorScreen( + onStartServer: (ip: String) -> Unit, + onStopServer: () -> Unit, + isServerRunning: () -> Boolean, + onSwitchCamera: () -> Unit, + bindCamera: (PreviewView) -> Unit, + getIp: () -> String?, + getFrames: () -> Int, + rebindToken: Int +) { + val context = LocalContext.current + var ip by remember { mutableStateOf(getIp()) } + var running by remember { mutableStateOf(isServerRunning()) } + + // UI refresh of IP when screen recomposes + LaunchedEffect(Unit) { ip = getIp() } + + Box(Modifier.fillMaxSize()) { + + val previewView = remember { + PreviewView(context).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + } + } + + if (FrameStore.isStreaming) { + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Streaming active", color = Color.White) + Text("Frames: ${getFrames()}", color = Color.White) + } + + } else { + + AndroidView( + factory = { previewView }, + modifier = Modifier.fillMaxSize() + ) + + } + + LaunchedEffect(rebindToken) { + if (!FrameStore.isStreaming) { + bindCamera(previewView) + } + } + + Column( + Modifier + .statusBarsPadding() + .padding(top = 8.dp) + .padding(horizontal = 12.dp) + .background(Color(0x80000000)) + .padding(10.dp) + ) { + if (ip != null) { + Text( + text = "URL: http://$ip:8080/", + color = Color.White + ) + } else { + Text( + text = "Not connected to Wi-Fi", + color = Color.Red + ) + } + + Spacer(Modifier.height(8.dp)) + + Text( + text = if (running) "Status: STREAMING" else "Status: STOPPED", + color = if (running) Color.Green else Color.Yellow + ) + + Spacer(Modifier.height(10.dp)) + + Row { + Button(onClick = { + onSwitchCamera() + ip = getIp() + }) { Text("Switch camera") } + + Spacer(Modifier.width(8.dp)) + + Button(onClick = { + ip = getIp() + val currentIp = ip + if (currentIp == null) { + Toast.makeText(context, "Connect to Wi-Fi first", Toast.LENGTH_LONG).show() + return@Button + } + + if (!running) { + onStartServer(currentIp) + running = true + } else { + onStopServer() + running = false + } + + }) { + Text(if (!running) "Start streaming" else "Stop streaming") + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dzecevic/babymonitor/MjpegServer.kt b/app/src/main/java/com/dzecevic/babymonitor/MjpegServer.kt new file mode 100644 index 0000000..d5bdc1b --- /dev/null +++ b/app/src/main/java/com/dzecevic/babymonitor/MjpegServer.kt @@ -0,0 +1,116 @@ +package com.dzecevic.babymonitor + +import fi.iki.elonen.NanoHTTPD +import java.io.ByteArrayOutputStream +import java.util.concurrent.atomic.AtomicReference + +class MjpegServer( + port: Int, + private val latestJpeg: AtomicReference +) : NanoHTTPD(port) { + + override fun serve(session: IHTTPSession): Response { + return when (session.uri) { + "/" -> newFixedLengthResponse( + Response.Status.OK, + "text/html", + """ + + + + + + + + + + + """.trimIndent() + ) + "/mjpeg" -> { + val boundary = "mjpegboundary" + val mime = "multipart/x-mixed-replace; boundary=$boundary" + + val stream = object : java.io.InputStream() { + private var buffer = ByteArray(0) + private var idx = 0 + + override fun read(): Int { + if (idx >= buffer.size) { + buffer = nextChunk(boundary) + idx = 0 + } + return buffer[idx++].toInt() and 0xFF + } + } + + newChunkedResponse(Response.Status.OK, mime, stream).apply { + addHeader("Cache-Control", "no-cache") + addHeader("Pragma", "no-cache") + addHeader("Connection", "close") + } + } + else -> newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not found") + } + } + + private fun nextChunk(boundary: String): ByteArray { + var jpg = latestJpeg.get() + while (jpg == null) { + Thread.sleep(30) + jpg = latestJpeg.get() + } + + val out = ByteArrayOutputStream() + out.write(("--$boundary\r\n").toByteArray()) + out.write("Content-Type: image/jpeg\r\n".toByteArray()) + out.write(("Content-Length: ${jpg.size}\r\n\r\n").toByteArray()) + out.write(jpg) + out.write("\r\n".toByteArray()) + + // ~20 FPS max; tune later + Thread.sleep(50) + return out.toByteArray() + } +} diff --git a/app/src/main/java/com/dzecevic/babymonitor/StreamService.kt b/app/src/main/java/com/dzecevic/babymonitor/StreamService.kt new file mode 100644 index 0000000..43e686c --- /dev/null +++ b/app/src/main/java/com/dzecevic/babymonitor/StreamService.kt @@ -0,0 +1,134 @@ +package com.dzecevic.babymonitor + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Intent +import android.os.Build +import android.os.PowerManager +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleService +import fi.iki.elonen.NanoHTTPD +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +class StreamService : LifecycleService() { + + private var wakeLock: PowerManager.WakeLock? = null + private var server: MjpegServer? = null + + private var cameraProvider: ProcessCameraProvider? = null + private lateinit var cameraExecutor: ExecutorService + + // start with back camera + private var lensFacing = CameraSelector.LENS_FACING_BACK + + override fun onCreate() { + super.onCreate() + FrameStore.isStreaming = true + startForeground(1, buildNotification()) + acquireWakeLock() + + cameraExecutor = Executors.newSingleThreadExecutor() + + // Server reads from FrameStore + server = MjpegServer(8080, FrameStore.latestJpeg).apply { + start(NanoHTTPD.SOCKET_READ_TIMEOUT, false) + } + + startCameraAnalysis() + } + + override fun onDestroy() { + try { + cameraProvider?.unbindAll() + } catch (_: Exception) { } + FrameStore.isStreaming = false + + server?.stop() + server = null + + cameraExecutor.shutdown() + releaseWakeLock() + + super.onDestroy() + } + + private fun startCameraAnalysis() { + val future = ProcessCameraProvider.getInstance(this) + future.addListener({ + val provider = future.get() + cameraProvider = provider + provider.unbindAll() + + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) + .build() + + analysis.setAnalyzer(cameraExecutor) { image: ImageProxy -> + try { + val rotation = image.imageInfo.rotationDegrees + val isFront = (lensFacing == CameraSelector.LENS_FACING_FRONT) + + val jpg = YuvToJpegConverter.toJpeg( + image = image, + quality = 70, + rotationDegrees = rotation, + mirrorHorizontally = isFront + ) + + FrameStore.latestJpeg.set(jpg) + FrameStore.frameCount.incrementAndGet() + } catch (_: Exception) { + } finally { + image.close() + } + } + + val selector = CameraSelector.Builder() + .requireLensFacing(lensFacing) + .build() + + provider.bindToLifecycle(this, selector, analysis) + }, ContextCompat.getMainExecutor(this)) + } + + // Optional: later we can add Intent actions to switch camera + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // If you later send ACTION_SWITCH, handle it here. + return super.onStartCommand(intent, flags, startId) + } + + private fun acquireWakeLock() { + val pm = getSystemService(POWER_SERVICE) as PowerManager + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "BabyMonitor:StreamLock") + wakeLock?.acquire() + } + + private fun releaseWakeLock() { + if (wakeLock?.isHeld == true) wakeLock?.release() + wakeLock = null + } + + private fun buildNotification(): Notification { + val channelId = "babymonitor_stream" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + nm.createNotificationChannel( + NotificationChannel(channelId, "BabyMonitor streaming", NotificationManager.IMPORTANCE_LOW) + ) + } + return NotificationCompat.Builder(this, channelId) + .setSmallIcon(android.R.drawable.presence_video_online) + .setContentTitle("BabyMonitor streaming") + .setContentText("Streaming on http://:8080/") + .setOngoing(true) + .build() + } +} diff --git a/app/src/main/java/com/dzecevic/babymonitor/YuvToJpegConverter.kt b/app/src/main/java/com/dzecevic/babymonitor/YuvToJpegConverter.kt new file mode 100644 index 0000000..8db1886 --- /dev/null +++ b/app/src/main/java/com/dzecevic/babymonitor/YuvToJpegConverter.kt @@ -0,0 +1,69 @@ +package com.dzecevic.babymonitor + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.ImageFormat +import android.graphics.Matrix +import android.graphics.Rect +import android.graphics.YuvImage +import androidx.camera.core.ImageProxy +import java.io.ByteArrayOutputStream + +object YuvToJpegConverter { + + fun toJpeg( + image: ImageProxy, + quality: Int = 70, + rotationDegrees: Int = 0, + mirrorHorizontally: Boolean = false + ): ByteArray { + // --- YUV -> NV21 --- + val yBuffer = image.planes[0].buffer + val uBuffer = image.planes[1].buffer + val vBuffer = image.planes[2].buffer + + val ySize = yBuffer.remaining() + val uSize = uBuffer.remaining() + val vSize = vBuffer.remaining() + + val nv21 = ByteArray(ySize + uSize + vSize) + yBuffer.get(nv21, 0, ySize) + vBuffer.get(nv21, ySize, vSize) // V + uBuffer.get(nv21, ySize + vSize, uSize) // U + + // --- NV21 -> JPEG bytes --- + val yuv = YuvImage(nv21, ImageFormat.NV21, image.width, image.height, null) + val jpegOut = ByteArrayOutputStream() + yuv.compressToJpeg(Rect(0, 0, image.width, image.height), quality.coerceIn(1, 100), jpegOut) + val jpegBytes = jpegOut.toByteArray() + + if (rotationDegrees % 360 == 0 && !mirrorHorizontally) { + return jpegBytes + } + + // --- JPEG bytes -> Bitmap -> rotate/mirror -> JPEG bytes --- + val bitmap = BitmapFactory.decodeByteArray(jpegBytes, 0, jpegBytes.size) + ?: return jpegBytes + + val matrix = Matrix().apply { + if (mirrorHorizontally) { + // mirror around center + preScale(-1f, 1f) + } + if (rotationDegrees % 360 != 0) { + postRotate(rotationDegrees.toFloat()) + } + } + + val transformed = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + + val finalOut = ByteArrayOutputStream() + transformed.compress(Bitmap.CompressFormat.JPEG, quality.coerceIn(1, 100), finalOut) + + // Cleanup bitmaps + bitmap.recycle() + if (transformed != bitmap) transformed.recycle() + + return finalOut.toByteArray() + } +} diff --git a/app/src/main/java/com/dzecevic/babymonitor/ui/theme/Color.kt b/app/src/main/java/com/dzecevic/babymonitor/ui/theme/Color.kt new file mode 100644 index 0000000..c1ef448 --- /dev/null +++ b/app/src/main/java/com/dzecevic/babymonitor/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.dzecevic.babymonitor.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/dzecevic/babymonitor/ui/theme/Theme.kt b/app/src/main/java/com/dzecevic/babymonitor/ui/theme/Theme.kt new file mode 100644 index 0000000..0702382 --- /dev/null +++ b/app/src/main/java/com/dzecevic/babymonitor/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.dzecevic.babymonitor.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun BabyMonitorTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/dzecevic/babymonitor/ui/theme/Type.kt b/app/src/main/java/com/dzecevic/babymonitor/ui/theme/Type.kt new file mode 100644 index 0000000..bc4f606 --- /dev/null +++ b/app/src/main/java/com/dzecevic/babymonitor/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.dzecevic.babymonitor.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..6e97fd1 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + BabyMonitor + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..20eab3c --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +