Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.compose.ui.test.performClick
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.test.platform.app.InstrumentationRegistry
import com.github.se.pooltrack.model.entry.EntryRepositoryLocal
import com.github.se.pooltrack.model.hint.HintRepositoryLocal
import com.github.se.pooltrack.model.subscription.SubscriptionRepositoryLocal
import kotlinx.coroutines.flow.flowOf
import org.junit.Before
Expand Down Expand Up @@ -48,6 +49,7 @@ class HistoryScreenTest {
HistoryViewModel(
EntryRepositoryLocal(application, currentUid = uid),
SubscriptionRepositoryLocal(application, currentUid = uid),
HintRepositoryLocal(application),
)
composeTestRule.setContent { HistoryScreen(viewModel = viewModel) }
}
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/github/se/pooltrack/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.github.se.pooltrack.model.entry.EntryRepositoryProvider
import com.github.se.pooltrack.model.hint.HintRepositoryProvider
import com.github.se.pooltrack.model.subscription.PassZoomRepositoryProvider
import com.github.se.pooltrack.model.subscription.SubscriptionRepositoryProvider
import com.github.se.pooltrack.model.swim.isSwimPending
Expand Down Expand Up @@ -65,6 +66,7 @@ class MainActivity : ComponentActivity() {
EntryRepositoryProvider.init(this)
SubscriptionRepositoryProvider.init(this)
PassZoomRepositoryProvider.init(this)
HintRepositoryProvider.init(this)
UpdateRepositoryProvider.init(this)
requestNotificationPermissionIfNeeded()
startSwimTrackingWhileASwimIsPending()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.github.se.pooltrack.model.hint

import kotlinx.coroutines.flow.Flow

/** A one-time tip the app shows until the user has seen it. */
enum class Hint {
/** Visits in the history list can be deleted by swiping them to the left. */
SwipeToDelete,
}

/** Represents a repository that remembers which [Hint]s the user has dealt with. */
interface HintRepository {

/** Whether [hint] was dismissed, emitting again whenever that changes. */
fun isDismissed(hint: Hint): Flow<Boolean>

/** Remembers that the user dealt with [hint], so it is not shown again. */
suspend fun dismiss(hint: Hint)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.github.se.pooltrack.model.hint

import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map

private val Context.hintDataStore by preferencesDataStore(name = "hint_prefs")

private fun dismissedKey(hint: Hint) = booleanPreferencesKey("dismissed_${hint.name}")

/**
* Stores which hints were dismissed on-device, using Jetpack DataStore. Like the pass zoom, it is a
* per-device preference: neither scoped to an account nor backed up.
*/
class HintRepositoryLocal(private val context: Context) : HintRepository {

override fun isDismissed(hint: Hint): Flow<Boolean> =
context.hintDataStore.data.map { it[dismissedKey(hint)] ?: false }.distinctUntilChanged()

override suspend fun dismiss(hint: Hint) {
context.hintDataStore.edit { it[dismissedKey(hint)] = true }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.github.se.pooltrack.model.hint

import android.content.Context

/**
* Provides a single instance of the repository in the app. `repository` is mutable for testing
* purposes. The on-device implementation needs a [Context], so [init] must be called (e.g. from
* `MainActivity`) before `repository` is first read.
*/
object HintRepositoryProvider {
lateinit var repository: HintRepository

/** Creates the on-device repository, unless one was already set (e.g. a fake in a test). */
fun init(context: Context) {
if (!::repository.isInitialized) {
repository = HintRepositoryLocal(context.applicationContext)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
Expand Down Expand Up @@ -75,7 +74,13 @@ internal fun weekLabel(

/** The sticky title above the entries of one week, with how many visits it had. */
@Composable
internal fun WeekHeader(label: String, visits: Int, meters: Int, modifier: Modifier = Modifier) {
internal fun WeekHeader(
label: String,
visits: Int,
meters: Int,
loggedVisits: Int,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.fillMaxWidth().testTag(HistoryScreenTestTags.WEEK_HEADER),
color = MaterialTheme.colorScheme.background,
Expand All @@ -91,7 +96,7 @@ internal fun WeekHeader(label: String, visits: Int, meters: Int, modifier: Modif
fontWeight = FontWeight.Bold,
)
Text(
text = weekTotals(visits, meters),
text = weekTotals(visits, meters, loggedVisits),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Expand Down Expand Up @@ -139,7 +144,9 @@ private fun DateBlock(day: LocalDate, isToday: Boolean, locale: Locale = Locale.
* @param texts What the row says, see [entryRowTexts].
* @param highlighted Whether to tint the row, e.g. because its day was picked in the calendar.
* @param onClick Called when the row is tapped.
* @param onDelete Called once the row was swiped away.
* @param onDeleteRequested Called when the row was swiped to the left (or the screen reader action
* was used). The row never goes away by itself: it springs back, and the caller asks the user to
* confirm before deleting anything.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
Expand All @@ -149,16 +156,16 @@ internal fun SwipeableEntryRow(
texts: EntryRowTexts,
highlighted: Boolean,
onClick: () -> Unit,
onDelete: () -> Unit,
onDeleteRequested: () -> Unit,
modifier: Modifier = Modifier,
) {
val dismissState =
rememberSwipeToDismissBoxState(
// Never settle on the dismissed position: the row springs back while the caller asks the
// user to confirm, so a swipe made by mistake leaves nothing changed.
confirmValueChange = { value ->
if (value == SwipeToDismissBoxValue.EndToStart) {
onDelete()
true
} else false
if (value == SwipeToDismissBoxValue.EndToStart) onDeleteRequested()
false
}
)
SwipeToDismissBox(
Expand All @@ -169,7 +176,7 @@ internal fun SwipeableEntryRow(
customActions =
listOf(
CustomAccessibilityAction("Delete entry") {
onDelete()
onDeleteRequested()
true
}
)
Expand Down Expand Up @@ -214,19 +221,21 @@ internal fun SwipeableEntryRow(
)
}
}
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
}

/** "5 visits 路 4.2 km"; the distance is left out when none was logged that week. */
internal fun weekTotals(visits: Int, meters: Int): String =
listOfNotNull(visitsLabel(visits), if (meters > 0) formatTotalDistance(meters) else null)
.joinToString(" 路 ")
/**
* "5 visits 路 4.2 km". The distance is left out when none was logged that week, and reads "logged"
* (as in "4.2 km logged") when only some of the visits have one, since the total then undercounts.
*/
internal fun weekTotals(visits: Int, meters: Int, loggedVisits: Int): String {
val distance =
if (meters > 0) {
formatTotalDistance(meters) + if (loggedVisits < visits) " logged" else ""
} else null
return listOfNotNull(visitsLabel(visits), distance).joinToString(" 路 ")
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,14 @@ sealed interface HistoryRow {
*
* @property visits Entries in the week.
* @property meters The distance logged during the week, in metres.
* @property loggedVisits How many of the [visits] have a distance, which the [meters] add up.
*/
data class Week(val start: LocalDate, val visits: Int, val meters: Int) : HistoryRow {
data class Week(
val start: LocalDate,
val visits: Int,
val meters: Int,
val loggedVisits: Int,
) : HistoryRow {
override val key: Any = "week-$start"
}

Expand Down Expand Up @@ -50,6 +56,7 @@ fun buildHistoryRows(
start = start,
visits = items.size,
meters = items.sumOf { it.entry.swimDistanceMeters ?: 0 },
loggedVisits = items.count { it.entry.swimDistanceMeters != null },
)
) + items
}
Expand Down
Loading
Loading