feat(search): implement community list

This commit is contained in:
Diego Beraldin 2023-08-04 15:18:57 +02:00
parent bb4b099735
commit 8015b065e3
47 changed files with 890 additions and 156 deletions

View File

@ -1,4 +1,5 @@
# Raccon for Lemmy
A Kotlin Multiplatform Mobile client for Lemmy.
<div align="center">
@ -14,21 +15,29 @@ A Kotlin Multiplatform Mobile client for Lemmy.
</table>
</div>
This is mostly an exercise to play around with KMM and Compose Multiplatform and implement a Lemmy client.
This is mostly an exercise to play around with KMM and Compose Multiplatform and implement a Lemmy
client.
The project is still at an early stage and not ready for production, expect things to change and even major changes to the source code.
The project is still at an early stage and not ready for production, expect things to change and
even major changes to the source code.
Libraries used:
- Koin for dependency injection
- Voyager for screen navigation
- Ktor with Ktorfit for networking in conjunction with kotlinx-serialization for JSON marshalling
- Moko resources for resource management
- Kamel for lazy image loading
- Multiplatform settings for encrypted preferences
- Markdown by Jetbrains for markdown parsing
- [Koin](https://github.com/InsertKoinIO/koin) for dependency injection
- [Voyager](https://github.com/adrielcafe/voyager) for screen navigation
- [Ktor](https://github.com/ktorio/ktor) with [Ktorfit](https://github.com/Foso/Ktorfit) for
networking in conjunction with kotlinx-serialization for JSON marshalling
- [Moko resources](https://github.com/icerockdev/moko-resources) for resource management
- [Kamel](https://github.com/Kamel-Media/Kamel) for lazy image loading
- [Multiplatform settings](https://github.com/russhwolf/multiplatform-settings) for encrypted
preferences
- [Markdown](https://github.com/JetBrains/markdown) for markdown parsing
- ... more to come (e.g. SQLdelight for persistence)
Credits:
- the `core-api` module is heavily inspired by [Jerboa for Lemmy](https://github.com/dessalines/jerboa)
- the `core-md` module is copied from [Multiplatform Markdown Renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
Credits:
- the `core-api` module is heavily inspired
by [Jerboa for Lemmy](https://github.com/dessalines/jerboa)
- the `core-md` module is copied
from [Multiplatform Markdown Renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
- the UI is vaguely inspired by the [Thunder](https://github.com/thunder-app/thunder) app

39
core-api/core_api.podspec Normal file
View File

@ -0,0 +1,39 @@
Pod::Spec.new do |spec|
spec.name = 'core_api'
spec.version = '1.0'
spec.homepage = 'Link to the Shared Module homepage'
spec.source = { :http=> ''}
spec.authors = ''
spec.license = ''
spec.summary = 'Some description for the Shared Module'
spec.vendored_frameworks = 'build/cocoapods/framework/core-api.framework'
spec.libraries = 'c++'
spec.ios.deployment_target = '14.1'
spec.pod_target_xcconfig = {
'KOTLIN_PROJECT_PATH' => ':core-api',
'PRODUCT_MODULE_NAME' => 'core-api',
}
spec.script_phases = [
{
:name => 'Build core_api',
:execution_position => :before_compile,
:shell_path => '/bin/sh',
:script => <<-SCRIPT
if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then
echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\""
exit 0
fi
set -ev
REPO_ROOT="$PODS_TARGET_SRCROOT"
"$REPO_ROOT/../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \
-Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \
-Pkotlin.native.cocoapods.archs="$ARCHS" \
-Pkotlin.native.cocoapods.configuration="$CONFIGURATION"
SCRIPT
}
]
end

View File

@ -0,0 +1,13 @@
package com.github.diegoberaldin.raccoonforlemmy.core.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SearchResponse(
@SerialName("type_") val type: SearchType,
@SerialName("comments") val comments: List<CommentView>,
@SerialName("posts") val posts: List<PostView>,
@SerialName("communities") val communities: List<CommunityView>,
@SerialName("users") val users: List<PersonView>,
)

View File

@ -0,0 +1,23 @@
package com.github.diegoberaldin.raccoonforlemmy.core.api.dto
import kotlinx.serialization.SerialName
enum class SearchType {
@SerialName("All")
All,
@SerialName("Comments")
Comments,
@SerialName("Posts")
Posts,
@SerialName("Communities")
Communities,
@SerialName("Users")
Users,
@SerialName("Url")
Url,
}

View File

@ -4,6 +4,7 @@ import com.github.diegoberaldin.raccoonforlemmy.core.api.service.AuthService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.CommentService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.CommunityService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.PostService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.SearchService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.SiteService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.UserService
import de.jensklingenberg.ktorfit.Ktorfit
@ -43,6 +44,9 @@ internal class DefaultServiceProvider : ServiceProvider {
override lateinit var comment: CommentService
private set
override lateinit var search: SearchService
private set
private val baseUrl: String get() = "https://$currentInstance/api/$VERSION/"
private val client = HttpClient {
defaultRequest {
@ -79,5 +83,6 @@ internal class DefaultServiceProvider : ServiceProvider {
user = ktorfit.create()
site = ktorfit.create()
comment = ktorfit.create()
search = ktorfit.create()
}
}

View File

@ -4,6 +4,7 @@ import com.github.diegoberaldin.raccoonforlemmy.core.api.service.AuthService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.CommentService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.CommunityService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.PostService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.SearchService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.SiteService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.UserService
@ -16,6 +17,7 @@ interface ServiceProvider {
val user: UserService
val site: SiteService
val comment: CommentService
val search: SearchService
fun changeInstance(value: String)
}

View File

@ -14,15 +14,15 @@ import de.jensklingenberg.ktorfit.http.Query
interface CommunityService {
@GET("community")
suspend fun getCommunity(
suspend fun get(
@Query("auth") auth: String? = null,
@Query("id") id: Int? = null,
@Query("name") name: String? = null,
): Response<GetCommunityResponse>
@POST("community/follow")
suspend fun followCommunity(@Body form: FollowCommunityForm): Response<CommunityResponse>
suspend fun follow(@Body form: FollowCommunityForm): Response<CommunityResponse>
@POST("community/block")
suspend fun blockCommunity(@Body form: BlockCommunityForm): Response<BlockCommunityResponse>
suspend fun block(@Body form: BlockCommunityForm): Response<BlockCommunityResponse>
}

View File

@ -0,0 +1,25 @@
package com.github.diegoberaldin.raccoonforlemmy.core.api.service
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.ListingType
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.SearchResponse
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.SearchType
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.SortType
import de.jensklingenberg.ktorfit.Response
import de.jensklingenberg.ktorfit.http.GET
import de.jensklingenberg.ktorfit.http.Query
interface SearchService {
@GET("search")
suspend fun search(
@Query("q") q: String,
@Query("community_id") communityId: Int? = null,
@Query("community_name") communityName: String? = null,
@Query("creator_id") creatorId: Int? = null,
@Query("type_") type: SearchType? = null,
@Query("sort") sort: SortType? = null,
@Query("listing_type") listingType: ListingType? = null,
@Query("page") page: Int? = null,
@Query("limit") limit: Int? = null,
@Query("auth") auth: String? = null,
): Response<SearchResponse>
}

View File

@ -7,7 +7,7 @@ import de.jensklingenberg.ktorfit.http.Query
interface SiteService {
@GET("site")
suspend fun getSite(
suspend fun get(
@Query("auth") auth: String? = null,
): Response<GetSiteResponse>
}

View File

@ -9,7 +9,7 @@ import de.jensklingenberg.ktorfit.http.Query
interface UserService {
@GET("user")
suspend fun getPersonDetails(
suspend fun getDetails(
@Query("auth") auth: String? = null,
@Query("community_id") communityId: Int? = null,
@Query("person_id") personId: Int? = null,

View File

@ -0,0 +1,39 @@
Pod::Spec.new do |spec|
spec.name = 'core_appearance'
spec.version = '1.0.0'
spec.homepage = 'Link to the Shared Module homepage'
spec.source = { :http=> ''}
spec.authors = ''
spec.license = ''
spec.summary = 'Some description for the Shared Module'
spec.vendored_frameworks = 'build/cocoapods/framework/core-appearance.framework'
spec.libraries = 'c++'
spec.ios.deployment_target = '14.1'
spec.pod_target_xcconfig = {
'KOTLIN_PROJECT_PATH' => ':core-appearance',
'PRODUCT_MODULE_NAME' => 'core-appearance',
}
spec.script_phases = [
{
:name => 'Build core_appearance',
:execution_position => :before_compile,
:shell_path => '/bin/sh',
:script => <<-SCRIPT
if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then
echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\""
exit 0
fi
set -ev
REPO_ROOT="$PODS_TARGET_SRCROOT"
"$REPO_ROOT/../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \
-Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \
-Pkotlin.native.cocoapods.archs="$ARCHS" \
-Pkotlin.native.cocoapods.configuration="$CONFIGURATION"
SCRIPT
}
]
end

View File

@ -0,0 +1,39 @@
Pod::Spec.new do |spec|
spec.name = 'core_architecture'
spec.version = '1.0'
spec.homepage = 'Link to the Shared Module homepage'
spec.source = { :http=> ''}
spec.authors = ''
spec.license = ''
spec.summary = 'Some description for the Shared Module'
spec.vendored_frameworks = 'build/cocoapods/framework/core-architecture.framework'
spec.libraries = 'c++'
spec.ios.deployment_target = '14.1'
spec.pod_target_xcconfig = {
'KOTLIN_PROJECT_PATH' => ':core-architecture',
'PRODUCT_MODULE_NAME' => 'core-architecture',
}
spec.script_phases = [
{
:name => 'Build core_architecture',
:execution_position => :before_compile,
:shell_path => '/bin/sh',
:script => <<-SCRIPT
if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then
echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\""
exit 0
fi
set -ev
REPO_ROOT="$PODS_TARGET_SRCROOT"
"$REPO_ROOT/../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \
-Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \
-Pkotlin.native.cocoapods.archs="$ARCHS" \
-Pkotlin.native.cocoapods.configuration="$CONFIGURATION"
SCRIPT
}
]
end

View File

@ -0,0 +1,39 @@
Pod::Spec.new do |spec|
spec.name = 'core_commonui'
spec.version = '1.0'
spec.homepage = 'Link to the Shared Module homepage'
spec.source = { :http=> ''}
spec.authors = ''
spec.license = ''
spec.summary = 'Some description for the Shared Module'
spec.vendored_frameworks = 'build/cocoapods/framework/core-commonui.framework'
spec.libraries = 'c++'
spec.ios.deployment_target = '14.1'
spec.pod_target_xcconfig = {
'KOTLIN_PROJECT_PATH' => ':core-commonui',
'PRODUCT_MODULE_NAME' => 'core-commonui',
}
spec.script_phases = [
{
:name => 'Build core_commonui',
:execution_position => :before_compile,
:shell_path => '/bin/sh',
:script => <<-SCRIPT
if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then
echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\""
exit 0
fi
set -ev
REPO_ROOT="$PODS_TARGET_SRCROOT"
"$REPO_ROOT/../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \
-Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \
-Pkotlin.native.cocoapods.archs="$ARCHS" \
-Pkotlin.native.cocoapods.configuration="$CONFIGURATION"
SCRIPT
}
]
end

View File

@ -86,7 +86,7 @@ class PostDetailScreenViewModel(
val auth = identityRepository.authToken.value
val refreshing = currentState.refreshing
val sort = keyStore[KeyStoreKeys.DefaultCommentSortType, 3].toSortType()
val commentList = commentRepository.getComments(
val commentList = commentRepository.getAll(
auth = auth,
postId = post.id,
page = currentPage,

View File

@ -0,0 +1,39 @@
Pod::Spec.new do |spec|
spec.name = 'core_preferences'
spec.version = '1.0.0'
spec.homepage = 'Link to the Shared Module homepage'
spec.source = { :http=> ''}
spec.authors = ''
spec.license = ''
spec.summary = 'Some description for the Shared Module'
spec.vendored_frameworks = 'build/cocoapods/framework/core-preferences.framework'
spec.libraries = 'c++'
spec.ios.deployment_target = '14.1'
spec.pod_target_xcconfig = {
'KOTLIN_PROJECT_PATH' => ':core-preferences',
'PRODUCT_MODULE_NAME' => 'core-preferences',
}
spec.script_phases = [
{
:name => 'Build core_preferences',
:execution_position => :before_compile,
:shell_path => '/bin/sh',
:script => <<-SCRIPT
if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then
echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\""
exit 0
fi
set -ev
REPO_ROOT="$PODS_TARGET_SRCROOT"
"$REPO_ROOT/../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \
-Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \
-Pkotlin.native.cocoapods.archs="$ARCHS" \
-Pkotlin.native.cocoapods.configuration="$CONFIGURATION"
SCRIPT
}
]
end

View File

@ -0,0 +1,39 @@
Pod::Spec.new do |spec|
spec.name = 'core_utils'
spec.version = '1.0.0'
spec.homepage = 'Link to the Shared Module homepage'
spec.source = { :http=> ''}
spec.authors = ''
spec.license = ''
spec.summary = 'Some description for the Shared Module'
spec.vendored_frameworks = 'build/cocoapods/framework/core-utils.framework'
spec.libraries = 'c++'
spec.ios.deployment_target = '14.1'
spec.pod_target_xcconfig = {
'KOTLIN_PROJECT_PATH' => ':core-utils',
'PRODUCT_MODULE_NAME' => 'core-utils',
}
spec.script_phases = [
{
:name => 'Build core_utils',
:execution_position => :before_compile,
:shell_path => '/bin/sh',
:script => <<-SCRIPT
if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then
echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\""
exit 0
fi
set -ev
REPO_ROOT="$PODS_TARGET_SRCROOT"
"$REPO_ROOT/../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \
-Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \
-Pkotlin.native.cocoapods.archs="$ARCHS" \
-Pkotlin.native.cocoapods.configuration="$CONFIGURATION"
SCRIPT
}
]
end

View File

@ -0,0 +1,39 @@
Pod::Spec.new do |spec|
spec.name = 'domain_identity'
spec.version = '1.0'
spec.homepage = 'Link to the Shared Module homepage'
spec.source = { :http=> ''}
spec.authors = ''
spec.license = ''
spec.summary = 'Some description for the Shared Module'
spec.vendored_frameworks = 'build/cocoapods/framework/domain-identity.framework'
spec.libraries = 'c++'
spec.ios.deployment_target = '14.1'
spec.pod_target_xcconfig = {
'KOTLIN_PROJECT_PATH' => ':domain-identity',
'PRODUCT_MODULE_NAME' => 'domain-identity',
}
spec.script_phases = [
{
:name => 'Build domain_identity',
:execution_position => :before_compile,
:shell_path => '/bin/sh',
:script => <<-SCRIPT
if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then
echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\""
exit 0
fi
set -ev
REPO_ROOT="$PODS_TARGET_SRCROOT"
"$REPO_ROOT/../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \
-Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \
-Pkotlin.native.cocoapods.archs="$ARCHS" \
-Pkotlin.native.cocoapods.configuration="$CONFIGURATION"
SCRIPT
}
]
end

View File

@ -3,6 +3,7 @@ package com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data
data class CommunityModel(
val id: Int = 0,
val name: String = "",
val title: String = "",
val host: String = "",
val icon: String? = null,
)

View File

@ -16,7 +16,7 @@ class CommentRepository(
const val DEFAULT_PAGE_SIZE = 20
}
suspend fun getComments(
suspend fun getAll(
postId: Int,
auth: String? = null,
page: Int,

View File

@ -1,6 +1,7 @@
package com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.CommunityView
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.SearchType
import com.github.diegoberaldin.raccoonforlemmy.core.api.provider.ServiceProvider
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.CommunityModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.utils.toModel
@ -9,11 +10,40 @@ class CommunityRepository(
private val services: ServiceProvider,
) {
suspend fun getCommunity(
companion object {
const val DEFAULT_PAGE_SIZE = 20
}
suspend fun getAll(
query: String = "",
auth: String? = null,
page: Int,
limit: Int = DEFAULT_PAGE_SIZE,
): List<CommunityModel> {
val response = services.search.search(
q = query,
auth = auth,
page = page,
limit = limit,
type = SearchType.Communities,
).body()
return response?.communities?.map {
it.toModel()
}.orEmpty()
}
suspend fun getSubscribed(
auth: String? = null,
): List<CommunityModel> {
val response = services.site.get(auth).body()
return response?.myUser?.follows?.map { it.toModel() }.orEmpty()
}
suspend fun get(
auth: String? = null,
id: Int,
): CommunityModel? {
val response = services.community.getCommunity(
val response = services.community.get(
auth = auth,
id = id,
).body()

View File

@ -17,7 +17,7 @@ class PostsRepository(
const val DEFAULT_PAGE_SIZE = 20
}
suspend fun getPosts(
suspend fun getAll(
auth: String? = null,
page: Int,
limit: Int = DEFAULT_PAGE_SIZE,

View File

@ -8,7 +8,7 @@ class SiteRepository(
private val serviceProvider: ServiceProvider,
) {
suspend fun getCurrentUser(auth: String): UserModel? {
val response = serviceProvider.site.getSite(
val response = serviceProvider.site.get(
auth = auth,
)
return response.body()?.myUser?.let {

View File

@ -13,11 +13,11 @@ class UserRepository(
private val serviceProvider: ServiceProvider,
) {
suspend fun getUser(
suspend fun get(
id: Int,
auth: String? = null,
): UserModel? {
val response = serviceProvider.user.getPersonDetails(
val response = serviceProvider.user.getDetails(
auth = auth,
personId = id,
)
@ -31,7 +31,7 @@ class UserRepository(
)
}
suspend fun getUserPosts(
suspend fun getPosts(
id: Int,
auth: String? = null,
page: Int,
@ -39,7 +39,7 @@ class UserRepository(
sort: SortType = SortType.Active,
savedOnly: Boolean = false,
): List<PostModel> {
val response = serviceProvider.user.getPersonDetails(
val response = serviceProvider.user.getDetails(
auth = auth,
personId = id,
page = page,
@ -51,14 +51,14 @@ class UserRepository(
return dto.posts.map { it.toModel() }
}
suspend fun getUserComments(
suspend fun getComments(
id: Int,
auth: String? = null,
page: Int,
limit: Int = PostsRepository.DEFAULT_PAGE_SIZE,
sort: SortType = SortType.Active,
): List<CommentModel> {
val response = serviceProvider.user.getPersonDetails(
val response = serviceProvider.user.getDetails(
auth = auth,
personId = id,
page = page,

View File

@ -2,6 +2,7 @@ package com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.utils
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.CommentView
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.Community
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.CommunityFollowerView
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.ListingType.All
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.ListingType.Local
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.ListingType.Subscribed
@ -88,10 +89,19 @@ internal fun CommentView.toModel() = CommentModel(
internal fun Community.toModel() = CommunityModel(
id = id,
name = name,
title = title,
icon = icon,
host = actorId.toHost(),
)
internal fun CommunityFollowerView.toModel() = CommunityModel(
id = community.id,
name = community.name,
title = community.title,
icon = community.icon,
host = community.actorId.toHost(),
)
internal fun String.toHost(): String = this.replace("https://", "").let {
val i = it.indexOf("/")
it.substring(0, i)

View File

@ -102,7 +102,7 @@ class HomeScreenModel(
val type = currentState.listingType
val sort = currentState.sortType
val refreshing = currentState.refreshing
val postList = postsRepository.getPosts(
val postList = postsRepository.getAll(
auth = auth,
page = currentPage,
type = type,

View File

@ -49,7 +49,7 @@ class ProfileCommentsViewModel(
mvi.updateState { it.copy(loading = true) }
val auth = identityRepository.authToken.value
val refreshing = currentState.refreshing
val commentList = userRepository.getUserComments(
val commentList = userRepository.getComments(
auth = auth,
id = user.id,
page = currentPage,

View File

@ -50,7 +50,7 @@ class ProfilePostsViewModel(
mvi.updateState { it.copy(loading = true) }
val auth = identityRepository.authToken.value
val refreshing = currentState.refreshing
val postList = userRepository.getUserPosts(
val postList = userRepository.getPosts(
auth = auth,
id = user.id,
savedOnly = savedOnly,

View File

@ -41,13 +41,20 @@ kotlin {
implementation(compose.materialIconsExtended)
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
implementation(compose.components.resources)
implementation(compose.material)
implementation(libs.voyager.navigator)
implementation(libs.voyager.tab)
implementation(libs.kamel)
implementation(projects.resources)
implementation(projects.coreArchitecture)
implementation(projects.coreAppearance)
implementation(projects.domainIdentity)
implementation(projects.domainLemmy.data)
implementation(projects.domainLemmy.repository)
implementation(projects.resources)
}
}
val commonTest by getting {

View File

@ -1,18 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.search
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.DefaultMviModel
import org.koin.dsl.module
import org.koin.java.KoinJavaComponent.inject
actual val searchTabModule = module {
factory {
SearchScreenModel(
mvi = DefaultMviModel(SearchScreenMviModel.UiState()),
)
}
}
actual fun getSearchScreenModel(): SearchScreenModel {
val res: SearchScreenModel by inject(SearchScreenModel::class.java)
return res
}

View File

@ -0,0 +1,9 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.search.di
import com.github.diegoberaldin.raccoonforlemmy.feature.search.viewmodel.SearchScreenModel
import org.koin.java.KoinJavaComponent.inject
actual fun getSearchScreenModel(): SearchScreenModel {
val res: SearchScreenModel by inject(SearchScreenModel::class.java)
return res
}

View File

@ -1,7 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.search
import org.koin.core.module.Module
expect val searchTabModule: Module
expect fun getSearchScreenModel(): SearchScreenModel

View File

@ -1,10 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.search
import cafe.adriel.voyager.core.model.ScreenModel
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.DefaultMviModel
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.MviModel
class SearchScreenModel(
private val mvi: DefaultMviModel<SearchScreenMviModel.Intent, SearchScreenMviModel.UiState, SearchScreenMviModel.Effect>,
) : ScreenModel,
MviModel<SearchScreenMviModel.Intent, SearchScreenMviModel.UiState, SearchScreenMviModel.Effect> by mvi

View File

@ -1,12 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.search
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.MviModel
interface SearchScreenMviModel :
MviModel<SearchScreenMviModel.Intent, SearchScreenMviModel.UiState, SearchScreenMviModel.Effect> {
sealed interface Intent
data class UiState(val loading: Boolean = false)
sealed interface Effect
}

View File

@ -1,53 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.search
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.bindToLifecycle
import com.github.diegoberaldin.raccoonforlemmy.resources.MR
import com.github.diegoberaldin.raccoonforlemmy.resources.di.getLanguageRepository
import com.github.diegoberaldin.raccoonforlemmy.resources.di.staticString
import dev.icerock.moko.resources.desc.desc
object SearchTab : Tab {
override val options: TabOptions
@Composable
get() {
val icon = rememberVectorPainter(Icons.Default.Explore)
val languageRepository = remember { getLanguageRepository() }
val lang by languageRepository.currentLanguage.collectAsState()
return remember(lang) {
val title = staticString(MR.strings.navigation_search.desc())
TabOptions(
index = 1u,
title = title,
icon = icon,
)
}
}
@Composable
override fun Content() {
val model = rememberScreenModel { getSearchScreenModel() }
model.bindToLifecycle(key)
Column(modifier = Modifier.padding(Spacing.xs)) {
Text(
text = "Search content",
)
}
}
}

View File

@ -0,0 +1,17 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.search.di
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.DefaultMviModel
import com.github.diegoberaldin.raccoonforlemmy.feature.search.viewmodel.SearchScreenModel
import com.github.diegoberaldin.raccoonforlemmy.feature.search.viewmodel.SearchScreenMviModel
import org.koin.dsl.module
val searchTabModule = module {
factory {
SearchScreenModel(
mvi = DefaultMviModel(SearchScreenMviModel.UiState()),
apiConfigRepository = get(),
identityRepository = get(),
communityRepository = get(),
)
}
}

View File

@ -0,0 +1,5 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.search.di
import com.github.diegoberaldin.raccoonforlemmy.feature.search.viewmodel.SearchScreenModel
expect fun getSearchScreenModel(): SearchScreenModel

View File

@ -0,0 +1,84 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.search.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.CommunityModel
import io.kamel.image.KamelImage
import io.kamel.image.asyncPainterResource
@Composable
internal fun CommunityItem(
community: CommunityModel,
modifier: Modifier = Modifier,
) {
val title = community.title
val communityName = community.name
val communityIcon = community.icon.orEmpty()
val communityHost = community.host
val iconSize = 30.dp
Row(
modifier = modifier.padding(
vertical = Spacing.xs,
horizontal = Spacing.s,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.xs),
) {
if (communityIcon.isNotEmpty()) {
val painterResource = asyncPainterResource(data = communityIcon)
KamelImage(
modifier = Modifier.padding(Spacing.xxxs).size(iconSize)
.clip(RoundedCornerShape(iconSize / 2)),
resource = painterResource,
contentDescription = null,
contentScale = ContentScale.FillBounds,
)
} else {
Box(
modifier = Modifier.padding(Spacing.xxxs).size(iconSize)
.background(MaterialTheme.colorScheme.primary)
.clip(RoundedCornerShape(iconSize / 2)),
contentAlignment = Alignment.Center,
) {
Text(
text = community.name.firstOrNull()?.toString().orEmpty(),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
Column {
Text(
text = buildString {
append(title)
},
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = buildString {
append("!")
append(communityName)
if (communityHost.isNotEmpty()) {
append("@$communityHost")
}
},
style = MaterialTheme.typography.bodySmall,
)
}
}
}

View File

@ -0,0 +1,168 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.search.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.bindToLifecycle
import com.github.diegoberaldin.raccoonforlemmy.feature.search.di.getSearchScreenModel
import com.github.diegoberaldin.raccoonforlemmy.feature.search.viewmodel.SearchScreenMviModel
import com.github.diegoberaldin.raccoonforlemmy.resources.MR
import com.github.diegoberaldin.raccoonforlemmy.resources.di.getLanguageRepository
import com.github.diegoberaldin.raccoonforlemmy.resources.di.staticString
import dev.icerock.moko.resources.compose.stringResource
import dev.icerock.moko.resources.desc.desc
object SearchTab : Tab {
override val options: TabOptions
@Composable get() {
val icon = rememberVectorPainter(Icons.Default.Explore)
val languageRepository = remember { getLanguageRepository() }
val lang by languageRepository.currentLanguage.collectAsState()
return remember(lang) {
val title = staticString(MR.strings.navigation_search.desc())
TabOptions(
index = 1u,
title = title,
icon = icon,
)
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
override fun Content() {
val model = rememberScreenModel { getSearchScreenModel() }
model.bindToLifecycle(key)
val uiState by model.uiState.collectAsState()
Column(
modifier = Modifier.padding(Spacing.xxs),
verticalArrangement = Arrangement.spacedBy(Spacing.xxs),
) {
TextField(
modifier = Modifier.padding(
horizontal = Spacing.m,
vertical = Spacing.s,
).fillMaxWidth(),
label = {
Text(text = stringResource(MR.strings.explore_search_placeholder))
},
singleLine = true,
value = uiState.searchText,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
),
onValueChange = { value ->
model.reduce(SearchScreenMviModel.Intent.SetSearch(value))
},
)
Row(
modifier = Modifier.padding(horizontal = Spacing.xxs),
verticalAlignment = Alignment.CenterVertically,
) {
if (uiState.isLogged) {
Checkbox(
checked = uiState.subscribedOnly,
onCheckedChange = {
model.reduce(SearchScreenMviModel.Intent.SetSubscribedOnly(it))
},
colors = CheckboxDefaults.colors(checkedColor = MaterialTheme.colorScheme.primary),
)
Text(
text = stringResource(MR.strings.explore_subscribed_only),
style = MaterialTheme.typography.labelMedium,
)
}
Spacer(modifier = Modifier.weight(1f))
Button(onClick = {
model.reduce(SearchScreenMviModel.Intent.SearchFired)
}) {
Text(
text = stringResource(MR.strings.button_search),
style = MaterialTheme.typography.labelMedium,
)
}
}
val pullRefreshState = rememberPullRefreshState(uiState.refreshing, {
model.reduce(SearchScreenMviModel.Intent.Refresh)
})
Box(
modifier = Modifier.padding(Spacing.xxs).pullRefresh(pullRefreshState),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(Spacing.xs),
) {
items(uiState.communities) { community ->
CommunityItem(
modifier = Modifier.fillMaxWidth(),
community = community,
)
}
item {
if (!uiState.loading && !uiState.refreshing && uiState.canFetchMore) {
model.reduce(SearchScreenMviModel.Intent.LoadNextPage)
}
if (uiState.loading && !uiState.refreshing) {
Box(
modifier = Modifier.fillMaxWidth().padding(Spacing.xs),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(25.dp),
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
PullRefreshIndicator(
refreshing = uiState.refreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter),
backgroundColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}

View File

@ -0,0 +1,126 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.search.viewmodel
import cafe.adriel.voyager.core.model.ScreenModel
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.DefaultMviModel
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.MviModel
import com.github.diegoberaldin.raccoonforlemmy.domain.identity.repository.ApiConfigurationRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.identity.repository.IdentityRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.CommunityRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.PostsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class SearchScreenModel(
private val mvi: DefaultMviModel<SearchScreenMviModel.Intent, SearchScreenMviModel.UiState, SearchScreenMviModel.Effect>,
private val apiConfigRepository: ApiConfigurationRepository,
private val identityRepository: IdentityRepository,
private val communityRepository: CommunityRepository,
) : ScreenModel,
MviModel<SearchScreenMviModel.Intent, SearchScreenMviModel.UiState, SearchScreenMviModel.Effect> by mvi {
private var currentPage: Int = 1
override fun onStarted() {
mvi.onStarted()
mvi.updateState {
it.copy(
instance = apiConfigRepository.getInstance(),
)
}
mvi.scope.launch {
identityRepository.authToken.map { !it.isNullOrEmpty() }.onEach { isLogged ->
mvi.updateState {
it.copy(isLogged = isLogged)
}
}.launchIn(this)
}
refresh()
}
override fun reduce(intent: SearchScreenMviModel.Intent) {
when (intent) {
SearchScreenMviModel.Intent.LoadNextPage -> loadNextPage()
SearchScreenMviModel.Intent.Refresh -> refresh()
SearchScreenMviModel.Intent.SearchFired -> refresh()
is SearchScreenMviModel.Intent.SetSearch -> setSearch(intent.value)
is SearchScreenMviModel.Intent.SetSubscribedOnly -> applySubscribedOnly(intent.value)
}
}
private fun setSearch(value: String) {
mvi.updateState { it.copy(searchText = value) }
}
private fun applySubscribedOnly(value: Boolean) {
mvi.updateState { it.copy(subscribedOnly = value) }
refresh()
}
private fun refresh() {
currentPage = 1
mvi.updateState { it.copy(canFetchMore = true, refreshing = true) }
loadNextPage()
}
private fun loadNextPage() {
val currentState = mvi.uiState.value
if (!currentState.canFetchMore || currentState.loading) {
return
}
mvi.scope.launch(Dispatchers.IO) {
mvi.updateState { it.copy(loading = true) }
val searchText = mvi.uiState.value.searchText
val auth = identityRepository.authToken.value
val refreshing = currentState.refreshing
val subscribedOnly = currentState.subscribedOnly
if (subscribedOnly) {
val items = communityRepository.getSubscribed(
auth = auth,
).filter {
it.name.contains(searchText)
}
currentPage++
mvi.updateState {
val newItems = if (refreshing) {
items
} else {
it.communities + items
}
it.copy(
communities = newItems,
loading = false,
canFetchMore = false,
refreshing = false,
)
}
} else {
val items = communityRepository.getAll(
query = searchText,
auth = auth,
page = currentPage,
)
currentPage++
val canFetchMore = items.size >= PostsRepository.DEFAULT_PAGE_SIZE
mvi.updateState {
val newItems = if (refreshing) {
items
} else {
it.communities + items
}
it.copy(
communities = newItems,
loading = false,
canFetchMore = canFetchMore,
refreshing = false,
)
}
}
}
}
}

View File

@ -0,0 +1,28 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.search.viewmodel
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.MviModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.CommunityModel
interface SearchScreenMviModel :
MviModel<SearchScreenMviModel.Intent, SearchScreenMviModel.UiState, SearchScreenMviModel.Effect> {
sealed interface Intent {
object Refresh : Intent
object LoadNextPage : Intent
object SearchFired : Intent
data class SetSearch(val value: String) : Intent
data class SetSubscribedOnly(val value: Boolean) : Intent
}
data class UiState(
val refreshing: Boolean = false,
val loading: Boolean = false,
val canFetchMore: Boolean = true,
val isLogged: Boolean = false,
val instance: String = "",
val searchText: String = "",
val subscribedOnly: Boolean = true,
val communities: List<CommunityModel> = emptyList(),
)
sealed interface Effect
}

View File

@ -1,20 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.search
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.DefaultMviModel
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.dsl.module
actual val searchTabModule = module {
factory {
SearchScreenModel(
mvi = DefaultMviModel(SearchScreenMviModel.UiState()),
)
}
}
actual fun getSearchScreenModel() = SearchScreenModelHelper.model
object SearchScreenModelHelper : KoinComponent {
val model: SearchScreenModel by inject()
}

View File

@ -0,0 +1,11 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.search.di
import com.github.diegoberaldin.raccoonforlemmy.feature.search.viewmodel.SearchScreenModel
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
actual fun getSearchScreenModel() = SearchScreenModelHelper.model
object SearchScreenModelHelper : KoinComponent {
val model: SearchScreenModel by inject()
}

View File

@ -15,6 +15,7 @@
<string name="message_missing_field">Missing field</string>
<string name="button_confirm">Confirm</string>
<string name="button_search">Search</string>
<string name="home_listing_type_all">All</string>
<string name="home_listing_type_local">Local</string>
@ -36,6 +37,9 @@
<string name="home_sort_type_top_year">Top year</string>
<string name="home_instance_via">via %1$s</string>
<string name="explore_search_placeholder">Search for communities</string>
<string name="explore_subscribed_only">Subscribed only</string>
<string name="profile_not_logged_message">You are currently not logged in.\nPlease add an
account to continue.
</string>

View File

@ -12,6 +12,7 @@
<string name="message_missing_field">Campo obbligatorio</string>
<string name="button_confirm">Conferma</string>
<string name="button_search">Cerca</string>
<string name="home_listing_type_all">Tutti</string>
<string name="home_listing_type_local">Locali</string>
@ -33,6 +34,9 @@
<string name="home_sort_type_top_year">Top anno</string>
<string name="home_instance_via">via %1$s</string>
<string name="explore_search_placeholder">Cerca comunità</string>
<string name="explore_subscribed_only">Solo sottoscrizioni</string>
<string name="profile_not_logged_message">Login non effettuato.\nAggiungi un account per
continuare.
</string>

View File

@ -8,7 +8,7 @@ import com.github.diegoberaldin.raccoonforlemmy.domain.identity.di.coreIdentityM
import com.github.diegoberaldin.raccoonforlemmy.feature.home.di.homeTabModule
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.inboxTabModule
import com.github.diegoberaldin.raccoonforlemmy.feature.profile.di.profileTabModule
import com.github.diegoberaldin.raccoonforlemmy.feature.search.searchTabModule
import com.github.diegoberaldin.raccoonforlemmy.feature.search.di.searchTabModule
import com.github.diegoberaldin.raccoonforlemmy.feature.settings.di.settingsTabModule
import com.github.diegoberaldin.raccoonforlemmy.resources.di.localizationModule
import org.koin.dsl.module

View File

@ -22,7 +22,7 @@ import com.github.diegoberaldin.raccoonforlemmy.domain.identity.di.getApiConfigu
import com.github.diegoberaldin.raccoonforlemmy.feature.home.ui.HomeTab
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.InboxTab
import com.github.diegoberaldin.raccoonforlemmy.feature.profile.ui.ProfileTab
import com.github.diegoberaldin.raccoonforlemmy.feature.search.SearchTab
import com.github.diegoberaldin.raccoonforlemmy.feature.search.ui.SearchTab
import com.github.diegoberaldin.raccoonforlemmy.feature.settings.ui.SettingsTab
import com.github.diegoberaldin.raccoonforlemmy.resources.MR
import com.github.diegoberaldin.raccoonforlemmy.resources.di.getLanguageRepository

View File

@ -8,7 +8,7 @@ import com.github.diegoberaldin.raccoonforlemmy.domain.identity.di.coreIdentityM
import com.github.diegoberaldin.raccoonforlemmy.feature.home.di.homeTabModule
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.inboxTabModule
import com.github.diegoberaldin.raccoonforlemmy.feature.profile.di.profileTabModule
import com.github.diegoberaldin.raccoonforlemmy.feature.search.searchTabModule
import com.github.diegoberaldin.raccoonforlemmy.feature.search.di.searchTabModule
import com.github.diegoberaldin.raccoonforlemmy.feature.settings.di.settingsTabModule
import com.github.diegoberaldin.raccoonforlemmy.resources.di.localizationModule
import org.koin.core.context.startKoin