6 Commits

Author SHA1 Message Date
d81e197d8c Remove redundant conversion. Use val where possible.
All checks were successful
Build Android / build (push) Successful in 7m19s
2023-07-18 20:18:35 +02:00
7c940eacb0 Remove obsolete null check. 2023-07-18 20:16:46 +02:00
410c948882 Update dependencies 2023-07-18 20:16:02 +02:00
4cccc26849 Show fabs next to each other. Optimize imports.
All checks were successful
Build Android / build (push) Successful in 7m52s
2023-07-09 16:08:55 +02:00
ac615560ae Add setting and a button to change between modes.
All checks were successful
Build Android / build (push) Successful in 9m0s
2023-07-04 23:23:39 +02:00
6fe4e1f790 Show graph 2023-07-04 22:07:07 +02:00
21 changed files with 572 additions and 773 deletions

View File

@@ -32,13 +32,13 @@ jobs:
- name: Deploy latest to Nextcloud
run: |
curl -k -u "${{ secrets.NEXTCLOUD_USERNAME }}:${{ secrets.NEXTCLOUD_PASSWORD }}" -T "app/build/outputs/apk/release/app-release.apk" "${{ secrets.NEXTCLOUD_BASE_URL }}/TichuCounter/latest/app-release.apk"
curl -k -u "${{ secrets.NEXTCLOUD_USERNAME }}:${{ secrets.NEXTCLOUD_PASSWORD }}" -T "app/build/outputs/bundle/release/app-release.aab" "${{ secrets.NEXTCLOUD_BASE_URL }}/TichuCounter/latest/app-release.aab"
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" "${{ secrets.NEXTCLOUD_BASE_URL }}/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" "${{ secrets.NEXTCLOUD_BASE_URL }}/TichuCounter/tagged/app-release-${GITHUB_REF_NAME##*/}.aab"
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()

View File

@@ -16,7 +16,7 @@ def keystoreProperties = new Properties()
def versionProperties = new Properties()
def versionMajor = 2
def versionMinor = 3
def versionMinor = 2
// Load your keystore.properties file into the keystoreProperties object.
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
@@ -24,12 +24,12 @@ versionProperties.load(new FileInputStream(versionPropertiesFile))
android {
compileSdk 34
compileSdkVersion 33
defaultConfig {
applicationId "me.zobrist.tichucounter"
minSdkVersion 21
targetSdkVersion 34
targetSdkVersion 33
versionCode versionProperties["versionCode"].toInteger()
versionName "${versionMajor}.${versionMinor}.${versionProperties["versionCode"].toInteger()}"
resourceConfigurations += ['de', 'en']
@@ -63,11 +63,12 @@ android {
}
buildFeatures {
viewBinding = true
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.4.8"
kotlinCompilerExtensionVersion = "1.4.7"
}
compileOptions {
@@ -95,37 +96,38 @@ dependencies {
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.7.1'
implementation 'androidx.navigation:navigation-ui-ktx:2.7.1'
implementation 'androidx.navigation:navigation-fragment-ktx:2.6.0'
implementation 'androidx.navigation:navigation-ui-ktx:2.6.0'
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.6.1'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.recyclerview:recyclerview:1.3.1'
implementation 'androidx.fragment:fragment-ktx:1.6.0'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.recyclerview:recyclerview:1.3.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
implementation 'androidx.compose.material:material-icons-extended:1.5.0'
implementation 'androidx.compose.material:material-icons-extended:1.4.3'
implementation "com.google.accompanist:accompanist-systemuicontroller:0.27.0"
implementation 'androidx.activity:activity-compose:1.7.2'
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.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.6.0"
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.5.0"
debugImplementation "androidx.compose.ui:ui-tooling:1.5.0"
debugImplementation "androidx.compose.ui:ui-test-manifest:1.5.0"
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"
kapt "com.google.dagger:hilt-compiler:2.44"
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.7.1"
api "androidx.navigation:navigation-fragment-ktx:2.6.0"
implementation "com.patrykandpatrick.vico:compose-m3:1.6.5"
}
// Allow references to generated code

View File

@@ -23,8 +23,6 @@ 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
@@ -34,10 +32,9 @@ 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.compose.ui.unit.dp
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
@@ -45,16 +42,16 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import me.zobrist.tichucounter.domain.DrawerItem
import me.zobrist.tichucounter.domain.ISystemSettingsListener
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.composable
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
@@ -70,59 +67,21 @@ import me.zobrist.tichucounter.ui.settings.SettingsViewModel
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
class MainActivity : AppCompatActivity(), ISystemSettingsListener {
@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)
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()
}
}
}
settingsAdapter.registerOnChangeListener(this)
setContent {
AppTheme {
@@ -133,11 +92,16 @@ class MainActivity : AppCompatActivity() {
}
}
private fun changeLanguage(language: Language) {
override fun onDestroy() {
super.onDestroy()
settingsAdapter.unregisterOnChangeListener(this)
}
override fun onLanguageChanged(language: Language) {
AppCompatDelegate.setApplicationLocales(language.value)
}
private fun changeTheme(theme: Theme) {
override fun onThemeChanged(theme: Theme) {
val themeValue = when (theme) {
Theme.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
Theme.DARK -> AppCompatDelegate.MODE_NIGHT_YES
@@ -146,7 +110,7 @@ class MainActivity : AppCompatActivity() {
AppCompatDelegate.setDefaultNightMode(themeValue)
}
private fun setKeepScreenOn(keepOn: KeepScreenOn) {
override fun onScreenOnChanged(keepOn: KeepScreenOn) {
if (keepOn.value) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
@@ -217,13 +181,12 @@ class MainActivity : AppCompatActivity() {
) {
var topBarState by remember { mutableStateOf(TopBarState()) }
var snackbarHostState by remember { mutableStateOf(SnackbarHostState()) }
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
if (showFab) {
FloatingActionButton(
modifier = Modifier.padding(end = 75.dp),
onClick = { fabAction() }) {
Icon(Icons.Outlined.Keyboard, null)
}
@@ -236,7 +199,7 @@ class MainActivity : AppCompatActivity() {
startDestination = Route.COUNTER.name,
modifier = Modifier.padding(paddings)
) {
this.composable(Route.COUNTER.name.toString()) {
composable(Route.COUNTER) {
var expanded by remember { mutableStateOf(false) }
@@ -256,16 +219,15 @@ class MainActivity : AppCompatActivity() {
mainViewModel.activeGameHasRounds,
{ expanded = true }
) {
val newGameTranslated = stringResource(R.string.new_game)
DropDownMenu(
listOf(newGameTranslated),
mapOf("new" to R.string.newGame),
"",
expanded,
) {
expanded = false
it?.let {
when (it) {
newGameTranslated -> lifecycleScope.launch { repository.newGame() }
"new" -> mainViewModel.newGame()
}
}
}
@@ -281,23 +243,20 @@ class MainActivity : AppCompatActivity() {
Counter(counterViewModel)
}
composable(Route.HISTORY.name) {
composable(Route.HISTORY) {
topBarState =
TopBarState(title = stringResource(R.string.menu_history)) { scope.launch { drawerState.open() } }
HistoryList(
historyViewModel,
snackbarHostState
) { navController.navigate(Route.COUNTER) }
HistoryList(historyViewModel) { navController.navigate(Route.COUNTER) }
}
composable(Route.SETTINGS.name) {
composable(Route.SETTINGS) {
topBarState =
TopBarState(title = stringResource(R.string.menu_settings)) { scope.launch { drawerState.open() } }
SettingsView(settingsViewModel)
}
composable(Route.ABOUT.name) {
composable(Route.ABOUT) {
topBarState =
TopBarState(title = stringResource(R.string.menu_about)) { scope.launch { drawerState.open() } }

View File

@@ -1,6 +1,12 @@
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) {
@@ -17,3 +23,12 @@ fun NavController.navigate(route: Route) {
restoreState = true
}
}
fun NavGraphBuilder.composable(
route: Route,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
content: @Composable (NavBackStackEntry) -> Unit
) {
this.composable(route.name, arguments, deepLinks, content)
}

View File

@@ -1,56 +0,0 @@
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 {
}
}
}
}
}
}

View File

@@ -4,10 +4,6 @@ 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
@@ -18,95 +14,143 @@ enum class Language(val value: LocaleListCompat) {
GERMAN(LocaleListCompat.forLanguageTags("de"))
}
enum class CounterMode { LIST, GRAPH }
enum class KeepScreenOn(val value: Boolean) { ON(true), OFF(false) }
typealias VictoryPoints = Int
typealias GameWon = Boolean
interface ISystemSettingsListener {
fun onLanguageChanged(language: Language)
fun onThemeChanged(theme: Theme)
fun onScreenOnChanged(keepOn: KeepScreenOn)
}
interface IDisplaySettingsListener {
fun onCounterModeChanged(counterMode: CounterMode)
}
@Singleton
class SettingsAdapter @Inject constructor(@ApplicationContext private val context: Context) {
private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
private var systemSettingsListeners = mutableListOf<ISystemSettingsListener>()
private var displaySettingsListeners = mutableListOf<IDisplaySettingsListener>()
val language = MutableStateFlow(Language.DEFAULT)
var language: Language
private set
val theme = MutableStateFlow(Theme.DEFAULT)
var theme: Theme
private set
val keepScreenOn = MutableStateFlow(KeepScreenOn.OFF)
var keepScreenOn: KeepScreenOn
private set
val victoryPoints = MutableStateFlow(0)
val gameFinished = MutableStateFlow(false)
var counterMode: CounterMode
private set
init {
language.value = try {
language = try {
enumValueOf(sharedPreferences.getString(Language::class.simpleName, null)!!)
} catch (_: NullPointerException) {
} catch (_: Exception) {
Language.DEFAULT
}
theme.value = try {
theme = try {
enumValueOf(sharedPreferences.getString(Theme::class.simpleName, null)!!)
} catch (_: java.lang.Exception) {
} catch (_: Exception) {
Theme.DEFAULT
}
keepScreenOn.value = try {
keepScreenOn = try {
enumValueOf(sharedPreferences.getString(KeepScreenOn::class.simpleName, null)!!)
} catch (_: java.lang.Exception) {
} catch (_: Exception) {
KeepScreenOn.OFF
}
victoryPoints.value = sharedPreferences.getInt(VictoryPoints::class.simpleName, 1000)
gameFinished.value = sharedPreferences.getBoolean(GameWon::class.simpleName, false)
CoroutineScope(Dispatchers.IO).launch {
language.collect {
updatePreference(Language::class.simpleName, it.name)
}
counterMode = try {
enumValueOf(sharedPreferences.getString(CounterMode::class.simpleName, null)!!)
} catch (_: Exception) {
CounterMode.LIST
}
}
CoroutineScope(Dispatchers.IO).launch {
theme.collect {
updatePreference(Theme::class.simpleName, it.name)
}
}
fun registerOnChangeListener(listener: IDisplaySettingsListener) {
displaySettingsListeners.add(listener)
CoroutineScope(Dispatchers.IO).launch {
keepScreenOn.collect {
updatePreference(KeepScreenOn::class.simpleName, it.name)
}
}
listener.onCounterModeChanged(counterMode)
}
CoroutineScope(Dispatchers.IO).launch {
victoryPoints.collect {
updatePreference(VictoryPoints::class.simpleName, it)
}
fun unregisterOnChangeListener(listener: IDisplaySettingsListener?) {
if (listener != null) {
displaySettingsListeners.remove(listener)
}
}
CoroutineScope(Dispatchers.IO).launch {
gameFinished.collect {
updatePreference(GameWon::class.simpleName, it)
}
fun registerOnChangeListener(listener: ISystemSettingsListener) {
systemSettingsListeners.add(listener)
listener.onThemeChanged(theme)
listener.onLanguageChanged(language)
listener.onScreenOnChanged(keepScreenOn)
}
fun unregisterOnChangeListener(listener: ISystemSettingsListener?) {
if (listener != null) {
systemSettingsListeners.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)
}
fun setCounterMode(counterMode: CounterMode) {
this.counterMode = counterMode
updatePreference(CounterMode::class.simpleName, counterMode.name)
notifyListeners(counterMode)
}
private fun updatePreference(name: String?, value: String) {
val editor = sharedPreferences.edit()
editor.putString(name, value)
editor.apply()
}
private fun updatePreference(name: String?, value: Boolean) {
val editor = sharedPreferences.edit()
editor.putBoolean(name, value)
editor.apply()
private fun notifyListeners(language: Language) {
systemSettingsListeners.forEach {
it.onLanguageChanged(language)
}
}
private fun updatePreference(name: String?, value: Int) {
val editor = sharedPreferences.edit()
editor.putInt(name, value)
editor.apply()
private fun notifyListeners(theme: Theme) {
systemSettingsListeners.forEach {
it.onThemeChanged(theme)
}
}
private fun notifyListeners(keepScreenOn: KeepScreenOn) {
systemSettingsListeners.forEach {
it.onScreenOnChanged(keepScreenOn)
}
}
private fun notifyListeners(counterMode: CounterMode) {
displaySettingsListeners.forEach {
it.onCounterModeChanged(counterMode)
}
}
}

View File

@@ -3,7 +3,6 @@ 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
@@ -22,12 +21,7 @@ class GameRepository @Inject constructor(
private val roundDao: RoundDao
) {
var activeGame: Game = Game(true, "TeamA", "TeamB", Date(), Date())
private set
private val newGameFlow = MutableStateFlow(Game())
private var deletedGame: GameWithScores? = null
private var activeGame: Game = Game(true, "TeamA", "TeamB", Date(), Date())
init {
CoroutineScope(Dispatchers.IO).launch {
@@ -45,7 +39,6 @@ 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)
}
}
@@ -106,33 +99,14 @@ class GameRepository @Inject constructor(
withContext(Dispatchers.IO) {
try {
val game = gameDao.getGameById(uid)
val rounds = roundDao.getAllForGame(game.uid)
deletedGame = GameWithScores(game, rounds)
gameDao.delete(game)
val rounds = roundDao.getAllForGame(game.uid)
roundDao.delete(rounds)
} catch (_: NullPointerException) {
}
}
}
suspend fun restoreLastDeletedGame() {
if (deletedGame == null) {
return
}
val revert = deletedGame!!
deletedGame = null
withContext(Dispatchers.IO) {
gameDao.insert(revert.game)
revert.rounds.forEach {
roundDao.insert(it)
}
}
}
suspend fun deleteAllInactive() {
withContext(Dispatchers.IO) {
try {
@@ -160,8 +134,4 @@ class GameRepository @Inject constructor(
fun getDistinctTeamNames(): Flow<List<String>> {
return gameDao.getDistinctTeamNames()
}
fun getNewGameStarted(): Flow<Game> {
return newGameFlow
}
}

View File

@@ -8,7 +8,6 @@ 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
@@ -30,8 +29,6 @@ class MainViewModel @Inject constructor(
var activeGameHasRounds by mutableStateOf(false)
private set
private var newGame: Game? = null
init {
viewModelScope.launch {
@@ -48,16 +45,6 @@ class MainViewModel @Inject constructor(
expectedRoundCount = it.rounds.count()
}
}
viewModelScope.launch {
gameRepository.getNewGameStarted().collect {
if (newGame == null) {
newGame = it
return@collect
}
redoRounds.clear()
}
}
}
fun undoLastRound() {
@@ -82,4 +69,11 @@ class MainViewModel @Inject constructor(
}
}
}
fun newGame() {
viewModelScope.launch {
redoRounds.clear()
gameRepository.newGame()
}
}
}

View File

@@ -7,29 +7,25 @@ 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 <T> DropDownMenu(
list: Collection<T>,
selected: T,
expanded: Boolean,
onSelected: (T?) -> Unit
) {
fun <T> DropDownMenu(map: Map<T, Int>, selected: T, expanded: Boolean, onSelected: (T?) -> Unit) {
DropdownMenu(
expanded = expanded,
onDismissRequest = { onSelected(null) }
) {
list.forEach {
map.forEach {
DropdownMenuItem(
onClick = {
onSelected(it)
onSelected(it.key)
},
trailingIcon = {
if (it == selected) {
if (it.key == selected) {
Icon(Icons.Outlined.Check, null)
}
},
text = { Text(it.toString()) },
text = { Text(stringResource(it.value)) },
)
}
}

View File

@@ -1,27 +1,31 @@
package me.zobrist.tichucounter.ui.counter
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material3.AlertDialog
import androidx.compose.material.icons.outlined.ShowChart
import androidx.compose.material.icons.outlined.TableRows
import androidx.compose.material3.FloatingActionButton
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
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.vector.ImageVector
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 androidx.compose.ui.unit.dp
import me.zobrist.tichucounter.data.entity.Round
import me.zobrist.tichucounter.domain.CounterMode
import me.zobrist.tichucounter.ui.AppTheme
@@ -31,18 +35,6 @@ 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)
@@ -70,10 +62,13 @@ fun Landscape(viewModel: ICounterViewModel) {
viewModel.totalScoreB
)
RoundListView(
viewModel.roundScoreList,
Modifier.weight(1f)
RoundView(
Modifier.weight(1f),
viewModel.counterMode,
viewModel.roundScoreList
)
{ viewModel.changeCounterMode() }
}
if (!viewModel.keyboardHidden) {
Column(Modifier.weight(1f)) {
@@ -101,10 +96,12 @@ fun Portrait(viewModel: ICounterViewModel) {
viewModel.totalScoreB
)
RoundListView(
viewModel.roundScoreList,
Modifier.weight(1f)
RoundView(
Modifier.weight(1f),
viewModel.counterMode,
viewModel.roundScoreList
)
{ viewModel.changeCounterMode() }
if (!viewModel.keyboardHidden) {
KeyBoardView(viewModel = viewModel)
@@ -112,6 +109,41 @@ fun Portrait(viewModel: ICounterViewModel) {
}
}
@Composable
private fun RoundView(
modifier: Modifier = Modifier,
counterMode: CounterMode,
rounds: List<Round>,
changeCounterMode: () -> Unit
) {
Box(modifier) {
if (counterMode == CounterMode.LIST) {
RoundListView(
rounds,
Modifier.fillMaxHeight()
)
} else {
RoundGraphView(
rounds,
Modifier.fillMaxHeight()
)
}
FloatingActionButton(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(15.dp),
onClick = { changeCounterMode() }) {
Icon(getIcon(counterMode), null)
}
}
}
private fun getIcon(counterMode: CounterMode): ImageVector {
return when (counterMode) {
CounterMode.LIST -> Icons.Outlined.ShowChart
CounterMode.GRAPH -> Icons.Outlined.TableRows
}
}
@Preview(name = "Light Mode")
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@@ -122,54 +154,6 @@ 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<Round> =
listOf(Round(1, 10, 90), Round(1, 50, 50), Round(1, 70, 30))
@@ -191,7 +175,7 @@ internal class PreviewViewModel : ICounterViewModel {
listOf("TeamA", "asdffd", "TeamB", "really really long Team Name that is way too long")
override val teamNameSuggestionsB: List<String> =
listOf("TeamA", "asdffd", "TeamB", "really really long Team Name that is way too long")
override var showVictoryDialog: Boolean = false
override val counterMode: CounterMode = CounterMode.GRAPH
override fun focusLastInput() {
}
@@ -224,7 +208,7 @@ internal class PreviewViewModel : ICounterViewModel {
override fun updateNameB(value: String) {
}
override fun victoryDialogExecuted(result: Boolean) {
override fun changeCounterMode() {
}
override fun updateFocusStateA(state: Boolean) {

View File

@@ -1,7 +1,6 @@
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
@@ -11,8 +10,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.CounterMode
import me.zobrist.tichucounter.domain.IDisplaySettingsListener
import me.zobrist.tichucounter.domain.SettingsAdapter
import me.zobrist.tichucounter.domain.Tichu
import me.zobrist.tichucounter.domain.digitCount
@@ -60,27 +60,27 @@ interface ICounterViewModel : IKeyBoardViewModel {
val teamNameB: String
val teamNameSuggestionsA: List<String>
val teamNameSuggestionsB: List<String>
val showVictoryDialog: Boolean
val counterMode: CounterMode
fun updateNameA(value: String)
fun updateNameB(value: String)
fun victoryDialogExecuted(result: Boolean)
fun changeCounterMode()
}
@HiltViewModel
class CounterViewModel @Inject constructor(
private val gameRepository: GameRepository,
private val settings: SettingsAdapter
private val settingsAdapter: SettingsAdapter
) :
ViewModel(), ICounterViewModel {
ViewModel(), ICounterViewModel, IDisplaySettingsListener {
override var roundScoreList by mutableStateOf(emptyList<Round>())
private set
override var totalScoreA by mutableIntStateOf(0)
override var totalScoreA by mutableStateOf(0)
private set
override var totalScoreB by mutableIntStateOf(0)
override var totalScoreB by mutableStateOf(0)
private set
override var teamNameA by mutableStateOf("")
@@ -118,9 +118,18 @@ class CounterViewModel @Inject constructor(
override var teamNameSuggestionsB by mutableStateOf(listOf<String>())
private set
override var showVictoryDialog by mutableStateOf(false)
override var counterMode by mutableStateOf(CounterMode.LIST)
private set
init {
settingsAdapter.registerOnChangeListener(this)
}
override fun onCleared() {
settingsAdapter.unregisterOnChangeListener(this)
}
override var activeValue: String
get() {
return if (isBFocused) {
@@ -162,18 +171,9 @@ class CounterViewModel @Inject constructor(
private var distinctTeamNames = listOf<String>()
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
@@ -184,32 +184,6 @@ class CounterViewModel @Inject constructor(
teamNameB = it.game.nameB
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
}
}
}
@@ -220,17 +194,6 @@ 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() {
@@ -339,15 +302,12 @@ class CounterViewModel @Inject constructor(
}
}
override fun victoryDialogExecuted(result: Boolean) {
showVictoryDialog = false
settings.gameFinished.value = true
if (result) {
viewModelScope.launch {
gameRepository.newGame()
}
override fun changeCounterMode() {
val nextMode = when (settingsAdapter.counterMode) {
CounterMode.LIST -> CounterMode.GRAPH
CounterMode.GRAPH -> CounterMode.LIST
}
settingsAdapter.setCounterMode(nextMode)
}
override fun updateFocusStateA(state: Boolean) {
@@ -423,4 +383,8 @@ class CounterViewModel @Inject constructor(
return filtered.sorted().sortedBy { it.length }.take(10)
}
override fun onCounterModeChanged(counterMode: CounterMode) {
this.counterMode = counterMode
}
}

View File

@@ -33,7 +33,6 @@ 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
@@ -44,7 +43,6 @@ 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
@@ -292,40 +290,37 @@ fun CenteredTextField(
}
Box(contentAlignment = Alignment.Center) {
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)
}
)
}
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(label = "blinkingCursor")
val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
0f,
cursorColor.alpha,
animationSpec = infiniteRepeatable(
animation = tween(500),
repeatMode = RepeatMode.Reverse
), label = "blinkingCursor"
)
)
Row {

View File

@@ -0,0 +1,104 @@
package me.zobrist.tichucounter.ui.counter
import android.content.res.Configuration
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.tooling.preview.Preview
import com.patrykandpatrick.vico.compose.axis.vertical.startAxis
import com.patrykandpatrick.vico.compose.chart.Chart
import com.patrykandpatrick.vico.compose.chart.line.lineChart
import com.patrykandpatrick.vico.compose.chart.scroll.rememberChartScrollSpec
import com.patrykandpatrick.vico.core.axis.formatter.DecimalFormatAxisValueFormatter
import com.patrykandpatrick.vico.core.chart.line.LineChart
import com.patrykandpatrick.vico.core.chart.values.AxisValuesOverrider
import com.patrykandpatrick.vico.core.entry.FloatEntry
import com.patrykandpatrick.vico.core.entry.composed.ComposedChartEntryModelProducer.Companion.composedChartEntryModelOf
import com.patrykandpatrick.vico.core.entry.entryModelOf
import me.zobrist.tichucounter.data.entity.Round
import me.zobrist.tichucounter.ui.AppTheme
import kotlin.math.ceil
import kotlin.math.floor
@Composable
fun RoundGraphView(rounds: List<Round>, modifier: Modifier = Modifier) {
val points = getPoints(rounds)
val range = getRange(points.first, points.second, 50)
val specA = LineChart.LineSpec(MaterialTheme.colorScheme.primary.toArgb())
val specB = LineChart.LineSpec(MaterialTheme.colorScheme.secondary.toArgb())
Chart(
chart = lineChart(
lines = listOf(specA, specB),
axisValuesOverrider = AxisValuesOverrider.fixed(
minY = range.first,
maxY = range.second
)
),
model = composedChartEntryModelOf(
listOf(
entryModelOf(points.first),
entryModelOf(points.second)
)
),
startAxis = startAxis(
maxLabelCount = 10,
valueFormatter = DecimalFormatAxisValueFormatter("#")
),
chartScrollSpec = rememberChartScrollSpec(isScrollEnabled = false),
modifier = modifier
)
}
private fun getPoints(rounds: List<Round>): Pair<List<FloatEntry>, List<FloatEntry>> {
val a = mutableListOf(FloatEntry(0f, 0f))
val b = mutableListOf(FloatEntry(0f, 0f))
var sumA = 0
var sumB = 0
rounds.forEachIndexed { index, round ->
sumA += round.scoreA
sumB += round.scoreB
a.add(FloatEntry((index + 1).toFloat(), sumA.toFloat()))
b.add(FloatEntry((index + 1).toFloat(), sumB.toFloat()))
}
return Pair(a, b)
}
private fun getRange(a: List<FloatEntry>, b: List<FloatEntry>, step: Int): Pair<Float, Float> {
val min = (a + b).minBy { it.y }
val max = (a + b).maxBy { it.y }
val lo = floor(min.y / step) * step
val hi = ceil(max.y / step) * step
return Pair(lo, hi)
}
@Preview(name = "Light Mode")
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
fun RoundGraphViewPreview() {
val rounds = listOf(
Round(1, 10, -10),
Round(1, 5, 95),
Round(1, 100, 0),
Round(1, 125, -25),
Round(1, 50, 50)
)
AppTheme {
Surface {
RoundGraphView(rounds, Modifier)
}
}
}

View File

@@ -21,7 +21,7 @@ import me.zobrist.tichucounter.data.entity.Round
import me.zobrist.tichucounter.ui.AppTheme
@Composable
fun RoundListView(rounds: List<Round>, modifier: Modifier) {
fun RoundListView(rounds: List<Round>, modifier: Modifier = Modifier) {
val lazyListState = rememberLazyListState()
val scope = rememberCoroutineScope()

View File

@@ -1,64 +1,44 @@
package me.zobrist.tichucounter.ui.history
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.clickable
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.RestartAlt
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.DismissDirection
import androidx.compose.material3.DismissValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.CardDefaults
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.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
@@ -67,13 +47,10 @@ import java.util.Locale
@Composable
fun HistoryList(
viewModel: HistoryViewModel,
snackbarHostState: SnackbarHostState,
navigateToCalculator: () -> Unit,
navigateToCalculator: () -> Unit
) {
val scope = rememberCoroutineScope()
val lazyListState = rememberLazyListState()
var showDeleteDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
DeleteConfirmDialog(showDeleteDialog) {
showDeleteDialog = false
@@ -82,51 +59,14 @@ 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(
games = viewModel.gameAndHistory,
onOpenClicked = {
scope.launch {
viewModel.activateGame(it)
lazyListState.animateScrollToItem(0)
snackbarHostState.currentSnackbarData?.dismiss()
val result = snackbarHostState.showSnackbar(
message = activatedMessage,
actionLabel = activatedActionLabel,
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) {
navigateToCalculator()
}
}
viewModel.gameAndHistory,
{
viewModel.activateGame(it)
navigateToCalculator()
},
onDeleteClicked = {
scope.launch {
viewModel.deleteGame(it)
snackbarHostState.currentSnackbarData?.dismiss()
val result = snackbarHostState.showSnackbar(
message = deletedMessage,
actionLabel = deletedActionLabel,
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.Dismissed) {
viewModel.deleteGame(it)
} else {
viewModel.restoreLastDeletedGame()
}
}
},
onDeleteAllClicked = { showDeleteDialog = true },
lazyListState = lazyListState
{ viewModel.deleteGame(it) },
{ showDeleteDialog = true },
)
}
@@ -138,12 +78,14 @@ 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))
}
},
@@ -153,193 +95,133 @@ fun DeleteConfirmDialog(show: Boolean = true, onExecuted: (Boolean) -> Unit = {}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HistoryList(
games: List<GameWithScores>,
onOpenClicked: (gameId: Long) -> Unit,
onDeleteClicked: (gameId: Long) -> Unit,
onDeleteAllClicked: () -> Unit,
lazyListState: LazyListState = LazyListState(),
onOpenClicked: (GameId: Long) -> Unit,
onDeleteClicked: (GameId: Long) -> Unit,
onDeleteAllClicked: () -> Unit
) {
Row {
LazyColumn(state = lazyListState) {
items(
items = games,
key = { it.hashCode() }) {
if (it.game.active) {
HistoryListItem(
it,
Modifier
.animateItemPlacement()
.padding(2.dp)
)
} else {
DismissibleHistoryListItem(
it,
Modifier.animateItemPlacement(),
onOpenClicked,
onDeleteClicked
)
}
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)
}
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))
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))
}
}
}
}
}
}
@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, modifier: Modifier = Modifier
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
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)
) {
Box(modifier = modifier.fillMaxSize()) {
Column {
Text(
text = game.game.nameA + " vs " + game.game.nameB,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall
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
)
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
DropDownMenu(
mapOf("delete" to R.string.delete),
"",
expanded,
) {
Text(
text = stringResource(id = R.string.active),
style = MaterialTheme.typography.labelSmall
)
expanded = false
it?.let {
when (it) {
"delete" -> onDeleteClicked(game.game.uid)
}
}
}
}
}
@@ -352,17 +234,25 @@ fun HistoryListItem(
private fun HistoryListPreview() {
val tempData = listOf(
GameWithScores(
Game(true, "abcsdf sdaf asdf sdf ", "defsadf asdf sadf ", Date(), Date()),
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))
),
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, {}, {}) {}
}

View File

@@ -20,15 +20,12 @@ class HistoryViewModel @Inject constructor(
var gameAndHistory by mutableStateOf(emptyList<GameWithScores>())
private set
private var fullList: List<GameWithScores> = emptyList()
init {
viewModelScope.launch {
gameRepository.getAllWithRoundFlow().collect { games ->
fullList =
gameAndHistory =
games.sortedBy { it.game.modified }.sortedBy { it.game.active }.reversed()
gameAndHistory = fullList
}
}
}
@@ -39,12 +36,6 @@ class HistoryViewModel @Inject constructor(
}
}
fun restoreLastDeletedGame() {
viewModelScope.launch {
gameRepository.restoreLastDeletedGame()
}
}
fun activateGame(gameId: Long) {
viewModelScope.launch {
gameRepository.setActive(gameId)

View File

@@ -44,7 +44,6 @@ val themeMap = mapOf(
Theme.LIGHT to R.string.light
)
val victoryPointsList = listOf(500, 1000, 1500, 2000)
@Composable
fun SettingsView(viewModel: SettingsViewModel) {
@@ -52,11 +51,9 @@ fun SettingsView(viewModel: SettingsViewModel) {
viewModel.screenOn.value,
viewModel.language,
viewModel.theme,
viewModel.victoryPoints,
{ viewModel.updateScreenOn(it) },
{ viewModel.updateLanguage(it) },
{ viewModel.updateTheme(it) },
{ viewModel.updateVictoryPoints(it) })
{ viewModel.updateTheme(it) })
}
@Composable
@@ -64,22 +61,11 @@ 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 = {},
updateVictoryPoints: (Int) -> Unit = {}
updateTheme: (Theme) -> Unit = {}
) {
Column(
Modifier
.padding(20.dp)
) {
Text(
text = stringResource(R.string.display),
style = MaterialTheme.typography.headlineMedium
)
Column {
BooleanSetting(
stringResource(R.string.keep_screen_on),
valueScreenOn
@@ -96,18 +82,6 @@ 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) }
}
}
@@ -116,7 +90,7 @@ fun BooleanSetting(name: String, value: Boolean, updateValue: (Boolean) -> Unit)
Row(
Modifier
.padding(bottom = 15.dp, top = 5.dp)
.padding(20.dp)
.fillMaxWidth()
) {
Column(Modifier.weight(5f)) {
@@ -145,32 +119,21 @@ fun BooleanSetting(name: String, value: Boolean, updateValue: (Boolean) -> Unit)
@Composable
fun <T> StringSetting(name: String, map: Map<T, Int>, 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 <T> ListSetting(name: String, list: Collection<T>, selected: T, onSelected: (T) -> Unit) {
var expanded by remember { mutableStateOf(false) }
Row(
Modifier
.fillMaxWidth()
.padding(bottom = 15.dp, top = 5.dp)
.padding(20.dp)
.clickable { expanded = true }) {
Column(Modifier.weight(5f)) {
Text(name, style = MaterialTheme.typography.bodyLarge, overflow = TextOverflow.Ellipsis)
Text(
selected.toString(),
style = MaterialTheme.typography.labelLarge
)
map[selected]?.let {
Text(
stringResource(it),
style = MaterialTheme.typography.labelLarge
)
}
}
Column(Modifier.weight(1f)) {
@@ -179,15 +142,15 @@ fun <T> ListSetting(name: String, list: Collection<T>, selected: T, onSelected:
contentDescription = null,
modifier = Modifier.align(End)
)
}
DropDownMenu(
list,
selected,
expanded,
) {
expanded = false
it?.let { onSelected(it) }
}
DropDownMenu(
map,
selected,
expanded,
) {
expanded = false
it?.let { onSelected(it) }
}
}
}
@@ -204,3 +167,20 @@ 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,
) {}
}
}
}

View File

@@ -1,7 +1,6 @@
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
@@ -16,35 +15,28 @@ import javax.inject.Inject
class SettingsViewModel @Inject constructor(private val settings: SettingsAdapter) : ViewModel() {
var language by mutableStateOf(settings.language.value)
var language by mutableStateOf(settings.language)
private set
var theme by mutableStateOf(settings.theme.value)
var theme by mutableStateOf(settings.theme)
private set
var screenOn by mutableStateOf(settings.keepScreenOn.value)
private set
var victoryPoints by mutableIntStateOf(settings.victoryPoints.value)
var screenOn by mutableStateOf(settings.keepScreenOn)
private set
fun updateLanguage(language: Language) {
settings.language.value = language
this.language = language
settings.setLanguage(language)
this.language = settings.language
}
fun updateTheme(theme: Theme) {
settings.theme.value = theme
this.theme = theme
settings.setTheme(theme)
this.theme = settings.theme
}
fun updateScreenOn(value: KeepScreenOn) {
settings.keepScreenOn.value = value
screenOn = value
settings.setKeepScreenOn(value)
screenOn = settings.keepScreenOn
}
fun updateVictoryPoints(value: Int) {
settings.victoryPoints.value = value
victoryPoints = value
}
}

View File

@@ -12,7 +12,7 @@
<string name="menu_settings">Einstellungen</string>
<string name="on">Ein</string>
<string name="off">Aus</string>
<string name="new_game">Neues Spiel</string>
<string name="newGame">Neues Spiel</string>
<string name="delete_inactive_title">Verlauf löschen</string>
<string name="delete_inactive_text">Wirklich den gesamten Verlauf löschen? Diese Aktion kann nicht rückgängig gemacht werden.</string>
<string name="cancel">Abbrechen</string>
@@ -24,17 +24,5 @@
<string name="menu_counter">Counter</string>
<string name="menu_about">About</string>
<string name="contact_us">Schreib uns</string>
<string name="continue_play">Weiterspielen</string>
<string name="delete_success">Spiel gelöscht.</string>
<string name="undo_question">RÜCKGÄNGIG</string>
<string name="activated_success">Spiel aktiviert.</string>
<string name="to_calculator_question">WEITERSPIELEN</string>
<string name="display">Anzeige</string>
<string name="game">Spiel</string>
<string name="victory_points">Siegespunkte</string>
<string name="victory_title">%1$s hat gewonnen</string>
<string name="draw_message">Sieht aus, als ob ihr ein neues Spiel starten solltet, um das endgültig zu klären.</string>
<string name="draw_title">Unentschieden</string>
<string name="victory_message">Herzliche Gratulation! Wie wäre es mit einer Revanche?</string>
<string name="abort">Abbrechen</string>
</resources>

View File

@@ -15,9 +15,9 @@
<string name="menu_settings">Settings</string>
<string name="on">On</string>
<string name="off">Off</string>
<string name="new_game">New Game</string>
<string name="newGame">New Game</string>
<string name="delete_inactive_title">Delete history</string>
<string name="delete_inactive_text">You really want to delete the history? This action can\'t be undone.</string>
<string name="delete_inactive_text">You really want to delete the the history? This action can\'t be undone.</string>
<string name="cancel">Cancel</string>
<string name="ok">OK</string>
<string name="delete">Delete</string>
@@ -28,17 +28,4 @@
<string name="menu_about">About</string>
<string name="contact_us">Contact us</string>
<string name="play_store" translatable="false">Play Store</string>
<string name="continue_play">Continue game</string>
<string name="delete_success">Game deleted.</string>
<string name="undo_question">UNDO</string>
<string name="activated_success">Game activated.</string>
<string name="to_calculator_question">CONTINUE PLAYING</string>
<string name="display">Display</string>
<string name="game">Game</string>
<string name="victory_points">Victory points</string>
<string name="victory_title">%1$s won the game</string>
<string name="draw_message">Looks like you should start a new game to settle this for good.</string>
<string name="draw_title">Draw</string>
<string name="victory_message">Congratulations! How about a rematch?</string>
<string name="abort">Abort</string>
</resources>

View File

@@ -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.22"
ext.kotlin_version = "1.8.21"
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.1.2'
classpath 'com.android.tools.build:gradle:8.0.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
@@ -26,6 +26,6 @@ allprojects {
}
}
tasks.register('clean', Delete) {
task clean(type: Delete) {
delete rootProject.buildDir
}