diff --git a/app/build.gradle b/app/build.gradle index 048198ae..f7699199 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,5 +1,4 @@ plugins { - id 'kotlin-android' id 'kotlin-kapt' id 'dagger.hilt.android.plugin' } @@ -45,17 +44,24 @@ android { tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { kotlinOptions { - freeCompilerArgs += ["-Xallow-jvm-ir-dependencies", "-Xskip-prerelease-check", "-Xopt-in=kotlin.RequiresOptIn"] + freeCompilerArgs += [ + "-Xallow-jvm-ir-dependencies", + "-Xskip-prerelease-check", + "-Xopt-in=kotlin.RequiresOptIn", + "-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + ] } } dependencies { - kapt "com.google.dagger:hilt-android-compiler:$hilt_version" + kapt "com.google.dagger:hilt-android-compiler:$hilt_dagger_version" + kapt "androidx.hilt:hilt-compiler:$hilt_androidx_version" implementation(project(":data")) implementation(project(":lobsters-api")) implementation(project(":model")) implementation 'androidx.core:core-ktx:1.5.0-alpha03' + implementation 'androidx.activity:activity-ktx:1.2.0-alpha08' implementation 'androidx.appcompat:appcompat:1.3.0-alpha02' implementation "androidx.compose.foundation:foundation:$compose_version" implementation "androidx.compose.foundation:foundation-layout:$compose_version" @@ -67,10 +73,14 @@ dependencies { implementation "androidx.compose.ui:ui-text:$compose_version" implementation "androidx.compose.ui:ui-text-android:$compose_version" implementation "androidx.compose.ui:ui-unit:$compose_version" + implementation "androidx.hilt:hilt-lifecycle-viewmodel:$hilt_androidx_version" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" implementation "androidx.ui:ui-tooling:$compose_version" implementation 'com.google.android.material:material:1.3.0-alpha02' - implementation "com.google.dagger:hilt-android:$hilt_version" - androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version" + implementation "com.google.dagger:hilt-android:$hilt_dagger_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" + androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_dagger_version" testImplementation 'junit:junit:4.13' androidTestImplementation "androidx.ui:ui-test:$compose_version" coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.0.10" diff --git a/app/src/main/java/dev/msfjarvis/lobsters/MainActivity.kt b/app/src/main/java/dev/msfjarvis/lobsters/MainActivity.kt index 9780be37..4a37df84 100644 --- a/app/src/main/java/dev/msfjarvis/lobsters/MainActivity.kt +++ b/app/src/main/java/dev/msfjarvis/lobsters/MainActivity.kt @@ -1,25 +1,24 @@ package dev.msfjarvis.lobsters import android.os.Bundle +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.Text -import androidx.compose.foundation.lazy.LazyColumnFor +import androidx.compose.foundation.lazy.LazyColumnForIndexed import androidx.compose.material.Scaffold import androidx.compose.material.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.Providers import androidx.compose.runtime.ambientOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.collectAsState import androidx.compose.ui.platform.setContent import androidx.compose.ui.res.stringResource import dagger.hilt.android.AndroidEntryPoint import dev.msfjarvis.lobsters.api.LobstersApi -import dev.msfjarvis.lobsters.model.LobstersPost +import dev.msfjarvis.lobsters.data.LobstersViewModel import dev.msfjarvis.lobsters.ui.LobstersItem import dev.msfjarvis.lobsters.ui.LobstersTheme import dev.msfjarvis.lobsters.urllauncher.UrlLauncher -import kotlinx.coroutines.launch import javax.inject.Inject val UrlLauncherAmbient = ambientOf { error("Needs to be provided") } @@ -28,18 +27,14 @@ val UrlLauncherAmbient = ambientOf { error("Needs to be provided") class MainActivity : AppCompatActivity() { @Inject lateinit var urlLauncher: UrlLauncher @Inject lateinit var apiClient: LobstersApi + private val viewModel: LobstersViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { Providers(UrlLauncherAmbient provides urlLauncher) { LobstersTheme { - val coroutineScope = rememberCoroutineScope() - val posts = mutableStateListOf() - coroutineScope.launch { - posts.addAll(apiClient.getHottestPosts()) - } - LobstersApp(posts) + LobstersApp(viewModel) } } } @@ -48,14 +43,19 @@ class MainActivity : AppCompatActivity() { @Composable fun LobstersApp( - items: List, + viewModel: LobstersViewModel ) { val urlLauncher = UrlLauncherAmbient.current + val state = viewModel.posts.collectAsState() + val lastIndex = state.value.lastIndex Scaffold( topBar = { TopAppBar({ Text(text = stringResource(R.string.app_name)) }) }, bodyContent = { - LazyColumnFor(items) { item -> + LazyColumnForIndexed(state.value) { index, item -> + if (lastIndex == index) { + viewModel.getMorePosts() + } LobstersItem(item) { post -> urlLauncher.launch(post.url) } diff --git a/app/src/main/java/dev/msfjarvis/lobsters/data/LobstersViewModel.kt b/app/src/main/java/dev/msfjarvis/lobsters/data/LobstersViewModel.kt new file mode 100644 index 00000000..cd32193f --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/lobsters/data/LobstersViewModel.kt @@ -0,0 +1,30 @@ +package dev.msfjarvis.lobsters.data + +import androidx.hilt.lifecycle.ViewModelInject +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.msfjarvis.lobsters.api.LobstersApi +import dev.msfjarvis.lobsters.model.LobstersPost +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + + +class LobstersViewModel @ViewModelInject constructor( + private val lobstersApi: LobstersApi, +) : ViewModel() { + private var apiPage = 1 + private val _posts = MutableStateFlow>(emptyList()) + val posts: StateFlow> get() = _posts + + init { + getMorePosts() + } + + fun getMorePosts() { + viewModelScope.launch { + _posts.value += lobstersApi.getHottestPosts(apiPage) + apiPage += 1 + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/lobsters/di/PersistenceModule.kt b/app/src/main/java/dev/msfjarvis/lobsters/di/PersistenceModule.kt new file mode 100644 index 00000000..d75303de --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/lobsters/di/PersistenceModule.kt @@ -0,0 +1,22 @@ +package dev.msfjarvis.lobsters.di + +import android.content.Context +import androidx.room.Room +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.qualifiers.ApplicationContext +import dev.msfjarvis.lobsters.data.source.PostsDatabase + +@Module +@InstallIn(ActivityComponent::class) +object PersistenceModule { + + @Provides + fun providePostsDatabase(@ApplicationContext context: Context): PostsDatabase { + return Room.databaseBuilder(context, PostsDatabase::class.java, "posts.db") + .fallbackToDestructiveMigration() + .build() + } +} diff --git a/build.gradle b/build.gradle index 8f992308..a6d55a8d 100644 --- a/build.gradle +++ b/build.gradle @@ -2,9 +2,13 @@ buildscript { ext { compose_version = '1.0.0-alpha03' + coroutines_version = '1.3.9' dagger_version = '2.29.1' - hilt_version = '2.29-alpha' + hilt_androidx_version = '1.0.0-alpha02' + hilt_dagger_version = '2.29-alpha' kotlin_version = '1.4.10' + lifecycle_version = '2.3.0-alpha07' + moshi_version = '1.9.3' room_version = '2.3.0-alpha02' } repositories { @@ -14,7 +18,7 @@ buildscript { dependencies { classpath "com.android.tools.build:gradle:4.2.0-alpha12" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version" + classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_dagger_version" } } @@ -35,6 +39,7 @@ subprojects { } else { apply plugin: 'com.android.library' } + apply plugin: 'kotlin-android' android { compileSdkVersion 30 @@ -49,6 +54,13 @@ subprojects { targetCompatibility JavaVersion.VERSION_1_8 } } + configurations.all { + resolutionStrategy { + force "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" + force "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" + force "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" + } + } tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { kotlinOptions { jvmTarget = '1.8' diff --git a/data/build.gradle b/data/build.gradle index 97b4eaca..b44e1a5a 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -1,10 +1,12 @@ plugins { - id 'kotlin-android' id 'kotlin-kapt' } dependencies { + implementation project(":model") kapt "androidx.room:room-compiler:$room_version" api "androidx.room:room-runtime:$room_version" - api "androidx.room:room-ktx:$room_version" + implementation "androidx.room:room-ktx:$room_version" + implementation "com.squareup.moshi:moshi:$moshi_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" } diff --git a/data/src/main/java/dev/msfjarvis/lobsters/data/source/LobstersApiTypeConverters.kt b/data/src/main/java/dev/msfjarvis/lobsters/data/source/LobstersApiTypeConverters.kt new file mode 100644 index 00000000..11dd1f9a --- /dev/null +++ b/data/src/main/java/dev/msfjarvis/lobsters/data/source/LobstersApiTypeConverters.kt @@ -0,0 +1,63 @@ +package dev.msfjarvis.lobsters.data.source + +import androidx.room.TypeConverter +import com.squareup.moshi.Moshi +import dev.msfjarvis.lobsters.model.KeybaseSignature +import dev.msfjarvis.lobsters.model.KeybaseSignatureJsonAdapter +import dev.msfjarvis.lobsters.model.LobstersPost +import dev.msfjarvis.lobsters.model.LobstersPostJsonAdapter +import dev.msfjarvis.lobsters.model.Submitter +import dev.msfjarvis.lobsters.model.SubmitterJsonAdapter + +object LobstersApiTypeConverters { + private val moshi = Moshi.Builder().build() + private const val SEPARATOR = "," + + @TypeConverter + @JvmStatic + fun toSubmitterUser(value: String?): Submitter? { + return value?.let { SubmitterJsonAdapter(moshi).fromJson(value) } + } + + @TypeConverter + @JvmStatic + fun fromSubmitterUser(value: Submitter?): String? { + return value?.let { SubmitterJsonAdapter(moshi).toJson(value) } + } + + @TypeConverter + @JvmStatic + fun toKeybaseSignature(value: String?): KeybaseSignature? { + return value?.let { KeybaseSignatureJsonAdapter(moshi).fromJson(value) } + } + + @TypeConverter + @JvmStatic + fun fromKeybaseSignature(value: KeybaseSignature?): String? { + return value?.let { KeybaseSignatureJsonAdapter(moshi).toJson(value) } + } + + @TypeConverter + @JvmStatic + fun toLobstersPost(value: String?): LobstersPost? { + return value?.let { LobstersPostJsonAdapter(moshi).fromJson(value) } + } + + @TypeConverter + @JvmStatic + fun fromLobstersPost(value: LobstersPost?): String? { + return value?.let { LobstersPostJsonAdapter(moshi).toJson(value) } + } + + @TypeConverter + @JvmStatic + fun toTagList(value: String?): List? { + return value?.split(SEPARATOR) + } + + @TypeConverter + @JvmStatic + fun fromTagList(value: List?): String? { + return value?.joinToString(SEPARATOR) + } +} diff --git a/data/src/main/java/dev/msfjarvis/lobsters/data/source/PostsDao.kt b/data/src/main/java/dev/msfjarvis/lobsters/data/source/PostsDao.kt new file mode 100644 index 00000000..9247ebe2 --- /dev/null +++ b/data/src/main/java/dev/msfjarvis/lobsters/data/source/PostsDao.kt @@ -0,0 +1,23 @@ +package dev.msfjarvis.lobsters.data.source + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import dev.msfjarvis.lobsters.model.LobstersPost +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class PostsDao { + @Query("SELECT * FROM lobsters_posts") + abstract fun loadPosts(): Flow> + + @Insert + abstract suspend fun insertPosts(vararg posts: LobstersPost) + + @Delete + abstract suspend fun deletePosts(vararg posts: LobstersPost) + + @Query("DELETE FROM lobsters_posts") + abstract suspend fun deleteAllPosts() +} diff --git a/data/src/main/java/dev/msfjarvis/lobsters/data/source/PostsDatabase.kt b/data/src/main/java/dev/msfjarvis/lobsters/data/source/PostsDatabase.kt new file mode 100644 index 00000000..0640e663 --- /dev/null +++ b/data/src/main/java/dev/msfjarvis/lobsters/data/source/PostsDatabase.kt @@ -0,0 +1,21 @@ +package dev.msfjarvis.lobsters.data.source + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import dev.msfjarvis.lobsters.model.LobstersPost + +@Database( + entities = [ + LobstersPost::class, + ], + version = 1, + exportSchema = false, +) +@TypeConverters( + LobstersApiTypeConverters::class, + DateTimeTypeConverters::class, +) +abstract class PostsDatabase : RoomDatabase() { + abstract fun postsDao(): PostsDao +} diff --git a/lobsters-api/build.gradle b/lobsters-api/build.gradle index 4de188c3..8f3d6216 100644 --- a/lobsters-api/build.gradle +++ b/lobsters-api/build.gradle @@ -1,5 +1,4 @@ plugins { - id 'kotlin-android' id 'kotlin-kapt' } diff --git a/lobsters-api/src/main/java/dev/msfjarvis/lobsters/api/LobstersApi.kt b/lobsters-api/src/main/java/dev/msfjarvis/lobsters/api/LobstersApi.kt index f90b4e1b..4a6d1621 100644 --- a/lobsters-api/src/main/java/dev/msfjarvis/lobsters/api/LobstersApi.kt +++ b/lobsters-api/src/main/java/dev/msfjarvis/lobsters/api/LobstersApi.kt @@ -2,8 +2,9 @@ package dev.msfjarvis.lobsters.api import dev.msfjarvis.lobsters.model.LobstersPost import retrofit2.http.GET +import retrofit2.http.Query interface LobstersApi { @GET("hottest.json") - suspend fun getHottestPosts(): List + suspend fun getHottestPosts(@Query("page") page: Int): List } diff --git a/lobsters-api/src/test/java/dev/msfjarvis/lobsters/api/LobstersApiTest.kt b/lobsters-api/src/test/java/dev/msfjarvis/lobsters/api/LobstersApiTest.kt index 9e567ce9..d28903b6 100644 --- a/lobsters-api/src/test/java/dev/msfjarvis/lobsters/api/LobstersApiTest.kt +++ b/lobsters-api/src/test/java/dev/msfjarvis/lobsters/api/LobstersApiTest.kt @@ -27,7 +27,7 @@ class LobstersApiTest { @Test fun `api gets correct number of items`() = runBlocking { - val posts = apiClient.getHottestPosts() + val posts = apiClient.getHottestPosts(1) assertEquals(25, posts.size) } diff --git a/model/build.gradle b/model/build.gradle index 51aade2d..5ed00f0f 100644 --- a/model/build.gradle +++ b/model/build.gradle @@ -1,10 +1,8 @@ plugins { - id 'kotlin-android' id 'kotlin-kapt' } dependencies { - def moshi_version = "1.9.3" api "androidx.room:room-runtime:$room_version" kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_version" implementation "com.squareup.moshi:moshi:$moshi_version" diff --git a/model/src/main/java/dev/msfjarvis/lobsters/model/Submitter.kt b/model/src/main/java/dev/msfjarvis/lobsters/model/Submitter.kt index 929bc520..1260d02a 100644 --- a/model/src/main/java/dev/msfjarvis/lobsters/model/Submitter.kt +++ b/model/src/main/java/dev/msfjarvis/lobsters/model/Submitter.kt @@ -13,7 +13,7 @@ class Submitter( val about: String, @Json(name = "is_moderator") val isModerator: Boolean, - val karma: Long, + val karma: Long = 0, @Json(name = "avatar_url") val avatarUrl: String, @Json(name = "invited_by_user")