diff --git a/.drone.yml b/.drone.yml index 82e296e..a2c0734 100644 --- a/.drone.yml +++ b/.drone.yml @@ -4,11 +4,71 @@ type: docker name: Android steps: +- name: prepare signing + image: busybox + environment: + STOREPASSWORD: + from_secret: StorePassword + KEYPASSWORD: + from_secret: KeyPassword + commands: + - touch keystore.properties + - echo "storePassword=$STOREPASSWORD" >> keystore.properties + - echo "keyPassword=$KEYPASSWORD" >> keystore.properties + - echo "keyAlias=key0" >> keystore.properties + - echo "storeFile=../AndroidKey" >> keystore.properties + +- name: generate versionCode + image: busybox + commands: + - touch version.properties + - let timestamp=$(date +%s)/10 + - echo "versionCode=$timestamp" >> version.properties + - name: build image: mingc/android-build-box commands: - - ./gradlew build + - ./gradlew test + - ./gradlew assembleRelease + - ./gradlew bundleRelease + +- name: deploy latest build + image: curlimages/curl + environment: + SEAFILE_API_KEY: + from_secret: SeafileApiKey + APK_FILE: app/build/outputs/apk/release/app-release.apk + BUNDLE_FILE: app/build/outputs/bundle/release/app-release.aab + SEAFILE_REPO: daffda8b-5840-4a65-b6d0-73b991facfb6 + commands: + - 'UPLOAD_URL=$(curl -H "Authorization: Token $SEAFILE_API_KEY" https://seafile.zobrist.me/api2/repos/$SEAFILE_REPO/upload-link/ | tr -d "\"")' + - 'curl -H "Authorization: Token $SEAFILE_API_KEY" -F file=@$APK_FILE -F parent_dir=/ -F relative_path=latest/ -F replace=1 "$UPLOAD_URL"' + - 'curl -H "Authorization: Token $SEAFILE_API_KEY" -F file=@$BUNDLE_FILE -F parent_dir=/ -F relative_path=latest/ -F replace=1 "$UPLOAD_URL"' + +- name: deploy tagged build + image: curlimages/curl + environment: + SEAFILE_API_KEY: + from_secret: SeafileApiKey + APK_FILE: app/build/outputs/apk/release/app-release.apk + BUNDLE_FILE: app/build/outputs/bundle/release/app-release.aab + SEAFILE_REPO: daffda8b-5840-4a65-b6d0-73b991facfb6 + commands: + - 'UPLOAD_URL=$(curl -H "Authorization: Token $SEAFILE_API_KEY" https://seafile.zobrist.me/api2/repos/$SEAFILE_REPO/upload-link/ | tr -d "\"")' + - 'curl -H "Authorization: Token $SEAFILE_API_KEY" -F file=@$APK_FILE -F parent_dir=/ -F relative_path=tagged/$DRONE_TAG/ -F replace=1 "$UPLOAD_URL"' + - 'curl -H "Authorization: Token $SEAFILE_API_KEY" -F file=@$BUNDLE_FILE -F parent_dir=/ -F relative_path=tagged/$DRONE_TAG/ -F replace=1 "$UPLOAD_URL"' + - 'curl -d "operation=rename&newname=app-release$DRONE_TAG.apk" -H "Authorization: Token $SEAFILE_API_KEY" https://seafile.zobrist.me/api2/repos/$SEAFILE_REPO/file/?p=/tagged/$DRONE_TAG/app-release.apk' + - 'curl -d "operation=rename&newname=app-release$DRONE_TAG.aab" -H "Authorization: Token $SEAFILE_API_KEY" https://seafile.zobrist.me/api2/repos/$SEAFILE_REPO/file/?p=/tagged/$DRONE_TAG/app-release.aab' when: - event: - - push - - pull_request \ No newline at end of file + event: + - tag + +- name: slack notification + image: plugins/slack + settings: + webhook: + from_secret: SlackWebhook + when: + status: + - failure + - success \ No newline at end of file diff --git a/.gitignore b/.gitignore index f0f71e2..5f99499 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ .externalNativeBuild .cxx .idea +keystore.properties +version.properties diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index 7dcb52c..0000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -Tichu Counter \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 88ea3aa..0000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,122 +0,0 @@ - - - - - - - - - -
- - - - xmlns:android - - ^$ - - - -
-
- - - - xmlns:.* - - ^$ - - - BY_NAME - -
-
- - - - .*:id - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:name - - http://schemas.android.com/apk/res/android - - - -
-
- - - - name - - ^$ - - - -
-
- - - - style - - ^$ - - - -
-
- - - - .* - - ^$ - - - BY_NAME - -
-
- - - - .* - - http://schemas.android.com/apk/res/android - - - ANDROID_ATTRIBUTE_ORDER - -
-
- - - - .* - - .* - - - BY_NAME - -
-
-
-
- - -
-
\ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index 79ee123..0000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index fb7f4a8..0000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/dictionaries/fabian.xml b/.idea/dictionaries/fabian.xml deleted file mode 100644 index 0fad81e..0000000 --- a/.idea/dictionaries/fabian.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - checkmark - tichu - tichucounter - zobrist - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 66ff961..0000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index bbc6cd7..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml deleted file mode 100644 index e34606c..0000000 --- a/.idea/jarRepositories.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 9f83b5d..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/AndroidKey b/AndroidKey new file mode 100644 index 0000000..5d88afa Binary files /dev/null and b/AndroidKey differ diff --git a/app/.gitignore b/app/.gitignore index 42afabf..956c004 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1 +1,2 @@ -/build \ No newline at end of file +/build +/release \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index a93790f..6628138 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,29 +1,74 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'com.google.dagger.hilt.android' + id 'kotlin-kapt' +} + +// Create a variable called keystorePropertiesFile, and initialize it to your +// keystore.properties file, in the rootProject folder. +def keystorePropertiesFile = rootProject.file("keystore.properties") +def versionPropertiesFile = rootProject.file("version.properties") + + +// Initialize a new Properties() object called keystoreProperties. +def keystoreProperties = new Properties() +def versionProperties = new Properties() + +def versionMajor = 2 +def versionMinor = 0 + +// Load your keystore.properties file into the keystoreProperties object. +keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +versionProperties.load(new FileInputStream(versionPropertiesFile)) + android { - compileSdkVersion 32 + compileSdkVersion 33 defaultConfig { applicationId "me.zobrist.tichucounter" - minSdkVersion 16 - targetSdkVersion 32 - versionCode 7 - versionName "1.0.0" - + minSdkVersion 21 + targetSdkVersion 33 + versionCode versionProperties["versionCode"].toInteger() + versionName "${versionMajor}.${versionMinor}.${versionProperties["versionCode"].toInteger()}" + resConfigs 'de', 'en' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } + multiDexEnabled true + vectorDrawables { + useSupportLibrary true + } + javaCompileOptions { + annotationProcessorOptions { + arguments += ["room.schemaLocation": "$projectDir/schemas".toString()] + } + } + } + signingConfigs { + create("release") { + keyAlias = keystoreProperties["keyAlias"] + keyPassword = keystoreProperties["keyPassword"] + storeFile = file(keystoreProperties["storeFile"]) + storePassword = keystoreProperties["storePassword"] + } + } buildTypes { release { minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + signingConfig = signingConfigs.getByName("release") } } buildFeatures { viewBinding = true + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.3.2" } compileOptions { @@ -34,22 +79,57 @@ android { jvmTarget = '1.8' } namespace 'me.zobrist.tichucounter' + packagingOptions { + resources { + excludes += '/META-INF/{AL2.0,LGPL2.1}' + } + } } dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation 'androidx.core:core-ktx:1.8.0' - implementation 'androidx.appcompat:appcompat:1.6.0-alpha05' - implementation 'com.google.android.material:material:1.6.1' + implementation 'androidx.core:core-ktx:1.9.0' + implementation 'androidx.appcompat:appcompat:1.6.0-rc01' + implementation "androidx.compose.material3:material3:1.0.1" implementation 'com.google.android.play:core-ktx:1.8.1' implementation 'com.google.android.play:core-ktx:1.8.1' - implementation 'com.google.code.gson:gson:2.8.9' + implementation 'com.google.code.gson:gson:2.9.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation 'androidx.navigation:navigation-fragment-ktx:2.4.2' - implementation 'androidx.navigation:navigation-ui-ktx:2.4.2' - testImplementation 'junit:junit:4.12' - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3' + implementation 'androidx.navigation:navigation-ui-ktx:2.5.3' + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' + implementation 'androidx.fragment:fragment-ktx:1.5.5' + implementation 'androidx.preference:preference-ktx:1.2.0' + implementation 'androidx.recyclerview:recyclerview:1.2.1' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1' + implementation 'androidx.compose.material:material-icons-extended:1.3.1' + implementation "com.google.accompanist:accompanist-systemuicontroller:0.27.0" + implementation 'androidx.activity:activity-compose:1.6.1' + implementation "androidx.compose.ui:ui:1.3.3" + implementation "androidx.compose.ui:ui-tooling-preview:1.3.3" + implementation "androidx.compose.runtime:runtime-livedata:1.3.3" + implementation "androidx.navigation:navigation-compose:2.5.3" + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1" + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + implementation "com.google.dagger:hilt-android:2.44" + androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.3.3" + debugImplementation "androidx.compose.ui:ui-tooling:1.3.3" + debugImplementation "androidx.compose.ui:ui-test-manifest:1.3.3" + kapt "com.google.dagger:hilt-compiler:2.44" + implementation "androidx.room:room-runtime:2.5.0" + annotationProcessor "androidx.room:room-compiler:2.5.0" + kapt "androidx.room:room-compiler:2.5.0" + implementation "androidx.room:room-ktx:2.5.0" + implementation "androidx.multidex:multidex:2.0.1" + api "androidx.navigation:navigation-fragment-ktx:2.5.3" +} +// Allow references to generated code +kapt { + correctErrorTypes true } \ No newline at end of file diff --git a/app/release/app-release.aab b/app/release/app-release.aab deleted file mode 100644 index f803bcf..0000000 Binary files a/app/release/app-release.aab and /dev/null differ diff --git a/app/schemas/me.zobrist.tichucounter.data.AppDatabase/1.json b/app/schemas/me.zobrist.tichucounter.data.AppDatabase/1.json new file mode 100644 index 0000000..324b84a --- /dev/null +++ b/app/schemas/me.zobrist.tichucounter.data.AppDatabase/1.json @@ -0,0 +1,102 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "1739540cd7d5436941316932a1036d83", + "entities": [ + { + "tableName": "Round", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gameId` INTEGER NOT NULL, `scoreA` INTEGER NOT NULL, `scoreB` INTEGER NOT NULL, `uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "gameId", + "columnName": "gameId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scoreA", + "columnName": "scoreA", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scoreB", + "columnName": "scoreB", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Game", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`active` INTEGER NOT NULL, `nameA` TEXT NOT NULL, `nameB` TEXT NOT NULL, `created` INTEGER NOT NULL, `modified` INTEGER NOT NULL, `uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nameA", + "columnName": "nameA", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nameB", + "columnName": "nameB", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1739540cd7d5436941316932a1036d83')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/me.zobrist.tichucounter.data.AppDatabase/2.json b/app/schemas/me.zobrist.tichucounter.data.AppDatabase/2.json new file mode 100644 index 0000000..914f81c --- /dev/null +++ b/app/schemas/me.zobrist.tichucounter.data.AppDatabase/2.json @@ -0,0 +1,102 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "1739540cd7d5436941316932a1036d83", + "entities": [ + { + "tableName": "Round", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gameId` INTEGER NOT NULL, `scoreA` INTEGER NOT NULL, `scoreB` INTEGER NOT NULL, `uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "gameId", + "columnName": "gameId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scoreA", + "columnName": "scoreA", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scoreB", + "columnName": "scoreB", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Game", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`active` INTEGER NOT NULL, `nameA` TEXT NOT NULL, `nameB` TEXT NOT NULL, `created` INTEGER NOT NULL, `modified` INTEGER NOT NULL, `uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nameA", + "columnName": "nameA", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nameB", + "columnName": "nameB", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1739540cd7d5436941316932a1036d83')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/me/zobrist/tichucounter/ExampleInstrumentedTest.kt b/app/src/androidTest/java/me/zobrist/tichucounter/ExampleInstrumentedTest.kt index 20588ed..ef6f3a2 100644 --- a/app/src/androidTest/java/me/zobrist/tichucounter/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/me/zobrist/tichucounter/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package me.zobrist.tichucounter -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3f3aab9..df25535 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,25 +2,31 @@ + android:theme="@style/AppTheme"> + android:exported="true" + android:windowSoftInputMode="adjustPan"> + + + () - - fun getScoreA(): Int - { - var tempScore=0 - scores.forEach { - tempScore+=it.scoreA - } - return tempScore - } - - fun getScoreB(): Int - { - var tempScore=0 - scores.forEach { - tempScore+=it.scoreB - } - return tempScore - } - - fun getHistoryA(): String - { - var tempHistory=String() - scores.forEach { - tempHistory+=it.scoreA.toString() + "\n" - } - return tempHistory - } - - fun getHistoryB(): String - { - var tempHistory=String() - scores.forEach { - tempHistory+=it.scoreB.toString() + "\n" - } - return tempHistory - } - - fun logRound(round: Round) - { - scores.add(round) - } - - fun revertLastRound() - { - if (scores.isNotEmpty()) - { - scores.removeAt(scores.size - 1) - } - } - - fun clearAll() - { - scores.clear() - } - - fun isEmpty(): Boolean - { - return scores.isEmpty() - } -} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/MainActivity.kt b/app/src/main/java/me/zobrist/tichucounter/MainActivity.kt index 21dd10d..b964c6c 100644 --- a/app/src/main/java/me/zobrist/tichucounter/MainActivity.kt +++ b/app/src/main/java/me/zobrist/tichucounter/MainActivity.kt @@ -1,614 +1,234 @@ package me.zobrist.tichucounter -import android.app.AlertDialog -import android.content.Intent import android.os.Bundle -import android.text.InputType -import android.view.Menu -import android.view.MenuItem import android.view.WindowManager -import android.view.inputmethod.InputMethodManager -import android.widget.ScrollView +import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate -import androidx.core.os.LocaleListCompat -import androidx.core.widget.doOnTextChanged -import com.google.gson.Gson -import me.zobrist.tichucounter.databinding.ActivityMainBinding -import java.util.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import me.zobrist.tichucounter.domain.* +import me.zobrist.tichucounter.ui.AppTheme +import me.zobrist.tichucounter.ui.MainViewModel +import me.zobrist.tichucounter.ui.about.AboutView +import me.zobrist.tichucounter.ui.composables.DropDownMenu +import me.zobrist.tichucounter.ui.counter.* +import me.zobrist.tichucounter.ui.history.HistoryList +import me.zobrist.tichucounter.ui.history.HistoryViewModel +import me.zobrist.tichucounter.ui.layout.DrawerContent +import me.zobrist.tichucounter.ui.layout.TopBar +import me.zobrist.tichucounter.ui.settings.SettingsView +import me.zobrist.tichucounter.ui.settings.SettingsViewModel +import javax.inject.Inject -class MainActivity : AppCompatActivity() -{ +@AndroidEntryPoint +class MainActivity : AppCompatActivity(), ISettingsChangeListener { - private var updateOnChange: Boolean=true + @Inject + lateinit var settingsAdapter: SettingsAdapter - private lateinit var history: History - private var currentRound=Round() - private var systemLocale=Locale.getDefault() + private val counterViewModel: CounterViewModel by viewModels() + private val historyViewModel: HistoryViewModel by viewModels() + private val settingsViewModel: SettingsViewModel by viewModels() + private val mainViewModel: MainViewModel by viewModels() - private lateinit var binding: ActivityMainBinding - - - override fun onCreate(savedInstanceState: Bundle?) - { + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding=ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - setSupportActionBar(binding.toolbar) + settingsAdapter.registerOnChangeListener(this) - binding.contentMain.inputTeamA.setRawInputType(InputType.TYPE_NULL) - binding.contentMain.inputTeamB.setRawInputType(InputType.TYPE_NULL) - binding.contentMain.inputTeamA.requestFocus() - disableSubmitButton() - updateTheme(this.getSharedPreferences("Settings" , MODE_PRIVATE).getInt("Theme" , 2)) - keepScreenOn( - this.getSharedPreferences("Settings" , MODE_PRIVATE) - .getBoolean("Screen_On" , false) - ) - - - val json=this.getSharedPreferences("Settings" , MODE_PRIVATE) - .getString("history" , "{\"scores\":[]}") - history=Gson().fromJson(json , History::class.java) - binding.contentMain.nameTeamA.setText( - this.getSharedPreferences("Settings" , MODE_PRIVATE).getString("nameTeamA" , "TeamA") - ) - binding.contentMain.nameTeamB.setText( - this.getSharedPreferences("Settings" , MODE_PRIVATE).getString("nameTeamB" , "TeamB") - ) - updateView() - - this.setListenes() - - - } - - private fun setListenes() - { - binding.contentMain.inputTeamA.setOnFocusChangeListener { _ , b -> - if (b) - { - hideKeyboard() - } - } - - binding.contentMain.inputTeamB.setOnFocusChangeListener { _ , b -> - if (b) - { - hideKeyboard() - } - } - - binding.contentMain.inputTeamA.doOnTextChanged { text , _ , _ , _ -> - if (binding.contentMain.inputTeamA.isFocused) - { - if (binding.contentMain.inputTeamA.text.isNotEmpty()) - { - if (updateOnChange) - { - currentRound=try - { - Round(text.toString().toInt() , true) - - } - catch (e: java.lang.Exception) - { - Round(1 , 1) - } - binding.contentMain.inputTeamB.setText(currentRound.scoreB.toString()) - } - else - { - updateOnChange=true - } - } - else - { - binding.contentMain.inputTeamA.text.clear() - binding.contentMain.inputTeamB.text.clear() - } - } - - if (currentRound.isValidRound() && binding.contentMain.inputTeamA.text.isNotEmpty() && binding.contentMain.inputTeamB.text.isNotEmpty()) - { - enableSubmitButton() - } - else - { - disableSubmitButton() - } - } - - binding.contentMain.inputTeamB.doOnTextChanged { text , _ , _ , _ -> - if (binding.contentMain.inputTeamB.isFocused) - { - if (binding.contentMain.inputTeamB.text.isNotEmpty()) - { - if (updateOnChange) - { - currentRound=try - { - Round(text.toString().toInt() , false) - - } - catch (e: java.lang.Exception) - { - Round(1 , 1) - } - binding.contentMain.inputTeamA.setText(currentRound.scoreA.toString()) - - } - else - { - updateOnChange=true - } - - } - else - { - binding.contentMain.inputTeamA.text.clear() - binding.contentMain.inputTeamB.text.clear() - } - } - - if (currentRound.isValidRound() && binding.contentMain.inputTeamA.text.isNotEmpty() && binding.contentMain.inputTeamB.text.isNotEmpty()) - { - enableSubmitButton() - } - else - { - disableSubmitButton() - } - } - - binding.contentMain.buttonAdd100.setOnClickListener { - giveFocusToAIfNone() - - if (binding.contentMain.inputTeamA.isFocused) - { - - currentRound.scoreA=try - { - binding.contentMain.inputTeamA.text.toString().toInt() + 100 - } - catch (e: Exception) - { - currentRound.scoreB=0 - binding.contentMain.inputTeamB.setText(currentRound.scoreB.toString()) - 100 - } - updateOnChange=false - binding.contentMain.inputTeamA.setText(currentRound.scoreA.toString()) - } - - if (binding.contentMain.inputTeamB.isFocused) - { - currentRound.scoreB=try - { - binding.contentMain.inputTeamB.text.toString().toInt() + 100 - } - catch (e: Exception) - { - currentRound.scoreA=0 - binding.contentMain.inputTeamA.setText(currentRound.scoreA.toString()) - 100 - - } - updateOnChange=false - binding.contentMain.inputTeamB.setText(currentRound.scoreB.toString()) - - } - } - - binding.contentMain.buttonSub100.setOnClickListener { - giveFocusToAIfNone() - - if (binding.contentMain.inputTeamA.isFocused) - { - currentRound.scoreA=try - { - binding.contentMain.inputTeamA.text.toString().toInt() - 100 - } - catch (e: Exception) - { - currentRound.scoreB=0 - binding.contentMain.inputTeamB.setText(currentRound.scoreB.toString()) - -100 - } - updateOnChange=false - binding.contentMain.inputTeamA.setText(currentRound.scoreA.toString()) - } - - if (binding.contentMain.inputTeamB.isFocused) - { - currentRound.scoreB=try - { - binding.contentMain.inputTeamB.text.toString().toInt() - 100 - } - catch (e: Exception) - { - currentRound.scoreA=0 - binding.contentMain.inputTeamA.setText(currentRound.scoreA.toString()) - -100 - } - updateOnChange=false - binding.contentMain.inputTeamB.setText(currentRound.scoreB.toString()) - } - } - - binding.contentMain.button0.setOnClickListener { - giveFocusToAIfNone() - appendToFocusedInput('0') - } - - binding.contentMain.button1.setOnClickListener { - giveFocusToAIfNone() - appendToFocusedInput('1') - } - - binding.contentMain.button2.setOnClickListener { - giveFocusToAIfNone() - appendToFocusedInput('2') - } - - binding.contentMain.button3.setOnClickListener { - giveFocusToAIfNone() - appendToFocusedInput('3') - } - - binding.contentMain.button4.setOnClickListener { - giveFocusToAIfNone() - appendToFocusedInput('4') - } - - binding.contentMain.button5.setOnClickListener { - giveFocusToAIfNone() - appendToFocusedInput('5') - } - - binding.contentMain.button6.setOnClickListener { - giveFocusToAIfNone() - appendToFocusedInput('6') - } - - binding.contentMain.button7.setOnClickListener { - giveFocusToAIfNone() - appendToFocusedInput('7') - } - - binding.contentMain.button8.setOnClickListener { - giveFocusToAIfNone() - appendToFocusedInput('8') - } - - binding.contentMain.button9.setOnClickListener { - giveFocusToAIfNone() - appendToFocusedInput('9') - } - - binding.contentMain.buttonInv.setOnClickListener { - val tempInt: Int - - giveFocusToAIfNone() - - if (binding.contentMain.inputTeamA.isFocused) - { - if (binding.contentMain.inputTeamA.text.toString().equals("-")) - { - binding.contentMain.inputTeamA.text.clear() - } - else if (binding.contentMain.inputTeamA.text.isNotEmpty()) - { - tempInt=binding.contentMain.inputTeamA.text.toString().toInt() * -1 - binding.contentMain.inputTeamA.setText(tempInt.toString()) - } - else - { - updateOnChange=false - appendToFocusedInput('-') - currentRound=Round(1 , 1) - } - - - } - else if (binding.contentMain.inputTeamB.isFocused) - { - if (binding.contentMain.inputTeamB.text.toString().equals("-")) - { - binding.contentMain.inputTeamB.text.clear() - } - else if (binding.contentMain.inputTeamB.text.isNotEmpty()) - { - tempInt=binding.contentMain.inputTeamB.text.toString().toInt() * -1 - binding.contentMain.inputTeamB.setText(tempInt.toString()) - } - else - { - updateOnChange=false - appendToFocusedInput('-') - currentRound=Round(1 , 1) - } - } - } - - binding.contentMain.buttonBack.setOnClickListener { - giveFocusToAIfNone() - - if (binding.contentMain.inputTeamA.isFocused) - { - if (binding.contentMain.inputTeamA.text.isNotEmpty()) - { - val string=binding.contentMain.inputTeamA.text.toString() - binding.contentMain.inputTeamA.setText(string.substring(0 , string.length - 1)) - } - - } - else if (binding.contentMain.inputTeamB.isFocused) - { - if (binding.contentMain.inputTeamB.text.isNotEmpty()) - { - val string=binding.contentMain.inputTeamB.text.toString() - binding.contentMain.inputTeamB.setText(string.substring(0 , string.length - 1)) - } - } - } - - binding.contentMain.submit.setOnClickListener { - giveFocusToAIfNone() - - if (binding.contentMain.inputTeamA.text.isNotEmpty() && binding.contentMain.inputTeamB.text.isNotEmpty()) - { - - history.logRound( - Round( - binding.contentMain.inputTeamA.text.toString().toInt() , - binding.contentMain.inputTeamB.text.toString().toInt() - ) - ) - - updateView() - - binding.contentMain.inputTeamA.text.clear() - binding.contentMain.inputTeamB.text.clear() - disableSubmitButton() - - binding.contentMain.scrollViewHistory.fullScroll(ScrollView.FOCUS_DOWN) + setContent { + AppTheme { + val systemUiController = rememberSystemUiController() + systemUiController.setStatusBarColor(MaterialTheme.colorScheme.background) + NavigationDrawer() } } } - override fun onSaveInstanceState(outState: Bundle) - { - super.onSaveInstanceState(outState) - - val prefs=this.getSharedPreferences("Settings" , MODE_PRIVATE).edit() - prefs.putString("history" , Gson().toJson(history)) - prefs.putString("nameTeamA" , binding.contentMain.nameTeamA.text.toString()) - prefs.putString("nameTeamB" , binding.contentMain.nameTeamB.text.toString()) - prefs.apply() - + override fun onDestroy() { + super.onDestroy() + settingsAdapter.unregisterOnChangeListener(this) } - override fun onCreateOptionsMenu(menu: Menu): Boolean - { - // Inflate the menu; this adds items to the action bar if it is present. - menuInflater.inflate(R.menu.menu_main , menu) - - menu.findItem(R.id.action_screenOn).isChecked= - this.getSharedPreferences("Settings" , MODE_PRIVATE) - .getBoolean("Screen_On" , false) - return true + override fun onLanguageChanged(language: Language) { + AppCompatDelegate.setApplicationLocales(language.value) } - - override fun onOptionsItemSelected(item: MenuItem): Boolean - { - return when (item.itemId) - { - R.id.action_clear -> - { - val builder=AlertDialog.Builder(this) - builder.setMessage(getString(R.string.confirmClear)) - .setTitle(R.string.clear) - .setCancelable(false) - .setPositiveButton(getString(R.string.yes)) { dialog , _ -> - dialog.dismiss() - clearAll() - } - .setNegativeButton(getString(R.string.no)) { dialog , _ -> - dialog.cancel() - } - - builder.create().show() - true - } - R.id.action_undo -> - { - undoLastRound() - true - } - R.id.action_theme -> - { - chooseThemeDialog() - true - } - R.id.action_language -> - { - chooseLanguageDialog() - true - } - R.id.action_screenOn -> - { - item.isChecked=!item.isChecked - keepScreenOn(item.isChecked) - true - } - else -> super.onOptionsItemSelected(item) + override fun onThemeChanged(theme: Theme) { + val themeValue = when (theme) { + Theme.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO + Theme.DARK -> AppCompatDelegate.MODE_NIGHT_YES + Theme.DEFAULT -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM } + AppCompatDelegate.setDefaultNightMode(themeValue) } - private fun hideKeyboard() - { - val imm: InputMethodManager= - getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(currentFocus!!.windowToken , 0) - } - - private fun giveFocusToAIfNone() - { - if (!binding.contentMain.inputTeamA.isFocused && !binding.contentMain.inputTeamB.isFocused) - { - binding.contentMain.inputTeamA.requestFocus() - } - } - - private fun undoLastRound() - { - history.revertLastRound() - updateView() - } - - private fun updateView() - { - binding.contentMain.scoreA.text=history.getScoreA().toString() - binding.contentMain.scoreB.text=history.getScoreB().toString() - - binding.contentMain.historyA.text=history.getHistoryA() - binding.contentMain.historyB.text=history.getHistoryB() - } - - private fun clearAll() - { - binding.contentMain.historyA.text="" - binding.contentMain.historyB.text="" - binding.contentMain.inputTeamA.text.clear() - binding.contentMain.inputTeamB.text.clear() - binding.contentMain.scoreA.text="0" - binding.contentMain.scoreB.text="0" - - history.clearAll() - } - - private fun appendToFocusedInput(toAppend: Char) - { - if (binding.contentMain.inputTeamA.isFocused) - { - binding.contentMain.inputTeamA.text.append(toAppend) - } - else if (binding.contentMain.inputTeamB.isFocused) - { - binding.contentMain.inputTeamB.text.append(toAppend) - } - } - - private fun enableSubmitButton() - { - binding.contentMain.submit.imageAlpha=255 // 0 being transparent and 255 being opaque - binding.contentMain.submit.isEnabled=true - } - - private fun disableSubmitButton() - { - binding.contentMain.submit.imageAlpha=60 // 0 being transparent and 255 being opaque - binding.contentMain.submit.isEnabled=false - } - - private fun chooseThemeDialog() - { - - val builder=AlertDialog.Builder(this) - builder.setTitle(getString(R.string.choose_theme_text)) - val styles=arrayOf( - getString(R.string.light) , - getString(R.string.dark) , - getString(R.string.android_default_text) - ) - - val checkedItem= - this.getSharedPreferences("Settings" , MODE_PRIVATE).getInt("Theme" , 2) - - val prefs=this.getSharedPreferences("Settings" , MODE_PRIVATE).edit() - - - builder.setSingleChoiceItems(styles , checkedItem) { dialog , which -> - - prefs.putInt("Theme" , which) - prefs.apply() - - updateTheme(which) - - dialog.dismiss() - } - - val dialog=builder.create() - dialog.show() - } - - private fun chooseLanguageDialog() - { - - val builder=AlertDialog.Builder(this) - builder.setTitle(getString(R.string.choose_language_text)) - - val languages_map=mapOf( - getString(R.string.english) to "en" , - getString(R.string.german) to "de" - ) - - val languages_display_keys=languages_map.keys.toTypedArray() - val languages_display_values=languages_map.values.toTypedArray() - - val checkedItem=AppCompatDelegate.getApplicationLocales()[0].toString() - var checkedItemIndex=languages_display_values.indexOf(checkedItem) - - if (checkedItemIndex == -1) - { - checkedItemIndex=0 - } - - builder.setSingleChoiceItems(languages_display_keys , checkedItemIndex) { dialog , which -> - - val newLocale= - LocaleListCompat.forLanguageTags(languages_map[languages_display_keys[which]]) - - AppCompatDelegate.setApplicationLocales(newLocale) - - startActivity(Intent(this , MainActivity::class.java)) - finish() - - dialog.dismiss() - } - - val dialog=builder.create() - dialog.show() - } - - private fun updateTheme(which: Int) - { - when (which) - { - 0 -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) - 1 -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) - 2 -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) - } - delegate.applyDayNight() - } - - private fun keepScreenOn(keepOn: Boolean) - { - if (keepOn) - { + override fun onScreenOnChanged(keepOn: KeepScreenOn) { + if (keepOn.value) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - else - { + } else { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } + } - val prefs=this.getSharedPreferences("Settings" , MODE_PRIVATE).edit() - prefs.putBoolean("Screen_On" , keepOn) - prefs.apply() + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun NavigationDrawer() { + val drawerState = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() + val navController = rememberNavController() + + val items = listOf( + DrawerItem( + Route.COUNTER, + Icons.Outlined.Calculate, + stringResource(R.string.menu_counter) + ), + DrawerItem(Route.HISTORY, Icons.Outlined.List, stringResource(R.string.menu_history)), + DrawerItem( + Route.SETTINGS, + Icons.Outlined.Settings, + stringResource(R.string.menu_settings) + ), + DrawerItem( + Route.ABOUT, + Icons.Outlined.Info, + stringResource(R.string.menu_about) + ) + ) + + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = + Route.valueOf(navBackStackEntry?.destination?.route ?: Route.COUNTER.name) + + ModalNavigationDrawer( + drawerState = drawerState, + gesturesEnabled = drawerState.isOpen, + drawerContent = { + DrawerContent( + drawerItems = items, + selectedDrawerItem = items.first { it.route == currentDestination }) { + scope.launch { + drawerState.close() + + } + navController.navigate(it) + } + } + ) { + MyScaffoldLayout( + drawerState, + scope, + navController, + counterViewModel.keyboardHidden && (currentDestination == Route.COUNTER) + ) { counterViewModel.showKeyboard() } + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun MyScaffoldLayout( + drawerState: DrawerState, + scope: CoroutineScope, + navController: NavHostController, + showFab: Boolean, + fabAction: () -> Unit + ) { + + var topBarState by remember { mutableStateOf(TopBarState()) } + + Scaffold( + floatingActionButton = { + if (showFab) { + FloatingActionButton( + onClick = { fabAction() }) { + Icon(Icons.Outlined.Keyboard, null) + } + } + }, + topBar = { TopBar(topBarState) }) { paddings -> + + NavHost( + navController = navController, + startDestination = Route.COUNTER.name, + modifier = Modifier.padding(paddings) + ) { + composable(Route.COUNTER) { + + var expanded by remember { mutableStateOf(false) } + + topBarState = TopBarState( + title = stringResource(R.string.app_name), + actions = (listOf( + TopBarAction( + Icons.Outlined.Undo, + mainViewModel.isUndoActionActive, + { mainViewModel.undoLastRound() }), + TopBarAction( + Icons.Outlined.Redo, + mainViewModel.isRedoActionActive, + { mainViewModel.redoLastRound() }), + TopBarAction( + Icons.Outlined.MoreVert, + mainViewModel.activeGameHasRounds, + { expanded = true } + ) { + DropDownMenu( + mapOf("new" to R.string.newGame), + "", + expanded, + ) { + expanded = false + it?.let { + when (it) { + "new" -> mainViewModel.newGame() + } + } + } + }, + + )) + ) { scope.launch { drawerState.open() } } + + Counter(counterViewModel) + } + composable(Route.HISTORY) { + topBarState = + TopBarState(title = stringResource(R.string.menu_history)) { scope.launch { drawerState.open() } } + + HistoryList(historyViewModel) { navController.navigate(Route.COUNTER) } + } + composable(Route.SETTINGS) { + topBarState = + TopBarState(title = stringResource(R.string.menu_settings)) { scope.launch { drawerState.open() } } + + SettingsView(settingsViewModel) + } + + composable(Route.ABOUT) { + topBarState = + TopBarState(title = stringResource(R.string.menu_about)) { scope.launch { drawerState.open() } } + + AboutView() + } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/Round.kt b/app/src/main/java/me/zobrist/tichucounter/Round.kt deleted file mode 100644 index 93188e3..0000000 --- a/app/src/main/java/me/zobrist/tichucounter/Round.kt +++ /dev/null @@ -1,47 +0,0 @@ -package me.zobrist.tichucounter - -import java.io.Serializable - -class Round() : Serializable -{ - var scoreA: Int=0 - var scoreB: Int=0 - - constructor(score: Int , isScoreA: Boolean) : this() - { - if (isScoreA) - { - scoreA=score - scoreB=calculateOtherScore(scoreA) - } - else - { - scoreB=score - scoreA=calculateOtherScore(scoreB) - } - } - - constructor(scoreA: Int , scoreB: Int) : this() - { - this.scoreA=scoreA - this.scoreB=scoreB - } - - private fun calculateOtherScore(score: Int): Int - { - if (score.isMultipleOf100() && score != 0) - { - return 0 - } - if (score in 101 ..125) - { - return 0 - (score % 100) - } - return 100 - (score % 100) - } - - fun isValidRound(): Boolean - { - return (scoreA.isMultipleOf5()) && scoreB.isMultipleOf5() && (scoreA + scoreB).isMultipleOf100() - } -} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/data/AppDatabase.kt b/app/src/main/java/me/zobrist/tichucounter/data/AppDatabase.kt new file mode 100644 index 0000000..06eca31 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/data/AppDatabase.kt @@ -0,0 +1,14 @@ +package me.zobrist.tichucounter.data + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import me.zobrist.tichucounter.data.entity.Game +import me.zobrist.tichucounter.data.entity.Round + +@Database(entities = [Round::class, Game::class], version = 1) +@TypeConverters(DateConverter::class) +abstract class AppDatabase : RoomDatabase() { + abstract fun roundDao(): RoundDao + abstract fun gameDao(): GameDao +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/data/DaoBase.kt b/app/src/main/java/me/zobrist/tichucounter/data/DaoBase.kt new file mode 100644 index 0000000..5f4a9c3 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/data/DaoBase.kt @@ -0,0 +1,22 @@ +package me.zobrist.tichucounter.data + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Update + +@Dao +interface DaoBase { + + @Insert + fun insert(entity: T): Long + + @Update + fun update(entity: T) + + @Delete + fun delete(entity: T) + + @Delete + fun delete(entity: List) +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/data/DatabaseModule.kt b/app/src/main/java/me/zobrist/tichucounter/data/DatabaseModule.kt new file mode 100644 index 0000000..4ac90d2 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/data/DatabaseModule.kt @@ -0,0 +1,34 @@ +package me.zobrist.tichucounter.data + +import android.content.Context +import androidx.room.Room +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +class DatabaseModule { + @Provides + fun provideRoundDao(appDatabase: AppDatabase): RoundDao { + return appDatabase.roundDao() + } + + @Provides + fun provideGameDao(appDatabase: AppDatabase): GameDao { + return appDatabase.gameDao() + } + + @Provides + @Singleton + fun provideAppDatabase(@ApplicationContext appContext: Context): AppDatabase { + return Room.databaseBuilder( + appContext, + AppDatabase::class.java, + "TichuCounterDb" + ).build() + } +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/data/DateConverter.kt b/app/src/main/java/me/zobrist/tichucounter/data/DateConverter.kt new file mode 100644 index 0000000..be2a9db --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/data/DateConverter.kt @@ -0,0 +1,18 @@ +package me.zobrist.tichucounter.data + +import androidx.room.ProvidedTypeConverter +import androidx.room.TypeConverter +import java.util.* + +@ProvidedTypeConverter +object DateConverter { + @TypeConverter + fun toDate(dateLong: Long?): Date? { + return dateLong?.let { Date(it) } + } + + @TypeConverter + fun fromDate(date: Date?): Long? { + return date?.time + } +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/data/GameDao.kt b/app/src/main/java/me/zobrist/tichucounter/data/GameDao.kt new file mode 100644 index 0000000..00a9855 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/data/GameDao.kt @@ -0,0 +1,35 @@ +package me.zobrist.tichucounter.data + +import androidx.room.* +import kotlinx.coroutines.flow.Flow +import me.zobrist.tichucounter.data.entity.Game + + +@Dao +interface GameDao : DaoBase { + + @Query("SELECT * FROM game") + fun getAll(): Flow> + + @Transaction + @Query("SELECT * FROM game where uid ") + fun getGamesWithRounds(): Flow> + + @Transaction + @Query("SELECT * FROM game WHERE active is 1") + fun getActiveWithRounds(): Flow + + @Query("SELECT * FROM game WHERE uid is :gameId") + fun getGameById(gameId: Long): Flow + + @Query("SELECT * FROM game WHERE active is 1") + fun getActive(): Flow + + + @Query("UPDATE game SET active = 1 WHERE uid is :gameId;") + fun setActive(gameId: Long) + + @Query("UPDATE game SET active = 0 WHERE uid is not :gameId;") + fun setOthersInactive(gameId: Long) + +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/data/GameWithScores.kt b/app/src/main/java/me/zobrist/tichucounter/data/GameWithScores.kt new file mode 100644 index 0000000..44d2d54 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/data/GameWithScores.kt @@ -0,0 +1,17 @@ +package me.zobrist.tichucounter.data + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.Relation +import me.zobrist.tichucounter.data.entity.Game +import me.zobrist.tichucounter.data.entity.Round + +@Entity +data class GameWithScores( + @Embedded val game: Game, + @Relation( + parentColumn = "uid", + entityColumn = "gameId" + ) + val rounds: List +) \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/data/RoundDao.kt b/app/src/main/java/me/zobrist/tichucounter/data/RoundDao.kt new file mode 100644 index 0000000..2da3a09 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/data/RoundDao.kt @@ -0,0 +1,15 @@ +package me.zobrist.tichucounter.data + +import androidx.room.* +import me.zobrist.tichucounter.data.entity.Round + +@Dao +interface RoundDao : DaoBase { + + @Query("SELECT * FROM round") + fun getAll(): List + + @Query("SELECT * FROM round WHERE gameId is :gameId") + fun getAllForGame(gameId: Long?): List + +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/data/entity/Game.kt b/app/src/main/java/me/zobrist/tichucounter/data/entity/Game.kt new file mode 100644 index 0000000..e70a2de --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/data/entity/Game.kt @@ -0,0 +1,15 @@ +package me.zobrist.tichucounter.data.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.util.* + +@Entity +data class Game( + var active: Boolean, + var nameA: String, + var nameB: String, + val created: Date, + var modified: Date, + @PrimaryKey(autoGenerate = true) override val uid: Long = 0 +) : IEntity \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/data/entity/IEntity.kt b/app/src/main/java/me/zobrist/tichucounter/data/entity/IEntity.kt new file mode 100644 index 0000000..23b9c73 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/data/entity/IEntity.kt @@ -0,0 +1,5 @@ +package me.zobrist.tichucounter.data.entity + +interface IEntity { + val uid: Long +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/data/entity/Round.kt b/app/src/main/java/me/zobrist/tichucounter/data/entity/Round.kt new file mode 100644 index 0000000..2e21660 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/data/entity/Round.kt @@ -0,0 +1,12 @@ +package me.zobrist.tichucounter.data.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class Round( + var gameId: Long, + var scoreA: Int, + var scoreB: Int, + @PrimaryKey(autoGenerate = true) override val uid: Long = 0 +) : IEntity \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/domain/DrawerItem.kt b/app/src/main/java/me/zobrist/tichucounter/domain/DrawerItem.kt new file mode 100644 index 0000000..b3757ef --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/domain/DrawerItem.kt @@ -0,0 +1,5 @@ +package me.zobrist.tichucounter.domain + +import androidx.compose.ui.graphics.vector.ImageVector + +data class DrawerItem(val route: Route, val menuIcon: ImageVector, val menuName: String) \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/domain/GameWithScoresExtension.kt b/app/src/main/java/me/zobrist/tichucounter/domain/GameWithScoresExtension.kt new file mode 100644 index 0000000..5094493 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/domain/GameWithScoresExtension.kt @@ -0,0 +1,14 @@ +package me.zobrist.tichucounter.domain + +import me.zobrist.tichucounter.data.GameWithScores + +fun GameWithScores.getTotalPoints(): Pair { + var scoreA = 0 + var scoreB = 0 + + this.rounds.forEach { + scoreA += it.scoreA + scoreB += it.scoreB + } + return Pair(scoreA, scoreB) +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/domain/IntExtensions.kt b/app/src/main/java/me/zobrist/tichucounter/domain/IntExtensions.kt new file mode 100644 index 0000000..12e7864 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/domain/IntExtensions.kt @@ -0,0 +1,11 @@ +package me.zobrist.tichucounter.domain + + +fun Int.isMultipleOf5(): Boolean { + return (this % 5) == 0 +} + +fun Int.isMultipleOf100(): Boolean { + return (this % 100) == 0 +} + diff --git a/app/src/main/java/me/zobrist/tichucounter/domain/NavExtensions.kt b/app/src/main/java/me/zobrist/tichucounter/domain/NavExtensions.kt new file mode 100644 index 0000000..68771fb --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/domain/NavExtensions.kt @@ -0,0 +1,30 @@ +package me.zobrist.tichucounter.domain + +import androidx.compose.runtime.Composable +import androidx.navigation.* +import androidx.navigation.compose.composable + +fun NavController.navigate(route: Route) { + this.navigate(route.name){ + // Pop up to the start destination of the graph to + // avoid building up a large stack of destinations + // on the back stack as users select items + popUpTo(Route.COUNTER.name) { + saveState = true + } + // Avoid multiple copies of the same destination when + // reselecting the same item + launchSingleTop = true + // Restore state when reselecting a previously selected item + restoreState = true + } +} + +fun NavGraphBuilder.composable( + route: Route, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + content: @Composable (NavBackStackEntry) -> Unit +) { + this.composable(route.name, arguments, deepLinks, content) +} diff --git a/app/src/main/java/me/zobrist/tichucounter/domain/Route.kt b/app/src/main/java/me/zobrist/tichucounter/domain/Route.kt new file mode 100644 index 0000000..5ae62a8 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/domain/Route.kt @@ -0,0 +1,3 @@ +package me.zobrist.tichucounter.domain + +enum class Route { COUNTER, HISTORY, SETTINGS, ABOUT } diff --git a/app/src/main/java/me/zobrist/tichucounter/domain/SettingsAdapter.kt b/app/src/main/java/me/zobrist/tichucounter/domain/SettingsAdapter.kt new file mode 100644 index 0000000..452e678 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/domain/SettingsAdapter.kt @@ -0,0 +1,116 @@ +package me.zobrist.tichucounter.domain + +import android.content.Context +import androidx.core.os.LocaleListCompat +import androidx.preference.PreferenceManager +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +enum class Theme { DEFAULT, DARK, LIGHT } +enum class Language(val value: LocaleListCompat) { + DEFAULT(LocaleListCompat.getEmptyLocaleList()), + ENGLISH(LocaleListCompat.forLanguageTags("en")), + GERMAN(LocaleListCompat.forLanguageTags("de")) +} + +enum class KeepScreenOn(val value: Boolean) { ON(true), OFF(false) } + +interface ISettingsChangeListener { + fun onLanguageChanged(language: Language) + fun onThemeChanged(theme: Theme) + fun onScreenOnChanged(keepOn: KeepScreenOn) +} + +@Singleton +class SettingsAdapter @Inject constructor(@ApplicationContext private val context: Context) { + + private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + private var listenerList = mutableListOf() + + var language: Language + private set + + var theme: Theme + private set + + var keepScreenOn: KeepScreenOn + private set + + init { + language = try { + enumValueOf(sharedPreferences.getString(Language::class.simpleName, null)!!) + } catch (_: NullPointerException) { + Language.DEFAULT + } + + theme = try { + enumValueOf(sharedPreferences.getString(Theme::class.simpleName, null)!!) + } catch (_: java.lang.Exception) { + Theme.DEFAULT + } + + keepScreenOn = try { + enumValueOf(sharedPreferences.getString(KeepScreenOn::class.simpleName, null)!!) + } catch (_: java.lang.Exception) { + KeepScreenOn.OFF + } + } + + fun registerOnChangeListener(listener: ISettingsChangeListener) { + listenerList.add(listener) + + listener.onThemeChanged(theme) + listener.onLanguageChanged(language) + listener.onScreenOnChanged(keepScreenOn) + } + + fun unregisterOnChangeListener(listener: ISettingsChangeListener?) { + if (listener != null) { + listenerList.remove(listener) + } + } + + fun setLanguage(language: Language) { + this.language = language + updatePreference(Language::class.simpleName, language.name) + notifyListeners(language) + } + + fun setTheme(theme: Theme) { + this.theme = theme + updatePreference(Theme::class.simpleName, theme.name) + notifyListeners(theme) + } + + fun setKeepScreenOn(setting: KeepScreenOn) { + this.keepScreenOn = setting + updatePreference(KeepScreenOn::class.simpleName, setting.name) + notifyListeners(setting) + } + + private fun updatePreference(name: String?, value: String) { + val editor = sharedPreferences.edit() + editor.putString(name, value) + editor.apply() + } + + private fun notifyListeners(language: Language) { + listenerList.forEach { + it.onLanguageChanged(language) + } + } + + private fun notifyListeners(theme: Theme) { + listenerList.forEach { + it.onThemeChanged(theme) + } + } + + private fun notifyListeners(keepScreenOn: KeepScreenOn) { + listenerList.forEach { + it.onScreenOnChanged(keepScreenOn) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/domain/Tichu.kt b/app/src/main/java/me/zobrist/tichucounter/domain/Tichu.kt new file mode 100644 index 0000000..95599eb --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/domain/Tichu.kt @@ -0,0 +1,26 @@ +package me.zobrist.tichucounter.domain + +import javax.inject.Inject + +class Tichu @Inject constructor() { + + fun calculateOtherScore(score: Int): Int? { + if (!score.isMultipleOf5()) { + return null + } + if (score.isMultipleOf100() && score != 0) { + return 0 + } + if (score in 101..125) { + return 0 - (score % 100) + } + return 100 - (score % 100) + } + + fun isValidRound(scoreA: Int?, scoreB: Int?): Boolean { + if (scoreA == null || scoreB == null) { + return false + } + return (scoreA.isMultipleOf5()) && scoreB.isMultipleOf5() && (scoreA + scoreB).isMultipleOf100() + } +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/domain/TopBarAction.kt b/app/src/main/java/me/zobrist/tichucounter/domain/TopBarAction.kt new file mode 100644 index 0000000..0a9eb39 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/domain/TopBarAction.kt @@ -0,0 +1,11 @@ +package me.zobrist.tichucounter.domain + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector + +class TopBarAction( + val imageVector: ImageVector, + val isActive: Boolean, + val action: () -> Unit, + val composeCode: @Composable () -> Unit = {} +) \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/domain/TopBarState.kt b/app/src/main/java/me/zobrist/tichucounter/domain/TopBarState.kt new file mode 100644 index 0000000..46bbd46 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/domain/TopBarState.kt @@ -0,0 +1,12 @@ +package me.zobrist.tichucounter.domain + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Menu +import androidx.compose.ui.graphics.vector.ImageVector + +data class TopBarState( + var title: String = "", + var icon: ImageVector = Icons.Outlined.Menu, + var actions: List = emptyList(), + var onNavigate: () -> Unit = {} +) \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/framework/TichuCounterApplication.kt b/app/src/main/java/me/zobrist/tichucounter/framework/TichuCounterApplication.kt new file mode 100644 index 0000000..786dfd8 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/framework/TichuCounterApplication.kt @@ -0,0 +1,7 @@ +package me.zobrist.tichucounter.framework + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class TichuCounterApplication : Application() diff --git a/app/src/main/java/me/zobrist/tichucounter/repository/GameRepository.kt b/app/src/main/java/me/zobrist/tichucounter/repository/GameRepository.kt new file mode 100644 index 0000000..5716266 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/repository/GameRepository.kt @@ -0,0 +1,129 @@ +package me.zobrist.tichucounter.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.zobrist.tichucounter.data.GameDao +import me.zobrist.tichucounter.data.GameWithScores +import me.zobrist.tichucounter.data.RoundDao +import me.zobrist.tichucounter.data.entity.Game +import me.zobrist.tichucounter.data.entity.Round +import java.util.* +import javax.inject.Inject + +class GameRepository @Inject constructor( + private val gameDao: GameDao, + private val roundDao: RoundDao +) { + + private var _activeGame: Game? = null + + val activeGame: Game + get() { + return _activeGame!! + } + + init { + CoroutineScope(Dispatchers.IO).launch { + gameDao.getActive().collect { + if (it == null) { + gameDao.insert(Game(true, "TeamA", "TeamB", Date(), Date())) + } else { + _activeGame = it + } + } + } + } + + suspend fun newGame() { + withContext(Dispatchers.IO) { + val id = + gameDao.insert(Game(true, activeGame.nameA, activeGame.nameB, Date(), Date())) + setActive(id) + } + } + + suspend fun updateGame(game: Game) { + game.modified = Date() + withContext(Dispatchers.IO) { + gameDao.update(game) + } + } + + suspend fun setActive(id: Long) { + withContext(Dispatchers.IO) { + gameDao.setActive(id) + gameDao.setOthersInactive(id) + } + } + + suspend fun getLastRound(): Round? { + return try { + withContext(Dispatchers.IO) { + roundDao.getAllForGame(activeGame.uid).last() + } + } catch (_: NoSuchElementException) { + null + } + } + + suspend fun deleteLastRound() { + withContext(Dispatchers.IO) { + try { + roundDao.delete(getLastRound()!!) + } catch (_: NullPointerException) { + } + } + } + + suspend fun addRoundToActiveGame(scoreA: Int, scoreB: Int) { + withContext(Dispatchers.IO) { + val active = activeGame + active.modified = Date() + val round = Round(active.uid, scoreA, scoreB) + roundDao.insert(round) + gameDao.update(active) + } + } + + suspend fun deleteGame(uid: Long) { + withContext(Dispatchers.IO) { + try { + gameDao.getGameById(uid).take(1).collect { + gameDao.delete(it) + val rounds = roundDao.getAllForGame(it.uid) + roundDao.delete(rounds) + } + } catch (_: NullPointerException) { + } + } + } + + suspend fun deleteAllInactive() { + withContext(Dispatchers.IO) { + try { + gameDao.getAll().take(1).collect { games -> + + val activeId = games.first { it.active }.uid + val gamesToDelete = games.filter { !it.active } + val roundsToDelete = roundDao.getAll().filter { it.gameId != activeId } + + gameDao.delete(gamesToDelete) + roundDao.delete(roundsToDelete) + } + } catch (_: NullPointerException) { + } + } + } + + fun getActiveGameFlow(): Flow { + return gameDao.getActiveWithRounds() + } + + fun getAllWithRoundFlow(): Flow> { + return gameDao.getGamesWithRounds() + } +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/ui/Color.kt b/app/src/main/java/me/zobrist/tichucounter/ui/Color.kt new file mode 100644 index 0000000..4dea1c7 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/ui/Color.kt @@ -0,0 +1,70 @@ +@file:Suppress("unused") + +package me.zobrist.tichucounter.ui + +import androidx.compose.ui.graphics.Color + +val md_theme_light_primary = Color(0xFF9C404D) +val md_theme_light_onPrimary = Color(0xFFFFFFFF) +val md_theme_light_primaryContainer = Color(0xFFFFDADB) +val md_theme_light_onPrimaryContainer = Color(0xFF40000F) +val md_theme_light_secondary = Color(0xFF765659) +val md_theme_light_onSecondary = Color(0xFFFFFFFF) +val md_theme_light_secondaryContainer = Color(0xFFFFDADB) +val md_theme_light_onSecondaryContainer = Color(0xFF2C1517) +val md_theme_light_tertiary = Color(0xFF775930) +val md_theme_light_onTertiary = Color(0xFFFFFFFF) +val md_theme_light_tertiaryContainer = Color(0xFFFFDDB5) +val md_theme_light_onTertiaryContainer = Color(0xFF2A1800) +val md_theme_light_error = Color(0xFFBA1A1A) +val md_theme_light_errorContainer = Color(0xFFFFDAD6) +val md_theme_light_onError = Color(0xFFFFFFFF) +val md_theme_light_onErrorContainer = Color(0xFF410002) +val md_theme_light_background = Color(0xFFFFFBFF) +val md_theme_light_onBackground = Color(0xFF201A1A) +val md_theme_light_surface = Color(0xFFFFFBFF) +val md_theme_light_onSurface = Color(0xFF201A1A) +val md_theme_light_surfaceVariant = Color(0xFFF4DDDE) +val md_theme_light_onSurfaceVariant = Color(0xFF524344) +val md_theme_light_outline = Color(0xFF857374) +val md_theme_light_inverseOnSurface = Color(0xFFFBEEEE) +val md_theme_light_inverseSurface = Color(0xFF362F2F) +val md_theme_light_inversePrimary = Color(0xFFFFB2B9) +val md_theme_light_shadow = Color(0xFF000000) +val md_theme_light_surfaceTint = Color(0xFF9C404D) +val md_theme_light_outlineVariant = Color(0xFFD7C1C2) +val md_theme_light_scrim = Color(0xFF000000) + +val md_theme_dark_primary = Color(0xFFFFB2B9) +val md_theme_dark_onPrimary = Color(0xFF5F1222) +val md_theme_dark_primaryContainer = Color(0xFF7D2937) +val md_theme_dark_onPrimaryContainer = Color(0xFFFFDADB) +val md_theme_dark_secondary = Color(0xFFE5BDBF) +val md_theme_dark_onSecondary = Color(0xFF44292C) +val md_theme_dark_secondaryContainer = Color(0xFF5C3F41) +val md_theme_dark_onSecondaryContainer = Color(0xFFFFDADB) +val md_theme_dark_tertiary = Color(0xFFE8C08E) +val md_theme_dark_onTertiary = Color(0xFF442B06) +val md_theme_dark_tertiaryContainer = Color(0xFF5D411B) +val md_theme_dark_onTertiaryContainer = Color(0xFFFFDDB5) +val md_theme_dark_error = Color(0xFFFFB4AB) +val md_theme_dark_errorContainer = Color(0xFF93000A) +val md_theme_dark_onError = Color(0xFF690005) +val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val md_theme_dark_background = Color(0xFF201A1A) +val md_theme_dark_onBackground = Color(0xFFECE0DF) +val md_theme_dark_surface = Color(0xFF201A1A) +val md_theme_dark_onSurface = Color(0xFFECE0DF) +val md_theme_dark_surfaceVariant = Color(0xFF524344) +val md_theme_dark_onSurfaceVariant = Color(0xFFD7C1C2) +val md_theme_dark_outline = Color(0xFF9F8C8D) +val md_theme_dark_inverseOnSurface = Color(0xFF201A1A) +val md_theme_dark_inverseSurface = Color(0xFFECE0DF) +val md_theme_dark_inversePrimary = Color(0xFF9C404D) +val md_theme_dark_shadow = Color(0xFF000000) +val md_theme_dark_surfaceTint = Color(0xFFFFB2B9) +val md_theme_dark_outlineVariant = Color(0xFF524344) +val md_theme_dark_scrim = Color(0xFF000000) + + +val seed = Color(0xFF833842) diff --git a/app/src/main/java/me/zobrist/tichucounter/ui/MainViewModel.kt b/app/src/main/java/me/zobrist/tichucounter/ui/MainViewModel.kt new file mode 100644 index 0000000..53fde44 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/ui/MainViewModel.kt @@ -0,0 +1,81 @@ +package me.zobrist.tichucounter.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import me.zobrist.tichucounter.data.entity.Round +import me.zobrist.tichucounter.repository.GameRepository +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor( + private val gameRepository: GameRepository +) : ViewModel() { + + + private var redoRounds = mutableStateListOf() + private var expectedRoundCount = 0 + + var isUndoActionActive by mutableStateOf(false) + + val isRedoActionActive: Boolean + get() = redoRounds.isNotEmpty() + + var activeGameHasRounds by mutableStateOf(false) + private set + + init { + viewModelScope.launch { + + gameRepository.getActiveGameFlow().collect { + + activeGameHasRounds = it?.rounds?.isNotEmpty() == true + + if (it != null) { + isUndoActionActive = it.rounds.isNotEmpty() + + if (expectedRoundCount != it.rounds.count()) { + redoRounds.clear() + } + + expectedRoundCount = it.rounds.count() + } + } + } + } + + fun undoLastRound() { + viewModelScope.launch { + val round = gameRepository.getLastRound() + if (round != null) { + redoRounds.add(round) + expectedRoundCount-- + gameRepository.deleteLastRound() + } + } + } + + fun redoLastRound() { + viewModelScope.launch { + try { + val round = redoRounds.last() + redoRounds.remove(round) + expectedRoundCount++ + gameRepository.addRoundToActiveGame(round.scoreA, round.scoreB) + } catch (_: NoSuchElementException) { + } + } + } + + fun newGame() { + viewModelScope.launch { + redoRounds.clear() + gameRepository.newGame() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/ui/Theme.kt b/app/src/main/java/me/zobrist/tichucounter/ui/Theme.kt new file mode 100644 index 0000000..bb0a5c7 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/ui/Theme.kt @@ -0,0 +1,95 @@ +package me.zobrist.tichucounter.ui + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + + +private val LightColors = lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + errorContainer = md_theme_light_errorContainer, + onError = md_theme_light_onError, + onErrorContainer = md_theme_light_onErrorContainer, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + outline = md_theme_light_outline, + inverseOnSurface = md_theme_light_inverseOnSurface, + inverseSurface = md_theme_light_inverseSurface, + inversePrimary = md_theme_light_inversePrimary, + surfaceTint = md_theme_light_surfaceTint, + outlineVariant = md_theme_light_outlineVariant, + scrim = md_theme_light_scrim, +) + + +private val DarkColors = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + error = md_theme_dark_error, + errorContainer = md_theme_dark_errorContainer, + onError = md_theme_dark_onError, + onErrorContainer = md_theme_dark_onErrorContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + outline = md_theme_dark_outline, + inverseOnSurface = md_theme_dark_inverseOnSurface, + inverseSurface = md_theme_dark_inverseSurface, + inversePrimary = md_theme_dark_inversePrimary, + surfaceTint = md_theme_dark_surfaceTint, + outlineVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim, +) + +@Composable +fun AppTheme( + useDarkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + + val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + + val colors = when { + dynamicColor && useDarkTheme -> dynamicDarkColorScheme(LocalContext.current) + dynamicColor && !useDarkTheme -> dynamicLightColorScheme(LocalContext.current) + useDarkTheme -> DarkColors + else -> LightColors + } + + + MaterialTheme( + colorScheme = colors, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/ui/about/AboutView.kt b/app/src/main/java/me/zobrist/tichucounter/ui/about/AboutView.kt new file mode 100644 index 0000000..9202b49 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/ui/about/AboutView.kt @@ -0,0 +1,57 @@ +package me.zobrist.tichucounter.ui.about + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.Top +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.zobrist.tichucounter.BuildConfig +import me.zobrist.tichucounter.R +import me.zobrist.tichucounter.ui.AppTheme + + +@Composable +fun AboutView() { + Row(Modifier.padding(20.dp)) { + Image( + modifier = Modifier + .height(80.dp) + .padding(end = 10.dp) + .align(Top), + painter = painterResource(R.drawable.app_logo), + contentDescription = null, + contentScale = ContentScale.Fit + ) + + Column { + Text( + text = stringResource(id = R.string.app_name), + style = MaterialTheme.typography.headlineMedium + ) + Text(text = "V" + BuildConfig.VERSION_NAME) + } + } +} + +@Preview(name = "Light Mode") +@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +fun AboutViewPreview() { + AppTheme() { + Surface { + AboutView() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/ui/composables/DropDownMenu.kt b/app/src/main/java/me/zobrist/tichucounter/ui/composables/DropDownMenu.kt new file mode 100644 index 0000000..12a9700 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/ui/composables/DropDownMenu.kt @@ -0,0 +1,32 @@ +package me.zobrist.tichucounter.ui.composables + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource + +@Composable +fun DropDownMenu(map: Map, selected: T, expanded: Boolean, onSelected: (T?) -> Unit) { + DropdownMenu( + expanded = expanded, + onDismissRequest = { onSelected(null) } + ) { + map.forEach { + DropdownMenuItem( + onClick = { + onSelected(it.key) + }, + trailingIcon = { + if (it.key == selected) { + Icon(Icons.Outlined.Check, null) + } + }, + text = { Text(stringResource(it.value)) }, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/ui/counter/CounterView.kt b/app/src/main/java/me/zobrist/tichucounter/ui/counter/CounterView.kt new file mode 100644 index 0000000..1113d76 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/ui/counter/CounterView.kt @@ -0,0 +1,164 @@ +package me.zobrist.tichucounter.ui.counter + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Surface +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.tooling.preview.Preview +import me.zobrist.tichucounter.data.entity.Round +import me.zobrist.tichucounter.ui.AppTheme + + +@Composable +fun Counter(viewModel: ICounterViewModel = PreviewViewModel()) { + + var orientation by remember { mutableStateOf(Configuration.ORIENTATION_PORTRAIT) } + orientation = LocalConfiguration.current.orientation + + Surface { + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + Landscape(viewModel) + } else { + Portrait(viewModel) + } + } +} + +@Composable +fun Landscape(viewModel: ICounterViewModel) { + Row { + Column(Modifier.weight(1f)) { + TeamNamesView( + viewModel.teamNameA, + viewModel.teamNameB, + { viewModel.updateNameA(it) }, + { viewModel.updateNameB(it) } + ) + + TeamScoresView( + viewModel.totalScoreA, + viewModel.totalScoreB + ) + + RoundListView( + viewModel.roundScoreList, + Modifier.weight(1f) + ) + } + if (!viewModel.keyboardHidden) { + Column(Modifier.weight(1f)) { + KeyBoardView(viewModel = viewModel) + } + } + } +} + +@Composable +fun Portrait(viewModel: ICounterViewModel) { + + Column { + TeamNamesView( + viewModel.teamNameA, + viewModel.teamNameB, + { viewModel.updateNameA(it) }, + { viewModel.updateNameB(it) } + ) + + TeamScoresView( + viewModel.totalScoreA, + viewModel.totalScoreB + ) + + RoundListView( + viewModel.roundScoreList, + Modifier.weight(1f) + ) + + if (!viewModel.keyboardHidden) { + KeyBoardView(viewModel = viewModel) + } + } +} + + +@Preview(name = "Light Mode") +@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +fun CounterViewPreview() { + AppTheme { + Counter() + } +} + +internal class PreviewViewModel : ICounterViewModel { + override var roundScoreList: List = + listOf(Round(1, 10, 90), Round(1, 50, 50), Round(1, 70, 30)) + override var totalScoreA: Int = 350 + override var totalScoreB: Int = 750 + override var teamNameA: String = "Team A" + override var teamNameB: String = "Team B" + override var currentScoreA: String = "" + override var currentScoreB: String = "45" + override var enableSubmit: Boolean = false + override var isAFocused: Boolean = false + override var isBFocused: Boolean = false + override var requestFocusA: FocusRequester = FocusRequester() + override var requestFocusB: FocusRequester = FocusRequester() + override var activeValue: String = currentScoreA + override var inactiveValue: String = currentScoreB + override var keyboardHidden: Boolean = false + + override fun focusLastInput() { + } + + override fun updateOtherScore() { + } + + override fun isValidTichuRound(): Boolean { + return true + } + + override fun updateSubmitButton() { + } + + override fun submitClicked() { + } + + override fun digitClicked(digit: String) { + } + + override fun negateClicked() { + } + + override fun addSub100Clicked(toAdd: Int) { + } + + override fun deleteClicked() { + } + + override fun updateNameA(value: String) { + } + + override fun updateNameB(value: String) { + } + + override fun updateFocusStateA(state: Boolean) { + } + + override fun updateFocusStateB(state: Boolean) { + } + + override fun swapInputScores() { + } + + override fun hideKeyboard() { + } + + override fun showKeyboard() { + } + +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/ui/counter/CounterViewModel.kt b/app/src/main/java/me/zobrist/tichucounter/ui/counter/CounterViewModel.kt new file mode 100644 index 0000000..e6ae019 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/ui/counter/CounterViewModel.kt @@ -0,0 +1,289 @@ +package me.zobrist.tichucounter.ui.counter + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.focus.FocusRequester +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import me.zobrist.tichucounter.data.entity.Round +import me.zobrist.tichucounter.domain.Tichu +import me.zobrist.tichucounter.domain.getTotalPoints +import me.zobrist.tichucounter.repository.GameRepository +import javax.inject.Inject + +private enum class Focused { TEAM_A, TEAM_B } + +interface IKeyBoardViewModel { + + val currentScoreA: String + val currentScoreB: String + val enableSubmit: Boolean + val isAFocused: Boolean + val isBFocused: Boolean + val requestFocusA: FocusRequester + val requestFocusB: FocusRequester + val activeValue: String + val inactiveValue: String + val keyboardHidden: Boolean + + fun focusLastInput() + fun updateOtherScore() + fun isValidTichuRound(): Boolean + fun updateSubmitButton() + fun submitClicked() + fun digitClicked(digit: String) + fun negateClicked() + fun addSub100Clicked(toAdd: Int) + fun deleteClicked() + fun updateFocusStateA(state: Boolean) + fun updateFocusStateB(state: Boolean) + fun swapInputScores() + fun hideKeyboard() + fun showKeyboard() + +} + +interface ICounterViewModel : IKeyBoardViewModel { + val roundScoreList: List + val totalScoreA: Int + val totalScoreB: Int + val teamNameA: String + val teamNameB: String + + fun updateNameA(value: String) + fun updateNameB(value: String) +} + +@HiltViewModel +class CounterViewModel @Inject constructor( + private val gameRepository: GameRepository +) : + ViewModel(), ICounterViewModel { + + override var roundScoreList by mutableStateOf(emptyList()) + private set + + override var totalScoreA by mutableStateOf(0) + private set + + override var totalScoreB by mutableStateOf(0) + private set + + override var teamNameA by mutableStateOf("") + private set + + override var teamNameB by mutableStateOf("") + private set + + override var currentScoreA by mutableStateOf("") + private set + + override var currentScoreB by mutableStateOf("") + private set + + override var enableSubmit by mutableStateOf(false) + private set + + override var isAFocused by mutableStateOf(false) + private set + + override var isBFocused by mutableStateOf(false) + private set + + override var requestFocusA by mutableStateOf(FocusRequester()) + private set + + override var requestFocusB by mutableStateOf(FocusRequester()) + private set + + override var keyboardHidden by mutableStateOf(false) + private set + + override var activeValue: String + get() { + return if (isBFocused) { + currentScoreB + } else { + currentScoreA + } + } + set(value) { + if (isBFocused) { + currentScoreB = value + } else { + currentScoreA = value + } + } + + override var inactiveValue: String + get() { + return if (isAFocused) { + currentScoreB + } else { + currentScoreA + } + } + set(value) { + if (isAFocused) { + currentScoreB = value + } else { + currentScoreA = value + } + } + + + private var lastFocused = Focused.TEAM_A + + init { + viewModelScope.launch { + gameRepository.getActiveGameFlow().collect { + if (it != null) { + + val score = it.getTotalPoints() + + roundScoreList = it.rounds + totalScoreA = score.first + totalScoreB = score.second + + teamNameA = it.game.nameA + teamNameB = it.game.nameB + } + } + } + } + + override fun focusLastInput() { + when (lastFocused) { + Focused.TEAM_A -> if (!isAFocused) requestFocusA.requestFocus() + Focused.TEAM_B -> if (!isBFocused) requestFocusB.requestFocus() + } + } + + override fun updateOtherScore() { + inactiveValue = try { + val tichu = Tichu() + val myScore = activeValue.toInt() + val hisScore = tichu.calculateOtherScore(myScore) + if (tichu.isValidRound(myScore, hisScore)) { + hisScore?.toString() ?: "" + } else { + "" + } + } catch (_: Exception) { + "" + } + } + + override fun isValidTichuRound(): Boolean { + return try { + val tichu = Tichu() + tichu.isValidRound(currentScoreA.toInt(), currentScoreB.toInt()) + } catch (_: java.lang.NumberFormatException) { + false + } + } + + override fun updateSubmitButton() { + enableSubmit = isValidTichuRound() + } + + override fun submitClicked() { + viewModelScope.launch { + gameRepository.addRoundToActiveGame(currentScoreA.toInt(), currentScoreB.toInt()) + } + currentScoreA = "" + currentScoreB = "" + enableSubmit = false + } + + override fun digitClicked(digit: String) { + focusLastInput() + + activeValue += digit + updateOtherScore() + updateSubmitButton() + } + + override fun negateClicked() { + focusLastInput() + + activeValue = if (activeValue.contains("-")) { + activeValue.replace("-", "") + } else { + "-$activeValue" + } + updateOtherScore() + updateSubmitButton() + } + + override fun addSub100Clicked(toAdd: Int) { + focusLastInput() + + activeValue = try { + val temp = activeValue.toInt() + toAdd + temp.toString() + } catch (e: Exception) { + toAdd.toString() + } + if (inactiveValue == "") { + updateOtherScore() + } + + updateSubmitButton() + } + + override fun deleteClicked() { + if (activeValue != "") { + activeValue = activeValue.dropLast(1) + } + updateOtherScore() + updateSubmitButton() + } + + override fun updateNameA(value: String) { + viewModelScope.launch { + val game = gameRepository.activeGame + game.nameA = value + gameRepository.updateGame(game) + } + } + + override fun updateNameB(value: String) { + viewModelScope.launch { + val game = gameRepository.activeGame + game.nameB = value + gameRepository.updateGame(game) + } + } + + override fun updateFocusStateA(state: Boolean) { + isAFocused = state + if (state) { + lastFocused = Focused.TEAM_A + } + } + + override fun updateFocusStateB(state: Boolean) { + isBFocused = state + if (state) { + lastFocused = Focused.TEAM_B + } + } + + override fun swapInputScores() { + val swap = currentScoreA + currentScoreA = currentScoreB + currentScoreB = swap + } + + override fun hideKeyboard() { + keyboardHidden = true + } + + override fun showKeyboard() { + keyboardHidden = false + } +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/ui/counter/KeyboardView.kt b/app/src/main/java/me/zobrist/tichucounter/ui/counter/KeyboardView.kt new file mode 100644 index 0000000..273cd8b --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/ui/counter/KeyboardView.kt @@ -0,0 +1,329 @@ +package me.zobrist.tichucounter.ui.counter + +import android.content.res.Configuration +import androidx.compose.animation.core.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Backspace +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.KeyboardHide +import androidx.compose.material.icons.outlined.SwapHoriz +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.zobrist.tichucounter.ui.AppTheme + +@Composable +fun KeyBoardView(viewModel: IKeyBoardViewModel) { + KeyboardView( + viewModel.currentScoreA, + viewModel.currentScoreB, + viewModel.requestFocusA, + viewModel.requestFocusB, + viewModel.enableSubmit, + viewModel.isAFocused, + viewModel.isBFocused, + { viewModel.updateFocusStateA(it) }, + { viewModel.updateFocusStateB(it) }, + { viewModel.digitClicked(it) }, + { viewModel.addSub100Clicked(it) }, + { viewModel.deleteClicked() }, + { viewModel.negateClicked() }, + { viewModel.submitClicked() }, + { viewModel.hideKeyboard() }, + { viewModel.swapInputScores() } + ) +} + +@Composable +fun KeyboardView( + scoreA: String, + scoreB: String, + requestFocusA: FocusRequester, + requestFocusB: FocusRequester, + enableSubmit: Boolean, + focusStateA: Boolean, + focusStateB: Boolean, + updateFocusStateA: (Boolean) -> Unit, + updateFocusStateB: (Boolean) -> Unit, + digitClicked: (String) -> Unit, + addSub100Clicked: (Int) -> Unit, + deleteClicked: () -> Unit, + negateClicked: () -> Unit, + submitClicked: () -> Unit, + hideKeyboardClicked: () -> Unit, + onSwapClicked: () -> Unit +) { + Column { + Row(Modifier.height(IntrinsicSize.Max)) { + Column(Modifier.weight(1f)) { + CenteredTextField( + scoreA, + "0", + focusStateA, + requestFocusA + ) { updateFocusStateA(it.isFocused) } + + } + Surface( + Modifier + .wrapContentWidth() + .fillMaxHeight(), + tonalElevation = 3.dp, + shape = MaterialTheme.shapes.extraSmall + ) { + Column { + IconButton(onClick = onSwapClicked) { + Icon(Icons.Outlined.SwapHoriz, null) + } + } + } + Column(Modifier.weight(1f)) { + + CenteredTextField( + scoreB, + "0", + focusStateB, + requestFocusB + ) { + updateFocusStateB(it.isFocused) + } + } + } + + + Row { + Column(Modifier.weight(1f)) { + KeyboardTextButton("1") { + digitClicked("1") + } + } + Column(Modifier.weight(1f)) { + KeyboardTextButton("2") { + digitClicked("2") + } + } + Column(Modifier.weight(1f)) { + KeyboardTextButton("3") { + digitClicked("3") + } + } + Column(Modifier.weight(1f)) { + KeyboardTextButton("+100") { + addSub100Clicked(100) + } + } + } + Row { + Column(Modifier.weight(1f)) { + KeyboardTextButton("4") { + digitClicked("4") + } + } + Column(Modifier.weight(1f)) { + KeyboardTextButton("5") { + digitClicked("5") + } + } + Column(Modifier.weight(1f)) { + KeyboardTextButton("6") { + digitClicked("6") + } + } + Column(Modifier.weight(1f)) { + KeyboardTextButton("-100") { + addSub100Clicked(-100) + } + } + } + + Row { + Column(Modifier.weight(1f)) { + KeyboardTextButton("7") { + digitClicked("7") + } + } + Column(Modifier.weight(1f)) { + KeyboardTextButton("8") { + digitClicked("8") + } + } + Column(Modifier.weight(1f)) { + KeyboardTextButton("9") { + digitClicked("9") + } + } + Column(Modifier.weight(1f)) { + KeyboardIconButton(Icons.Outlined.Backspace) { + deleteClicked() + } + } + } + + Row { + Column(Modifier.weight(1f)) { + KeyboardIconButton(Icons.Outlined.KeyboardHide) { + hideKeyboardClicked() + } + } + Column(Modifier.weight(1f)) { + KeyboardTextButton("0") { + digitClicked("0") + } + } + + Column(Modifier.weight(1f)) { + KeyboardTextButton("+/-") { + negateClicked() + } + } + Column(Modifier.weight(1f)) { + KeyboardIconButton(Icons.Outlined.Check, enableSubmit) { + submitClicked() + } + } + } + } +} + +@Composable +fun KeyboardTextButton(text: String, onClicked: () -> Unit) { + + val configuration = LocalConfiguration.current + + val screenWidth = configuration.screenWidthDp.dp + + val style = if (screenWidth < 350.dp) { + MaterialTheme.typography.labelSmall + } else { + MaterialTheme.typography.labelLarge + } + + ElevatedButton( + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + .padding(2.dp), + onClick = { onClicked() }, + ) { Text(text, style = style) } +} + +@Composable +fun KeyboardIconButton(icon: ImageVector, enabled: Boolean = true, onClicked: () -> Unit) { + + ElevatedButton( + onClick = { onClicked() }, + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + .padding(2.dp), + enabled = enabled, + ) { + Icon( + icon, + contentDescription = null, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CenteredTextField( + value: String, + placeholder: String, + focused: Boolean, + focusRequester: FocusRequester? = null, + onFocusStateChanged: (FocusState) -> Unit +) { + + val modifier = if (focusRequester != null) { + Modifier.focusRequester(focusRequester) + } else { + Modifier + } + + Box(contentAlignment = Alignment.Center) { + TextField( + value = value, + onValueChange = { }, + placeholder = { + if (!focused) { + Text( + placeholder, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + }, + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + singleLine = true, + readOnly = true, + modifier = modifier + .fillMaxWidth() + .onFocusChanged { + onFocusStateChanged(it) + } + ) + if (focused) { + val cursorColor = MaterialTheme.colorScheme.onSurface + val infiniteTransition = rememberInfiniteTransition() + val alpha by infiniteTransition.animateFloat( + 0f, + cursorColor.alpha, + animationSpec = infiniteRepeatable( + animation = tween(500), + repeatMode = RepeatMode.Reverse + ) + ) + Row { + + Text(text = value, color = cursorColor.copy(alpha = 0f)) + Divider( + modifier = Modifier + .padding(start = 3.dp, top = 15.dp, bottom = 15.dp) + .width(1.dp) + .fillMaxHeight(), + color = cursorColor.copy(alpha = alpha) + ) + } + } + } +} + + +@Preview(name = "Light Mode") +@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +fun KeyboardViewPreview() { + AppTheme { + Surface { + KeyboardView( + "1", + "3511", + FocusRequester(), + FocusRequester(), + enableSubmit = false, + focusStateA = true, + focusStateB = false, + updateFocusStateA = {}, + updateFocusStateB = {}, + digitClicked = {}, + addSub100Clicked = {}, + deleteClicked = {}, + negateClicked = {}, + submitClicked = {}, + hideKeyboardClicked = {}, + onSwapClicked = {}) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/ui/counter/RoundListView.kt b/app/src/main/java/me/zobrist/tichucounter/ui/counter/RoundListView.kt new file mode 100644 index 0000000..e9d3dfb --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/ui/counter/RoundListView.kt @@ -0,0 +1,84 @@ +package me.zobrist.tichucounter.ui.counter + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import me.zobrist.tichucounter.data.entity.Round +import me.zobrist.tichucounter.ui.AppTheme + +@Composable +fun RoundListView(rounds: List, modifier: Modifier) { + val lazyListState = rememberLazyListState() + val scope = rememberCoroutineScope() + + LazyColumn(state = lazyListState, modifier = modifier) { + itemsIndexed(rounds) { index, item -> + RoundListItem(item, index) + } + + scope.launch { + lazyListState.animateScrollToItem(rounds.size) + } + } +} + +@Composable +private fun RoundListItem(round: Round, index: Int) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(all = 4.dp) + ) { + Text( + text = round.scoreA.toString(), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(5f), + textAlign = TextAlign.Center + ) + Text( + text = (index + 1).toString(), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center + ) + Text( + text = round.scoreB.toString(), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(5f), + textAlign = TextAlign.Center + ) + } +} + +@Preview(name = "Light Mode") +@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +fun RoundListViewPreview() { + val rounds = listOf( + Round(1, 10, 90), + Round(1, 5, 95), + Round(1, 100, 0), + Round(1, 125, -25), + Round(1, 50, 50) + ) + + AppTheme { + Surface { + RoundListView(rounds, Modifier) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/ui/counter/TeamNamesView.kt b/app/src/main/java/me/zobrist/tichucounter/ui/counter/TeamNamesView.kt new file mode 100644 index 0000000..596c861 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/ui/counter/TeamNamesView.kt @@ -0,0 +1,55 @@ +package me.zobrist.tichucounter.ui.counter + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* + +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.zobrist.tichucounter.ui.AppTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TeamNamesView( + nameA: String, + nameB: String, + updateA: (String) -> Unit, + updateB: (String) -> Unit +) { + + val color = TextFieldDefaults.textFieldColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) + ) + + Row { + TextField( + value = nameA, + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + onValueChange = { updateA(it) }, + singleLine = true, + modifier = Modifier.weight(1f), + colors = color + ) + + TextField( + value = nameB, + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + onValueChange = { updateB(it) }, + singleLine = true, + modifier = Modifier.weight(1f), + colors = color + ) + } +} + +@Preview(name = "Light Mode") +@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +private fun TeamNamesViewPreview() { + AppTheme { + TeamNamesView("TeamA", "TeamB", {}, {}) + } +} diff --git a/app/src/main/java/me/zobrist/tichucounter/ui/counter/TeamScoresView.kt b/app/src/main/java/me/zobrist/tichucounter/ui/counter/TeamScoresView.kt new file mode 100644 index 0000000..30adac4 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/ui/counter/TeamScoresView.kt @@ -0,0 +1,53 @@ +package me.zobrist.tichucounter.ui.counter + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.zobrist.tichucounter.ui.AppTheme + + +@Composable +fun TeamScoresView(scoreA: Int, scoreB: Int) { + + ElevatedCard(elevation = CardDefaults.elevatedCardElevation(3.dp)) { + Row { + Text( + style = MaterialTheme.typography.headlineSmall, + text = scoreA.toString(), + modifier = Modifier + .weight(5f) + .padding(6.dp), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.weight(1f)) + + Text( + style = MaterialTheme.typography.headlineSmall, + text = scoreB.toString(), + modifier = Modifier + .weight(5f) + .padding(6.dp), + textAlign = TextAlign.Center + ) + } + } +} + +@Preview(name = "Light Mode") +@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +private fun TeamScoresViewPreview() { + AppTheme { + Surface { + TeamScoresView(10, 90) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/ui/history/HistoryView.kt b/app/src/main/java/me/zobrist/tichucounter/ui/history/HistoryView.kt new file mode 100644 index 0000000..2dfc42e --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/ui/history/HistoryView.kt @@ -0,0 +1,240 @@ +package me.zobrist.tichucounter.ui.history + + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.DeleteForever +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.zobrist.tichucounter.R +import me.zobrist.tichucounter.data.GameWithScores +import me.zobrist.tichucounter.data.entity.Game +import me.zobrist.tichucounter.data.entity.Round +import me.zobrist.tichucounter.domain.getTotalPoints +import me.zobrist.tichucounter.ui.composables.DropDownMenu +import java.text.DateFormat +import java.util.* + + +@Composable +fun HistoryList( + viewModel: HistoryViewModel, + navigateToCalculator: () -> Unit +) { + + var showDeleteDialog by remember { mutableStateOf(false) } + + DeleteConfirmDialog(showDeleteDialog) { + showDeleteDialog = false + if (it) { + viewModel.deleteAllInactiveGames() + } + } + + HistoryList( + viewModel.gameAndHistory, + { + viewModel.activateGame(it) + navigateToCalculator() + }, + { viewModel.deleteGame(it) }, + { showDeleteDialog = true }, + ) +} + +@Preview +@Composable +fun DeleteConfirmDialog(show: Boolean = true, onExecuted: (Boolean) -> Unit = {}) { + if (show) { + + AlertDialog( + onDismissRequest = { onExecuted(false) }, + dismissButton = { + TextButton({ onExecuted(false) }) + { + Text(stringResource(R.string.cancel)) + } + }, + confirmButton = { + TextButton({ onExecuted(true) }) + { + Text(stringResource(R.string.ok)) + } + }, + title = { Text(stringResource(R.string.delete_inactive_title)) }, + text = { Text(stringResource(R.string.delete_inactive_text)) }, + ) + } +} + +@Composable +fun HistoryList( + games: List, + onOpenClicked: (GameId: Long) -> Unit, + onDeleteClicked: (GameId: Long) -> Unit, + onDeleteAllClicked: () -> Unit + +) { + Row { + LazyColumn { + item { + Text( + modifier = Modifier.padding(start = 10.dp, end = 10.dp), + text = stringResource(R.string.active), + style = MaterialTheme.typography.headlineSmall + ) + } + items(games.filter { it.game.active }) { + HistoryListItem(it, onOpenClicked, onDeleteClicked) + } + + if (games.count() > 1) { + item { + Text( + modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp), + text = stringResource(R.string.inactive), + style = MaterialTheme.typography.headlineSmall + ) + } + + items(games.filter { !it.game.active }) { + HistoryListItem(it, onOpenClicked, onDeleteClicked) + } + + item { + Button( + enabled = games.count() > 1, + modifier = Modifier + .padding(start = 4.dp, end = 4.dp, top = 10.dp) + .align(CenterVertically) + .fillMaxWidth(), + onClick = { onDeleteAllClicked() }) { + Icon(imageVector = Icons.Outlined.DeleteForever, contentDescription = null) + Text(text = stringResource(id = R.string.deleteAll)) + } + } + } + } + } +} + +@Composable +fun HistoryListItem( + game: GameWithScores, + onOpenClicked: (GameId: Long) -> Unit, + onDeleteClicked: (GameId: Long) -> Unit +) { + val format = + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, Locale.getDefault()) + + + val cardColor = if (game.game.active) { + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer) + + } else { + CardDefaults.cardColors() + } + + val totalScores = game.getTotalPoints() + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(all = 4.dp) + .clickable { onOpenClicked(game.game.uid) }, + colors = cardColor + ) { + Row( + Modifier + .padding(all = 12.dp) + ) { + Column(Modifier.weight(4f)) { + Text( + text = game.game.nameA + " vs " + game.game.nameB, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.headlineSmall + ) + Text( + text = totalScores.first.toString() + " : " + totalScores.second.toString(), + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.padding(5.dp)) + Text( + text = format.format(game.game.modified), + style = MaterialTheme.typography.labelSmall + ) + } + Column( + Modifier + .wrapContentSize() + .width(40.dp) + ) { + + if (!game.game.active) { + var expanded by remember { mutableStateOf(false) } + + Icon( + modifier = Modifier + .padding(start = 20.dp, bottom = 20.dp) + .clickable { expanded = true }, + imageVector = Icons.Outlined.MoreVert, + contentDescription = null + ) + + + DropDownMenu( + mapOf("delete" to R.string.delete), + "", + expanded, + ) { + expanded = false + it?.let { + when (it) { + "delete" -> onDeleteClicked(game.game.uid) + } + } + } + } + } + } + } +} + +@Preview +@Composable +private fun HistoryListPreview() { + val tempData = listOf( + GameWithScores( + Game(true, "abc", "def", Date(), Date()), + listOf(Round(1, 550, 500)) + ), + GameWithScores( + Game(false, "ADTH", "dogfg", Date(), Date()), + listOf(Round(2, 20, 60)) + ), + GameWithScores( + Game(false, "TeamA3 langer Name", "TeamB3", Date(), Date()), + listOf(Round(3, 30, 70)) + ), + GameWithScores( + Game(false, "TeamA4", "TeamB4", Date(), Date()), + listOf(Round(4, 40, 80)) + ), + GameWithScores( + Game(false, "TeamA5", "TeamB5", Date(), Date()), + listOf(Round(5, 50, 90)) + ) + ) + HistoryList(tempData, {}, {}) {} +} diff --git a/app/src/main/java/me/zobrist/tichucounter/ui/history/HistoryViewModel.kt b/app/src/main/java/me/zobrist/tichucounter/ui/history/HistoryViewModel.kt new file mode 100644 index 0000000..ed3f587 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/ui/history/HistoryViewModel.kt @@ -0,0 +1,51 @@ +package me.zobrist.tichucounter.ui.history + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import me.zobrist.tichucounter.data.GameWithScores +import me.zobrist.tichucounter.repository.GameRepository +import javax.inject.Inject + + +@HiltViewModel +class HistoryViewModel @Inject constructor( + private val gameRepository: GameRepository +) : ViewModel() { + + var gameAndHistory by mutableStateOf(emptyList()) + private set + + init { + viewModelScope.launch { + + gameRepository.getAllWithRoundFlow().collect { games -> + gameAndHistory = + games.sortedBy { it.game.modified }.sortedBy { it.game.active }.reversed() + } + } + } + + fun deleteGame(gameId: Long) { + viewModelScope.launch { + gameRepository.deleteGame(gameId) + } + } + + fun activateGame(gameId: Long) { + viewModelScope.launch { + gameRepository.setActive(gameId) + } + + } + + fun deleteAllInactiveGames() { + viewModelScope.launch { + gameRepository.deleteAllInactive() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/ui/layout/DrawerContent.kt b/app/src/main/java/me/zobrist/tichucounter/ui/layout/DrawerContent.kt new file mode 100644 index 0000000..e025a4a --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/ui/layout/DrawerContent.kt @@ -0,0 +1,64 @@ +package me.zobrist.tichucounter.ui.layout + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.zobrist.tichucounter.R +import me.zobrist.tichucounter.domain.* +import me.zobrist.tichucounter.ui.AppTheme +import me.zobrist.tichucounter.ui.counter.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DrawerContent( + drawerItems: List, + selectedDrawerItem: DrawerItem, + onElementClicked: (Route) -> Unit +) { + + ModalDrawerSheet { + + Text( + modifier = Modifier.padding(start = 10.dp, top = 10.dp), + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineSmall + ) + Divider(modifier = Modifier.padding(10.dp)) + + drawerItems.forEach { screen -> + NavigationDrawerItem( + icon = { Icon(screen.menuIcon, contentDescription = null) }, + label = { Text(screen.menuName) }, + selected = screen == selectedDrawerItem, + onClick = { onElementClicked(screen.route) }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) + ) + } + } +} + +@Preview(name = "Light Mode") +@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +fun DrawerContentPreview() { + + val counter = DrawerItem(Route.COUNTER, Icons.Outlined.Calculate, "Counter") + val history = DrawerItem(Route.HISTORY, Icons.Outlined.List, "History") + val settings = DrawerItem(Route.SETTINGS, Icons.Outlined.Settings, "Settings") + AppTheme { + Surface { + DrawerContent( + listOf(counter, history, settings), + counter + ) {} + } + } +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/ui/layout/TopBar.kt b/app/src/main/java/me/zobrist/tichucounter/ui/layout/TopBar.kt new file mode 100644 index 0000000..a4dbd23 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/ui/layout/TopBar.kt @@ -0,0 +1,56 @@ +package me.zobrist.tichucounter.ui.layout + +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import me.zobrist.tichucounter.domain.TopBarAction +import me.zobrist.tichucounter.domain.TopBarState + +@Composable +fun TopBar(topBarState: TopBarState) { + TopBar( + topBarState.title, + topBarState.icon, + topBarState.onNavigate, + topBarState.actions + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopBar( + title: String, + icon: ImageVector, + navigateAction: () -> Unit, + actions: List +) { + TopAppBar( + title = { + Text( + title, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { + IconButton(onClick = { navigateAction() }) { + Icon( + imageVector = icon, + contentDescription = "Localized description" + ) + } + }, + actions = { + actions.forEach { + IconButton(onClick = { it.action() }, enabled = it.isActive) { + Icon( + imageVector = it.imageVector, + contentDescription = null, + ) + it.composeCode() + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/ui/settings/SettingsView.kt b/app/src/main/java/me/zobrist/tichucounter/ui/settings/SettingsView.kt new file mode 100644 index 0000000..38840be --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/ui/settings/SettingsView.kt @@ -0,0 +1,178 @@ +package me.zobrist.tichucounter.ui.settings + +import android.content.res.Configuration +import androidx.compose.foundation.clickable +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.icons.Icons +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment.Companion.End +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.zobrist.tichucounter.R +import me.zobrist.tichucounter.domain.KeepScreenOn +import me.zobrist.tichucounter.domain.Language +import me.zobrist.tichucounter.domain.Theme +import me.zobrist.tichucounter.ui.AppTheme +import me.zobrist.tichucounter.ui.composables.DropDownMenu + + +val languageMap = mapOf( + Language.DEFAULT to R.string.android_default_text, + Language.ENGLISH to R.string.english, + Language.GERMAN to R.string.german +) + +val themeMap = mapOf( + Theme.DEFAULT to R.string.android_default_text, + Theme.DARK to R.string.dark, + Theme.LIGHT to R.string.light +) + + +@Composable +fun SettingsView(viewModel: SettingsViewModel) { + SettingsView( + viewModel.screenOn.value, + viewModel.language, + viewModel.theme, + { viewModel.updateScreenOn(it) }, + { viewModel.updateLanguage(it) }, + { viewModel.updateTheme(it) }) +} + +@Composable +fun SettingsView( + valueScreenOn: Boolean = true, + valueLanguage: Language = Language.ENGLISH, + valueTheme: Theme = Theme.DARK, + updateScreenOn: (KeepScreenOn) -> Unit = {}, + updateLanguage: (Language) -> Unit = {}, + updateTheme: (Theme) -> Unit = {} +) { + Column { + BooleanSetting( + stringResource(R.string.keep_screen_on), + valueScreenOn + ) { updateScreenOn(if (it) KeepScreenOn.ON else KeepScreenOn.OFF) } + + StringSetting( + stringResource(R.string.choose_language_text), + languageMap, + valueLanguage, + ) { updateLanguage(it) } + + StringSetting( + stringResource(R.string.choose_theme_text), + themeMap, + valueTheme, + ) { updateTheme(it) } + } +} + +@Composable +fun BooleanSetting(name: String, value: Boolean, updateValue: (Boolean) -> Unit) { + + Row( + Modifier + .padding(20.dp) + .fillMaxWidth() + ) { + Column(Modifier.weight(5f)) { + Text( + name, + maxLines = 1, + style = MaterialTheme.typography.bodyLarge, + overflow = TextOverflow.Ellipsis + ) + Text( + stringResource(if (value) R.string.on else R.string.off), + style = MaterialTheme.typography.labelLarge + ) + + } + Column(Modifier.weight(1f)) + { + Switch( + checked = value, + modifier = Modifier.align(End), + onCheckedChange = { updateValue(it) }) + } + } +} + +@Composable +fun StringSetting(name: String, map: Map, selected: T, onSelected: (T) -> Unit) { + + var expanded by remember { mutableStateOf(false) } + + Row( + Modifier + .fillMaxWidth() + .padding(20.dp) + .clickable { expanded = true }) { + Column(Modifier.weight(5f)) { + Text(name, style = MaterialTheme.typography.bodyLarge, overflow = TextOverflow.Ellipsis) + map[selected]?.let { + Text( + stringResource(it), + style = MaterialTheme.typography.labelLarge + ) + } + } + + Column(Modifier.weight(1f)) { + Icon( + Icons.Outlined.ArrowDropDown, + contentDescription = null, + modifier = Modifier.align(End) + ) + } + + DropDownMenu( + map, + selected, + expanded, + ) { + expanded = false + it?.let { onSelected(it) } + } + } +} + + +@Preview(name = "Light Mode") +@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +fun SettingsViewPreview() { + + AppTheme { + Surface { + SettingsView() + } + } +} + +@Preview(name = "Light Mode") +@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +fun StringSettingPreview() { + + AppTheme { + Surface { + DropDownMenu( + themeMap, + Theme.LIGHT, + true, + ) {} + } + } +} + diff --git a/app/src/main/java/me/zobrist/tichucounter/ui/settings/SettingsViewModel.kt b/app/src/main/java/me/zobrist/tichucounter/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..b37e1bd --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/ui/settings/SettingsViewModel.kt @@ -0,0 +1,42 @@ +package me.zobrist.tichucounter.ui.settings + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import me.zobrist.tichucounter.domain.KeepScreenOn +import me.zobrist.tichucounter.domain.Language +import me.zobrist.tichucounter.domain.SettingsAdapter +import me.zobrist.tichucounter.domain.Theme +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor(private val settings: SettingsAdapter) : ViewModel() { + + + var language by mutableStateOf(settings.language) + private set + + var theme by mutableStateOf(settings.theme) + private set + + var screenOn by mutableStateOf(settings.keepScreenOn) + private set + + fun updateLanguage(language: Language) { + settings.setLanguage(language) + this.language = settings.language + } + + fun updateTheme(theme: Theme) { + settings.setTheme(theme) + this.theme = settings.theme + } + + fun updateScreenOn(value: KeepScreenOn) { + settings.setKeepScreenOn(value) + screenOn = settings.keepScreenOn + } + +} \ No newline at end of file diff --git a/app/src/main/res/drawable-mdpi/back.png b/app/src/main/res/drawable-mdpi/back.png deleted file mode 100644 index 72b8bd7..0000000 Binary files a/app/src/main/res/drawable-mdpi/back.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/checkmark.png b/app/src/main/res/drawable-mdpi/checkmark.png deleted file mode 100644 index 75d2263..0000000 Binary files a/app/src/main/res/drawable-mdpi/checkmark.png and /dev/null differ diff --git a/app/src/main/res/drawable-night/back.png b/app/src/main/res/drawable-night/back.png deleted file mode 100644 index 770c83c..0000000 Binary files a/app/src/main/res/drawable-night/back.png and /dev/null differ diff --git a/app/src/main/res/drawable/app_logo.png b/app/src/main/res/drawable/app_logo.png new file mode 100644 index 0000000..7681534 Binary files /dev/null and b/app/src/main/res/drawable/app_logo.png differ diff --git a/app/src/main/res/layout-land/content_main.xml b/app/src/main/res/layout-land/content_main.xml deleted file mode 100644 index a205dec..0000000 --- a/app/src/main/res/layout-land/content_main.xml +++ /dev/null @@ -1,321 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -