commit cb5c04132f1c2827a66549ceadc5cb121657dd01 Author: warzazel Date: Mon Mar 16 18:20:22 2026 +0100 Stromzaehler-App 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/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..02c4aa5 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7061a0d --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,61 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file 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 b/app/build.gradle new file mode 100644 index 0000000..3abcf8e --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,84 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + id 'com.google.devtools.ksp' +} + +android { + namespace 'com.warzazel.stromzaehler' + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId "com.warzazel.stromzaehler" + minSdk 26 + targetSdk 36 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + 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 + ksp 'org.jetbrains.kotlin:kotlin-metadata-jvm:2.2.10' + // LiveData Compose + implementation 'androidx.compose.runtime:runtime-livedata:1.7.8' + + // ViewModel Compose + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7' + + // Room + implementation 'androidx.room:room-runtime:2.7.0' + implementation 'androidx.room:room-ktx:2.7.0' + ksp 'androidx.room:room-compiler:2.7.0' + + // ViewModel + LiveData + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0' + + // Diagramm + implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0' + + // Material Design 3 + implementation 'com.google.android.material:material:1.12.0' + + // Coroutines + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + + // AppCompat + implementation 'androidx.appcompat:appcompat:1.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/warzazel/stromzaehler/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/warzazel/stromzaehler/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..2861cda --- /dev/null +++ b/app/src/androidTest/java/com/warzazel/stromzaehler/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.warzazel.stromzaehler + +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.warzazel.stromzaehler", 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..890587f --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/warzazel/stromzaehler/MainActivity.kt b/app/src/main/java/com/warzazel/stromzaehler/MainActivity.kt new file mode 100644 index 0000000..5b589cd --- /dev/null +++ b/app/src/main/java/com/warzazel/stromzaehler/MainActivity.kt @@ -0,0 +1,420 @@ +package com.warzazel.stromzaehler + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.warzazel.stromzaehler.data.Ablesung +import com.warzazel.stromzaehler.ui.StromViewModel +import com.warzazel.stromzaehler.ui.theme.StromzaehlerTheme +import java.text.SimpleDateFormat +import java.util.* + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + StromzaehlerTheme { + StromScreen() + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StromScreen(viewModel: StromViewModel = viewModel()) { + val ablesungen by viewModel.alleAblesungen.observeAsState(emptyList()) + var selectedTab by remember { mutableIntStateOf(0) } + val tabs = listOf("Eingabe", "Tage", "Monate", "Gesamt", "Diagramm") + + var eingabe by remember { mutableStateOf("") } + var notiz by remember { mutableStateOf("") } + var zeitstempel by remember { mutableStateOf(System.currentTimeMillis()) } + var datumText by remember { mutableStateOf(datumFormatieren(System.currentTimeMillis())) } + + var verbrauchTag by remember { mutableStateOf(null) } + var verbrauchWoche by remember { mutableStateOf(null) } + var verbrauchMonat by remember { mutableStateOf(null) } + var verbrauchGesamt by remember { mutableStateOf(null) } + + var tagesListe by remember { mutableStateOf>>(emptyList()) } + var monatsListe by remember { mutableStateOf>>(emptyList()) } + var gesamtInfo by remember { mutableStateOf?>(null) } + + LaunchedEffect(ablesungen) { + verbrauchTag = viewModel.verbrauchSeit(StromViewModel.EINEN_TAG) + verbrauchWoche = viewModel.verbrauchSeit(StromViewModel.SIEBEN_TAGE) + verbrauchMonat = viewModel.verbrauchSeit(StromViewModel.DREISSIG_TAGE) + verbrauchGesamt = viewModel.verbrauchGesamt() + tagesListe = viewModel.tagesverbrauchLetzte(100) + monatsListe = viewModel.monatsverbrauch(12) + gesamtInfo = viewModel.gesamtInfo() + } + + Scaffold( + topBar = { + Column { + TopAppBar( + title = { Text("Stromzähler") }, + actions = { + var zeigeSpinneDialog by remember { mutableStateOf(false) } + + if (zeigeSpinneDialog) { + AlertDialog( + onDismissRequest = { zeigeSpinneDialog = false }, + title = { Text("Info") }, + text = { + Column { + Text("Freeware") + Text("admin@voidofxulub.com") + } + }, + confirmButton = { + TextButton(onClick = { zeigeSpinneDialog = false }) { Text("OK") } + } + ) + } + + IconButton(onClick = { zeigeSpinneDialog = true }) { + Image( + painter = painterResource(id = R.drawable.vs), + contentDescription = "Logo", + modifier = Modifier.size(32.dp) + ) + } + } + ) + TabRow(selectedTabIndex = selectedTab) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(title) } + ) + } + } + } + } + ) { padding -> + when (selectedTab) { + 0 -> TabEingabe( + padding = padding, + ablesungen = ablesungen, + eingabe = eingabe, + onEingabeChange = { eingabe = it }, + notiz = notiz, + onNotizChange = { notiz = it }, + datumText = datumText, + onDatumChange = { + datumText = it + parseDatum(it)?.let { ts -> zeitstempel = ts } + }, + onEintragen = { + val stand = eingabe.replace(",", ".").toDoubleOrNull() + if (stand != null) { + viewModel.speichern(stand, zeitstempel, notiz) + eingabe = "" + notiz = "" + zeitstempel = System.currentTimeMillis() + datumText = datumFormatieren(zeitstempel) + } + }, + verbrauchTag = verbrauchTag, + verbrauchWoche = verbrauchWoche, + verbrauchMonat = verbrauchMonat, + verbrauchGesamt = verbrauchGesamt, + onLoeschen = { viewModel.loeschen(it) } + ) + 1 -> TabTage(padding = padding, liste = tagesListe) + 2 -> TabMonate(padding = padding, liste = monatsListe) + 3 -> TabGesamt(padding = padding, info = gesamtInfo) + 4 -> TabDiagramm(padding = padding, liste = tagesListe) + } + } +} + +@Composable +fun TabEingabe( + padding: PaddingValues, + ablesungen: List, + eingabe: String, + onEingabeChange: (String) -> Unit, + notiz: String, + onNotizChange: (String) -> Unit, + datumText: String, + onDatumChange: (String) -> Unit, + onEintragen: () -> Unit, + verbrauchTag: Double?, + verbrauchWoche: Double?, + verbrauchMonat: Double?, + verbrauchGesamt: Double?, + onLoeschen: (Ablesung) -> Unit +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Neue Ablesung", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = eingabe, + onValueChange = onEingabeChange, + label = { Text("Zählerstand (kWh)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = notiz, + onValueChange = onNotizChange, + label = { Text("Notiz (optional)") }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = datumText, + onValueChange = onDatumChange, + label = { Text("Datum (dd.MM.yyyy HH:mm)") }, + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = onEintragen, + modifier = Modifier.fillMaxWidth() + ) { + Text("Eintragen") + } + } + } + } + + item { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Verbrauch", style = MaterialTheme.typography.titleMedium) + VerbrauchZeile("Seit gestern", verbrauchTag) + VerbrauchZeile("Letzte 7 Tage", verbrauchWoche) + VerbrauchZeile("Letzte 30 Tage", verbrauchMonat) + VerbrauchZeile("Gesamt", verbrauchGesamt) + } + } + } + + item { + Text("Verlauf", style = MaterialTheme.typography.titleMedium) + } + + items(ablesungen) { ablesung -> + AblesungEintrag(ablesung = ablesung, onLoeschen = { onLoeschen(ablesung) }) + } + } +} + +@Composable +fun TabTage(padding: PaddingValues, liste: List>) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (liste.isEmpty()) { + item { Text("Noch nicht genug Daten.") } + } else { + items(liste.reversed()) { (datum, kwh) -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(datum, style = MaterialTheme.typography.bodyMedium) + Text("%.2f kWh".format(kwh), style = MaterialTheme.typography.bodyMedium) + } + HorizontalDivider() + } + } + } +} + +@Composable +fun TabMonate(padding: PaddingValues, liste: List>) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (liste.isEmpty()) { + item { Text("Noch nicht genug Daten.") } + } else { + items(liste.reversed()) { (monat, kwh) -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(monat, style = MaterialTheme.typography.bodyMedium) + Text("%.2f kWh".format(kwh), style = MaterialTheme.typography.bodyMedium) + } + HorizontalDivider() + } + } + } +} + +@Composable +fun TabGesamt(padding: PaddingValues, info: Triple?) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + ) { + if (info == null) { + Text("Noch nicht genug Daten.") + } else { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Gesamtverbrauch", style = MaterialTheme.typography.titleMedium) + Text("%.2f kWh".format(info.first), style = MaterialTheme.typography.headlineLarge) + Text( + "${datumFormatieren(info.second)} – ${datumFormatieren(info.third)}", + style = MaterialTheme.typography.bodyMedium + ) + } + } + } +} + +@Composable +fun TabDiagramm(padding: PaddingValues, liste: List>) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (liste.isEmpty()) { + item { Text("Noch nicht genug Daten.") } + } else { + val max = liste.maxOf { it.second } + items(liste.takeLast(30).reversed()) { (datum, kwh) -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + datum, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.width(40.dp) + ) + LinearProgressIndicator( + progress = { (kwh / max).toFloat() }, + modifier = Modifier + .weight(1f) + .height(8.dp) + ) + Text( + "%.1f".format(kwh), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.width(40.dp) + ) + } + } + } + } +} + +@Composable +fun VerbrauchZeile(label: String, wert: Double?) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label) + Text(if (wert != null) "%.2f kWh".format(wert) else "—") + } +} + +@Composable +fun AblesungEintrag(ablesung: Ablesung, onLoeschen: () -> Unit) { + var zeigeDialog by remember { mutableStateOf(false) } + + if (zeigeDialog) { + AlertDialog( + onDismissRequest = { zeigeDialog = false }, + title = { Text("Eintrag löschen") }, + text = { Text("Sicher löschen?\n%.2f kWh vom %s".format(ablesung.zaehlerstand, datumFormatieren(ablesung.zeitstempel))) }, + confirmButton = { + TextButton(onClick = { + onLoeschen() + zeigeDialog = false + }) { Text("Löschen") } + }, + dismissButton = { + TextButton(onClick = { zeigeDialog = false }) { Text("Abbrechen") } + } + ) + } + + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .padding(12.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + "%.2f kWh".format(ablesung.zaehlerstand), + style = MaterialTheme.typography.bodyLarge + ) + Text( + datumFormatieren(ablesung.zeitstempel), + style = MaterialTheme.typography.bodySmall + ) + if (ablesung.notiz.isNotEmpty()) { + Text(ablesung.notiz, style = MaterialTheme.typography.bodySmall) + } + } + TextButton(onClick = { zeigeDialog = true }) { Text("Löschen") } + } + } +} + +fun datumFormatieren(timestamp: Long): String { + val sdf = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMANY) + return sdf.format(Date(timestamp)) +} + +fun parseDatum(text: String): Long? { + return try { + val sdf = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMANY) + sdf.parse(text)?.time + } catch (e: Exception) { + null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/warzazel/stromzaehler/data/Ablesung.kt b/app/src/main/java/com/warzazel/stromzaehler/data/Ablesung.kt new file mode 100644 index 0000000..f35dba6 --- /dev/null +++ b/app/src/main/java/com/warzazel/stromzaehler/data/Ablesung.kt @@ -0,0 +1,13 @@ +package com.warzazel.stromzaehler.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "ablesungen") +data class Ablesung( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val zaehlerstand: Double, + val zeitstempel: Long, + val notiz: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/com/warzazel/stromzaehler/data/AblesungDao.kt b/app/src/main/java/com/warzazel/stromzaehler/data/AblesungDao.kt new file mode 100644 index 0000000..44eb71a --- /dev/null +++ b/app/src/main/java/com/warzazel/stromzaehler/data/AblesungDao.kt @@ -0,0 +1,32 @@ +package com.warzazel.stromzaehler.data + +import androidx.lifecycle.LiveData +import androidx.room.* + +@Dao +interface AblesungDao { + + @Insert + suspend fun einfuegen(ablesung: Ablesung) + + @Delete + suspend fun loeschen(ablesung: Ablesung) + + @Query("SELECT * FROM ablesungen ORDER BY zeitstempel DESC") + fun alle(): LiveData> + + @Query("SELECT * FROM ablesungen ORDER BY zeitstempel DESC LIMIT 1") + suspend fun letzteAblesung(): Ablesung? + + @Query("SELECT * FROM ablesungen WHERE zeitstempel <= :bis ORDER BY zeitstempel DESC LIMIT 1") + suspend fun naechsteVor(bis: Long): Ablesung? + + @Query("SELECT * FROM ablesungen ORDER BY zeitstempel ASC LIMIT 1") + suspend fun ersteAblesung(): Ablesung? + + @Query("SELECT * FROM ablesungen WHERE zeitstempel >= :von AND zeitstempel <= :bis ORDER BY zeitstempel ASC") + suspend fun ablesungenImZeitraum(von: Long, bis: Long): List + + @Query("SELECT * FROM ablesungen ORDER BY zeitstempel ASC") + suspend fun alleAsList(): List +} \ No newline at end of file diff --git a/app/src/main/java/com/warzazel/stromzaehler/data/StromDatenbank.kt b/app/src/main/java/com/warzazel/stromzaehler/data/StromDatenbank.kt new file mode 100644 index 0000000..941c291 --- /dev/null +++ b/app/src/main/java/com/warzazel/stromzaehler/data/StromDatenbank.kt @@ -0,0 +1,27 @@ +package com.warzazel.stromzaehler.data + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database(entities = [Ablesung::class], version = 1) +abstract class StromDatenbank : RoomDatabase() { + + abstract fun ablesungDao(): AblesungDao + + companion object { + @Volatile + private var INSTANCE: StromDatenbank? = null + + fun getInstance(context: Context): StromDatenbank { + return INSTANCE ?: synchronized(this) { + Room.databaseBuilder( + context.applicationContext, + StromDatenbank::class.java, + "strom_datenbank" + ).build().also { INSTANCE = it } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/warzazel/stromzaehler/ui/theme/Color.kt b/app/src/main/java/com/warzazel/stromzaehler/ui/theme/Color.kt new file mode 100644 index 0000000..a43e080 --- /dev/null +++ b/app/src/main/java/com/warzazel/stromzaehler/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.warzazel.stromzaehler.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/warzazel/stromzaehler/ui/theme/StromViewModel.kt b/app/src/main/java/com/warzazel/stromzaehler/ui/theme/StromViewModel.kt new file mode 100644 index 0000000..ecc950b --- /dev/null +++ b/app/src/main/java/com/warzazel/stromzaehler/ui/theme/StromViewModel.kt @@ -0,0 +1,132 @@ +package com.warzazel.stromzaehler.ui + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.viewModelScope +import com.warzazel.stromzaehler.data.Ablesung +import com.warzazel.stromzaehler.data.StromDatenbank +import kotlinx.coroutines.launch +import java.util.* +import java.util.concurrent.TimeUnit + +class StromViewModel(application: Application) : AndroidViewModel(application) { + + private val dao = StromDatenbank.getInstance(application).ablesungDao() + + val alleAblesungen: LiveData> = dao.alle() + + fun speichern(zaehlerstand: Double, zeitstempel: Long, notiz: String = "") { + viewModelScope.launch { + dao.einfuegen(Ablesung( + zaehlerstand = zaehlerstand, + zeitstempel = zeitstempel, + notiz = notiz + )) + } + } + + fun loeschen(ablesung: Ablesung) { + viewModelScope.launch { + dao.loeschen(ablesung) + } + } + + suspend fun verbrauchSeit(millisZurueck: Long): Double? { + val jetzt = System.currentTimeMillis() + val letzte = dao.letzteAblesung() ?: return null + val frueher = dao.naechsteVor(jetzt - millisZurueck) ?: return null + if (frueher.id == letzte.id) return null + return letzte.zaehlerstand - frueher.zaehlerstand + } + + suspend fun verbrauchGesamt(): Double? { + val letzte = dao.letzteAblesung() ?: return null + val erste = dao.ersteAblesung() ?: return null + if (erste.id == letzte.id) return null + return letzte.zaehlerstand - erste.zaehlerstand + } + + // Tagesverbrauch für die letzten X Tage + // Gibt Liste von Paaren (DatumLabel, kWh) zurück + suspend fun tagesverbrauchLetzte(tage: Int): List> { + val alle = dao.alleAsList() + if (alle.size < 2) return emptyList() + + val ergebnis = mutableListOf>() + val cal = Calendar.getInstance() + val heute = cal.timeInMillis + + for (i in 0 until tage) { + val tagEnde = heute - i * EINEN_TAG + val tagStart = tagEnde - EINEN_TAG + + val letzterAmTag = alle.lastOrNull { it.zeitstempel <= tagEnde } ?: continue + val letzterVorTag = alle.lastOrNull { it.zeitstempel <= tagStart } ?: continue + if (letzterAmTag.id == letzterVorTag.id) continue + + val verbrauch = letzterAmTag.zaehlerstand - letzterVorTag.zaehlerstand + if (verbrauch < 0) continue + + val datum = Calendar.getInstance().apply { timeInMillis = tagEnde } + val label = "%02d.%02d".format(datum.get(Calendar.DAY_OF_MONTH), datum.get(Calendar.MONTH) + 1) + ergebnis.add(0, Pair(label, verbrauch)) + } + return ergebnis + } + + // Monatsverbrauch für die letzten X Monate + suspend fun monatsverbrauch(monate: Int): List> { + val alle = dao.alleAsList() + if (alle.size < 2) return emptyList() + + val ergebnis = mutableListOf>() + val cal = Calendar.getInstance() + + for (i in 0 until monate) { + val endCal = Calendar.getInstance().apply { + set(Calendar.DAY_OF_MONTH, 1) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + add(Calendar.MONTH, -i) + add(Calendar.MONTH, 1) + add(Calendar.MILLISECOND, -1) + } + val startCal = Calendar.getInstance().apply { + set(Calendar.DAY_OF_MONTH, 1) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + add(Calendar.MONTH, -i) + } + + val letzterImMonat = alle.lastOrNull { it.zeitstempel <= endCal.timeInMillis } ?: continue + val letzterVorMonat = alle.lastOrNull { it.zeitstempel < startCal.timeInMillis } ?: continue + if (letzterImMonat.id == letzterVorMonat.id) continue + + val verbrauch = letzterImMonat.zaehlerstand - letzterVorMonat.zaehlerstand + if (verbrauch < 0) continue + + val label = "%02d.%04d".format(startCal.get(Calendar.MONTH) + 1, startCal.get(Calendar.YEAR)) + ergebnis.add(0, Pair(label, verbrauch)) + } + return ergebnis + } + + suspend fun gesamtInfo(): Triple? { + val letzte = dao.letzteAblesung() ?: return null + val erste = dao.ersteAblesung() ?: return null + if (erste.id == letzte.id) return null + val verbrauch = letzte.zaehlerstand - erste.zaehlerstand + return Triple(verbrauch, erste.zeitstempel, letzte.zeitstempel) + } + + companion object { + val EINEN_TAG = TimeUnit.DAYS.toMillis(1) + val SIEBEN_TAGE = TimeUnit.DAYS.toMillis(7) + val DREISSIG_TAGE = TimeUnit.DAYS.toMillis(30) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/warzazel/stromzaehler/ui/theme/Theme.kt b/app/src/main/java/com/warzazel/stromzaehler/ui/theme/Theme.kt new file mode 100644 index 0000000..5b38555 --- /dev/null +++ b/app/src/main/java/com/warzazel/stromzaehler/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.warzazel.stromzaehler.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 StromzaehlerTheme( + 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/warzazel/stromzaehler/ui/theme/Type.kt b/app/src/main/java/com/warzazel/stromzaehler/ui/theme/Type.kt new file mode 100644 index 0000000..c6de716 --- /dev/null +++ b/app/src/main/java/com/warzazel/stromzaehler/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.warzazel.stromzaehler.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/drawable/vs.png b/app/src/main/res/drawable/vs.png new file mode 100644 index 0000000..374852c Binary files /dev/null and b/app/src/main/res/drawable/vs.png differ 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..0859427 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Stromzaehler + \ 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..ddba0c7 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +