diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index 195de40..0000000 --- a/.drone.yml +++ /dev/null @@ -1,89 +0,0 @@ ---- -kind: pipeline -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 test - - ./gradlew assembleRelease - - ./gradlew bundleRelease - -- name: upload latest apk - image: vividboarder/drone-webdav - settings: - file: app/build/outputs/apk/release/app-release.apk - destination: https://nextcloud.zobrist.me/remote.php/dav/files/deploy/TichuCounter/latest/app-release.apk - username: - from_secret: NextCloudUser - password: - from_secret: NextCloudPassword - -- name: upload latest bundle - image: vividboarder/drone-webdav - settings: - file: app/build/outputs/bundle/release/app-release.aab - destination: https://nextcloud.zobrist.me/remote.php/dav/files/deploy/TichuCounter/latest/app-release.aab - username: - from_secret: NextCloudUser - password: - from_secret: NextCloudPassword - -- name: upload tagged apk - image: vividboarder/drone-webdav - settings: - file: app/build/outputs/apk/release/app-release.apk - destination: 'https://nextcloud.zobrist.me/remote.php/dav/files/deploy/TichuCounter/tagged/app-release$DRONE_TAG.apk' - username: - from_secret: NextCloudUser - password: - from_secret: NextCloudPassword - when: - event: - - tag - -- name: upload tagged bundle - image: vividboarder/drone-webdav - settings: - file: app/build/outputs/bundle/release/app-release.aab - destination: 'https://nextcloud.zobrist.me/remote.php/dav/files/deploy/TichuCounter/tagged/app-release$DRONE_TAG.aab' - username: - from_secret: NextCloudUser - password: - from_secret: NextCloudPassword - when: - 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/.gitea/workflows/buildAndroid.yaml b/.gitea/workflows/buildAndroid.yaml new file mode 100644 index 0000000..501fc60 --- /dev/null +++ b/.gitea/workflows/buildAndroid.yaml @@ -0,0 +1,48 @@ +name: Build Android +on: [pull_request, push] +jobs: + build: + runs-on: ubuntu-latest-android + steps: + - name: Checkout the code + uses: actions/checkout@v2 + + - name: Prepare signing + run: | + touch keystore.properties + echo "storePassword=${{ secrets.STOREPASSWORD }}" >> keystore.properties + echo "keyPassword=${{ secrets.KEYPASSWORD }}" >> keystore.properties + echo "keyAlias=key0" >> keystore.properties + echo "storeFile=../AndroidKey" >> keystore.properties + + - name: Generate versionCode + run: | + touch version.properties + let timestamp=$(date +%s)/10 + echo "versionCode=$timestamp" >> version.properties + + - name: Test the app + run: ./gradlew test + + - name: Build apk + run: ./gradlew assembleRelease + + - name: Build abb + run: ./gradlew bundleRelease + + - name: Deploy latest to Nextcloud + run: | + curl -k -u "${{ secrets.NEXTCLOUD_USERNAME }}:${{ secrets.NEXTCLOUD_PASSWORD }}" -T "app/build/outputs/apk/release/app-release.apk" "https://nextcloud.zobrist.me/remote.php/dav/files/deploy/TichuCounter/latest/app-release.apk" + curl -k -u "${{ secrets.NEXTCLOUD_USERNAME }}:${{ secrets.NEXTCLOUD_PASSWORD }}" -T "app/build/outputs/bundle/release/app-release.aab" "https://nextcloud.zobrist.me/remote.php/dav/files/deploy/TichuCounter/latest/app-release.aab" + + - name: Deploy tagged build to Nextcloud + run: | + curl -k -u "${{ secrets.NEXTCLOUD_USERNAME }}:${{ secrets.NEXTCLOUD_PASSWORD }}" -T "app/build/outputs/apk/release/app-release.apk" "https://nextcloud.zobrist.me/remote.php/dav/files/deploy/TichuCounter/tagged/app-release-${GITHUB_REF_NAME##*/}.apk" + curl -k -u "${{ secrets.NEXTCLOUD_USERNAME }}:${{ secrets.NEXTCLOUD_PASSWORD }}" -T "app/build/outputs/bundle/release/app-release.aab" "https://nextcloud.zobrist.me/remote.php/dav/files/deploy/TichuCounter/tagged/app-release-${GITHUB_REF_NAME##*/}.aab" + + - uses: https://github.com/ravsamhq/notify-slack-action@v2 + if: always() + with: + status: ${{ job.status }} # required + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} # required \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 663caf8..a636bc3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,7 +16,7 @@ def keystoreProperties = new Properties() def versionProperties = new Properties() def versionMajor = 2 -def versionMinor = 2 +def versionMinor = 3 // Load your keystore.properties file into the keystoreProperties object. keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) @@ -24,12 +24,12 @@ versionProperties.load(new FileInputStream(versionPropertiesFile)) android { - compileSdkVersion 33 + compileSdk 34 defaultConfig { applicationId "me.zobrist.tichucounter" minSdkVersion 21 - targetSdkVersion 33 + targetSdkVersion 34 versionCode versionProperties["versionCode"].toInteger() versionName "${versionMajor}.${versionMinor}.${versionProperties["versionCode"].toInteger()}" resourceConfigurations += ['de', 'en'] @@ -63,12 +63,11 @@ android { } buildFeatures { - viewBinding = true compose = true } composeOptions { - kotlinCompilerExtensionVersion = "1.4.7" + kotlinCompilerExtensionVersion = "1.4.8" } compileOptions { @@ -91,42 +90,42 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation 'androidx.core:core-ktx:1.10.1' implementation 'androidx.appcompat:appcompat:1.6.1' - implementation "androidx.compose.material3:material3:1.1.0" + implementation "androidx.compose.material3:material3:1.1.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.9.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3' - implementation 'androidx.navigation:navigation-ui-ktx:2.5.3' + implementation 'androidx.navigation:navigation-fragment-ktx:2.7.1' + implementation 'androidx.navigation:navigation-ui-ktx:2.7.1' implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' - implementation 'androidx.fragment:fragment-ktx:1.5.7' - implementation 'androidx.preference:preference-ktx:1.2.0' - implementation 'androidx.recyclerview:recyclerview:1.3.0' + implementation 'androidx.fragment:fragment-ktx:1.6.1' + implementation 'androidx.preference:preference-ktx:1.2.1' + implementation 'androidx.recyclerview:recyclerview:1.3.1' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1' - implementation 'androidx.compose.material:material-icons-extended:1.4.3' + implementation 'androidx.compose.material:material-icons-extended:1.5.0' implementation "com.google.accompanist:accompanist-systemuicontroller:0.27.0" implementation 'androidx.activity:activity-compose:1.7.2' - implementation "androidx.compose.ui:ui:1.4.3" - implementation "androidx.compose.ui:ui-tooling-preview:1.4.3" - implementation "androidx.compose.runtime:runtime-livedata:1.4.3" - implementation "androidx.navigation:navigation-compose:2.5.3" + implementation "androidx.compose.ui:ui:1.5.0" + implementation "androidx.compose.ui:ui-tooling-preview:1.5.0" + implementation "androidx.compose.runtime:runtime-livedata:1.5.0" + implementation "androidx.navigation:navigation-compose:2.7.1" implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.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.4.3" - debugImplementation "androidx.compose.ui:ui-tooling:1.4.3" - debugImplementation "androidx.compose.ui:ui-test-manifest:1.4.3" + androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.5.0" + debugImplementation "androidx.compose.ui:ui-tooling:1.5.0" + debugImplementation "androidx.compose.ui:ui-test-manifest:1.5.0" kapt "com.google.dagger:hilt-compiler:2.44" - implementation "androidx.room:room-runtime:2.5.1" - annotationProcessor "androidx.room:room-compiler:2.5.1" - kapt "androidx.room:room-compiler:2.5.1" - implementation "androidx.room:room-ktx:2.5.1" + implementation "androidx.room:room-runtime:2.5.2" + annotationProcessor "androidx.room:room-compiler:2.5.2" + kapt "androidx.room:room-compiler:2.5.2" + implementation "androidx.room:room-ktx:2.5.2" implementation "androidx.multidex:multidex:2.0.1" - api "androidx.navigation:navigation-fragment-ktx:2.5.3" + api "androidx.navigation:navigation-fragment-ktx:2.7.1" } // Allow references to generated code diff --git a/app/src/main/java/me/zobrist/tichucounter/MainActivity.kt b/app/src/main/java/me/zobrist/tichucounter/MainActivity.kt index 0aacbf2..72fe2bb 100644 --- a/app/src/main/java/me/zobrist/tichucounter/MainActivity.kt +++ b/app/src/main/java/me/zobrist/tichucounter/MainActivity.kt @@ -6,28 +6,61 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.padding 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.material.icons.outlined.Calculate +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Keyboard +import androidx.compose.material.icons.outlined.List +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.Redo +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Undo +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable 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.domain.DrawerItem +import me.zobrist.tichucounter.domain.KeepScreenOn +import me.zobrist.tichucounter.domain.Language +import me.zobrist.tichucounter.domain.ReviewService +import me.zobrist.tichucounter.domain.Route +import me.zobrist.tichucounter.domain.SettingsAdapter +import me.zobrist.tichucounter.domain.Theme +import me.zobrist.tichucounter.domain.TopBarAction +import me.zobrist.tichucounter.domain.TopBarState +import me.zobrist.tichucounter.domain.navigate +import me.zobrist.tichucounter.repository.GameRepository 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.counter.Counter +import me.zobrist.tichucounter.ui.counter.CounterViewModel import me.zobrist.tichucounter.ui.history.HistoryList import me.zobrist.tichucounter.ui.history.HistoryViewModel import me.zobrist.tichucounter.ui.layout.DrawerContent @@ -37,21 +70,59 @@ import me.zobrist.tichucounter.ui.settings.SettingsViewModel import javax.inject.Inject @AndroidEntryPoint -class MainActivity : AppCompatActivity(), ISettingsChangeListener { +class MainActivity : AppCompatActivity() { @Inject lateinit var settingsAdapter: SettingsAdapter + @Inject + lateinit var repository: GameRepository + + @Inject + lateinit var reviewService: ReviewService + 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 var requestReview: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - settingsAdapter.registerOnChangeListener(this) + changeTheme(settingsAdapter.theme.value) + setKeepScreenOn(settingsAdapter.keepScreenOn.value) + changeLanguage(settingsAdapter.language.value) + + + lifecycleScope.launch { + settingsAdapter.theme.collect { + changeTheme(it) + } + } + lifecycleScope.launch { + settingsAdapter.keepScreenOn.collect { + setKeepScreenOn(it) + } + } + lifecycleScope.launch { + settingsAdapter.language.collect { + changeLanguage(it) + } + } + + lifecycleScope.launch { + settingsAdapter.gameFinished.collect { + if (!requestReview) { + requestReview = true + return@collect + } + if (it) { + reviewService.request() + } + } + } setContent { AppTheme { @@ -62,16 +133,11 @@ class MainActivity : AppCompatActivity(), ISettingsChangeListener { } } - override fun onDestroy() { - super.onDestroy() - settingsAdapter.unregisterOnChangeListener(this) - } - - override fun onLanguageChanged(language: Language) { + private fun changeLanguage(language: Language) { AppCompatDelegate.setApplicationLocales(language.value) } - override fun onThemeChanged(theme: Theme) { + private fun changeTheme(theme: Theme) { val themeValue = when (theme) { Theme.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO Theme.DARK -> AppCompatDelegate.MODE_NIGHT_YES @@ -80,7 +146,7 @@ class MainActivity : AppCompatActivity(), ISettingsChangeListener { AppCompatDelegate.setDefaultNightMode(themeValue) } - override fun onScreenOnChanged(keepOn: KeepScreenOn) { + private fun setKeepScreenOn(keepOn: KeepScreenOn) { if (keepOn.value) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { @@ -151,8 +217,10 @@ class MainActivity : AppCompatActivity(), ISettingsChangeListener { ) { var topBarState by remember { mutableStateOf(TopBarState()) } + var snackbarHostState by remember { mutableStateOf(SnackbarHostState()) } Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, floatingActionButton = { if (showFab) { FloatingActionButton( @@ -168,7 +236,7 @@ class MainActivity : AppCompatActivity(), ISettingsChangeListener { startDestination = Route.COUNTER.name, modifier = Modifier.padding(paddings) ) { - composable(Route.COUNTER) { + this.composable(Route.COUNTER.name.toString()) { var expanded by remember { mutableStateOf(false) } @@ -188,15 +256,16 @@ class MainActivity : AppCompatActivity(), ISettingsChangeListener { mainViewModel.activeGameHasRounds, { expanded = true } ) { + val newGameTranslated = stringResource(R.string.new_game) DropDownMenu( - mapOf("new" to R.string.newGame), + listOf(newGameTranslated), "", expanded, ) { expanded = false it?.let { when (it) { - "new" -> mainViewModel.newGame() + newGameTranslated -> lifecycleScope.launch { repository.newGame() } } } } @@ -212,20 +281,23 @@ class MainActivity : AppCompatActivity(), ISettingsChangeListener { Counter(counterViewModel) } - composable(Route.HISTORY) { + composable(Route.HISTORY.name) { topBarState = TopBarState(title = stringResource(R.string.menu_history)) { scope.launch { drawerState.open() } } - HistoryList(historyViewModel) { navController.navigate(Route.COUNTER) } + HistoryList( + historyViewModel, + snackbarHostState + ) { navController.navigate(Route.COUNTER) } } - composable(Route.SETTINGS) { + composable(Route.SETTINGS.name) { topBarState = TopBarState(title = stringResource(R.string.menu_settings)) { scope.launch { drawerState.open() } } SettingsView(settingsViewModel) } - composable(Route.ABOUT) { + composable(Route.ABOUT.name) { topBarState = TopBarState(title = stringResource(R.string.menu_about)) { scope.launch { drawerState.open() } } diff --git a/app/src/main/java/me/zobrist/tichucounter/domain/NavExtensions.kt b/app/src/main/java/me/zobrist/tichucounter/domain/NavExtensions.kt index a6fdf6d..905cb28 100644 --- a/app/src/main/java/me/zobrist/tichucounter/domain/NavExtensions.kt +++ b/app/src/main/java/me/zobrist/tichucounter/domain/NavExtensions.kt @@ -1,12 +1,6 @@ package me.zobrist.tichucounter.domain -import androidx.compose.runtime.Composable -import androidx.navigation.NamedNavArgument -import androidx.navigation.NavBackStackEntry import androidx.navigation.NavController -import androidx.navigation.NavDeepLink -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable fun NavController.navigate(route: Route) { this.navigate(route.name) { @@ -23,12 +17,3 @@ fun NavController.navigate(route: Route) { 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/ReviewService.kt b/app/src/main/java/me/zobrist/tichucounter/domain/ReviewService.kt new file mode 100644 index 0000000..bc2c806 --- /dev/null +++ b/app/src/main/java/me/zobrist/tichucounter/domain/ReviewService.kt @@ -0,0 +1,56 @@ +package me.zobrist.tichucounter.domain + +import android.app.Activity +import android.content.Context +import androidx.preference.PreferenceManager +import com.google.android.play.core.review.ReviewManagerFactory +import dagger.hilt.android.qualifiers.ActivityContext +import java.util.Date +import javax.inject.Inject + +class ReviewService @Inject constructor(@ActivityContext private val appContext: Context) { + + private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(appContext) + + private val THREE_MONTHS: Long = 7776000000 + + private var requestCalled: Int + get() = sharedPreferences.getInt("requestCalled", 0) + set(value) { + val editor = sharedPreferences.edit() + editor.putInt("requestCalled", value) + editor.apply() + } + + private var nextReviewedDate: Date + get() = Date(sharedPreferences.getLong("lastReviewedDate", 0)) + set(value) { + val editor = sharedPreferences.edit() + editor.putLong("lastReviewedDate", value.time) + editor.apply() + } + + fun request() { + requestCalled += 1 + + if (requestCalled >= 3) { + if (nextReviewedDate.time < System.currentTimeMillis()) { + requestCalled = 0 + nextReviewedDate = Date(System.currentTimeMillis() + THREE_MONTHS) + + val manager = ReviewManagerFactory.create(appContext) + + val request = manager.requestReviewFlow() + request.addOnCompleteListener { task -> + if (task.isSuccessful) { + // We got the ReviewInfo object + val reviewInfo = task.result + manager.launchReviewFlow(appContext as Activity, reviewInfo) + + } else { + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/domain/SettingsAdapter.kt b/app/src/main/java/me/zobrist/tichucounter/domain/SettingsAdapter.kt index 452e678..27a4a77 100644 --- a/app/src/main/java/me/zobrist/tichucounter/domain/SettingsAdapter.kt +++ b/app/src/main/java/me/zobrist/tichucounter/domain/SettingsAdapter.kt @@ -4,6 +4,10 @@ import android.content.Context import androidx.core.os.LocaleListCompat import androidx.preference.PreferenceManager import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -16,77 +20,76 @@ enum class Language(val value: LocaleListCompat) { enum class KeepScreenOn(val value: Boolean) { ON(true), OFF(false) } -interface ISettingsChangeListener { - fun onLanguageChanged(language: Language) - fun onThemeChanged(theme: Theme) - fun onScreenOnChanged(keepOn: KeepScreenOn) -} +typealias VictoryPoints = Int +typealias GameWon = Boolean @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 + val language = MutableStateFlow(Language.DEFAULT) - var theme: Theme - private set + val theme = MutableStateFlow(Theme.DEFAULT) - var keepScreenOn: KeepScreenOn - private set + val keepScreenOn = MutableStateFlow(KeepScreenOn.OFF) + + val victoryPoints = MutableStateFlow(0) + + val gameFinished = MutableStateFlow(false) init { - language = try { + language.value = try { enumValueOf(sharedPreferences.getString(Language::class.simpleName, null)!!) } catch (_: NullPointerException) { Language.DEFAULT } - theme = try { + theme.value = try { enumValueOf(sharedPreferences.getString(Theme::class.simpleName, null)!!) } catch (_: java.lang.Exception) { Theme.DEFAULT } - keepScreenOn = try { + keepScreenOn.value = try { enumValueOf(sharedPreferences.getString(KeepScreenOn::class.simpleName, null)!!) } catch (_: java.lang.Exception) { KeepScreenOn.OFF } - } - fun registerOnChangeListener(listener: ISettingsChangeListener) { - listenerList.add(listener) + victoryPoints.value = sharedPreferences.getInt(VictoryPoints::class.simpleName, 1000) - listener.onThemeChanged(theme) - listener.onLanguageChanged(language) - listener.onScreenOnChanged(keepScreenOn) - } + gameFinished.value = sharedPreferences.getBoolean(GameWon::class.simpleName, false) - fun unregisterOnChangeListener(listener: ISettingsChangeListener?) { - if (listener != null) { - listenerList.remove(listener) + CoroutineScope(Dispatchers.IO).launch { + language.collect { + updatePreference(Language::class.simpleName, it.name) + } } - } - fun setLanguage(language: Language) { - this.language = language - updatePreference(Language::class.simpleName, language.name) - notifyListeners(language) - } + CoroutineScope(Dispatchers.IO).launch { + theme.collect { + updatePreference(Theme::class.simpleName, it.name) + } + } - fun setTheme(theme: Theme) { - this.theme = theme - updatePreference(Theme::class.simpleName, theme.name) - notifyListeners(theme) - } + CoroutineScope(Dispatchers.IO).launch { + keepScreenOn.collect { + updatePreference(KeepScreenOn::class.simpleName, it.name) + } + } - fun setKeepScreenOn(setting: KeepScreenOn) { - this.keepScreenOn = setting - updatePreference(KeepScreenOn::class.simpleName, setting.name) - notifyListeners(setting) + CoroutineScope(Dispatchers.IO).launch { + victoryPoints.collect { + updatePreference(VictoryPoints::class.simpleName, it) + } + } + + CoroutineScope(Dispatchers.IO).launch { + gameFinished.collect { + updatePreference(GameWon::class.simpleName, it) + } + } } private fun updatePreference(name: String?, value: String) { @@ -95,22 +98,15 @@ class SettingsAdapter @Inject constructor(@ApplicationContext private val contex editor.apply() } - private fun notifyListeners(language: Language) { - listenerList.forEach { - it.onLanguageChanged(language) - } + private fun updatePreference(name: String?, value: Boolean) { + val editor = sharedPreferences.edit() + editor.putBoolean(name, value) + editor.apply() } - private fun notifyListeners(theme: Theme) { - listenerList.forEach { - it.onThemeChanged(theme) - } + private fun updatePreference(name: String?, value: Int) { + val editor = sharedPreferences.edit() + editor.putInt(name, value) + editor.apply() } - - 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/repository/GameRepository.kt b/app/src/main/java/me/zobrist/tichucounter/repository/GameRepository.kt index ce01b3c..40b02df 100644 --- a/app/src/main/java/me/zobrist/tichucounter/repository/GameRepository.kt +++ b/app/src/main/java/me/zobrist/tichucounter/repository/GameRepository.kt @@ -3,6 +3,7 @@ package me.zobrist.tichucounter.repository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.take @@ -21,7 +22,10 @@ class GameRepository @Inject constructor( private val roundDao: RoundDao ) { - private var activeGame: Game = Game(true, "TeamA", "TeamB", Date(), Date()) + var activeGame: Game = Game(true, "TeamA", "TeamB", Date(), Date()) + private set + + private val newGameFlow = MutableStateFlow(Game()) init { CoroutineScope(Dispatchers.IO).launch { @@ -39,6 +43,7 @@ class GameRepository @Inject constructor( withContext(Dispatchers.IO) { val id = gameDao.insert(Game(true, activeGame.nameA, activeGame.nameB, Date(), Date())) setActive(id) + newGameFlow.value = gameDao.getGameById(id) } } @@ -134,4 +139,8 @@ class GameRepository @Inject constructor( fun getDistinctTeamNames(): Flow> { return gameDao.getDistinctTeamNames() } + + fun getNewGameStarted(): Flow { + return newGameFlow + } } \ No newline at end of file diff --git a/app/src/main/java/me/zobrist/tichucounter/ui/MainViewModel.kt b/app/src/main/java/me/zobrist/tichucounter/ui/MainViewModel.kt index 5905bf0..87d1232 100644 --- a/app/src/main/java/me/zobrist/tichucounter/ui/MainViewModel.kt +++ b/app/src/main/java/me/zobrist/tichucounter/ui/MainViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import me.zobrist.tichucounter.data.entity.Game import me.zobrist.tichucounter.data.entity.Round import me.zobrist.tichucounter.repository.GameRepository import javax.inject.Inject @@ -29,6 +30,8 @@ class MainViewModel @Inject constructor( var activeGameHasRounds by mutableStateOf(false) private set + private var newGame: Game? = null + init { viewModelScope.launch { @@ -45,6 +48,16 @@ class MainViewModel @Inject constructor( expectedRoundCount = it.rounds.count() } } + + viewModelScope.launch { + gameRepository.getNewGameStarted().collect { + if (newGame == null) { + newGame = it + return@collect + } + redoRounds.clear() + } + } } fun undoLastRound() { @@ -69,11 +82,4 @@ class MainViewModel @Inject constructor( } } } - - 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/composables/DropDownMenu.kt b/app/src/main/java/me/zobrist/tichucounter/ui/composables/DropDownMenu.kt index 12a9700..6042af4 100644 --- a/app/src/main/java/me/zobrist/tichucounter/ui/composables/DropDownMenu.kt +++ b/app/src/main/java/me/zobrist/tichucounter/ui/composables/DropDownMenu.kt @@ -7,25 +7,29 @@ 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) { +fun DropDownMenu( + list: Collection, + selected: T, + expanded: Boolean, + onSelected: (T?) -> Unit +) { DropdownMenu( expanded = expanded, onDismissRequest = { onSelected(null) } ) { - map.forEach { + list.forEach { DropdownMenuItem( onClick = { - onSelected(it.key) + onSelected(it) }, trailingIcon = { - if (it.key == selected) { + if (it == selected) { Icon(Icons.Outlined.Check, null) } }, - text = { Text(stringResource(it.value)) }, + text = { Text(it.toString()) }, ) } } 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 index a338d72..9288313 100644 --- a/app/src/main/java/me/zobrist/tichucounter/ui/counter/CounterView.kt +++ b/app/src/main/java/me/zobrist/tichucounter/ui/counter/CounterView.kt @@ -3,7 +3,13 @@ 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.material.icons.Icons +import androidx.compose.material.icons.outlined.EmojiEvents +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -12,7 +18,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import me.zobrist.tichucounter.R import me.zobrist.tichucounter.data.entity.Round import me.zobrist.tichucounter.ui.AppTheme @@ -23,6 +31,18 @@ fun Counter(viewModel: ICounterViewModel = PreviewViewModel()) { var orientation by remember { mutableStateOf(Configuration.ORIENTATION_PORTRAIT) } orientation = LocalConfiguration.current.orientation + if (viewModel.showVictoryDialog) { + GameVictoryDialog( + viewModel.totalScoreA, + viewModel.totalScoreB, + viewModel.teamNameA, + viewModel.teamNameB, + { viewModel.victoryDialogExecuted(false) }) + { + viewModel.victoryDialogExecuted(true) + } + } + Surface { if (orientation == Configuration.ORIENTATION_LANDSCAPE) { Landscape(viewModel) @@ -102,6 +122,54 @@ fun CounterViewPreview() { } } +@Preview() +@Composable +fun GameVictoryDialog( + pointsA: Int = 2000, + pointsB: Int = 50, + nameA: String = "nameA", + nameB: String = "nameB", + onDismiss: () -> Unit = {}, + onNewGame: () -> Unit = {}, +) { + + val winner = if (pointsA > pointsB) { + nameA + } else { + nameB + } + + val message = if (pointsA == pointsB) { + stringResource(R.string.draw_message, winner, 100) + } else { + stringResource(R.string.victory_message) + } + + val title = if (pointsA == pointsB) { + stringResource(R.string.draw_title) + } else { + stringResource(R.string.victory_title, winner) + } + + AlertDialog( + onDismissRequest = { onDismiss() }, + dismissButton = { + TextButton({ onDismiss() }) { + Text(stringResource(R.string.abort)) + } + }, + confirmButton = { + TextButton({ onNewGame() }) { + Text(stringResource(R.string.new_game)) + } + }, + icon = { Icon(Icons.Outlined.EmojiEvents, null) }, + title = { Text(title) }, + text = { Text(message) } + + ) +} + internal class PreviewViewModel : ICounterViewModel { override var roundScoreList: List = listOf(Round(1, 10, 90), Round(1, 50, 50), Round(1, 70, 30)) @@ -123,6 +191,7 @@ internal class PreviewViewModel : ICounterViewModel { listOf("TeamA", "asdffd", "TeamB", "really really long Team Name that is way too long") override val teamNameSuggestionsB: List = listOf("TeamA", "asdffd", "TeamB", "really really long Team Name that is way too long") + override var showVictoryDialog: Boolean = false override fun focusLastInput() { } @@ -155,6 +224,9 @@ internal class PreviewViewModel : ICounterViewModel { override fun updateNameB(value: String) { } + override fun victoryDialogExecuted(result: Boolean) { + } + override fun updateFocusStateA(state: Boolean) { } 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 index 751d186..8d2c5c9 100644 --- a/app/src/main/java/me/zobrist/tichucounter/ui/counter/CounterViewModel.kt +++ b/app/src/main/java/me/zobrist/tichucounter/ui/counter/CounterViewModel.kt @@ -1,6 +1,7 @@ package me.zobrist.tichucounter.ui.counter import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.focus.FocusRequester @@ -10,7 +11,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import me.zobrist.tichucounter.data.entity.Game import me.zobrist.tichucounter.data.entity.Round +import me.zobrist.tichucounter.domain.SettingsAdapter import me.zobrist.tichucounter.domain.Tichu import me.zobrist.tichucounter.domain.digitCount import me.zobrist.tichucounter.domain.getTotalPoints @@ -57,24 +60,27 @@ interface ICounterViewModel : IKeyBoardViewModel { val teamNameB: String val teamNameSuggestionsA: List val teamNameSuggestionsB: List + val showVictoryDialog: Boolean fun updateNameA(value: String) fun updateNameB(value: String) + fun victoryDialogExecuted(result: Boolean) } @HiltViewModel class CounterViewModel @Inject constructor( - private val gameRepository: GameRepository + private val gameRepository: GameRepository, + private val settings: SettingsAdapter ) : ViewModel(), ICounterViewModel { override var roundScoreList by mutableStateOf(emptyList()) private set - override var totalScoreA by mutableStateOf(0) + override var totalScoreA by mutableIntStateOf(0) private set - override var totalScoreB by mutableStateOf(0) + override var totalScoreB by mutableIntStateOf(0) private set override var teamNameA by mutableStateOf("") @@ -112,6 +118,8 @@ class CounterViewModel @Inject constructor( override var teamNameSuggestionsB by mutableStateOf(listOf()) private set + override var showVictoryDialog by mutableStateOf(false) + private set override var activeValue: String get() { @@ -154,11 +162,18 @@ class CounterViewModel @Inject constructor( private var distinctTeamNames = listOf() + private var lastGame: Game? = null + private var lastVictoryPoints: Int? = null + + private val gameWon: Boolean + get() = totalScoreA >= settings.victoryPoints.value || totalScoreB >= settings.victoryPoints.value + + private var lastRoundCount: Int = 0 + init { viewModelScope.launch { gameRepository.getActiveGameFlow().collect { if (it != null) { - val score = it.getTotalPoints() roundScoreList = it.rounds @@ -170,6 +185,30 @@ class CounterViewModel @Inject constructor( buildTeamNameSuggestions() + // Game has changed + if (it.game.uid != lastGame?.uid) { + if (lastGame != null) { + settings.gameFinished.value = false + } + lastGame = it.game + lastRoundCount = it.rounds.size + return@collect + } + + // Game winning condition + if (!settings.gameFinished.value) { + if (gameWon) { + showVictoryDialog = true + } + } + + // Undo game winning if rounds were removed + if (lastRoundCount > it.rounds.size) { + if (!gameWon) { + settings.gameFinished.value = false + } + } + lastRoundCount = it.rounds.size } } } @@ -181,6 +220,17 @@ class CounterViewModel @Inject constructor( buildTeamNameSuggestions() } } + viewModelScope.launch { + settings.victoryPoints.collect { + if (lastVictoryPoints == null) { + lastVictoryPoints = it + return@collect + } + + // Game was already won and will be won also with new settings + settings.gameFinished.value = settings.gameFinished.value && gameWon + } + } } override fun focusLastInput() { @@ -289,6 +339,17 @@ class CounterViewModel @Inject constructor( } } + override fun victoryDialogExecuted(result: Boolean) { + showVictoryDialog = false + settings.gameFinished.value = true + + if (result) { + viewModelScope.launch { + gameRepository.newGame() + } + } + } + override fun updateFocusStateA(state: Boolean) { isAFocused = state if (state) { 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 index 3ab3407..85880a6 100644 --- a/app/src/main/java/me/zobrist/tichucounter/ui/counter/KeyboardView.kt +++ b/app/src/main/java/me/zobrist/tichucounter/ui/counter/KeyboardView.kt @@ -1,17 +1,41 @@ package me.zobrist.tichucounter.ui.counter import android.content.res.Configuration -import androidx.compose.animation.core.* +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth 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.material3.Divider +import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -20,6 +44,7 @@ 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.platform.LocalTextInputService import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -267,37 +292,40 @@ fun CenteredTextField( } 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) - } - ) + CompositionLocalProvider(LocalTextInputService provides null) { + 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 infiniteTransition = rememberInfiniteTransition(label = "blinkingCursor") val alpha by infiniteTransition.animateFloat( 0f, cursorColor.alpha, animationSpec = infiniteRepeatable( animation = tween(500), repeatMode = RepeatMode.Reverse - ) + ), label = "blinkingCursor" ) Row { 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 index 9527c73..2e911a9 100644 --- a/app/src/main/java/me/zobrist/tichucounter/ui/counter/TeamNamesView.kt +++ b/app/src/main/java/me/zobrist/tichucounter/ui/counter/TeamNamesView.kt @@ -1,9 +1,12 @@ package me.zobrist.tichucounter.ui.counter import android.content.res.Configuration -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview 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 index fa6eabe..cb4cfd7 100644 --- a/app/src/main/java/me/zobrist/tichucounter/ui/history/HistoryView.kt +++ b/app/src/main/java/me/zobrist/tichucounter/ui/history/HistoryView.kt @@ -1,44 +1,65 @@ package me.zobrist.tichucounter.ui.history -import androidx.compose.foundation.clickable +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.DeleteForever -import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.RestartAlt import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Badge import androidx.compose.material3.Button import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DismissDirection +import androidx.compose.material3.DismissValue +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SwipeToDismiss import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDismissState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Alignment.Companion.TopEnd import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.platform.LocalDensity 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 kotlinx.coroutines.delay +import kotlinx.coroutines.launch 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.Date import java.util.Locale @@ -47,11 +68,14 @@ import java.util.Locale @Composable fun HistoryList( viewModel: HistoryViewModel, - navigateToCalculator: () -> Unit + snackbarHostState: SnackbarHostState, + navigateToCalculator: () -> Unit, ) { - + val scope = rememberCoroutineScope() + val lazyListState = rememberLazyListState() var showDeleteDialog by remember { mutableStateOf(false) } + DeleteConfirmDialog(showDeleteDialog) { showDeleteDialog = false if (it) { @@ -59,14 +83,48 @@ fun HistoryList( } } + val deletedMessage = stringResource(id = R.string.delete_success) + val deletedActionLabel = stringResource(id = R.string.undo_question) + + val activatedMessage = stringResource(id = R.string.activated_success) + val activatedActionLabel = stringResource(id = R.string.to_calculator_question) + HistoryList( - viewModel.gameAndHistory, - { - viewModel.activateGame(it) - navigateToCalculator() + games = viewModel.gameAndHistory, + onOpenClicked = { + scope.launch { + viewModel.activateGame(it) + lazyListState.animateScrollToItem(0) + + val result = snackbarHostState.showSnackbar( + message = activatedMessage, + actionLabel = activatedActionLabel, + duration = SnackbarDuration.Short + ) + + if (result == SnackbarResult.ActionPerformed) { + navigateToCalculator() + } + } }, - { viewModel.deleteGame(it) }, - { showDeleteDialog = true }, + onDeleteClicked = { + scope.launch { + viewModel.markToDelete(it) + val result = snackbarHostState.showSnackbar( + message = deletedMessage, + actionLabel = deletedActionLabel, + duration = SnackbarDuration.Short + ) + + if (result == SnackbarResult.Dismissed) { + viewModel.deleteGame(it) + } else { + viewModel.unmarkToDelete(it) + } + } + }, + onDeleteAllClicked = { showDeleteDialog = true }, + lazyListState = lazyListState ) } @@ -78,14 +136,12 @@ fun DeleteConfirmDialog(show: Boolean = true, onExecuted: (Boolean) -> Unit = {} AlertDialog( onDismissRequest = { onExecuted(false) }, dismissButton = { - TextButton({ onExecuted(false) }) - { + TextButton({ onExecuted(false) }) { Text(stringResource(R.string.cancel)) } }, confirmButton = { - TextButton({ onExecuted(true) }) - { + TextButton({ onExecuted(true) }) { Text(stringResource(R.string.ok)) } }, @@ -95,133 +151,201 @@ fun DeleteConfirmDialog(show: Boolean = true, onExecuted: (Boolean) -> Unit = {} } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun HistoryList( games: List, - onOpenClicked: (GameId: Long) -> Unit, - onDeleteClicked: (GameId: Long) -> Unit, - onDeleteAllClicked: () -> Unit - + onOpenClicked: (gameId: Long) -> Unit, + onDeleteClicked: (gameId: Long) -> Unit, + onDeleteAllClicked: () -> Unit, + lazyListState: LazyListState = LazyListState(), ) { - 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) - } + val scope = rememberCoroutineScope() - 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 + Row { + LazyColumn(state = lazyListState) { + items( + items = games, + key = { it.game.uid }) { item -> + if (item.game.active) { + HistoryListItem( + item, + Modifier + .animateItemPlacement() + .padding(2.dp) + ) + } else { + DismissibleHistoryListItem( + item, + Modifier.animateItemPlacement(), + { + onOpenClicked(it) + scope.launch { + delay(100) + lazyListState.animateScrollToItem(0) + } + }, + onDeleteClicked ) } + } - 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)) - } + item { + Button(enabled = games.count() > 1, + modifier = Modifier + .padding(start = 4.dp, end = 4.dp, top = 10.dp) + .align(CenterVertically) + .fillMaxWidth() + .animateItemPlacement(), + onClick = { onDeleteAllClicked() }) { + Icon(imageVector = Icons.Outlined.DeleteForever, contentDescription = null) + Text(text = stringResource(id = R.string.deleteAll)) } } } } + + } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DismissibleHistoryListItem( + game: GameWithScores, + modifier: Modifier = Modifier, + onOpenClicked: (gameId: Long) -> Unit, + onDeleteClicked: (gameId: Long) -> Unit, +) { + + val density = LocalDensity.current + val dismissState = + rememberDismissState(positionalThreshold = { with(density) { 100.dp.toPx() } }, + confirmValueChange = { + if (it == DismissValue.DismissedToStart) { + onDeleteClicked(game.game.uid) + } + if (it == DismissValue.DismissedToEnd) { + onOpenClicked(game.game.uid) + } + true + }) + + val directions = if (game.game.active) { + setOf() + + } else { + setOf(DismissDirection.EndToStart, DismissDirection.StartToEnd) + } + + SwipeToDismiss( + modifier = modifier, + state = dismissState, + directions = directions, + background = { + val direction = dismissState.dismissDirection ?: return@SwipeToDismiss + val color by animateColorAsState( + when (dismissState.targetValue) { + DismissValue.DismissedToStart -> MaterialTheme.colorScheme.error + DismissValue.DismissedToEnd -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.background + + }, label = "" + ) + val textColor by animateColorAsState( + when (dismissState.targetValue) { + DismissValue.DismissedToStart -> MaterialTheme.colorScheme.onError + DismissValue.DismissedToEnd -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onBackground + + }, label = "" + ) + val alignment = when (direction) { + DismissDirection.StartToEnd -> Alignment.CenterStart + DismissDirection.EndToStart -> Alignment.CenterEnd + } + val icon = when (direction) { + DismissDirection.StartToEnd -> Icons.Outlined.RestartAlt + DismissDirection.EndToStart -> Icons.Outlined.Delete + } + val text = when (direction) { + DismissDirection.StartToEnd -> stringResource(id = R.string.continue_play) + DismissDirection.EndToStart -> stringResource(id = R.string.delete) + } + val scale by animateFloatAsState( + if (dismissState.targetValue == DismissValue.Default) 0.75f else 1f, label = "" + ) + + Box( + Modifier + .fillMaxSize() + .padding(top = 2.dp, bottom = 2.dp) + .background(color) + .padding(horizontal = 10.dp), + contentAlignment = alignment + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.scale(scale), + tint = textColor + ) + Text(text = text, color = textColor) + } + } + }, dismissContent = { + HistoryListItem(game = game, modifier = Modifier.padding(2.dp)) + }) +} + +@OptIn(ExperimentalMaterial3Api::class) @Composable fun HistoryListItem( - game: GameWithScores, - onOpenClicked: (GameId: Long) -> Unit, - onDeleteClicked: (GameId: Long) -> Unit + game: GameWithScores, modifier: Modifier = Modifier ) { 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 + modifier = modifier .fillMaxWidth() - .padding(all = 4.dp) - .clickable { onOpenClicked(game.game.uid) }, - colors = cardColor ) { Row( - Modifier - .padding(all = 12.dp) + 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 + Box(modifier = modifier.fillMaxSize()) { + Column { + Text( + text = game.game.nameA + " vs " + game.game.nameB, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.headlineSmall ) - - - DropDownMenu( - mapOf("delete" to R.string.delete), - "", - expanded, + 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 + ) + } + if (game.game.active) { + Badge( + modifier = Modifier.align(TopEnd), + contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = MaterialTheme.colorScheme.primary ) { - expanded = false - it?.let { - when (it) { - "delete" -> onDeleteClicked(game.game.uid) - } - } + Text( + text = stringResource(id = R.string.active), + style = MaterialTheme.typography.labelSmall + ) } } } @@ -234,25 +358,17 @@ fun HistoryListItem( private fun HistoryListPreview() { val tempData = listOf( GameWithScores( - Game(true, "abc", "def", Date(), Date()), + Game(true, "abcsdf sdaf asdf sdf ", "defsadf asdf sadf ", 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)) + ), 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, {}, {}) {} + 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 index ed3f587..9507838 100644 --- a/app/src/main/java/me/zobrist/tichucounter/ui/history/HistoryViewModel.kt +++ b/app/src/main/java/me/zobrist/tichucounter/ui/history/HistoryViewModel.kt @@ -20,16 +20,27 @@ class HistoryViewModel @Inject constructor( var gameAndHistory by mutableStateOf(emptyList()) private set + private var fullList: List = emptyList() + init { viewModelScope.launch { gameRepository.getAllWithRoundFlow().collect { games -> - gameAndHistory = + fullList = games.sortedBy { it.game.modified }.sortedBy { it.game.active }.reversed() + gameAndHistory = fullList } } } + fun markToDelete(gameId: Long) { + gameAndHistory = fullList.filter { it.game.uid != gameId } + } + + fun unmarkToDelete(gameId: Long) { + gameAndHistory = fullList + } + fun deleteGame(gameId: Long) { viewModelScope.launch { gameRepository.deleteGame(gameId) 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 index 1dd1b55..eaf1f43 100644 --- a/app/src/main/java/me/zobrist/tichucounter/ui/layout/DrawerContent.kt +++ b/app/src/main/java/me/zobrist/tichucounter/ui/layout/DrawerContent.kt @@ -1,20 +1,28 @@ package me.zobrist.tichucounter.ui.layout import android.content.res.Configuration -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.padding 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.material.icons.outlined.Calculate +import androidx.compose.material.icons.outlined.List +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable 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.domain.DrawerItem +import me.zobrist.tichucounter.domain.Route import me.zobrist.tichucounter.ui.AppTheme -import me.zobrist.tichucounter.ui.counter.* @Composable fun DrawerContent( 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 index bcdddf4..83ffacb 100644 --- a/app/src/main/java/me/zobrist/tichucounter/ui/settings/SettingsView.kt +++ b/app/src/main/java/me/zobrist/tichucounter/ui/settings/SettingsView.kt @@ -44,6 +44,7 @@ val themeMap = mapOf( Theme.LIGHT to R.string.light ) +val victoryPointsList = listOf(500, 1000, 1500, 2000) @Composable fun SettingsView(viewModel: SettingsViewModel) { @@ -51,9 +52,11 @@ fun SettingsView(viewModel: SettingsViewModel) { viewModel.screenOn.value, viewModel.language, viewModel.theme, + viewModel.victoryPoints, { viewModel.updateScreenOn(it) }, { viewModel.updateLanguage(it) }, - { viewModel.updateTheme(it) }) + { viewModel.updateTheme(it) }, + { viewModel.updateVictoryPoints(it) }) } @Composable @@ -61,11 +64,22 @@ fun SettingsView( valueScreenOn: Boolean = true, valueLanguage: Language = Language.ENGLISH, valueTheme: Theme = Theme.DARK, + valueVictoryPoints: Int = 1000, updateScreenOn: (KeepScreenOn) -> Unit = {}, updateLanguage: (Language) -> Unit = {}, - updateTheme: (Theme) -> Unit = {} + updateTheme: (Theme) -> Unit = {}, + updateVictoryPoints: (Int) -> Unit = {} ) { - Column { + Column( + Modifier + .padding(20.dp) + ) { + + Text( + text = stringResource(R.string.display), + style = MaterialTheme.typography.headlineMedium + ) + BooleanSetting( stringResource(R.string.keep_screen_on), valueScreenOn @@ -82,6 +96,18 @@ fun SettingsView( themeMap, valueTheme, ) { updateTheme(it) } + + Text( + text = stringResource(R.string.game), + style = MaterialTheme.typography.headlineMedium + ) + + + ListSetting( + stringResource(R.string.victory_points), + victoryPointsList, + valueVictoryPoints + ) { updateVictoryPoints(it) } } } @@ -90,7 +116,7 @@ fun BooleanSetting(name: String, value: Boolean, updateValue: (Boolean) -> Unit) Row( Modifier - .padding(20.dp) + .padding(bottom = 15.dp, top = 5.dp) .fillMaxWidth() ) { Column(Modifier.weight(5f)) { @@ -119,21 +145,32 @@ fun BooleanSetting(name: String, value: Boolean, updateValue: (Boolean) -> Unit) @Composable fun StringSetting(name: String, map: Map, selected: T, onSelected: (T) -> Unit) { + val translated = map.map { it.key to stringResource(it.value) }.toMap() + val getValue = map.map { stringResource(it.value) to it.key }.toMap() + + ListSetting( + name, + translated.values, + translated[selected] + ) { getValue[it]?.let { it1 -> onSelected(it1) } } +} + +@Composable +fun ListSetting(name: String, list: Collection, selected: T, onSelected: (T) -> Unit) { + var expanded by remember { mutableStateOf(false) } Row( Modifier .fillMaxWidth() - .padding(20.dp) + .padding(bottom = 15.dp, top = 5.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 - ) - } + Text( + selected.toString(), + style = MaterialTheme.typography.labelLarge + ) } Column(Modifier.weight(1f)) { @@ -142,15 +179,15 @@ fun StringSetting(name: String, map: Map, selected: T, onSelected: ( contentDescription = null, modifier = Modifier.align(End) ) - } - DropDownMenu( - map, - selected, - expanded, - ) { - expanded = false - it?.let { onSelected(it) } + DropDownMenu( + list, + selected, + expanded, + ) { + expanded = false + it?.let { onSelected(it) } + } } } } @@ -167,20 +204,3 @@ fun SettingsViewPreview() { } } } - -@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 index b37e1bd..cd41a25 100644 --- a/app/src/main/java/me/zobrist/tichucounter/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/me/zobrist/tichucounter/ui/settings/SettingsViewModel.kt @@ -1,6 +1,7 @@ package me.zobrist.tichucounter.ui.settings import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel @@ -15,28 +16,35 @@ import javax.inject.Inject class SettingsViewModel @Inject constructor(private val settings: SettingsAdapter) : ViewModel() { - var language by mutableStateOf(settings.language) + var language by mutableStateOf(settings.language.value) private set - var theme by mutableStateOf(settings.theme) + var theme by mutableStateOf(settings.theme.value) private set - var screenOn by mutableStateOf(settings.keepScreenOn) + var screenOn by mutableStateOf(settings.keepScreenOn.value) + private set + + var victoryPoints by mutableIntStateOf(settings.victoryPoints.value) private set fun updateLanguage(language: Language) { - settings.setLanguage(language) - this.language = settings.language + settings.language.value = language + this.language = language } fun updateTheme(theme: Theme) { - settings.setTheme(theme) - this.theme = settings.theme + settings.theme.value = theme + this.theme = theme } fun updateScreenOn(value: KeepScreenOn) { - settings.setKeepScreenOn(value) - screenOn = settings.keepScreenOn + settings.keepScreenOn.value = value + screenOn = value } + fun updateVictoryPoints(value: Int) { + settings.victoryPoints.value = value + victoryPoints = value + } } \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ae3d8f6..ec33beb 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -12,7 +12,7 @@ Einstellungen Ein Aus - Neues Spiel + Neues Spiel Verlauf löschen Wirklich den gesamten Verlauf löschen? Diese Aktion kann nicht rückgängig gemacht werden. Abbrechen @@ -24,5 +24,17 @@ Counter About Schreib uns - + Weiterspielen + Spiel gelöscht. + RÜCKGÄNGIG + Spiel aktiviert. + WEITERSPIELEN + Anzeige + Spiel + Siegespunkte + %1$s hat gewonnen + Sieht aus, als ob ihr ein neues Spiel starten solltet, um das endgültig zu klären. + Unentschieden + Herzliche Gratulation! Wie wäre es mit einer Revanche? + Abbrechen \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 48012ba..1cb4440 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,9 +15,9 @@ Settings On Off - New Game + New Game Delete history - You really want to delete the the history? This action can\'t be undone. + You really want to delete the history? This action can\'t be undone. Cancel OK Delete @@ -28,4 +28,17 @@ About Contact us Play Store + Continue game + Game deleted. + UNDO + Game activated. + CONTINUE PLAYING + Display + Game + Victory points + %1$s won the game + Looks like you should start a new game to settle this for good. + Draw + Congratulations! How about a rematch? + Abort \ No newline at end of file diff --git a/build.gradle b/build.gradle index 24af24d..75ce81e 100644 --- a/build.gradle +++ b/build.gradle @@ -1,12 +1,12 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = "1.8.21" + ext.kotlin_version = "1.8.22" repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.0.2' + classpath 'com.android.tools.build:gradle:8.1.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong @@ -26,6 +26,6 @@ allprojects { } } -task clean(type: Delete) { +tasks.register('clean', Delete) { delete rootProject.buildDir } \ No newline at end of file