diff --git a/asciidocs/images/viewmodel.png b/asciidocs/images/viewmodel.png new file mode 100644 index 0000000..85f7f95 Binary files /dev/null and b/asciidocs/images/viewmodel.png differ diff --git a/asciidocs/index.adoc b/asciidocs/index.adoc index 74b74ca..7cf4008 100644 --- a/asciidocs/index.adoc +++ b/asciidocs/index.adoc @@ -1,11 +1,13 @@ = 23/24 4bhif wmc - Lecture Notes -ifndef::imagesdir[:imagesdir: images] +Thomas Stütz +1.0.0, {docdate}: Lecture Notes for Courses at HTL Leonding :icons: font :experimental: :sectnums: +ifndef::imagesdir[:imagesdir: images] :toc: -ifdef::backend-html5[] +ifdef::backend-html5[] // https://fontawesome.com/v4.7.0/icons/ icon:file-text-o[link=https://github.com/2324-4bhif-wmc/2324-4bhif-wmc-lecture-notes/main/asciidocs/{docname}.adoc] ‏ ‏ ‎ icon:github-square[link=https://github.com/2324-4bhif-wmc/2324-4bhif-wmc-lecture-notes] ‏ ‏ ‎ @@ -835,9 +837,9 @@ public class Fahrzeug { Möglichkeit 3: JOINED -> Attribute der Basisklasse in einer Tabelle und je eine Tabelle pro abgeleiteter Klasse (mit -Diskriminator* DTYPE) +Diskriminator DTYPE) -__*Diskriminator = Unterscheidungsmerkmal__ +* __Diskriminator: Unterscheidungsmerkmal__ [source, java] ---- @@ -1287,7 +1289,7 @@ ng g c components/todo == 2024-04-16 Angular HttpClient -* http-client hat fetch gegenüber den Vorteil, dass gute Infrastruktur wir JWT support und middleware zum debuggen +* http-client hat fetch gegenüber den Vorteil, dass gute Infrastruktur mit JWT support und middleware zum debuggen == 2024-05-21 Android @@ -1364,3 +1366,25 @@ PGADMIN_DEFAULT_EMAIL= PGADMIN_DEFAULT_PASSWORD= ---- +== 2024-05-28 ViewModel in Android + +image::viewmodel.png[] + + +* https://medium.com/sahibinden-technology/package-by-layer-vs-package-by-feature-7e89cde2ae3a[Package by Layer vs Package by Feature^] + + +* https://medium.com/androiddevelopers/under-the-hood-of-jetpack-compose-part-2-of-2-37b2c20c6cdd[Under the hood of Jetpack Compose — part 2 of 2^] + + +* Anmerkungen zur https://github.com/caberger/android[Prof. Abergers Android-App^] + + +. Einführung des MVVM - Design Patterns, ein ViewModel pro View. Damit das verständlich wird vorher unbedingt die ersten 17 Minuten von https://www.youtube.com/watch?v=W1ymVx6dmvc ansehen. Hierbei im Kopf "Swift" durch „Java" ersetzen und "SwiftUI" durch "Jetpack Compose". +. Read Only State: In den ViewModels (Todo, Home, TabScreen) sind die Models jetzt Java Records, also readonly, damit sind die 3 Design Prinzipien OK (https://redux.js.org/understanding/thinking-in-redux/three-principles) +. Es gibt jetzt eine saubere Preview - Architektur für alle Views und das obwohl alles in Java und nicht in Kolin geschrieben ist (mit Ausnahme der Jetpack @Composables). +. Verbesserungen bei der Android - Implementierung (util Package) für: +.. RestEasy Client f. Android, +.. Microprofile config (=application.properties f. Android), +.. Jakarta Dependency Injection (@Inject für Android) und +.. RxJava (https://rxmarbles.com für Android). \ No newline at end of file diff --git a/labs/android-mvvm/.gitignore b/labs/android-mvvm/.gitignore new file mode 100644 index 0000000..347e252 --- /dev/null +++ b/labs/android-mvvm/.gitignore @@ -0,0 +1,33 @@ +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log/OS Files +*.log + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.apk +output.json + +# IntelliJ +*.iml +.idea/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof diff --git a/labs/android-mvvm/LICENSE b/labs/android-mvvm/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/labs/android-mvvm/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/labs/android-mvvm/app/.gitignore b/labs/android-mvvm/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/labs/android-mvvm/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/labs/android-mvvm/app/build.gradle.kts b/labs/android-mvvm/app/build.gradle.kts new file mode 100644 index 0000000..08593a8 --- /dev/null +++ b/labs/android-mvvm/app/build.gradle.kts @@ -0,0 +1,111 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + + kotlin("kapt") // we have to use kapt, because android hilt is still in alpha although kapt is deprecated already ?! :( + id("com.google.dagger.hilt.android") +} + +android { + namespace = "at.htl.leonding" + compileSdk = 34 + + defaultConfig { + applicationId = "at.htl.leonding" + minSdk = 28 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.4.3" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + excludes += "META-INF/INDEX.LIST" + excludes += "META-INF/DEPENDENCIES" + excludes += "META-INF/LICENSE.md" + excludes += "META-INF/NOTICE.md" + excludes += "META-INF/DEPENDENCIES.txt" + } + } +} + +dependencies { + implementation(libs.androidx.ktx) + implementation(libs.androidx.lifecycle) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.activity.compose) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.compose.ui.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.ui.tooling.preview) + implementation(libs.compose.material) + + testImplementation(libs.test.junit) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.espresso) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test) + + debugImplementation(libs.debug.compose.ui.tooling) + debugImplementation(libs.debug.compose.manifest) + + implementation(libs.rxjava.rxjava) + implementation(libs.rxjava.android) + implementation(libs.compose.rxjava) + + implementation(libs.dagger.hilt) + kapt(libs.kapt.hilt) + implementation(libs.fasterxml.jackson) + implementation(libs.resteasy.client) + implementation(libs.smallrye.config) +} +kapt { + correctErrorTypes = true +} + +/** JavaDoc helper. + * This tasks writes the the class-path to a file that can be used with javadoc + * javadoc ... @javadoc.txt + */ +tasks.register("javadoc-params") { + doLast { + val variant = project.android.applicationVariants + val release = variant.filter{ it.buildType.name == "release" }.first() + val outputFile = project.layout.buildDirectory.file("javadoc.txt").get().asFile + outputFile.printWriter().use { out -> + val classpath = release.compileConfiguration.joinToString(separator=":") { it.toString() } + out.println("--class-path " + classpath) + out.println() + } + println("javadoc options written to " + outputFile.absolutePath) + } +} \ No newline at end of file diff --git a/labs/android-mvvm/app/proguard-rules.pro b/labs/android-mvvm/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/labs/android-mvvm/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/labs/android-mvvm/app/src/main/AndroidManifest.xml b/labs/android-mvvm/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0abfbf9 --- /dev/null +++ b/labs/android-mvvm/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/MainActivity.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/MainActivity.java new file mode 100644 index 0000000..797d400 --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/MainActivity.java @@ -0,0 +1,28 @@ +package at.htl.leonding; + +import android.os.Bundle; + +import javax.inject.Inject; + +import androidx.activity.ComponentActivity; +import androidx.compose.ui.platform.ComposeView; +import dagger.hilt.android.AndroidEntryPoint; + +/** Our main activity implemented in java. + * We separate the application logic from the view. + * The View is implemented in a separate file (separation of concerns). + * We use Kotlin for the Jetpack Compose views. + */ +@AndroidEntryPoint +public class MainActivity extends ComponentActivity { + @Inject + MainViewRenderer mainViewRenderer; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + var view = new ComposeView(this); + mainViewRenderer.setContent(view); + setContentView(view); + } +} diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/MainViewRenderer.kt b/labs/android-mvvm/app/src/main/java/at/htl/leonding/MainViewRenderer.kt new file mode 100644 index 0000000..a6b9265 --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/MainViewRenderer.kt @@ -0,0 +1,53 @@ +package at.htl.leonding + +import android.content.res.Configuration +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import at.htl.leonding.feature.tabscreen.TabView +import at.htl.leonding.model.Store +import at.htl.leonding.model.UIState +import javax.inject.Inject +import javax.inject.Singleton + +/** Render the contents of the Main Compose View. + * We also watch orientation changes and forward those to the Model. + */ +@Singleton +class MainViewRenderer @Inject constructor() { + @Inject + lateinit var tabScreenView: TabView + @Inject + lateinit var store: Store + + fun setContent(view: ComposeView) { + view.setContent { + Surface(modifier = Modifier.fillMaxSize()) { + var orientation by remember { mutableIntStateOf(Configuration.ORIENTATION_PORTRAIT) } + val configuration = LocalConfiguration.current + LaunchedEffect(configuration) { + orientation = configuration.orientation + val currentOrientation = orientationFromConfiguration(configuration) + store.apply { it.uiState.orientation = currentOrientation } + } + tabScreenView.TabViewLayout() + } + } + } + private fun orientationFromConfiguration(configuration: Configuration): UIState.Orientation { + return when(configuration.orientation) { + Configuration.ORIENTATION_PORTRAIT -> UIState.Orientation.portrait + Configuration.ORIENTATION_LANDSCAPE -> UIState.Orientation.landscape + else -> UIState.Orientation.undefined + } + } +} + + diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/ToDoApplication.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/ToDoApplication.java new file mode 100644 index 0000000..9b57a43 --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/ToDoApplication.java @@ -0,0 +1,16 @@ +package at.htl.leonding; + +import android.app.Application; +import android.util.Log; + +import javax.inject.Singleton; + +import dagger.hilt.android.HiltAndroidApp; + +/** Our application entry point. + * Needed as the dependency injection container. + */ +@HiltAndroidApp +@Singleton +public class ToDoApplication extends Application { +} diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/home/HomeView.kt b/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/home/HomeView.kt new file mode 100644 index 0000000..156ebdd --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/home/HomeView.kt @@ -0,0 +1,144 @@ +package at.htl.leonding.feature.home + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rxjava3.subscribeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import at.htl.leonding.model.Store +import at.htl.leonding.model.UIState.Orientation +import at.htl.leonding.ui.theme.ToDoTheme +import javax.inject.Inject + +/** + * Example of an editing composable using remember. + */ +class HomeView @Inject constructor() { + @Inject + lateinit var homeScreenViewModel: HomeViewModel + @Composable + fun HomeScreen() { + val model = homeScreenViewModel.subject.subscribeAsState(homeScreenViewModel.value) + val text = remember { mutableStateOf(model.value.greetingText) } + val orientation = model.value.orientation + + /** we update the model whenever the text is changed by the user */ + SideEffect { + homeScreenViewModel.setGreetingText(text.value) + } + fun reset() { + homeScreenViewModel.cleanToDos() + text.value = "" + } + Column(modifier = Modifier.fillMaxSize()) { + Spacer(Modifier.weight(2.0f)) + Row( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(16.dp) + ) { + Text( + text = text.value, + textAlign = TextAlign.Center, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + } + Row( + Modifier + .align(Alignment.CenterHorizontally) + .padding(16.dp) + ) { + TextField(value = text.value, + onValueChange = {text.value = it}, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + placeholder = { + Text(text="Header to display", modifier = Modifier.alpha(0.2f)) + }) + } + Row(Modifier.align(Alignment.CenterHorizontally)) { + Text("${model.value.numberOfToDos} Todos have been loaded") + } + Spacer(Modifier.weight(1.0f)) + if (orientation == Orientation.landscape) { + Row(Modifier.align(Alignment.CenterHorizontally)) { + Buttons(Modifier.align(Alignment.CenterVertically)) { reset() } + } + } else { + Buttons(Modifier.align(Alignment.CenterHorizontally)) { reset() } + } + Spacer(Modifier.weight(2.0f)) + } + } + @Composable + fun LoadAllToDosButton(modifier: Modifier) { + Row(modifier) { + Button(modifier = Modifier.padding(16.dp), + onClick = { homeScreenViewModel.loadAllTodos() }) { + Text("load ToDos") + } + } + } + @Composable + fun ClearButton(modifier: Modifier, onClick: () -> Unit) { + Row(modifier) { + Button( + onClick = { + //homeScreenViewModel.cleanToDos(); + onClick() + }) { + Text("reset") + } + } + } + @Composable + fun Buttons(modifier: Modifier, reset: () -> Unit) { + LoadAllToDosButton(modifier) + ClearButton(modifier, reset) + } + + @Composable + fun Preview(orientation: Orientation) { + if (LocalInspectionMode.current) { + val store = Store() + store.pipe.value!!.uiState.orientation = orientation + homeScreenViewModel = HomeViewModel(store, null) + ToDoTheme { + HomeScreen() + } + } + } + @Preview(name = "85%", fontScale = 0.85f) + @Preview(name = "100%", fontScale = 1f) + @Preview(name = "200%", fontScale = 2f) + @Composable + fun HomeViewPreviewPortrait() { + Preview(Orientation.portrait) + } + + @Preview(name = "100%", fontScale = 1f, device = "spec:parent=pixel_5,orientation=landscape") + @Preview(name = "150%", fontScale = 1.5f, device = "spec:parent=pixel_5,orientation=landscape") + @Composable + fun HomeViewPreviewLandscape() { + Preview(Orientation.landscape) + } +} \ No newline at end of file diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/home/HomeViewModel.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/home/HomeViewModel.java new file mode 100644 index 0000000..acf21c0 --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/home/HomeViewModel.java @@ -0,0 +1,51 @@ +package at.htl.leonding.feature.home; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import at.htl.leonding.model.Model; +import at.htl.leonding.model.Store; +import at.htl.leonding.model.ToDo; +import at.htl.leonding.feature.todo.ToDoService; +import at.htl.leonding.model.UIState; +import at.htl.leonding.util.store.ViewModelBase; + +/** Map our global application state to vision that our HomeView has of the world. + */ +@Singleton +public class HomeViewModel extends ViewModelBase { + + /** the model for our HomeView, which only knows about a list of todos a text and the orientation */ + public record HomeModel( + int numberOfToDos, + String greetingText, + UIState.Orientation orientation + ) {} + + private final ToDoService toDoService; + + @Inject + HomeViewModel(Store store, ToDoService toDoService) { + super(store); + this.toDoService = toDoService; + } + @Override + protected HomeModel toViewModel(Model model) { + return new HomeModel( + model.toDos.length, + model.greetingModel.greetingText, + model.uiState.orientation); + } + + public void setGreetingText(String text) { + store.apply(model -> model.greetingModel.greetingText = text); + } + public void cleanToDos() { + store.apply(model -> { + model.toDos = new ToDo[0]; + }); + } + public void loadAllTodos() { + toDoService.getAll(); + } +} diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/settings/SettingsView.kt b/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/settings/SettingsView.kt new file mode 100644 index 0000000..366ed95 --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/settings/SettingsView.kt @@ -0,0 +1,39 @@ +package at.htl.leonding.feature.settings + +import androidx.compose.material3.Text + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import at.htl.leonding.ui.theme.ToDoTheme + +@Composable +fun SettingsScreen() { + Column(modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center) { + Row(modifier = Modifier.align(Alignment.CenterHorizontally)) { + Text( + text = "Settings", + textAlign = TextAlign.Center, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + } + } +} +@Preview(showBackground = true) +@Composable +fun SettingsViewPreview() { + ToDoTheme { + SettingsScreen() + } +} \ No newline at end of file diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/tabscreen/TabView.kt b/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/tabscreen/TabView.kt new file mode 100644 index 0000000..09b2d97 --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/tabscreen/TabView.kt @@ -0,0 +1,112 @@ +package at.htl.leonding.feature.tabscreen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Icon +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rxjava3.subscribeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import at.htl.leonding.feature.home.HomeView +import at.htl.leonding.feature.settings.SettingsScreen +import at.htl.leonding.feature.todo.ToDoView +import at.htl.leonding.model.Store +import at.htl.leonding.ui.theme.ToDoTheme +import javax.inject.Inject + +class TabView @Inject constructor() { + @Inject + lateinit var tabScreenViewModel: TabViewModel + @Inject + lateinit var homeScreenView: HomeView + @Inject + lateinit var toDoView: ToDoView + + @Composable + fun TabViewLayout() { + val model = tabScreenViewModel.subject.subscribeAsState(tabScreenViewModel.value) + val tab = model.value.selectedTab + val tabIndex = tab.index() + val selectedTab = remember { mutableIntStateOf(tabIndex) } + val numberOfTodos = model.value.numberOfToDos + val tabs = listOf("Home", "ToDos", "Settings") + Column(modifier = Modifier.fillMaxWidth()) { + TabRow(selectedTabIndex = selectedTab.intValue) { + tabs.forEachIndexed { index, title -> + Tab(text = { Text(title) }, + selected = selectedTab.intValue == index, + onClick = { + selectedTab.intValue = index + tabScreenViewModel.selectTabByIndex(index) + }, + icon = { + when (index) { + 0 -> Icon(imageVector = Icons.Default.Home, contentDescription = null) + 1 -> BadgedBox(badge = { Badge { Text("$numberOfTodos") }}) { + Icon(Icons.Filled.Favorite, contentDescription = "ToDos") + } + 2 -> Icon(imageVector = Icons.Default.Settings, contentDescription = null) + } + } + ) + } + } + ContentArea(selectedTab.intValue) + } + } + @Composable + fun ContentArea(selectedTab: Int) { + if (LocalInspectionMode.current) { + PreviewContentArea() + } else { + when (selectedTab) { + 0 -> homeScreenView.HomeScreen() + 1 -> toDoView.ToDos() + 2 -> SettingsScreen() + } + } + } + @Composable + fun PreviewContentArea() { + Column(modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center) { + Row(modifier = Modifier.align(Alignment.CenterHorizontally).padding(8.dp)) { + Text(text = "Content area of the selected tab", softWrap = true) + } + } + } + @Preview(showBackground = true) + @Preview(name="150%", fontScale = 1.5f) + @Composable + fun TabViewPreview() { + if (LocalInspectionMode.current) { + tabScreenViewModel = TabViewModel(Store()) + ToDoTheme { + TabViewLayout() + } + } + } +} + + + + diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/tabscreen/TabViewModel.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/tabscreen/TabViewModel.java new file mode 100644 index 0000000..77004eb --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/tabscreen/TabViewModel.java @@ -0,0 +1,32 @@ +package at.htl.leonding.feature.tabscreen; + +import java.util.Arrays; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import at.htl.leonding.model.Model; +import at.htl.leonding.model.Store; +import at.htl.leonding.model.UIState; +import at.htl.leonding.util.store.ViewModelBase; + +@Singleton +public class TabViewModel extends ViewModelBase { + + /** all the TabView needs to know is the number of todos and the selected tab */ + public record TabScreenModel(int getNumberOfToDos, UIState.Tab selectedTab) {} + + @Inject + TabViewModel(Store store) { + super(store); + } + @Override + protected TabScreenModel toViewModel(Model model) { + return new TabScreenModel(model.toDos.length, model.uiState.selectedTab); + } + public void selectTabByIndex(int index) { + var tabs = Arrays.stream(UIState.Tab.values()); + var tab = tabs.filter(t -> t.index() == index).findFirst().orElse(UIState.Tab.home); + store.apply(model -> model.uiState.selectedTab = tab); + } +} diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/todo/ToDoClient.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/todo/ToDoClient.java new file mode 100644 index 0000000..4afb176 --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/todo/ToDoClient.java @@ -0,0 +1,15 @@ +package at.htl.leonding.feature.todo; + +import at.htl.leonding.model.ToDo; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; + +@Path("/todos") +public interface ToDoClient { + @GET + @Produces(MediaType.APPLICATION_JSON) + ToDo[] all(@QueryParam("_start") int start, @QueryParam("_limit") int maxRecords); +} diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/todo/ToDoService.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/todo/ToDoService.java new file mode 100644 index 0000000..1136883 --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/todo/ToDoService.java @@ -0,0 +1,41 @@ +package at.htl.leonding.feature.todo; + +import org.eclipse.microprofile.config.Config; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import at.htl.leonding.model.Store; +import at.htl.leonding.model.ToDo; +import at.htl.leonding.util.resteasy.RestApiClientBuilder; + +import static java.util.concurrent.CompletableFuture.supplyAsync; + +@Singleton +public class ToDoService { + public static final String JSON_PLACEHOLDER_BASE_URL_SETTING = "json.placeholder.baseurl"; + public final ToDoClient toDoClient; + public final Store store; + + @Inject + ToDoService(Config config, RestApiClientBuilder builder, Store store) { + var baseUrl = config.getValue(JSON_PLACEHOLDER_BASE_URL_SETTING, String.class); + toDoClient = builder.build(ToDoClient.class, baseUrl); + this.store = store; + } + + /** read the first 20 todos from the service. + * TODO: add currentPage und pageSize to UIState + */ + public void getAll() { + Consumer setToDos = todos -> { + store.apply(model -> model.toDos = todos); + }; + CompletableFuture + .supplyAsync(() -> toDoClient.all(0, 40)) + .thenAccept(setToDos); + } +} diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/todo/ToDoView.kt b/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/todo/ToDoView.kt new file mode 100644 index 0000000..fbe5e9e --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/todo/ToDoView.kt @@ -0,0 +1,75 @@ +package at.htl.leonding.feature.todo + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rxjava3.subscribeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import at.htl.leonding.model.Store +import at.htl.leonding.model.ToDo +import at.htl.leonding.ui.theme.ToDoTheme +import javax.inject.Inject + +class ToDoView @Inject constructor() { + @Inject + lateinit var toDoViewModel: ToDoViewModel + + @Composable + fun ToDos() { + val model = toDoViewModel.subject.subscribeAsState(toDoViewModel.getValue()).value + val todos = model.toDos + LazyColumn(modifier = Modifier + .fillMaxSize() + .padding(16.dp)) { + items(todos.size) { ToDoRow(todos[it]) } + } + } + @Composable + fun ToDoRow(toDo: ToDo) { + Row(modifier = Modifier.padding(4.dp)) { + Text(toDo.id.toString(), modifier = Modifier.padding(horizontal = 6.dp)) + Text(text = toDo.title, overflow = TextOverflow.Ellipsis, maxLines = 1) + } + } + @Composable + fun preview() { + if (LocalInspectionMode.current) { + fun toDo(id: Long, title: String): ToDo { + val toDo = ToDo() + toDo.id = id + toDo.title = title + return toDo + } + val todos = arrayOf( + toDo(1, "short title"), + toDo(2, "title generated by ChatGPT: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Viverra ipsum nunc aliquet bibendum enim facilisis gravida neque convallis. Vulputate enim nulla aliquet porttitor lacus luctus accumsan tortor. Neque egestas congue quisque egestas diam in arcu. Est lorem ipsum dolor sit amet consectetur adipiscing elit pellentesque. Ut venenatis tellus in metus. Consectetur adipiscing elit pellentesque habitant morbi tristique. Ut tellus elementum sagittis vitae et leo duis ut. Vulputate ut pharetra sit amet aliquam id diam. Arcu cursus vitae congue mauris rhoncus aenean vel. Consequat interdum varius sit amet mattis. Faucibus purus in massa tempor nec feugiat nisl pretium fusce. Facilisi nullam vehicula ipsum a arcu cursus. Enim ut tellus elementum sagittis vitae et leo. Neque sodales ut etiam sit amet nisl purus. Vitae tempus quam pellentesque nec. Diam quam nulla porttitor massa id neque aliquam vestibulum morbi. Sed sed risus pretium quam vulputate dignissim suspendisse in est. Nibh mauris cursus mattis molestie a."), + toDo(3, "another tile that is too long for portrait mode, but ok for landscape") + ) + + val store = Store() + store.pipe.value!!.toDos = todos + toDoViewModel = ToDoViewModel(store) + ToDoTheme { + ToDos() + } + } + } + @Preview(showBackground = true) + @Composable + fun ToDoViewPreviewPortrait() { + preview() + } + @Preview(device = "spec:parent=pixel_5,orientation=landscape") + @Preview(name = "150%", fontScale = 1.5f, showBackground = true, device = "spec:parent=Nexus 5,orientation=landscape") + @Composable + fun ToDoViewPreviewLandscape() { + preview() + } +} diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/todo/ToDoViewModel.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/todo/ToDoViewModel.java new file mode 100644 index 0000000..ba96aaa --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/feature/todo/ToDoViewModel.java @@ -0,0 +1,25 @@ +package at.htl.leonding.feature.todo; + +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import at.htl.leonding.model.Model; +import at.htl.leonding.model.Store; +import at.htl.leonding.model.ToDo; +import at.htl.leonding.util.store.ViewModelBase; + +@Singleton +public class ToDoViewModel extends ViewModelBase { + public record ToDoModel(List toDos) {} + + @Inject + public ToDoViewModel(Store store) { + super(store); + } + @Override + protected ToDoModel toViewModel(Model model) { + return new ToDoModel(List.of(model.toDos)); + } +} diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/model/Model.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/model/Model.java new file mode 100644 index 0000000..1e31d18 --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/model/Model.java @@ -0,0 +1,11 @@ +package at.htl.leonding.model; + +/** Our read only single source of truth model */ +public class Model { + public static class GreetingModel { + public String greetingText = "Hello, world!"; + } + public ToDo[] toDos = new ToDo[0]; + public UIState uiState = new UIState(); // sub-model für ui state + public GreetingModel greetingModel = new GreetingModel(); +} diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/model/Store.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/model/Store.java new file mode 100644 index 0000000..001df3e --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/model/Store.java @@ -0,0 +1,15 @@ +package at.htl.leonding.model; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import at.htl.leonding.util.store.StoreBase; + +/** This is our Storage area for our single source of truth {@link Model}. */ +@Singleton +public class Store extends StoreBase { + @Inject + public Store() { + super(Model.class, new Model()); + } +} diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/model/ToDo.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/model/ToDo.java new file mode 100644 index 0000000..4451d5f --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/model/ToDo.java @@ -0,0 +1,10 @@ +package at.htl.leonding.model; + +/** A ToDo as we get it from jsonplaceholder.typicode.com +*/ +public class ToDo { + public Long userId; + public Long id; + public String title; + public boolean completed; +} diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/model/UIState.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/model/UIState.java new file mode 100644 index 0000000..133714a --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/model/UIState.java @@ -0,0 +1,31 @@ +package at.htl.leonding.model; + + +/** The current state of the user interface */ +public class UIState { + /** the type of Tab - Bars in our main view */ + public enum Tab { + home(0), + todo(1), + settings(2); + + public int index() { + return index; + } + private int index; + Tab(int index) { + this.index = index; + } + } + /** we define our own enum to have the model independent of the view technology */ + public enum Orientation { + undefined, + portrait, + landscape + } + /** the currently selected tab */ + public Tab selectedTab = Tab.home; + + /** the current orientation of the device. */ + public Orientation orientation = Orientation.undefined; +} diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/ui/theme/Color.kt b/labs/android-mvvm/app/src/main/java/at/htl/leonding/ui/theme/Color.kt new file mode 100644 index 0000000..bc42e73 --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package at.htl.leonding.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/labs/android-mvvm/app/src/main/java/at/htl/leonding/ui/theme/Theme.kt b/labs/android-mvvm/app/src/main/java/at/htl/leonding/ui/theme/Theme.kt new file mode 100644 index 0000000..8f36b91 --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/ui/theme/Theme.kt @@ -0,0 +1,70 @@ +package at.htl.leonding.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +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 ToDoTheme( + 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 + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/ui/theme/Type.kt b/labs/android-mvvm/app/src/main/java/at/htl/leonding/ui/theme/Type.kt new file mode 100644 index 0000000..336e795 --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package at.htl.leonding.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/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/config/ConfigModule.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/config/ConfigModule.java new file mode 100644 index 0000000..15c6f2a --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/config/ConfigModule.java @@ -0,0 +1,62 @@ +package at.htl.leonding.util.config; + +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.spi.ConfigSource; +import org.eclipse.microprofile.config.spi.ConfigSourceProvider; + +import java.io.IOException; +import java.net.URL; +import java.util.concurrent.CompletionException; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; +import dagger.hilt.InstallIn; +import dagger.hilt.components.SingletonComponent; +import io.smallrye.config.PropertiesConfigSource; +import io.smallrye.config.SmallRyeConfigBuilder; + +/* Provide ecplipse microprofile config settings + (see src/main/resources/application.properties) + */ +@Module +@InstallIn(SingletonComponent.class) +public class ConfigModule { + @Provides + @Singleton + public Config provideConfiguration() { + //return new SmallRyeConfigBuilder().forClassLoader(classLoader).build(); <=== does not work on android yet. + + var config = new SmallRyeConfigBuilder() + .forClassLoader(getClass().getClassLoader()) + .addDefaultInterceptors() + .setAddDefaultSources(false) + .withSources(new FixMissingJavaNioJarFileProviderConfigSourceProvider()) + .build(); + return config; + } +} +class FixMissingJavaNioJarFileProviderConfigSourceProvider implements ConfigSourceProvider { + Iterable configSources; + @Override + public Iterable getConfigSources(ClassLoader forClassLoader) { + if (configSources == null) { + Function createSource = url -> { + try { + return new PropertiesConfigSource(url); + } catch(IOException e) { + throw new CompletionException(e); + } + }; + configSources = Stream.of("application.properties", "META-INF/microprofile-config.properties") + .map(name -> getClass().getClassLoader().getResource(name)) + .map(createSource) + .collect(Collectors.toList()); + } + return configSources; + } +} diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/config/readme.md b/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/config/readme.md new file mode 100644 index 0000000..6f19ea0 --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/config/readme.md @@ -0,0 +1,11 @@ +# SmallRye config Module + +Propably it is not a SmallRye config problem, I found the following Android issue: +https://issuetracker.google.com/issues/153773248 + +This is from 2020, so i am not sure it will be fixed soon. + +The propblematic call comes from line 139 in ClassPathUtils.java: +``` java +try (FileSystem jarFs = FileSystems.newFileSystem(jar, (ClassLoader) null)) {...} +``` diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/immer/Immer.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/immer/Immer.java new file mode 100644 index 0000000..1948abb --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/immer/Immer.java @@ -0,0 +1,53 @@ +package at.htl.leonding.util.immer; + +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +import at.htl.leonding.util.mapper.Mapper; + +/** Immer simplifies handling immutable data structures. + * @author Christian Aberger (http://www.aberger.at) + * @see https://immerjs.github.io/immer/ + * @see https://redux.js.org/understanding/thinking-in-redux/motivation + * + * @param The type of the baseState + */ + +public class Immer { + private static final String TAG = Immer.class.getSimpleName(); + final public Mapper mapper; + final Handler handler; + + public Immer(Class type) { + mapper = new Mapper(type); + handler = new Handler(Looper.getMainLooper()); + } + /** Create a deep clone of the existing model, apply a recipe to it and finally pass the new state to the consumer. + * To reduce the load on the main thread we clone the current state in a separate thread. + * To avoid multithreading issues we call back the recipe and resultConsumer running on the one and only Main thread of the app. + * We do not call the resultConsumer if the clone equals the currentState, + * @param currentState the previous readonly single source or truth + * @param recipe the callback function that modifies parts of the cloned state + * @param resultConsumer the callback function that uses the cloned & modified model + */ + public void produce(final T currentState, Consumer recipe, Consumer resultConsumer) { + Consumer runOnMainThread = t -> handler.post(() -> { + var currentAsJson = mapper.toResource(t); + recipe.accept(t); + var nextAsJson = mapper.toResource(t); + if (!nextAsJson.equals(currentAsJson)) { + Log.d(TAG, String.format("=== state changed ===\n%s\n=>\n%s---", currentAsJson, nextAsJson)); + resultConsumer.accept(t); + } else { + Log.w(TAG, "produce() without change"); + } + }); + CompletableFuture + .supplyAsync(() -> mapper.clone(currentState)) + .thenAccept(runOnMainThread); + } +} diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/mapper/Mapper.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/mapper/Mapper.java new file mode 100644 index 0000000..fd6da3e --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/mapper/Mapper.java @@ -0,0 +1,51 @@ +package at.htl.leonding.util.mapper; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +import java.io.IOException; + +/** A Mapper that maps types to their json representation and back. + * ... plus a convenient deep-clone function + * @param the Class that is mapped + */ +public class Mapper { + private Class clazz; + private ObjectMapper mapper; + + public Mapper(Class clazz) { + this.clazz = clazz; + mapper = new ObjectMapper() + .configure(SerializationFeature.INDENT_OUTPUT, true) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); // records + } + public String toResource(T model) { + try { + return mapper.writeValueAsString(model); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + public T fromResource(String json) { + T model = null; + try { + model = mapper.readValue(json.getBytes(), clazz); + } catch (IOException e) { + throw new RuntimeException(e); + } + return model; + } + /** deep clone an object by converting it to its json representation and back. + * + * @param thing the thing to clone, unchanged + * @return the deeply cloned thing + */ + public T clone(final T thing) { + return fromResource(toResource(thing)); + } +} \ No newline at end of file diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/resteasy/JsonMediaTypeMatcher.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/resteasy/JsonMediaTypeMatcher.java new file mode 100644 index 0000000..3d20be8 --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/resteasy/JsonMediaTypeMatcher.java @@ -0,0 +1,14 @@ +package at.htl.leonding.util.resteasy; + +import jakarta.ws.rs.core.MediaType; + +/** an interface that amends a class with a function to check for the application/json MediaType + */ +public interface JsonMediaTypeMatcher { + public static final String APPLICATION = "application"; + public static final String JSON = "json"; + + default boolean isJson(MediaType mediaType) { + return mediaType.getType().equalsIgnoreCase(APPLICATION) && mediaType.getSubtype().equalsIgnoreCase(JSON); + } +} diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/resteasy/JsonMessageBodyReader.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/resteasy/JsonMessageBodyReader.java new file mode 100644 index 0000000..35a3972 --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/resteasy/JsonMessageBodyReader.java @@ -0,0 +1,24 @@ +package at.htl.leonding.util.resteasy; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; + +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.ext.MessageBodyReader; + +public class JsonMessageBodyReader implements MessageBodyReader, JsonMediaTypeMatcher { + ObjectMapper objectMapper = new ObjectMapper(); + @Override + public boolean isReadable(Class type, Type genericType, Annotation[] annotations, MediaType mediaType) { + return isJson(mediaType); + } + @Override + public T readFrom(Class type, Type genericType, Annotation[] annotations, MediaType mediaType, MultivaluedMap httpHeaders, InputStream entityStream) throws IOException, WebApplicationException { + return objectMapper.readValue(entityStream, type); + } +} diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/resteasy/JsonMessageBodyWriter.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/resteasy/JsonMessageBodyWriter.java new file mode 100644 index 0000000..2a5132b --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/resteasy/JsonMessageBodyWriter.java @@ -0,0 +1,26 @@ +package at.htl.leonding.util.resteasy; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.io.OutputStream; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; + +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.ext.MessageBodyWriter; + +public class JsonMessageBodyWriter implements MessageBodyWriter, JsonMediaTypeMatcher { + ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public boolean isWriteable(Class type, Type genericType, Annotation[] annotations, MediaType mediaType) { + return isJson(mediaType); + } + @Override + public void writeTo(T t, Class type, Type genericType, Annotation[] annotations, MediaType mediaType, MultivaluedMap httpHeaders, OutputStream entityStream) throws IOException, WebApplicationException { + objectMapper.writeValue(entityStream, t); + } +} diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/resteasy/RestApiClientBuilder.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/resteasy/RestApiClientBuilder.java new file mode 100644 index 0000000..842c8ad --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/resteasy/RestApiClientBuilder.java @@ -0,0 +1,49 @@ +package at.htl.leonding.util.resteasy; + +import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder; +import org.jboss.resteasy.client.jaxrs.engines.URLConnectionClientEngineBuilder; +import org.jboss.resteasy.client.jaxrs.internal.LocalResteasyProviderFactory; +import org.jboss.resteasy.spi.ResteasyProviderFactory; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import jakarta.ws.rs.client.ClientBuilder; + +/** Build a RestEasy Client with reduced dependencies on a lot of the standard Java Runtime classes so that it works even on the Android Java Runtime. + * @author Christian Aberger (http://www.aberger.at) + */ +@Singleton +public class RestApiClientBuilder { + final public ScheduledExecutorService scheduledExecutorService; + final public ExecutorService executorService; + + @Inject + public RestApiClientBuilder() { + scheduledExecutorService = Executors.newScheduledThreadPool(1); + executorService = Executors.newSingleThreadExecutor(); + } + /** Build a reasteasy client + * @see single source of truth + * @author Christian Aberger (http://www.aberger.at) + * @param the class of the ReadOnly Single Source of Truth. + */ +public class StoreBase { + public final BehaviorSubject pipe; + protected final Immer immer; + + protected StoreBase(Class type, T initialState) { + try { + pipe = BehaviorSubject.createDefault(initialState); + immer = new Immer(type); + } catch (Exception e) { + throw new CompletionException(e); + } + } + + public T get() { + return immer.mapper.clone(pipe.getValue()); + } + /** clone the current Model, apply the recipe to it and submit it to the pipe as the next Model. + * @param recipe + * The function that receives a clone of the current model and applies its changes to it. + */ + public void apply(Consumer recipe) { + Consumer onNext = nextState -> pipe.onNext(nextState); + immer.produce(pipe.getValue(), recipe, onNext); + } +} diff --git a/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/store/ViewModelBase.java b/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/store/ViewModelBase.java new file mode 100644 index 0000000..1ddb902 --- /dev/null +++ b/labs/android-mvvm/app/src/main/java/at/htl/leonding/util/store/ViewModelBase.java @@ -0,0 +1,38 @@ +package at.htl.leonding.util.store; + +import at.htl.leonding.model.Model; +import at.htl.leonding.model.Store; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.subjects.PublishSubject; +import io.reactivex.rxjava3.subjects.Subject; + +/** Map our global application state to the small vision that a view has of the world. + * A base class for our ViewModels in the sense of the MVVM Pattern. + * In a lot of texts the term "Model-View-ViewModel" is often explained incorrectly. + * + * For a detailed explanation of MVVM + * watch the first 17 minutes of Lecture 3 | Stanford CS193p 2023. + * In that text replace "SwiftUI" -> Jetpack Compose/"Swift" -> Java/"struct" -> record + * + * @param the type of the view model, the small world of the view that this special viemodel serves. + */ +public abstract class ViewModelBase { + public final Subject subject = PublishSubject.create(); + + protected final Store store; + private final Disposable subscription; + + protected ViewModelBase(Store store) { + this.store = store; + this.subscription = store.pipe + .map(this::toViewModel) + .distinctUntilChanged() + .subscribe(subject::onNext); + } + public T getValue() { + return toViewModel(store.pipe.getValue()); + } + + /** map the "big" model to our "small" viewmodel */ + protected abstract T toViewModel(Model model); +} diff --git a/labs/android-mvvm/app/src/main/res/drawable/ic_launcher_background.xml b/labs/android-mvvm/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..1e4408c --- /dev/null +++ b/labs/android-mvvm/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/labs/android-mvvm/app/src/main/res/drawable/ic_launcher_foreground.xml b/labs/android-mvvm/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..fde1368 --- /dev/null +++ b/labs/android-mvvm/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/labs/android-mvvm/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/labs/android-mvvm/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/labs/android-mvvm/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/labs/android-mvvm/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/labs/android-mvvm/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/labs/android-mvvm/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/labs/android-mvvm/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/labs/android-mvvm/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/labs/android-mvvm/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/labs/android-mvvm/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/labs/android-mvvm/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/labs/android-mvvm/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/labs/android-mvvm/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/labs/android-mvvm/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/labs/android-mvvm/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/labs/android-mvvm/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/labs/android-mvvm/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/labs/android-mvvm/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/labs/android-mvvm/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/labs/android-mvvm/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/labs/android-mvvm/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/labs/android-mvvm/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/labs/android-mvvm/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/labs/android-mvvm/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/labs/android-mvvm/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/labs/android-mvvm/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/labs/android-mvvm/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/labs/android-mvvm/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/labs/android-mvvm/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/labs/android-mvvm/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/labs/android-mvvm/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/labs/android-mvvm/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/labs/android-mvvm/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/labs/android-mvvm/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/labs/android-mvvm/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/labs/android-mvvm/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/labs/android-mvvm/app/src/main/res/values/colors.xml b/labs/android-mvvm/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/labs/android-mvvm/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/labs/android-mvvm/app/src/main/res/values/strings.xml b/labs/android-mvvm/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..58613ed --- /dev/null +++ b/labs/android-mvvm/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + ToDo + \ No newline at end of file diff --git a/labs/android-mvvm/app/src/main/res/values/themes.xml b/labs/android-mvvm/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4ac2d6f --- /dev/null +++ b/labs/android-mvvm/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +