diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..08ac532 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,44 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +insert_final_newline = true +max_line_length = 145 +indent_size = 4 +trim_trailing_whitespace = true + +[*.gradle] +ij_continuation_indent_size = 8 + +[{*.kt, *.kts}] +ij_continuation_indent_size = 8 +ij_kotlin_assignment_wrap = normal +ij_kotlin_call_parameters_new_line_after_left_paren = true +ij_kotlin_call_parameters_right_paren_on_new_line = true +ij_kotlin_call_parameters_wrap = on_every_item +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL +ij_kotlin_continuation_indent_for_chained_calls = false +ij_kotlin_continuation_indent_for_expression_bodies = false +ij_kotlin_continuation_indent_in_argument_lists = false +ij_kotlin_continuation_indent_in_elvis = false +ij_kotlin_continuation_indent_in_if_conditions = false +ij_kotlin_continuation_indent_in_parameter_lists = false +ij_kotlin_continuation_indent_in_supertype_lists = false +ij_kotlin_extends_list_wrap = normal +ij_kotlin_if_rparen_on_new_line = true +ij_kotlin_keep_blank_lines_before_right_brace = 0 +ij_kotlin_keep_blank_lines_in_code = 1 +ij_kotlin_keep_blank_lines_in_declarations = 1 +ij_kotlin_method_call_chain_wrap = normal +ij_kotlin_method_parameters_new_line_after_left_paren = true +ij_kotlin_method_parameters_right_paren_on_new_line = true +ij_kotlin_method_parameters_wrap = on_every_item +ij_kotlin_name_count_to_use_star_import = 2147483647 +ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 +ij_kotlin_wrap_expression_body_functions = 1 +ij_kotlin_imports_layout = * + +[*.md] +trim_trailing_whitespace = false +max_line_length = 80 diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..a2e4ad7 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,147 @@ +name: Android CI + +on: + push: + branches: + - main + - orbit/main + - feature/** + tags: + - '**' + pull_request: + branches: + - main + - orbit/main + - feature/** + +jobs: + static-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: set up JDK 1.8 + uses: actions/setup-java@v1 + with: + java-version: 1.8 + + - name: gradle cache + uses: actions/cache@v1 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: gradle wrapper cache + uses: actions/cache@v1 + with: + path: ~/.gradle/wrapper/dists + key: ${{ runner.os }}-gradlewrapper + + - name: Detekt + run: ./gradlew detekt + + - name: Markdown lint + run: ./gradlew markdownlint + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: set up JDK 1.8 + uses: actions/setup-java@v1 + with: + java-version: 1.8 + + - name: gradle cache + uses: actions/cache@v1 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: gradle wrapper cache + uses: actions/cache@v1 + with: + path: ~/.gradle/wrapper/dists + key: ${{ runner.os }}-gradlewrapper + + - name: Lint + run: ./gradlew lint + + unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: set up JDK 1.8 + uses: actions/setup-java@v1 + with: + java-version: 1.8 + + - name: gradle cache + uses: actions/cache@v1 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: gradle wrapper cache + uses: actions/cache@v1 + with: + path: ~/.gradle/wrapper/dists + key: ${{ runner.os }}-gradlewrapper + + - name: konan cache + uses: actions/cache@v1 + with: + path: ~/.konan + key: ${{ runner.os }}-konan + + - name: Unit tests + run: ./gradlew check -xlint + + - name: Upload test artifacts + if: failure() + uses: actions/upload-artifact@master + with: + name: test-results + path: '**/build/reports/tests/**' + + build: + needs: [ static-checks, lint, unit-tests ] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: set up JDK 1.8 + uses: actions/setup-java@v1 + with: + java-version: 1.8 + + - name: gradle cache + uses: actions/cache@v1 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: gradle wrapper cache + uses: actions/cache@v1 + with: + path: ~/.gradle/wrapper/dists + key: ${{ runner.os }}-gradlewrapper + + - name: konan cache + uses: actions/cache@v1 + with: + path: ~/.konan + key: ${{ runner.os }}-konan + + - name: Build + run: ./gradlew clean assemble -xassembleDebug diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml new file mode 100644 index 0000000..634bc49 --- /dev/null +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -0,0 +1,23 @@ +name: "Validate Gradle Wrapper" + +on: + push: + branches: + - main + - orbit/main + - feature/** + tags: + - '*' + pull_request: + branches: + - main + - orbit/main + - feature/** + +jobs: + validation: + name: "Validation" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: gradle/wrapper-validation-action@v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31d06eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,275 @@ +# Created by https://www.gitignore.io/api/linux,macos,kotlin,windows,android,androidstudio +# Edit at https://www.gitignore.io/?templates=linux,macos,kotlin,windows,android,androidstudio + +### Android ### +# Built application files +*.apk +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/ + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +### Android Patch ### +gen-external-apklibs +output.json + +# Replacement of .externalNativeBuild directories introduced +# with Android Studio 3.5. +.cxx/ + +### Kotlin ### +# Compiled class file + +# Log file + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### AndroidStudio ### +# Covers files to be ignored for android development using Android Studio. + +# Built application files + +# Files for the ART/Dalvik VM + +# Java class files + +# Generated files + +# Gradle files +.gradle + +# Signing files +.signing/ + +# Local configuration file (sdk path, etc) + +# Proguard folder generated by Eclipse + +# Log Files + +# Android Studio +/*/build/ +/*/local.properties +/*/out +/*/*/build +/*/*/production +*.ipr +*.swp + +# Android Patch + +# External native build folder generated in Android Studio 2.2 and later + +# NDK +obj/ + +# IntelliJ IDEA +*.iws +/out/ + +# User-specific configurations +.idea/caches/ +.idea/libraries/ +.idea/shelf/ +.idea/.name +.idea/compiler.xml +.idea/copyright/profiles_settings.xml +.idea/encodings.xml +.idea/misc.xml +.idea/scopes/scope_settings.xml +.idea/vcs.xml +.idea/jsLibraryMappings.xml +.idea/datasources.xml +.idea/dataSources.ids +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml + +# OS-specific files +.DS_Store? + +# Legacy Eclipse project files +.classpath +.project +.cproject +.settings/ + +# Mobile Tools for Java (J2ME) + +# Package Files # + +# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml) + +## Plugin-specific files: + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Mongo Explorer plugin +.idea/mongoSettings.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### AndroidStudio Patch ### + +!/gradle/wrapper/gradle-wrapper.jar + +# End of https://www.gitignore.io/api/linux,macos,kotlin,windows,android,androidstudio diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..b7f509e --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,13 @@ +# NOTICES + +## Orbit MVI + +Copyright © 2021 Mikołaj Leszczyński & Appmattus Limited. + +This software uses the following open source works: + +- [Kotlin](https://github.com/JetBrains/kotlin) +- [Android](https://developer.android.com) +- [Orbit MVI](https://github.com/orbit-mvi/orbit-mvi) +- [Lightstreamer](https://lightstreamer.com) +- [Dagger](https://dagger.dev) diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ff5838 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# Orbit Sample - Compose Stock List + +This sample implements a stock list using [Orbit MVI](https://github.com/orbit-mvi/orbit-mvi). + +- The application uses Dagger Hilt for dependency injection which is initialised + in [StockListApplication](app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/StockListApplication.kt). + +- Streaming data is provided by [Lightstreamer](https://lightstreamer.com) and + their demo server with callback interfaces converted to Kotlin Flow's with + [callbackFlow](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/callback-flow.html). + +- Navigation between the stock list and the detail view uses Jetpack's [Navigation](https://developer.android.com/jetpack/compose/navigation). + [ListViewModel](app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/list/business/ListViewModel.kt) + posts a side effect which [ListScreen](app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/list/ui/ListScreen.kt) + observes and sends to the `NavController`. + +- [Jetpack Compose](https://developer.android.com/jetpack/compose) + is used to render layouts throughout. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..76bb9a9 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,112 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("com.android.application") + kotlin("android") + id("kotlin-parcelize") + id("androidx.navigation.safeargs.kotlin") + id("dagger.hilt.android.plugin") + kotlin("kapt") +} + +android { + compileSdk = 30 + defaultConfig { + minSdk = 23 + targetSdk = 30 + versionCode = 1 + versionName = "1.0" + applicationId = "org.orbitmvi.orbit.sample.stocklist.compose" + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.0.0" + } + + buildFeatures { + // Enables Jetpack Compose for this module + compose = true + } + + testOptions.unitTests.isIncludeAndroidResources = true + + packagingOptions { + resources { + excludes.addAll( + listOf( + "META-INF/INDEX.LIST", + "META-INF/io.netty.versions.properties" + ) + ) + } + } + + sourceSets { + get("main").java.srcDir("src/main/kotlin") + get("test").java.srcDir("src/test/kotlin") + } +} + +dependencies { + implementation(kotlin("stdlib-jdk8")) + implementation("org.orbit-mvi:orbit-core:4.1.3") + implementation("org.orbit-mvi:orbit-viewmodel:4.1.3") + + implementation("androidx.navigation:navigation-fragment-ktx:2.3.5") + implementation("androidx.navigation:navigation-ui-ktx:2.3.5") + implementation("com.lightstreamer:ls-android-client:4.2.5") + implementation("androidx.lifecycle:lifecycle-common-java8:2.4.0-alpha02") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.0-alpha02") + + // Dependency Injection + implementation("com.google.dagger:hilt-android:2.38.1") + kapt("com.google.dagger:hilt-android-compiler:2.38.1") + + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5") + + // Jetpack Compose + implementation("androidx.activity:activity-compose:1.3.0") + implementation("androidx.compose.ui:ui:1.0.0") + // Tooling support (Previews, etc.) + implementation("androidx.compose.ui:ui-tooling:1.0.0") + // Foundation (Border, Background, Box, Image, Scroll, shapes, animations, etc.) + implementation("androidx.compose.foundation:foundation:1.0.0") + // Material Design + implementation("androidx.compose.material:material:1.0.0") + // Material design icons + implementation("androidx.compose.material:material-icons-core:1.0.0") + // Navigation + implementation("androidx.navigation:navigation-compose:2.4.0-alpha05") + implementation("androidx.hilt:hilt-navigation-compose:1.0.0-alpha03") + // Lifecycle + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha07") +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..61114b4 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..b11d7bd Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/MainActivity.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/MainActivity.kt new file mode 100644 index 0000000..30e4b0c --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/MainActivity.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import org.orbitmvi.orbit.sample.stocklist.detail.business.DetailViewModel +import org.orbitmvi.orbit.sample.stocklist.detail.ui.DetailScreen +import org.orbitmvi.orbit.sample.stocklist.list.business.ListViewModel +import org.orbitmvi.orbit.sample.stocklist.list.ui.ListScreen +import org.orbitmvi.orbit.sample.stocklist.streaming.StreamingClient + +@AndroidEntryPoint +class MainActivity : AppCompatActivity() { + + @Inject + lateinit var streamingClient: StreamingClient + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycle.addObserver(streamingClient) + + setContent { + val navController = rememberNavController() + NavHost(navController, startDestination = "list") { + composable("list") { + val viewModel = hiltViewModel() + ListScreen(navController, viewModel) + } + composable("detail/{itemName}") { + val viewModel = hiltViewModel() + DetailScreen(navController, viewModel) + } + } + } + } +} diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/StockListApplication.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/StockListApplication.kt new file mode 100644 index 0000000..970c7e1 --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/StockListApplication.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@Suppress("unused") +@HiltAndroidApp +class StockListApplication : Application() diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/common/ui/AppBar.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/common/ui/AppBar.kt new file mode 100644 index 0000000..2a4306d --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/common/ui/AppBar.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist.common.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Row +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import org.orbitmvi.orbit.sample.stocklist.R + +@Composable +fun AppBar(topAppBarText: String, onBackPressed: (() -> Unit)? = null) { + TopAppBar( + title = { + Row { + Image( + painterResource(id = R.drawable.ic_orbit_toolbar), + contentDescription = null + ) + Text( + text = topAppBarText + ) + } + }, + navigationIcon = onBackPressed?.let { + { + IconButton(onClick = onBackPressed) { + Icon( + Icons.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + }, + backgroundColor = Color.White, + contentColor = Color.Black + ) +} diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/common/ui/PriceBox.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/common/ui/PriceBox.kt new file mode 100644 index 0000000..1e9d44e --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/common/ui/PriceBox.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist.common.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +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.dp +import org.orbitmvi.orbit.sample.stocklist.R +import org.orbitmvi.orbit.sample.stocklist.streaming.stock.Tick + +@Composable +fun PriceBox( + price: String, + priceTick: Tick?, + color: Color, + style: TextStyle, + modifier: Modifier = Modifier +) { + Surface( + shape = RectangleShape, + elevation = 4.dp, + color = color, + modifier = modifier + .padding(vertical = 4.dp) + .padding(end = 4.dp) + ) { + Row(horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + priceTick?.let { + Icon( + painter = painterResource(id = if (priceTick == Tick.Up) R.drawable.arrow_up_bold else R.drawable.arrow_down_bold), + contentDescription = null, + tint = Color.White, + ) + } ?: Spacer(Modifier) + + Text( + price, + style = style, + color = Color.White, + textAlign = TextAlign.End, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(4.dp) + ) + } + } +} + +@Preview +@Composable +fun PriceBoxPreview() { + Column { + PriceBox("2.64", Tick.Up, colorResource(android.R.color.holo_red_dark), MaterialTheme.typography.body2) + + PriceBox("2.63", Tick.Down, colorResource(android.R.color.holo_blue_dark), MaterialTheme.typography.body2) + + PriceBox("2.61", null, colorResource(android.R.color.holo_red_dark), MaterialTheme.typography.body2) + } +} diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/common/ui/StockItem.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/common/ui/StockItem.kt new file mode 100644 index 0000000..5bc85d7 --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/common/ui/StockItem.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist.list.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.orbitmvi.orbit.sample.stocklist.common.ui.PriceBox +import org.orbitmvi.orbit.sample.stocklist.streaming.stock.Stock +import org.orbitmvi.orbit.sample.stocklist.streaming.stock.Tick + +@Composable +fun StockItem(stock: Stock, onClick: (stock: Stock) -> Unit) { + Column(modifier = Modifier + .fillMaxWidth() + .clickable { onClick(stock) } + .padding(8.dp)) { + Text( + stock.name, + style = MaterialTheme.typography.subtitle1, + modifier = Modifier.fillMaxWidth() + ) + + Row(horizontalArrangement = Arrangement.SpaceEvenly) { + + PriceBox( + price = stock.bid, + priceTick = stock.bidTick, + color = colorResource(android.R.color.holo_red_dark), + style = MaterialTheme.typography.body2, + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically) + ) + PriceBox( + price = stock.ask, + priceTick = stock.askTick, + color = colorResource(android.R.color.holo_blue_dark), + style = MaterialTheme.typography.body2, + modifier = Modifier + .weight(1f) + ) + + Box( + modifier = Modifier + .weight(1f) + .padding(vertical = 4.dp) + .padding(end = 4.dp), + contentAlignment = Alignment.CenterEnd + ) { + Text( + stock.timestamp, + style = MaterialTheme.typography.body2, + fontWeight = FontWeight.Bold, + modifier = Modifier + .padding(4.dp) + ) + } + } + } +} + +@Preview +@Composable +fun StocksPreview() { + val stock = Stock("1", "Anduct", "2.64", Tick.Up, "2.65", Tick.Down, "06:35:08") + StockItem(stock) {} +} + +@Preview +@Composable +fun StocksPreview3() { + val stock = Stock("1", "Anduct", "2.64", Tick.Down, "2.65", Tick.Up, "06:35:08") + StockItem(stock) {} +} + +@Preview +@Composable +fun StocksPreview2() { + val stock = Stock("1", "Anduct", "2.64", null, "2.65", null, "06:35:08") + StockItem(stock) {} +} diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/detail/business/DetailState.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/detail/business/DetailState.kt new file mode 100644 index 0000000..f00036a --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/detail/business/DetailState.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist.detail.business + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.orbitmvi.orbit.sample.stocklist.streaming.stock.StockDetail + +@Parcelize +data class DetailState( + val stock: StockDetail? = null +) : Parcelable diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/detail/business/DetailViewModel.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/detail/business/DetailViewModel.kt new file mode 100644 index 0000000..d2cf6aa --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/detail/business/DetailViewModel.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist.detail.business + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.collect +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.sample.stocklist.streaming.stock.StockRepository +import org.orbitmvi.orbit.syntax.simple.intent +import org.orbitmvi.orbit.syntax.simple.reduce +import org.orbitmvi.orbit.viewmodel.container + +@HiltViewModel +class DetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val stockRepository: StockRepository +) : ViewModel(), ContainerHost { + + private val itemName = savedStateHandle.get("itemName")!! + + override val container = container(DetailState(), savedStateHandle) { requestStock() } + + private fun requestStock(): Unit = intent(registerIdling = false) { + stockRepository.stockDetails(itemName).collect { + reduce { + state.copy(stock = it) + } + } + } +} diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/detail/ui/DetailScreen.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/detail/ui/DetailScreen.kt new file mode 100644 index 0000000..da16d0f --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/detail/ui/DetailScreen.kt @@ -0,0 +1,191 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist.detail.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import org.orbitmvi.orbit.sample.stocklist.R +import org.orbitmvi.orbit.sample.stocklist.common.ui.AppBar +import org.orbitmvi.orbit.sample.stocklist.common.ui.PriceBox +import org.orbitmvi.orbit.sample.stocklist.detail.business.DetailViewModel + +@Composable +@Suppress("LongMethod") +fun DetailScreen(navController: NavController, viewModel: DetailViewModel) { + + val state = viewModel.container.stateFlow.collectAsState().value + + Column { + AppBar(state.stock?.name ?: stringResource(id = R.string.app_name)) { + navController.popBackStack() + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier + .fillMaxWidth() + ) { + + PriceBox( + price = state.stock?.bid ?: "", + priceTick = state.stock?.bidTick, + color = colorResource(android.R.color.holo_red_dark), + style = MaterialTheme.typography.body1, + modifier = Modifier + .weight(1f) + .height(48.dp) + ) + PriceBox( + price = state.stock?.ask ?: "", + priceTick = state.stock?.askTick, + color = colorResource(android.R.color.holo_blue_dark), + style = MaterialTheme.typography.body1, + modifier = Modifier + .weight(1f) + .height(48.dp) + ) + } + + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier + ) { + Text( + text = state.stock?.bidQuantity ?: "", + textAlign = TextAlign.End, + style = MaterialTheme.typography.body2, + modifier = Modifier + .weight(1f) + .padding(horizontal = 4.dp) + ) + Text( + text = state.stock?.askQuantity ?: "", + textAlign = TextAlign.End, + style = MaterialTheme.typography.body2, + modifier = Modifier + .weight(1f) + .padding(horizontal = 4.dp) + ) + } + + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.padding(top = 16.dp) + ) { + Text( + text = "Change %:", + textAlign = TextAlign.End, + style = MaterialTheme.typography.body2.copy(fontWeight = FontWeight.Bold), + modifier = Modifier + .weight(1f) + .padding(horizontal = 4.dp) + ) + Text( + text = state.stock?.pctChange ?: "", + textAlign = TextAlign.Start, + style = MaterialTheme.typography.body2, + modifier = Modifier + .weight(1f) + .padding(horizontal = 4.dp) + ) + } + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.padding(top = 8.dp) + ) { + Text( + text = "Timestamp:", + textAlign = TextAlign.End, + style = MaterialTheme.typography.body2.copy(fontWeight = FontWeight.Bold), + modifier = Modifier + .weight(1f) + .padding(horizontal = 4.dp) + ) + Text( + text = state.stock?.timestamp ?: "", + textAlign = TextAlign.Start, + style = MaterialTheme.typography.body2, + modifier = Modifier + .weight(1f) + .padding(horizontal = 4.dp) + ) + } + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.padding(top = 8.dp) + ) { + Text( + text = "High:", + textAlign = TextAlign.End, + style = MaterialTheme.typography.body2.copy(fontWeight = FontWeight.Bold), + modifier = Modifier + .weight(1f) + .padding(horizontal = 4.dp) + ) + Text( + text = state.stock?.max ?: "", + textAlign = TextAlign.Start, + style = MaterialTheme.typography.body2, + modifier = Modifier + .weight(1f) + .padding(horizontal = 4.dp) + ) + } + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.padding(top = 8.dp) + ) { + Text( + text = "Low:", + textAlign = TextAlign.End, + style = MaterialTheme.typography.body2.copy(fontWeight = FontWeight.Bold), + modifier = Modifier + .weight(1f) + .padding(horizontal = 4.dp) + ) + Text( + text = state.stock?.min ?: "", + textAlign = TextAlign.Start, + style = MaterialTheme.typography.body2, + modifier = Modifier + .weight(1f) + .padding(horizontal = 4.dp) + ) + } + } + } +} diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/di/AppModule.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/di/AppModule.kt new file mode 100644 index 0000000..5b805c3 --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/di/AppModule.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import org.orbitmvi.orbit.sample.stocklist.streaming.StreamingClient +import org.orbitmvi.orbit.sample.stocklist.streaming.stock.StockRepository + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + @Provides + @Singleton + fun provideStreamingClient(): StreamingClient = StreamingClient() + + @Provides + @Singleton + fun provideStockRepository(streamingClient: StreamingClient): StockRepository = StockRepository(streamingClient) +} diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/list/business/ListSideEffect.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/list/business/ListSideEffect.kt new file mode 100644 index 0000000..5f695e5 --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/list/business/ListSideEffect.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist.list.business + +sealed class ListSideEffect { + data class NavigateToDetail(val itemName: String) : ListSideEffect() +} diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/list/business/ListState.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/list/business/ListState.kt new file mode 100644 index 0000000..415cb7d --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/list/business/ListState.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist.list.business + +import android.os.Parcelable +import org.orbitmvi.orbit.sample.stocklist.streaming.stock.Stock +import kotlinx.parcelize.Parcelize + +@Parcelize +data class ListState( + val stocks: List = emptyList() +) : Parcelable diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/list/business/ListViewModel.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/list/business/ListViewModel.kt new file mode 100644 index 0000000..179c2f5 --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/list/business/ListViewModel.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist.list.business + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.collect +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.sample.stocklist.streaming.stock.StockRepository +import org.orbitmvi.orbit.syntax.simple.intent +import org.orbitmvi.orbit.syntax.simple.postSideEffect +import org.orbitmvi.orbit.syntax.simple.reduce +import org.orbitmvi.orbit.viewmodel.container + +@HiltViewModel +class ListViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val stockRepository: StockRepository +) : ViewModel(), ContainerHost { + + override val container = container(ListState(), savedStateHandle) { requestStocks() } + + private fun requestStocks(): Unit = intent(registerIdling = false) { + stockRepository.stockList().collect { + reduce { + state.copy(stocks = it) + } + } + } + + fun viewMarket(itemName: String) = intent { + postSideEffect(ListSideEffect.NavigateToDetail(itemName)) + } +} diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/list/ui/ListScreen.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/list/ui/ListScreen.kt new file mode 100644 index 0000000..ee916cb --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/list/ui/ListScreen.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist.list.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import org.orbitmvi.orbit.sample.stocklist.R +import org.orbitmvi.orbit.sample.stocklist.common.ui.AppBar +import org.orbitmvi.orbit.sample.stocklist.list.business.ListSideEffect +import org.orbitmvi.orbit.sample.stocklist.list.business.ListViewModel + +@Composable +fun ListScreen(navController: NavController, viewModel: ListViewModel) { + + val state = viewModel.container.stateFlow.collectAsState().value + + LaunchedEffect(viewModel) { + launch { + viewModel.container.sideEffectFlow.collect { handleSideEffect(navController, it) } + } + } + + Column { + AppBar(stringResource(id = R.string.app_name)) + + LazyColumn { + items(state.stocks) { stock -> + StockItem(stock) { + viewModel.viewMarket(stock.itemName) + } + } + } + } +} + +private fun handleSideEffect(navController: NavController, sideEffect: ListSideEffect) { + when (sideEffect) { + is ListSideEffect.NavigateToDetail -> navController.navigate("detail/${sideEffect.itemName}") + } +} diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/streaming/EmptySubscriptionListener.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/streaming/EmptySubscriptionListener.kt new file mode 100644 index 0000000..e36f401 --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/streaming/EmptySubscriptionListener.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) Lightstreamer Srl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist.streaming + +import com.lightstreamer.client.ItemUpdate +import com.lightstreamer.client.Subscription +import com.lightstreamer.client.SubscriptionListener + +/** + * Empty SubscriptionListener + */ +@Suppress("TooManyFunctions") +object EmptySubscriptionListener : SubscriptionListener { + override fun onListenEnd(p0: Subscription) = Unit + + override fun onItemUpdate(p0: ItemUpdate) = Unit + + override fun onSubscription() = Unit + + override fun onEndOfSnapshot(p0: String?, p1: Int) = Unit + + override fun onItemLostUpdates(p0: String?, p1: Int, p2: Int) = Unit + + override fun onSubscriptionError(p0: Int, p1: String?) = Unit + + override fun onClearSnapshot(p0: String?, p1: Int) = Unit + + override fun onCommandSecondLevelSubscriptionError(p0: Int, p1: String?, p2: String?) = Unit + + override fun onUnsubscription() = Unit + + override fun onCommandSecondLevelItemLostUpdates(p0: Int, p1: String) = Unit + + override fun onListenStart(p0: Subscription) = Unit + + override fun onRealMaxFrequency(p0: String?) = Unit +} diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/streaming/StreamingClient.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/streaming/StreamingClient.kt new file mode 100644 index 0000000..6312b18 --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/streaming/StreamingClient.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist.streaming + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.lightstreamer.client.LightstreamerClient +import com.lightstreamer.client.Subscription +import kotlin.concurrent.thread + +class StreamingClient : DefaultLifecycleObserver { + private var connectionWish = false + + private val lsClient = LightstreamerClient( + "http://push.lightstreamer.com:80", + "DEMO" + ).apply { + connect() + } + + override fun onStart(owner: LifecycleOwner) { + synchronized(lsClient) { + connectionWish = true + lsClient.connect() + } + } + + override fun onStop(owner: LifecycleOwner) { + synchronized(lsClient) { + connectionWish = false + + thread { + @Suppress("MagicNumber") + Thread.sleep(5000) + synchronized(lsClient) { + if (!connectionWish) { + lsClient.disconnect() + } + } + } + } + } + + fun addSubscription(sub: Subscription) { + lsClient.subscribe(sub) + } + + fun removeSubscription(sub: Subscription) { + lsClient.unsubscribe(sub) + } +} diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/streaming/stock/Stock.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/streaming/stock/Stock.kt new file mode 100644 index 0000000..c209973 --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/streaming/stock/Stock.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist.streaming.stock + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Stock( + val itemName: String, + val name: String, + val bid: String, + val bidTick: Tick?, + val ask: String, + val askTick: Tick?, + val timestamp: String +) : Parcelable diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/streaming/stock/StockDetail.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/streaming/stock/StockDetail.kt new file mode 100644 index 0000000..a82da78 --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/streaming/stock/StockDetail.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist.streaming.stock + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class StockDetail( + val itemName: String, + val name: String, + val pctChange: String, + val bid: String, + val bidTick: Tick?, + val bidQuantity: String, + val ask: String, + val askTick: Tick?, + val askQuantity: String, + val min: String, + val max: String, + val timestamp: String +) : Parcelable diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/streaming/stock/StockRepository.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/streaming/stock/StockRepository.kt new file mode 100644 index 0000000..59ff4a4 --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/streaming/stock/StockRepository.kt @@ -0,0 +1,248 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist.streaming.stock + +import android.text.format.DateUtils +import com.lightstreamer.client.ItemUpdate +import com.lightstreamer.client.Subscription +import com.lightstreamer.client.SubscriptionListener +import java.math.RoundingMode +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import org.orbitmvi.orbit.sample.stocklist.streaming.EmptySubscriptionListener +import org.orbitmvi.orbit.sample.stocklist.streaming.StreamingClient + +@Suppress("MagicNumber", "ComplexCondition") +class StockRepository(private val client: StreamingClient) { + + private val items = (1..20).map { "item$it" }.toTypedArray() + private val subscriptionFields = arrayOf("stock_name", "bid", "ask", "timestamp") + private val detailSubscriptionFields = + arrayOf("stock_name", "timestamp", "pct_change", "bid_quantity", "bid", "ask", "ask_quantity", "min", "max") + + @Suppress("EXPERIMENTAL_API_USAGE") + fun stockList(): Flow> = callbackFlow { + val stockList = MutableList(20) { null } + + val bidJobs = mutableMapOf() + val askJobs = mutableMapOf() + + trySend(emptyList()) + + val subscription = Subscription("MERGE", items, subscriptionFields).apply { + dataAdapter = "QUOTE_ADAPTER" + requestedMaxFrequency = "1" + requestedSnapshot = "yes" + addListener( + object : SubscriptionListener by EmptySubscriptionListener { + override fun onItemUpdate(p0: ItemUpdate) { + val itemName = p0.itemName + val stockName = p0.getValue("stock_name") + val formattedBid = p0.getValue("bid")?.to2dp() + val formattedAsk = p0.getValue("ask")?.to2dp() + val formattedTimestamp = p0.getValue("timestamp")?.toFormattedTimestamp() + + if (itemName != null && stockName != null && formattedBid != null && formattedAsk != null && + formattedTimestamp != null + ) { + val bidTick = tickDirection(stockList[p0.itemPos - 1]?.bid, formattedBid) + val askTick = tickDirection(stockList[p0.itemPos - 1]?.ask, formattedAsk) + + stockList[p0.itemPos - 1] = stockList[p0.itemPos - 1]?.copy( + bid = formattedBid, + ask = formattedAsk, + timestamp = formattedTimestamp + ) ?: Stock(itemName, stockName, formattedBid, null, formattedAsk, null, formattedTimestamp) + + bidTick?.let { + bidJobs[p0.itemPos]?.cancel() + + stockList[p0.itemPos - 1] = stockList[p0.itemPos - 1]?.copy(bidTick = bidTick) + + bidJobs[p0.itemPos] = async { + @Suppress("MagicNumber") + (delay(300)) + + stockList[p0.itemPos - 1] = stockList[p0.itemPos - 1]?.copy(bidTick = null) + trySend(stockList.filterNotNull()) + } + } + + askTick?.let { + askJobs[p0.itemPos]?.cancel() + + stockList[p0.itemPos - 1] = stockList[p0.itemPos - 1]?.copy(askTick = askTick) + + askJobs[p0.itemPos] = async { + @Suppress("MagicNumber") + (delay(300)) + + stockList[p0.itemPos - 1] = stockList[p0.itemPos - 1]?.copy(askTick = null) + trySend(stockList.filterNotNull()) + } + } + + trySend(stockList.filterNotNull()) + } + } + } + ) + } + + client.addSubscription(subscription) + + awaitClose { + client.removeSubscription(subscription) + } + } + + private fun tickDirection(currentValue: String?, newValue: String): Tick? { + if (newValue != currentValue && !currentValue.isNullOrEmpty()) { + val diff = newValue.toDouble().compareTo(currentValue.toDouble()) + + if (diff != 0) { + return if (diff > 0) Tick.Up else Tick.Down + } + } + return null + } + + @Suppress("EXPERIMENTAL_API_USAGE") + fun stockDetails(itemName: String): Flow = callbackFlow { + var detail: StockDetail? = null + var bidJob: Job? = null + var askJob: Job? = null + + val subscription = Subscription("MERGE", itemName, detailSubscriptionFields).apply { + dataAdapter = "QUOTE_ADAPTER" + requestedSnapshot = "yes" + + addListener( + object : SubscriptionListener by EmptySubscriptionListener { + @Suppress("LongMethod") + override fun onItemUpdate(p0: ItemUpdate) { + val stockName = p0.getValue("stock_name") + val pctChange = p0.getValue("pct_change")?.to2dp() + val formattedBid = p0.getValue("bid")?.to2dp() + val bidQuantity = p0.getValue("bid_quantity") + val formattedAsk = p0.getValue("ask")?.to2dp() + val askQuantity = p0.getValue("ask_quantity") + val min = p0.getValue("min")?.to2dp() + val max = p0.getValue("max")?.to2dp() + val formattedTimestamp = p0.getValue("timestamp")?.toFormattedTimestamp() + + if (stockName != null && + pctChange != null && + formattedBid != null && + bidQuantity != null && + formattedAsk != null && + askQuantity != null && + min != null && + max != null && + formattedTimestamp != null + ) { + val bidTick = tickDirection(detail?.bid, formattedBid) + val askTick = tickDirection(detail?.ask, formattedAsk) + + detail = detail?.copy( + pctChange = pctChange, + bid = formattedBid, + bidQuantity = bidQuantity, + ask = formattedAsk, + askQuantity = askQuantity, + min = min, + max = max, + timestamp = formattedTimestamp + ) ?: StockDetail( + itemName = itemName, + name = stockName, + pctChange = pctChange, + bid = formattedBid, + bidTick = null, + bidQuantity = bidQuantity, + ask = formattedAsk, + askTick = null, + askQuantity = askQuantity, + min = min, + max = max, + timestamp = formattedTimestamp + ) + + bidTick?.let { + bidJob?.cancel() + + detail = detail?.copy(bidTick = bidTick) + + bidJob = async { + @Suppress("MagicNumber") + (delay(300)) + + detail = detail?.copy(bidTick = null) + trySend(detail!!) + } + } + + askTick?.let { + askJob?.cancel() + + detail = detail?.copy(askTick = askTick) + + askJob = async { + @Suppress("MagicNumber") + (delay(300)) + + detail = detail?.copy(askTick = null) + trySend(detail!!) + } + } + + trySend(detail!!) + } + } + } + ) + } + + client.addSubscription(subscription) + + awaitClose { + client.removeSubscription(subscription) + } + } + + fun String.to2dp(): String = toBigDecimal().setScale(2, RoundingMode.HALF_UP).toPlainString() + + fun String.toFormattedTimestamp(): String = toLong().let { rawTimestamp -> + if (DateUtils.isToday(rawTimestamp)) { + DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM) + LocalDateTime.ofInstant(Instant.ofEpochMilli(rawTimestamp), ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("HH:mm:ss")) + } else { + LocalDateTime.ofInstant(Instant.ofEpochMilli(rawTimestamp), ZoneId.systemDefault()) + .format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)) + } + } +} diff --git a/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/streaming/stock/Tick.kt b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/streaming/stock/Tick.kt new file mode 100644 index 0000000..40f00a0 --- /dev/null +++ b/app/src/main/kotlin/org/orbitmvi/orbit/sample/stocklist/streaming/stock/Tick.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.orbitmvi.orbit.sample.stocklist.streaming.stock + +enum class Tick { + Up, Down +} diff --git a/app/src/main/res/drawable/arrow_down_bold.xml b/app/src/main/res/drawable/arrow_down_bold.xml new file mode 100644 index 0000000..e751aa0 --- /dev/null +++ b/app/src/main/res/drawable/arrow_down_bold.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/arrow_up_bold.xml b/app/src/main/res/drawable/arrow_up_bold.xml new file mode 100644 index 0000000..607561d --- /dev/null +++ b/app/src/main/res/drawable/arrow_up_bold.xml @@ -0,0 +1,10 @@ + + + + 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..b4afc77 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_orbit_toolbar.xml b/app/src/main/res/drawable/ic_orbit_toolbar.xml new file mode 100644 index 0000000..c760523 --- /dev/null +++ b/app/src/main/res/drawable/ic_orbit_toolbar.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..bc0eef2 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..bc0eef2 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..b868402 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,19 @@ + + + + #000000 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..8e2fa83 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ + + + + Orbit Compose Stock List + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..d271e6c --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..861d605 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,85 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import com.appmattus.markdown.rules.LineLengthRule +import com.appmattus.markdown.rules.ProperNamesRule +import com.appmattus.markdown.rules.ProperNamesRule.Companion.DefaultNames +import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:7.0.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.10") + classpath("androidx.navigation:navigation-safe-args-gradle-plugin:2.3.5") + classpath("com.google.dagger:hilt-android-gradle-plugin:2.38.1") + } +} + +plugins { + id("com.github.ben-manes.versions") version "0.39.0" + id("com.appmattus.markdown") version "0.6.0" +} + +apply(from = "gradle/scripts/detekt.gradle.kts") + +tasks.register("clean") { + delete(rootProject.buildDir) +} + +tasks.withType { + resolutionStrategy { + componentSelection { + all { + fun isNonStable(version: String) = listOf( + "alpha", + "beta", + "rc", + "cr", + "m", + "preview", + "b", + "ea" + ).any { qualifier -> + version.matches(Regex("(?i).*[.-]$qualifier[.\\d-+]*")) + } + if (isNonStable(candidate.version) && !isNonStable(currentVersion)) { + reject("Release candidate") + } + } + } + } +} + +allprojects { + repositories { + google() + mavenCentral() + maven { setUrl("https://www.lightstreamer.com/repo/maven") } + maven { setUrl("https://dl.bintray.com/lisawray/maven") } + } +} + +markdownlint { + rules { + +LineLengthRule(codeBlocks = false, tables = false) + +ProperNamesRule(names = DefaultNames + "Orbit") + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..4a5436b --- /dev/null +++ b/gradle.properties @@ -0,0 +1,34 @@ +# +# Copyright 2021 Mikołaj Leszczyński & Appmattus Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. + +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +android.useAndroidX=true +android.enableJetifier=true diff --git a/gradle/scripts/detekt.gradle.kts b/gradle/scripts/detekt.gradle.kts new file mode 100644 index 0000000..1601bf6 --- /dev/null +++ b/gradle/scripts/detekt.gradle.kts @@ -0,0 +1,62 @@ +/* + * Copyright 2020 Babylon Partners Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import io.gitlab.arturbosch.detekt.Detekt +import io.gitlab.arturbosch.detekt.DetektPlugin + +buildscript { + repositories { + maven(url = "https://plugins.gradle.org/m2/") + } + dependencies { + classpath("io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.17.1") + } +} + +repositories { + mavenCentral() +} + +apply() + +tasks.named("detekt", Detekt::class.java).configure { + setSource(files(rootProject.projectDir)) + + include("**/*.kt") + include("**/*.kts") + exclude("**/resources/**") + exclude("**/build/**") + + parallel = true + + autoCorrect = true + buildUponDefaultConfig = true + config.setFrom(files("${rootProject.projectDir}/gradle/scripts/detekt.yml")) + + reports { + xml { + enabled = true + destination = file("build/reports/detekt/detekt.xml") + } + html { + enabled = true + } + } +} + +dependencies { + "detektPlugins"("io.gitlab.arturbosch.detekt:detekt-formatting:1.17.1") +} diff --git a/gradle/scripts/detekt.yml b/gradle/scripts/detekt.yml new file mode 100644 index 0000000..bdf51cb --- /dev/null +++ b/gradle/scripts/detekt.yml @@ -0,0 +1,16 @@ +build: + maxIssues: 0 + +formatting: + MaximumLineLength: + active: false + +style: + MaxLineLength: + maxLineLength: 145 # increased from 120 + MagicNumber: + excludes: "**/test/**,**/androidTest/**,**Test.kt,**Spec.kt,**Spek.kt,**.kts" + +complexity: + LongMethod: + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..29e4134 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/images/icon.svg b/images/icon.svg new file mode 100644 index 0000000..147bb91 --- /dev/null +++ b/images/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..b3d7ab1 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Mikołaj Leszczyński & Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +include("app")