diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 99a3dc2e..30178f9c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(compose.ui) implementation(Dependencies.AndroidX.appCompat) implementation(Dependencies.AndroidX.browser) + implementation(Dependencies.AndroidX.datastore) implementation(Dependencies.AndroidX.Compose.activity) implementation(Dependencies.AndroidX.Compose.lifecycleViewModel) implementation(Dependencies.AndroidX.Compose.navigation) diff --git a/app/screenshots/debug/dev.msfjarvis.lobsters.ui.main.LobstersTopBarTest_doesNotShowRefreshIconWhenOnSavedPostsScreen_DarkTheme.png b/app/screenshots/debug/dev.msfjarvis.lobsters.ui.main.LobstersTopBarTest_doesNotShowRefreshIconWhenOnSavedPostsScreen_DarkTheme.png new file mode 100644 index 00000000..51688aa9 Binary files /dev/null and b/app/screenshots/debug/dev.msfjarvis.lobsters.ui.main.LobstersTopBarTest_doesNotShowRefreshIconWhenOnSavedPostsScreen_DarkTheme.png differ diff --git a/app/screenshots/debug/dev.msfjarvis.lobsters.ui.main.LobstersTopBarTest_doesNotShowRefreshIconWhenOnSavedPostsScreen_LightTheme.png b/app/screenshots/debug/dev.msfjarvis.lobsters.ui.main.LobstersTopBarTest_doesNotShowRefreshIconWhenOnSavedPostsScreen_LightTheme.png new file mode 100644 index 00000000..5c44feca Binary files /dev/null and b/app/screenshots/debug/dev.msfjarvis.lobsters.ui.main.LobstersTopBarTest_doesNotShowRefreshIconWhenOnSavedPostsScreen_LightTheme.png differ diff --git a/app/screenshots/debug/dev.msfjarvis.lobsters.ui.main.LobstersTopBarTest_showsRefreshIconWhenOnHottestPostsScreen_DarkTheme.png b/app/screenshots/debug/dev.msfjarvis.lobsters.ui.main.LobstersTopBarTest_showsRefreshIconWhenOnHottestPostsScreen_DarkTheme.png new file mode 100644 index 00000000..ad4d0c8f Binary files /dev/null and b/app/screenshots/debug/dev.msfjarvis.lobsters.ui.main.LobstersTopBarTest_showsRefreshIconWhenOnHottestPostsScreen_DarkTheme.png differ diff --git a/app/screenshots/debug/dev.msfjarvis.lobsters.ui.main.LobstersTopBarTest_showsRefreshIconWhenOnHottestPostsScreen_LightTheme.png b/app/screenshots/debug/dev.msfjarvis.lobsters.ui.main.LobstersTopBarTest_showsRefreshIconWhenOnHottestPostsScreen_LightTheme.png new file mode 100644 index 00000000..19f85e24 Binary files /dev/null and b/app/screenshots/debug/dev.msfjarvis.lobsters.ui.main.LobstersTopBarTest_showsRefreshIconWhenOnHottestPostsScreen_LightTheme.png differ diff --git a/app/src/androidTest/java/dev/msfjarvis/lobsters/ui/main/LobstersTopBarTest.kt b/app/src/androidTest/java/dev/msfjarvis/lobsters/ui/main/LobstersTopBarTest.kt new file mode 100644 index 00000000..87a62496 --- /dev/null +++ b/app/src/androidTest/java/dev/msfjarvis/lobsters/ui/main/LobstersTopBarTest.kt @@ -0,0 +1,74 @@ +package dev.msfjarvis.lobsters.ui.main + +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import com.karumi.shot.ScreenshotTest +import dev.msfjarvis.lobsters.ui.DarkTestTheme +import dev.msfjarvis.lobsters.ui.LightTestTheme +import dev.msfjarvis.lobsters.ui.navigation.Destination +import org.junit.Rule +import org.junit.Test + +class LobstersTopBarTest : ScreenshotTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun showsRefreshIconWhenOnHottestPostsScreen_DarkTheme() { + composeTestRule.setContent { + DarkTestTheme { + LobstersTopAppBar( + currentDestination = Destination.Hottest, + toggleSortingOrder = { }, + ) + } + } + + compareScreenshot(composeTestRule.onRoot().captureToImage().asAndroidBitmap()) + } + + @Test + fun showsRefreshIconWhenOnHottestPostsScreen_LightTheme() { + composeTestRule.setContent { + LightTestTheme { + LobstersTopAppBar( + currentDestination = Destination.Hottest, + toggleSortingOrder = { }, + ) + } + } + + compareScreenshot(composeTestRule.onRoot().captureToImage().asAndroidBitmap()) + } + + @Test + fun doesNotShowRefreshIconWhenOnSavedPostsScreen_DarkTheme() { + composeTestRule.setContent { + DarkTestTheme { + LobstersTopAppBar( + currentDestination = Destination.Saved, + toggleSortingOrder = { }, + ) + } + } + + compareScreenshot(composeTestRule.onRoot().captureToImage().asAndroidBitmap()) + } + + @Test + fun doesNotShowRefreshIconWhenOnSavedPostsScreen_LightTheme() { + composeTestRule.setContent { + LightTestTheme { + LobstersTopAppBar( + currentDestination = Destination.Saved, + toggleSortingOrder = { }, + ) + } + } + + compareScreenshot(composeTestRule.onRoot().captureToImage().asAndroidBitmap()) + } +} diff --git a/app/src/main/java/dev/msfjarvis/lobsters/data/preferences/ClawPreferences.kt b/app/src/main/java/dev/msfjarvis/lobsters/data/preferences/ClawPreferences.kt new file mode 100644 index 00000000..4eb62aac --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/lobsters/data/preferences/ClawPreferences.kt @@ -0,0 +1,24 @@ +package dev.msfjarvis.lobsters.data.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class ClawPreferences @Inject constructor( + private val dataStore: DataStore, +) { + private val sortKey = booleanPreferencesKey("post_sorting_order") + + val sortingOrder: Flow + get() = dataStore.data.map { preferences -> preferences[sortKey] ?: false } + + suspend fun toggleSortingOrder() { + dataStore.edit { preferences -> + preferences[sortKey] = (preferences[sortKey] ?: false).not() + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/lobsters/injection/DataStoreModule.kt b/app/src/main/java/dev/msfjarvis/lobsters/injection/DataStoreModule.kt new file mode 100644 index 00000000..ee661602 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/lobsters/injection/DataStoreModule.kt @@ -0,0 +1,36 @@ +package dev.msfjarvis.lobsters.injection + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class PreferenceStoreFileNameQualifier + +@Module +@InstallIn(SingletonComponent::class) +object DataStoreModule { + + @Provides + fun provideDataStore( + @ApplicationContext context: Context, + @PreferenceStoreFileNameQualifier fileName: String, + ): DataStore { + return PreferenceDataStoreFactory.create { context.preferencesDataStoreFile(fileName) } + } + + @Provides + @PreferenceStoreFileNameQualifier + fun provideDataStoreFilename(): String { + return "Claw_preferences" + } +} diff --git a/app/src/main/java/dev/msfjarvis/lobsters/injection/PreferenceModule.kt b/app/src/main/java/dev/msfjarvis/lobsters/injection/PreferenceModule.kt new file mode 100644 index 00000000..68cb3e94 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/lobsters/injection/PreferenceModule.kt @@ -0,0 +1,23 @@ +package dev.msfjarvis.lobsters.injection + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.msfjarvis.lobsters.data.preferences.ClawPreferences +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object PreferenceModule { + + @Provides + @Singleton + fun provideClawPreferences( + dataStore: DataStore, + ): ClawPreferences { + return ClawPreferences(dataStore) + } +} diff --git a/app/src/main/java/dev/msfjarvis/lobsters/ui/main/LobstersApp.kt b/app/src/main/java/dev/msfjarvis/lobsters/ui/main/LobstersApp.kt index 65abc778..ff243edb 100644 --- a/app/src/main/java/dev/msfjarvis/lobsters/ui/main/LobstersApp.kt +++ b/app/src/main/java/dev/msfjarvis/lobsters/ui/main/LobstersApp.kt @@ -54,6 +54,12 @@ fun LobstersApp() { } Scaffold( + topBar = { + LobstersTopAppBar( + currentDestination, + viewModel::toggleSortOrder, + ) + }, bottomBar = { LobstersBottomNav( currentDestination, @@ -78,6 +84,7 @@ fun LobstersApp() { posts = savedPosts, saveAction = viewModel::toggleSave, modifier = Modifier.padding(bottom = innerPadding.calculateBottomPadding()), + sortReversed = viewModel.getSortOrder(), ) } } diff --git a/app/src/main/java/dev/msfjarvis/lobsters/ui/main/LobstersTopAppBar.kt b/app/src/main/java/dev/msfjarvis/lobsters/ui/main/LobstersTopAppBar.kt new file mode 100644 index 00000000..8bc2bacf --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/lobsters/ui/main/LobstersTopAppBar.kt @@ -0,0 +1,44 @@ +package dev.msfjarvis.lobsters.ui.main + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.msfjarvis.lobsters.R +import dev.msfjarvis.lobsters.ui.navigation.Destination +import dev.msfjarvis.lobsters.util.IconResource +import kotlinx.coroutines.launch + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun LobstersTopAppBar( + currentDestination: Destination, + toggleSortingOrder: suspend () -> Unit, +) { + val scope = rememberCoroutineScope() + TopAppBar( + title = { + Text( + text = stringResource(id = R.string.app_name), + modifier = Modifier.padding(vertical = 8.dp), + ) + }, + actions = { + if (currentDestination == Destination.Saved) { + IconResource( + resourceId = R.drawable.ic_sort_24px, + contentDescription = stringResource(id = R.string.change_sorting_order), + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 8.dp) + .clickable { scope.launch { toggleSortingOrder() } }, + ) + } + } + ) +} diff --git a/app/src/main/java/dev/msfjarvis/lobsters/ui/posts/SavedPosts.kt b/app/src/main/java/dev/msfjarvis/lobsters/ui/posts/SavedPosts.kt index 60b3e1d0..60570a82 100644 --- a/app/src/main/java/dev/msfjarvis/lobsters/ui/posts/SavedPosts.kt +++ b/app/src/main/java/dev/msfjarvis/lobsters/ui/posts/SavedPosts.kt @@ -10,6 +10,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -20,16 +22,19 @@ import dev.msfjarvis.lobsters.data.local.SavedPost import dev.msfjarvis.lobsters.ui.urllauncher.LocalUrlLauncher import dev.msfjarvis.lobsters.util.IconResource import dev.msfjarvis.lobsters.util.asZonedDateTime +import kotlinx.coroutines.flow.Flow @OptIn(ExperimentalFoundationApi::class) @Composable fun SavedPosts( posts: List, + sortReversed: Flow, modifier: Modifier = Modifier, saveAction: (SavedPost) -> Unit, ) { val listState = rememberLazyListState() val urlLauncher = LocalUrlLauncher.current + val sortOrder by sortReversed.collectAsState(false) if (posts.isEmpty()) { Column( @@ -55,6 +60,8 @@ fun SavedPosts( stickyHeader { MonthHeader(month = month) } + @Suppress("NAME_SHADOWING") + val posts = if (sortOrder) posts.reversed() else posts items(posts) { item -> LobstersItem( post = item, diff --git a/app/src/main/java/dev/msfjarvis/lobsters/ui/viewmodel/LobstersViewModel.kt b/app/src/main/java/dev/msfjarvis/lobsters/ui/viewmodel/LobstersViewModel.kt index ffbf6e3b..04b65958 100644 --- a/app/src/main/java/dev/msfjarvis/lobsters/ui/viewmodel/LobstersViewModel.kt +++ b/app/src/main/java/dev/msfjarvis/lobsters/ui/viewmodel/LobstersViewModel.kt @@ -7,9 +7,11 @@ import androidx.paging.PagingConfig import androidx.paging.cachedIn import dagger.hilt.android.lifecycle.HiltViewModel import dev.msfjarvis.lobsters.data.local.SavedPost +import dev.msfjarvis.lobsters.data.preferences.ClawPreferences import dev.msfjarvis.lobsters.data.remote.LobstersPagingSource import dev.msfjarvis.lobsters.data.repo.LobstersRepository import javax.inject.Inject +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn @@ -19,6 +21,7 @@ import kotlinx.coroutines.launch @HiltViewModel class LobstersViewModel @Inject constructor( private val lobstersRepository: LobstersRepository, + private val clawPreferences: ClawPreferences, ) : ViewModel() { private val _savedPosts = MutableStateFlow>(emptyList()) val savedPosts = _savedPosts.asStateFlow() @@ -35,6 +38,14 @@ class LobstersViewModel @Inject constructor( }.launchIn(viewModelScope) } + fun getSortOrder(): Flow { + return clawPreferences.sortingOrder + } + + suspend fun toggleSortOrder() { + clawPreferences.toggleSortingOrder() + } + fun reloadPosts() { pagingSource?.invalidate() } diff --git a/app/src/main/res/drawable/ic_sort_24px.xml b/app/src/main/res/drawable/ic_sort_24px.xml new file mode 100644 index 00000000..b37f2638 --- /dev/null +++ b/app/src/main/res/drawable/ic_sort_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 75e20d9d..95271949 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,4 +10,5 @@ Remove from saved posts Refresh posts Open comments + Change sort order diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 049f6a19..63dbf46b 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -34,6 +34,7 @@ object Dependencies { const val appCompat = "androidx.appcompat:appcompat:1.3.0-beta01" const val browser = "androidx.browser:browser:1.3.0" const val coreLibraryDesugaring = "com.android.tools:desugar_jdk_libs:1.0.10" + const val datastore = "androidx.datastore:datastore-preferences:1.0.0-alpha08" object Compose {