Repository: galaxygoldfish/pineapple Branch: main Commit: 198b3ca05c34 Files: 179 Total size: 349.0 KB Directory structure: gitextract_l136effu/ ├── .gitignore ├── .idea/ │ ├── .gitignore │ ├── .name │ ├── AndroidProjectSystem.xml │ ├── codeStyles/ │ │ ├── Project.xml │ │ └── codeStyleConfig.xml │ ├── compiler.xml │ ├── copilot.data.migration.agent.xml │ ├── copilot.data.migration.ask2agent.xml │ ├── copilot.data.migration.edit.xml │ ├── deploymentTargetSelector.xml │ ├── gradle.xml │ ├── inspectionProfiles/ │ │ └── Project_Default.xml │ ├── kotlinc.xml │ ├── migrations.xml │ ├── misc.xml │ ├── runConfigurations.xml │ └── vcs.xml ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── pineapple/ │ │ └── app/ │ │ └── ExampleInstrumentedTest.kt │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── pineapple/ │ │ │ └── app/ │ │ │ ├── MainActivity.kt │ │ │ ├── PineappleApp.kt │ │ │ ├── consts/ │ │ │ │ ├── MMKVKey.kt │ │ │ │ ├── NavDestinationKey.kt │ │ │ │ ├── OnboardingLoginType.kt │ │ │ │ ├── PageDestinationKey.kt │ │ │ │ ├── PostFilterSort.kt │ │ │ │ └── PostFilterTime.kt │ │ │ ├── di/ │ │ │ │ ├── DatabaseModule.kt │ │ │ │ ├── MMKVModule.kt │ │ │ │ └── NetworkModule.kt │ │ │ ├── network/ │ │ │ │ ├── api/ │ │ │ │ │ ├── RedditApi.kt │ │ │ │ │ └── RedditTokenApi.kt │ │ │ │ ├── caching/ │ │ │ │ │ ├── AppDatabase.kt │ │ │ │ │ ├── dao/ │ │ │ │ │ │ ├── CommentDao.kt │ │ │ │ │ │ ├── PostDao.kt │ │ │ │ │ │ ├── RemoteKeyDao.kt │ │ │ │ │ │ ├── SearchRemoteKeyDao.kt │ │ │ │ │ │ ├── SearchResultDao.kt │ │ │ │ │ │ ├── SubredditDao.kt │ │ │ │ │ │ └── UserDao.kt │ │ │ │ │ └── entity/ │ │ │ │ │ ├── CommentEntity.kt │ │ │ │ │ ├── PostEntity.kt │ │ │ │ │ ├── RemoteKeyEntity.kt │ │ │ │ │ ├── SearchRemoteKeyEntity.kt │ │ │ │ │ ├── SearchResultEntity.kt │ │ │ │ │ ├── SubredditEntity.kt │ │ │ │ │ └── UserEntity.kt │ │ │ │ ├── interceptor/ │ │ │ │ │ ├── AuthInterceptor.kt │ │ │ │ │ └── TokenUserAgentInterceptor.kt │ │ │ │ ├── model/ │ │ │ │ │ ├── auth/ │ │ │ │ │ │ └── AuthResponse.kt │ │ │ │ │ ├── cache/ │ │ │ │ │ │ ├── CommentWithUser.kt │ │ │ │ │ │ └── PostwithUser.kt │ │ │ │ │ └── reddit/ │ │ │ │ │ ├── AboutAccount.kt │ │ │ │ │ ├── AllAwarding.kt │ │ │ │ │ ├── CommentData.kt │ │ │ │ │ ├── CommentListing.kt │ │ │ │ │ ├── CondensedUserAbout.kt │ │ │ │ │ ├── FlairRichItem.kt │ │ │ │ │ ├── Gildings.kt │ │ │ │ │ ├── Image.kt │ │ │ │ │ ├── Listing.kt │ │ │ │ │ ├── ListingBase.kt │ │ │ │ │ ├── ListingItem.kt │ │ │ │ │ ├── PostData.kt │ │ │ │ │ ├── PostItem.kt │ │ │ │ │ ├── PostListing.kt │ │ │ │ │ ├── Preview.kt │ │ │ │ │ ├── ResizedIcon.kt │ │ │ │ │ ├── SecureMedia.kt │ │ │ │ │ ├── SubredditData.kt │ │ │ │ │ ├── SubredditInfo.kt │ │ │ │ │ ├── SubredditItem.kt │ │ │ │ │ ├── UserAbout.kt │ │ │ │ │ └── UserSubredditData.kt │ │ │ │ ├── paging/ │ │ │ │ │ ├── CommentsRemoteMediator.kt │ │ │ │ │ ├── PagingRepository.kt │ │ │ │ │ ├── PostsRemoteMediator.kt │ │ │ │ │ └── SearchRemoteMediator.kt │ │ │ │ ├── repository/ │ │ │ │ │ ├── RedditAuthRepository.kt │ │ │ │ │ └── RedditRepository.kt │ │ │ │ └── serialization/ │ │ │ │ └── RedditRepliesAdapter.kt │ │ │ ├── ui/ │ │ │ │ ├── components/ │ │ │ │ │ ├── ButtonComponents.kt │ │ │ │ │ ├── CardComponents.kt │ │ │ │ │ ├── ListComponents.kt │ │ │ │ │ └── MediaComponents.kt │ │ │ │ ├── modal/ │ │ │ │ │ ├── CommentDetailSheet.kt │ │ │ │ │ ├── CommentRepliesSheet.kt │ │ │ │ │ ├── PostOptionSheet.kt │ │ │ │ │ └── SortPostSheet.kt │ │ │ │ ├── state/ │ │ │ │ │ └── AuthViewState.kt │ │ │ │ ├── theme/ │ │ │ │ │ ├── Shape.kt │ │ │ │ │ ├── Theme.kt │ │ │ │ │ └── Type.kt │ │ │ │ ├── view/ │ │ │ │ │ ├── AccountPage.kt │ │ │ │ │ ├── BrowsePage.kt │ │ │ │ │ ├── ChatPage.kt │ │ │ │ │ ├── CommunityView.kt │ │ │ │ │ ├── HomeView.kt │ │ │ │ │ ├── KeyProviderView.kt │ │ │ │ │ ├── PostView.kt │ │ │ │ │ ├── SearchPage.kt │ │ │ │ │ ├── UserView.kt │ │ │ │ │ └── WelcomeView.kt │ │ │ │ └── viewmodel/ │ │ │ │ ├── BrowseViewModel.kt │ │ │ │ ├── HomeViewModel.kt │ │ │ │ ├── KeyProviderViewModel.kt │ │ │ │ ├── PostViewModel.kt │ │ │ │ └── SearchViewModel.kt │ │ │ └── utilities/ │ │ │ ├── NumberUtilities.kt │ │ │ └── TypeUtilities.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── async_image_placeholder.xml │ │ │ ├── generic_avatar.xml │ │ │ ├── generic_community.xml │ │ │ ├── ic_angry.xml │ │ │ ├── ic_arrow_up.xml │ │ │ ├── ic_back.xml │ │ │ ├── ic_bookmark.xml │ │ │ ├── ic_bookmark_filled.xml │ │ │ ├── ic_browse.xml │ │ │ ├── ic_calendar_day.xml │ │ │ ├── ic_calendar_month.xml │ │ │ ├── ic_check.xml │ │ │ ├── ic_close.xml │ │ │ ├── ic_community.xml │ │ │ ├── ic_copy.xml │ │ │ ├── ic_downvote.xml │ │ │ ├── ic_filter.xml │ │ │ ├── ic_fire.xml │ │ │ ├── ic_flag.xml │ │ │ ├── ic_forum.xml │ │ │ ├── ic_forward.xml │ │ │ ├── ic_help.xml │ │ │ ├── ic_history.xml │ │ │ ├── ic_hourglass.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── ic_launcher_foreground.xml │ │ │ ├── ic_launcher_monochrome.xml │ │ │ ├── ic_menu.xml │ │ │ ├── ic_more_vert.xml │ │ │ ├── ic_open_external.xml │ │ │ ├── ic_person.xml │ │ │ ├── ic_pineapple_logo.xml │ │ │ ├── ic_plus.xml │ │ │ ├── ic_reddit.xml │ │ │ ├── ic_search.xml │ │ │ ├── ic_settings.xml │ │ │ ├── ic_share.xml │ │ │ ├── ic_shine.xml │ │ │ ├── ic_trending.xml │ │ │ ├── ic_upvote.xml │ │ │ └── ic_week.xml │ │ ├── drawable-night/ │ │ │ └── async_image_placeholder.xml │ │ ├── drawable-night-v34/ │ │ │ ├── async_image_placeholder.xml │ │ │ ├── generic_avatar.xml │ │ │ └── generic_community.xml │ │ ├── drawable-v34/ │ │ │ ├── async_image_placeholder.xml │ │ │ ├── generic_avatar.xml │ │ │ └── generic_community.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── values/ │ │ │ ├── colors.xml │ │ │ ├── strings.xml │ │ │ └── themes.xml │ │ └── xml/ │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test/ │ └── java/ │ └── com/ │ └── pineapple/ │ └── app/ │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ *.iml .gradle /local.properties /.idea/caches /.idea/libraries /.idea/modules.xml /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml .DS_Store /build /captures .externalNativeBuild .cxx local.properties ================================================ FILE: .idea/.gitignore ================================================ # Default ignored files /shelf/ /workspace.xml ================================================ FILE: .idea/.name ================================================ Pineapple ================================================ FILE: .idea/AndroidProjectSystem.xml ================================================ ================================================ FILE: .idea/codeStyles/Project.xml ================================================ ================================================ FILE: .idea/codeStyles/codeStyleConfig.xml ================================================ ================================================ FILE: .idea/compiler.xml ================================================ ================================================ FILE: .idea/copilot.data.migration.agent.xml ================================================ ================================================ FILE: .idea/copilot.data.migration.ask2agent.xml ================================================ ================================================ FILE: .idea/copilot.data.migration.edit.xml ================================================ ================================================ FILE: .idea/deploymentTargetSelector.xml ================================================ ================================================ FILE: .idea/gradle.xml ================================================ ================================================ FILE: .idea/inspectionProfiles/Project_Default.xml ================================================ ================================================ FILE: .idea/kotlinc.xml ================================================ ================================================ FILE: .idea/migrations.xml ================================================ ================================================ FILE: .idea/misc.xml ================================================ ================================================ FILE: .idea/runConfigurations.xml ================================================ ================================================ FILE: .idea/vcs.xml ================================================ ================================================ FILE: README.md ================================================ # Pineapple 🍍 Pineapple is an Android reddit client application developed with Jetpack Compose following the Material 3 Expresive design guidelines with Dynamic Color and a clean architecture approach. The application is designed to be free to use, with each user supplying their own created Reddit key that remains on-device to avoid the new API pricing restrictions.
================================================ FILE: app/.gitignore ================================================ /build ================================================ FILE: app/build.gradle.kts ================================================ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) alias(libs.plugins.hilt.android) } android { namespace = "com.pineapple.app" compileSdk = 36 defaultConfig { applicationId = "com.pineapple.app" minSdk = 26 targetSdk = 36 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { jvmTarget = "11" } buildFeatures { compose = true } } dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) implementation(libs.androidx.navigation.compose) implementation(libs.mmkv) implementation(libs.retrofit) implementation(libs.converter.gson) implementation(libs.okhttp) implementation(libs.logging.interceptor) implementation(libs.hilt.android) ksp(libs.hilt.android.compiler) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.coil.compose) implementation(libs.coil.network.okhttp) implementation(libs.room.runtime) implementation(libs.room.ktx) implementation(libs.room.paging) ksp(libs.room.compiler) implementation(libs.paging.runtime) implementation(libs.paging.compose) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) } ================================================ FILE: app/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: app/src/androidTest/java/com/pineapple/app/ExampleInstrumentedTest.kt ================================================ package com.pineapple.app import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Test import org.junit.runner.RunWith import org.junit.Assert.* /** * Instrumented test, which will execute on an Android device. * * See [testing documentation](http://d.android.com/tools/testing). */ @RunWith(AndroidJUnit4::class) class ExampleInstrumentedTest { @Test fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("com.pineapple.app", appContext.packageName) } } ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/java/com/pineapple/app/MainActivity.kt ================================================ package com.pineapple.app import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.runtime.LaunchedEffect import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navDeepLink import com.pineapple.app.consts.MMKVKey import com.pineapple.app.consts.NavDestinationKey import com.pineapple.app.network.repository.RedditAuthRepository import com.pineapple.app.network.repository.RedditRepository import com.pineapple.app.ui.theme.PineappleTheme import com.pineapple.app.ui.view.CommunityView import com.pineapple.app.ui.view.HomeView import com.pineapple.app.ui.view.KeyProviderView import com.pineapple.app.ui.view.PostView import com.pineapple.app.ui.view.UserView import com.pineapple.app.ui.view.WelcomeView import com.tencent.mmkv.MMKV import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { @Inject lateinit var repository: RedditAuthRepository @Inject lateinit var mmkv: MMKV lateinit var navController: NavHostController @OptIn(ExperimentalMaterial3ExpressiveApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { navController = rememberNavController() PineappleTheme { NavHost( navController = navController, startDestination = if (mmkv.decodeBool(MMKVKey.ONBOARDING_COMPLETE)) { NavDestinationKey.HomeView } else { NavDestinationKey.WelcomeView }, enterTransition = { scaleIn(initialScale = 0.9f, animationSpec = tween(350)) + fadeIn(animationSpec = tween(350)) }, exitTransition = { scaleOut(targetScale = 0.95f, animationSpec = tween(350)) + fadeOut(animationSpec = tween(350)) } ) { composable(NavDestinationKey.WelcomeView) { WelcomeView(navController) } composable("${NavDestinationKey.KeyProviderView}/{authType}") { backStackEntry -> KeyProviderView( navController = navController, loginType = backStackEntry.arguments?.getString("authType")!! ) } composable(NavDestinationKey.HomeView) { HomeView(navController) } composable( route = "${NavDestinationKey.HomeView}/{error}/{code}/{state}", deepLinks = listOf( navDeepLink { uriPattern = "pineapple://login?error={error}&code={code}&state={state}" } ) ) { mmkv.encode(MMKVKey.API_LOGIN_AUTH_CODE, it.arguments?.getString("code")) mmkv.encode(MMKVKey.ONBOARDING_COMPLETE, true) LaunchedEffect(Unit) { repository.authenticateUser() } HomeView(navController) } composable("${NavDestinationKey.PostView}/{postID}") { val postIdArg = it.arguments?.getString("postID") android.util.Log.e("MainActivity", "Navigated to PostView with postID=$postIdArg") PostView( navController = navController, postID = postIdArg!! ) } composable("${NavDestinationKey.UserView}/{user}") { UserView( navController = navController, user = it.arguments?.getString("user")!! ) } composable("${NavDestinationKey.CommunityView}/{community}") { CommunityView( navController = navController, community = it.arguments?.getString("community")!! ) } } } } } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) navController.handleDeepLink(intent) } } ================================================ FILE: app/src/main/java/com/pineapple/app/PineappleApp.kt ================================================ package com.pineapple.app import android.app.Application import com.tencent.mmkv.MMKV import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp class PineappleApp : Application() { override fun onCreate() { super.onCreate() MMKV.initialize(this) } } ================================================ FILE: app/src/main/java/com/pineapple/app/consts/MMKVKey.kt ================================================ package com.pineapple.app.consts /** * Holds constants that represent all keys used in the MMKV preference table */ object MMKVKey { const val ONBOARDING_COMPLETE = "onboarding_complete" const val ACCESS_TOKEN = "access_token" const val TOKEN_EXPIRES = "token_expires" const val CLIENT_ID = "client_id" const val TOKEN_TYPE = "token_type" const val REFRESH_TOKEN = "refresh_token" const val USER_GUEST = "user_guest" const val API_LOGIN_AUTH_CODE = "api_login_auth_code" } ================================================ FILE: app/src/main/java/com/pineapple/app/consts/NavDestinationKey.kt ================================================ package com.pineapple.app.consts /** * Holds constants used to define all navigation destination routes * used in the main navigation graph. */ object NavDestinationKey { const val WelcomeView = "welcome" const val KeyProviderView = "keyprovider" const val HomeView = "home" const val PostView = "post" const val UserView = "user" const val CommunityView = "community" } ================================================ FILE: app/src/main/java/com/pineapple/app/consts/OnboardingLoginType.kt ================================================ package com.pineapple.app.consts /** * Represents the types of login methods available during onboarding. */ object OnboardingLoginType { const val Guest = "guest" const val RedditAuth = "reddit-auth" } ================================================ FILE: app/src/main/java/com/pineapple/app/consts/PageDestinationKey.kt ================================================ package com.pineapple.app.consts /** * Represents the different pages that can be navigated between in the [HomeView] * using the bottom navigation bar */ object PageDestinationKey { const val BROWSE = 0 const val SEARCH = 1 const val CHATS = 2 const val ACCOUNT = 3 } ================================================ FILE: app/src/main/java/com/pineapple/app/consts/PostFilterSort.kt ================================================ package com.pineapple.app.consts /** * Represent the available options for sorting posts in a list * These values represent the same strings that are sent in an API request */ object PostFilterSort { const val SORT_HOT = "hot" const val SORT_NEW = "new" const val SORT_TOP = "top" const val SORT_RISING = "rising" const val SORT_CONTROVERSIAL = "controversial" } ================================================ FILE: app/src/main/java/com/pineapple/app/consts/PostFilterTime.kt ================================================ package com.pineapple.app.consts /** * Represents the options for time range supplied when filtering posts by top or controversial * These values are the same strings used in API requests */ object PostFilterTime { const val TIME_DAY = "day" const val TIME_MONTH = "month" const val TIME_WEEK = "week" const val TIME_YEAR = "year" const val TIME_ALL = "all" } ================================================ FILE: app/src/main/java/com/pineapple/app/di/DatabaseModule.kt ================================================ package com.pineapple.app.di import android.content.Context import androidx.room.Room import com.pineapple.app.network.caching.AppDatabase import com.pineapple.app.network.caching.dao.PostDao import com.pineapple.app.network.caching.dao.RemoteKeyDao import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object DatabaseModule { @Provides @Singleton fun provideDatabase( @ApplicationContext context: Context ): AppDatabase { return Room.databaseBuilder( context, AppDatabase::class.java, "pineapple-db" ).build() } @Provides fun providePostDao(db: AppDatabase): PostDao = db.postDao() @Provides fun provideRemoteKeyDao(db: AppDatabase): RemoteKeyDao = db.remoteKeyDao() } ================================================ FILE: app/src/main/java/com/pineapple/app/di/MMKVModule.kt ================================================ package com.pineapple.app.di import android.content.Context import com.tencent.mmkv.MMKV import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object MMKVModule { @Provides @Singleton fun provideMMKV(): MMKV = MMKV.defaultMMKV() } ================================================ FILE: app/src/main/java/com/pineapple/app/di/NetworkModule.kt ================================================ package com.pineapple.app.di import com.google.gson.GsonBuilder import com.pineapple.app.network.interceptor.AuthInterceptor import com.pineapple.app.network.api.AuthRetrofit import com.pineapple.app.network.api.RedditApi import com.pineapple.app.network.repository.RedditRepository import com.pineapple.app.network.api.RedditTokenApi import com.pineapple.app.network.api.TokenRetrofit import com.pineapple.app.network.interceptor.TokenUserAgentInterceptor import com.pineapple.app.network.model.reddit.CommentDataNull import com.pineapple.app.network.repository.RedditAuthRepository import com.pineapple.app.network.serialization.RedditRepliesAdapter import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object NetworkModule { @Provides @Singleton fun provideLoggingInterceptor(): HttpLoggingInterceptor = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } @Provides @Singleton fun provideAuthInterceptor( repository: RedditAuthRepository ): AuthInterceptor = AuthInterceptor(repository) @Provides @Singleton fun provideTokenUaInterceptor(): TokenUserAgentInterceptor = TokenUserAgentInterceptor() @AuthRetrofit @Provides @Singleton fun provideOAuthOkHttpClient( logging: HttpLoggingInterceptor, authInterceptor: AuthInterceptor ): OkHttpClient = OkHttpClient.Builder() .addInterceptor(logging) .addInterceptor(authInterceptor) .build() @TokenRetrofit @Provides @Singleton fun provideTokenOkHttpClient( logging: HttpLoggingInterceptor, tokenUaInterceptor: TokenUserAgentInterceptor ): OkHttpClient = OkHttpClient.Builder() .addInterceptor(logging) .addInterceptor(tokenUaInterceptor) .build() @AuthRetrofit @Provides @Singleton fun provideOAuthRetrofit( @AuthRetrofit okHttpClient: OkHttpClient ): Retrofit = Retrofit.Builder() .baseUrl("https://oauth.reddit.com/") .client(okHttpClient) .addConverterFactory( GsonConverterFactory.create( GsonBuilder() .setLenient() .registerTypeAdapter(CommentDataNull::class.java, RedditRepliesAdapter()) .create() ) ) .build() @TokenRetrofit @Provides @Singleton fun provideTokenRetrofit( @TokenRetrofit okHttpClient: OkHttpClient ): Retrofit = Retrofit.Builder() .baseUrl("https://www.reddit.com/") .client(okHttpClient) .addConverterFactory( GsonConverterFactory.create( GsonBuilder() .setLenient() .registerTypeAdapter(CommentDataNull::class.java, RedditRepliesAdapter()) .create() ) ) .build() @Provides @Singleton fun provideRedditApi( @AuthRetrofit oauthRetrofit: Retrofit ): RedditApi = oauthRetrofit.create(RedditApi::class.java) @Provides @Singleton fun provideTokenApi( @TokenRetrofit tokenRetrofit: Retrofit ): RedditTokenApi = tokenRetrofit.create(RedditTokenApi::class.java) } ================================================ FILE: app/src/main/java/com/pineapple/app/network/api/RedditApi.kt ================================================ package com.pineapple.app.network.api import com.pineapple.app.network.model.reddit.CommentPreData import com.pineapple.app.network.model.reddit.CondensedUserAboutListing import com.pineapple.app.network.model.reddit.Listing import com.pineapple.app.network.model.reddit.ListingBase import com.pineapple.app.network.model.reddit.ListingItem import com.pineapple.app.network.model.reddit.PostData import com.pineapple.app.network.model.reddit.PostListing import com.pineapple.app.network.model.reddit.SubredditItem import com.pineapple.app.network.model.reddit.UserAboutListing import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query import javax.inject.Qualifier /** * Interface with the Reddit API endpoints giving access to app content */ interface RedditApi { /** * Fetch posts from a specific subreddit with sorting and time filters * @param name The name of the subreddit (without the r/ prefix) * @param sort The sorting method (use [com.pineapple.app.consts.PostFilterSort] * @param time The time filter (use [com.pineapple.app.consts.PostFilterTime]) * @param after The ID of the last post from the previous fetch for pagination * @param rawJson Whether to get raw JSON (1) or not (0) * @param limit The number of posts to fetch (default is 5) * @return A [PostListing] containing the fetched posts */ @GET("r/{name}/{sort}") suspend fun fetchSubreddit( @Path("name") name: String, @Path("sort") sort: String, @Query("t") time: String, @Query("after") after: String? = null, @Query("raw_json") rawJson: Int = 1, @Query("limit") limit: Int = 5 ): PostListing /** * Search posts globally (or within a subreddit if using /r/{subreddit}/search endpoint) * @param query The search query string * @param sort Sorting for the search (relevance, hot, new, top, comments) * @param time Time filter (use [com.pineapple.app.consts.PostFilterTime]) * @param after Pagination token * @param rawJson Whether to get raw JSON (1) or not (0) * @param limit Number of results to fetch */ @GET("search") suspend fun searchPosts( @Query("q") query: String, @Query("sort") sort: String? = null, @Query("t") time: String? = null, @Query("after") after: String? = null, @Query("raw_json") rawJson: Int = 1, @Query("limit") limit: Int = 25 ): PostListing /** * Fetch a specific post and its comments * @param subreddit The name of the subreddit (without the r/ prefix) * @param postID The ID of the post * @param post The post's slug (title in URL-friendly format) * @param rawJson Whether to get raw JSON (1) or not (0) * @return A [String] containing the raw JSON response */ @GET("r/{name}/comments/{id}/{post}") suspend fun fetchPost( @Path("name") subreddit: String, @Path("id") postID: String, @Path("post") post: String, @Query("raw_json") rawJson: Int = 1 ): List>> /** * Fetch a specific post and its comments using only the post id (no subreddit) * This hits /comments/{id} which returns the post listing and is useful when we don't have the subreddit/slug cached */ @GET("comments/{id}") suspend fun fetchPostById( @Path("id") postID: String, @Query("raw_json") rawJson: Int = 1 ): List>> /** * Fetch the subreddits the authenticated user is subscribed to * @return A [Listing] of [SubredditItem] representing the subscribed subreddits */ @GET("/subreddits/mine/subscriber") suspend fun fetchSubscribedSubreddits( @Query("raw_json") rawJson: Int = 1, @Query("after") after: String? = null, @Query("limit") limit: Int = 6 ): Listing /** * Fetch the current trending subreddits * @return A [Listing] of [SubredditItem] representing the top subreddits */ @GET("subreddits/popular") suspend fun fetchTopSubreddits( @Query("raw_json") rawJson: Int = 1, @Query("after") after: String? = null, @Query("limit") limit: Int = 5 ): Listing /** * Fetch the current popular users * @return A [Listing] of [CondensedUserAboutListing] representing the top users */ @GET("users/popular") suspend fun fetchTopUsers( @Query("raw_json") rawJson: Int = 1 ): Listing /** * Fetch detailed information about a specific user * @param user The username of the user * @param rawJson Whether to get raw JSON (1) or not (0) * @return A [UserAboutListing] containing the user's information */ @GET("/user/{user}/about") suspend fun fetchUserInfo( @Path("user") user: String, @Query("raw_json") rawJson: Int = 1 ) : UserAboutListing /** * Cast an upvote, downvote, or remove vote on a post or comment * @param id The full name of the post or comment (with the t* prefix) * @param dir The direction of the vote: 1 (upvote), -1 (downvote), 0 (remove vote) */ @POST("/api/vote") suspend fun castVote( @Query("id") id: String, @Query("dir") dir: Int ) /** * Save a post or comment to the user's saved items * @param id The full name of the post or comment (with the t*_ prefix) */ @POST("/api/save") suspend fun savePost( @Query("id") id: String ) /** * Unsave a post or comment from the user's saved items * @param id The full name of the post or comment (with the t*_ prefix) */ @POST("/api/unsave") suspend fun unsavePost( @Query("id") id: String ) /** * Search for communities (subreddits) by query. Returns Listing */ @GET("search") suspend fun searchCommunities( @Query("q") query: String, @Query("type") type: String = "sr", @Query("raw_json") rawJson: Int = 1, @Query("limit") limit: Int = 6 ): Listing /** * Search for users by query. Returns ListingBase */ @GET("search") suspend fun searchUsers( @Query("q") query: String, @Query("type") type: String = "user", @Query("raw_json") rawJson: Int = 1, @Query("limit") limit: Int = 6 ): ListingBase /** * Fetch comments for a post id (comments/{id}), returns the same structure as fetchPost */ @GET("comments/{id}") suspend fun fetchCommentsByPostId( @Path("id") postID: String, @Query("raw_json") rawJson: Int = 1 ): List> } /** * Qualifier annotation for Retrofit instance used for authenticated requests * (to differentiate between this and [RedditTokenApi] in dependency injection) */ @Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class AuthRetrofit ================================================ FILE: app/src/main/java/com/pineapple/app/network/api/RedditTokenApi.kt ================================================ package com.pineapple.app.network.api import com.pineapple.app.network.model.auth.AuthResponse import retrofit2.Response import retrofit2.http.Field import retrofit2.http.FormUrlEncoded import retrofit2.http.Header import retrofit2.http.POST import javax.inject.Qualifier /** * Interface with the Reddit OAuth API used to authenticate, request, and refresh tokens * that are then passed to all calls made with the RedditApi interface */ interface RedditTokenApi { /** * Request an access token for the API without a user context * @param basicAuth The basic authentication header containing the client ID as username and an empty password * @param grantType The type of grant being requested, leave as installed client * @param deviceID A unique device identifier, or leave as default to avoid tracking * @return A Response object containing the AuthResponse with access token details */ @FormUrlEncoded @POST("api/v1/access_token") suspend fun authenticateUserless( @Header("Authorization") basicAuth: String, @Field("grant_type") grantType: String = "https://oauth.reddit.com/grants/installed_client", @Field("device_id") deviceID: String = "DO_NOT_TRACK_THIS_DEVICE" ): Response /** * Request a new access token if you already have one that expired, and a refresh token * @param basicAuth The basic authentication header containing the client ID as username and an empty password * @param grantType The type of grant being requested, leave as refresh token * @param refreshToken The refresh token previously obtained during authentication * @return A Response object containing the AuthResponse with new access and refresh token details */ @FormUrlEncoded @POST("api/v1/access_token") suspend fun refreshAccessToken( @Header("Authorization") basicAuth: String, @Field("grant_type") grantType: String = "refresh_token", @Field("refresh_token") refreshToken: String ): Response /** * Request an access token after having authenticated using OAuth in the browser, so that * future API calls can be made on behalf of the authenticated user * @param basicAuth The basic authentication header containing the client ID as username and an empty password * @param grantType The type of grant being requested, leave as authorization code * @param authCode The authorization code received from the OAuth redirect after user login * @param redirectURI The redirect URI used during the OAuth authentication * @return A Response object containing the AuthResponse with access token and refresh token details */ @FormUrlEncoded @POST("/api/v1/access_token") suspend fun authenticateUser( @Header("Authorization") basicAuth: String , @Field("grant_type") grantType: String = "authorization_code", @Field("code") authCode: String, @Field("redirect_uri") redirectURI: String = "pineapple://login" ) : Response } /** * Qualifier annotation to identify the Retrofit instance for RedditTokenApi * (and differentiate it between [RedditApi] in dependency injection) */ @Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class TokenRetrofit ================================================ FILE: app/src/main/java/com/pineapple/app/network/caching/AppDatabase.kt ================================================ package com.pineapple.app.network.caching import androidx.room.Database import androidx.room.RoomDatabase import com.pineapple.app.network.caching.dao.PostDao import com.pineapple.app.network.caching.dao.RemoteKeyDao import com.pineapple.app.network.caching.dao.SubredditDao import com.pineapple.app.network.caching.dao.UserDao import com.pineapple.app.network.caching.dao.SearchResultDao import com.pineapple.app.network.caching.dao.SearchRemoteKeyDao import com.pineapple.app.network.caching.dao.CommentDao import com.pineapple.app.network.caching.entity.PostEntity import com.pineapple.app.network.caching.entity.RemoteKeyEntity import com.pineapple.app.network.caching.entity.SubredditEntity import com.pineapple.app.network.caching.entity.UserEntity import com.pineapple.app.network.caching.entity.SearchResultEntity import com.pineapple.app.network.caching.entity.SearchRemoteKeyEntity import com.pineapple.app.network.caching.entity.CommentEntity @Database( entities = [ PostEntity::class, RemoteKeyEntity::class, UserEntity::class, SubredditEntity::class, SearchResultEntity::class, SearchRemoteKeyEntity::class, CommentEntity::class ], version = 6 ) abstract class AppDatabase : RoomDatabase() { abstract fun postDao(): PostDao abstract fun remoteKeyDao(): RemoteKeyDao abstract fun userDao(): UserDao abstract fun subredditDao(): SubredditDao abstract fun searchResultDao(): SearchResultDao abstract fun searchRemoteKeyDao(): SearchRemoteKeyDao abstract fun commentDao(): CommentDao } ================================================ FILE: app/src/main/java/com/pineapple/app/network/caching/dao/CommentDao.kt ================================================ package com.pineapple.app.network.caching.dao import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import com.pineapple.app.network.caching.entity.CommentEntity import com.pineapple.app.network.model.cache.CommentWithUser import kotlinx.coroutines.flow.Flow @Dao interface CommentDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(comments: List) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(comment: CommentEntity) @Transaction @Query("SELECT * FROM comments WHERE postId = :postId ORDER BY sortKey ASC") fun pagingSourceForPost(postId: String): PagingSource @Query("SELECT MAX(sortKey) FROM comments WHERE postId = :postId") suspend fun maxSortKeyForPost(postId: String): Int? @Transaction @Query("SELECT * FROM comments WHERE id = :commentId") suspend fun getComment(commentId: String): CommentWithUser? // Replies handling: fetch replies whose parentId matches a given comment id @Transaction @Query("SELECT * FROM comments WHERE parentId = :parentId ORDER BY sortKey ASC") fun getRepliesForCommentFlow(parentId: String): Flow> @Query("SELECT COUNT(*) FROM comments WHERE parentId = :parentId") suspend fun countRepliesForComment(parentId: String): Int } ================================================ FILE: app/src/main/java/com/pineapple/app/network/caching/dao/PostDao.kt ================================================ package com.pineapple.app.network.caching.dao import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import com.pineapple.app.network.caching.entity.PostEntity import com.pineapple.app.network.model.cache.PostWithUser import kotlinx.coroutines.flow.Flow @Dao interface PostDao { @Transaction @Query("SELECT * FROM posts ORDER BY sortKey ASC") fun pagingSourceWithUser(): PagingSource // PagingSource for search results: select posts by postId from search_results for query @Transaction @Query("SELECT * FROM posts WHERE id IN (SELECT postId FROM search_results WHERE query = :q) ORDER BY (SELECT sortKey FROM search_results WHERE query = :q AND postId = posts.id) ASC") fun pagingSourceForSearchQuery(q: String): PagingSource @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(posts: List) @Query("DELETE FROM posts") suspend fun clearAll() @Query("SELECT COUNT(*) FROM posts") suspend fun countAll(): Int @Query("SELECT MAX(sortKey) FROM posts") suspend fun maxSortKey(): Int? @Query("SELECT * FROM posts WHERE id = :id LIMIT 1") suspend fun getPost(id: String): PostEntity? @Transaction @Query("SELECT * FROM posts WHERE id = :id LIMIT 1") fun getPostWithUserFlow(id: String): Flow @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(post: PostEntity) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(posts: List) } ================================================ FILE: app/src/main/java/com/pineapple/app/network/caching/dao/RemoteKeyDao.kt ================================================ package com.pineapple.app.network.caching.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.pineapple.app.network.caching.entity.RemoteKeyEntity @Dao interface RemoteKeyDao { @Query("SELECT * FROM remote_keys WHERE postId = :id") suspend fun remoteKeysPostId(id: String): RemoteKeyEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(keys: List) @Query("DELETE FROM remote_keys") suspend fun clearRemoteKeys() } ================================================ FILE: app/src/main/java/com/pineapple/app/network/caching/dao/SearchRemoteKeyDao.kt ================================================ package com.pineapple.app.network.caching.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.pineapple.app.network.caching.entity.SearchRemoteKeyEntity @Dao interface SearchRemoteKeyDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(keys: List) @Query("SELECT nextKey FROM search_remote_keys WHERE query = :q AND postId = :postId LIMIT 1") suspend fun remoteKeysPostId(q: String, postId: String): String? @Query("DELETE FROM search_remote_keys WHERE query = :q") suspend fun clearRemoteKeysForQuery(q: String) } ================================================ FILE: app/src/main/java/com/pineapple/app/network/caching/dao/SearchResultDao.kt ================================================ package com.pineapple.app.network.caching.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.pineapple.app.network.caching.entity.SearchResultEntity @Dao interface SearchResultDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(results: List) @Query("DELETE FROM search_results WHERE query = :q") suspend fun clearQuery(q: String) @Query("SELECT postId FROM search_results WHERE query = :q ORDER BY sortKey ASC") suspend fun getPostIdsForQuery(q: String): List } ================================================ FILE: app/src/main/java/com/pineapple/app/network/caching/dao/SubredditDao.kt ================================================ package com.pineapple.app.network.caching.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.pineapple.app.network.caching.entity.SubredditEntity import kotlinx.coroutines.flow.Flow @Dao interface SubredditDao { @Query("SELECT * FROM subreddits ORDER BY subscribers DESC") fun getPopularSubreddits(): Flow> @Query("SELECT * FROM subreddits WHERE isSubscribed = 1 ORDER BY name COLLATE NOCASE ASC") fun getSubscribedSubreddits(): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(subreddits: List) @Query("UPDATE subreddits SET isSubscribed = 0") suspend fun markAllUnsubscribed() } ================================================ FILE: app/src/main/java/com/pineapple/app/network/caching/dao/UserDao.kt ================================================ package com.pineapple.app.network.caching.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.pineapple.app.network.caching.entity.UserEntity @Dao interface UserDao { @Query("SELECT * FROM users WHERE name = :name") suspend fun getUser(name: String): UserEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(users: List) } ================================================ FILE: app/src/main/java/com/pineapple/app/network/caching/entity/CommentEntity.kt ================================================ package com.pineapple.app.network.caching.entity import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "comments") data class CommentEntity( @PrimaryKey val id: String, val postId: String, val parentId: String?, val author: String?, val body: String?, val bodyHtml: String?, val ups: Int?, val sortKey: Int, val depth: Int = 0, val replyCount: Int = 0, val createdUtc: Long? = null, val saved: Boolean? = null, val likes: Boolean? = null, val permalink: String? = null ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/caching/entity/PostEntity.kt ================================================ package com.pineapple.app.network.caching.entity import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "posts") data class PostEntity( @PrimaryKey val id: String, val title: String, val author: String?, val subreddit: String?, val createdUtc: Long, val ups: Int?, val thumbnail: String?, val permalink: String, val url: String?, val previewImageUrl: String?, val previewWidth: Long?, val previewHeight: Long?, val sortKey: Int, val saved: Boolean? = null, val likes: Boolean? = null, val selftext: String? = null ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/caching/entity/RemoteKeyEntity.kt ================================================ package com.pineapple.app.network.caching.entity import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "remote_keys") data class RemoteKeyEntity( @PrimaryKey val postId: String, val prevKey: String?, val nextKey: String? ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/caching/entity/SearchRemoteKeyEntity.kt ================================================ package com.pineapple.app.network.caching.entity import androidx.room.Entity @Entity( tableName = "search_remote_keys", primaryKeys = ["query", "postId"] ) data class SearchRemoteKeyEntity( val query: String, val postId: String, val prevKey: String?, val nextKey: String? ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/caching/entity/SearchResultEntity.kt ================================================ package com.pineapple.app.network.caching.entity import androidx.room.Entity @Entity( tableName = "search_results", primaryKeys = ["query", "postId"] ) data class SearchResultEntity( val query: String, val postId: String, val sortKey: Int ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/caching/entity/SubredditEntity.kt ================================================ package com.pineapple.app.network.caching.entity import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "subreddits") data class SubredditEntity( @PrimaryKey val id: String, // "t5_xxx" or short id val name: String, // "androiddev" val title: String, val iconUrl: String, val subscribers: Long, val isNsfw: Boolean, val isSubscribed: Boolean ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/caching/entity/UserEntity.kt ================================================ package com.pineapple.app.network.caching.entity import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "users") data class UserEntity( @PrimaryKey val name: String, val iconUrl: String?, val snoovatarUrl: String? ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/interceptor/AuthInterceptor.kt ================================================ package com.pineapple.app.network.interceptor import com.pineapple.app.network.repository.RedditAuthRepository import com.pineapple.app.network.repository.RedditRepository import com.pineapple.app.network.repository.USER_AGENT import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Response /** * Injects our auth token into all requests, handling token refresh and validity * checks as needed so callers do not need to */ class AuthInterceptor(private val repository: RedditAuthRepository) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val original = chain.request() // Skip auth for token endpoint if (original.url.host == "www.reddit.com" && original.url.encodedPath.startsWith("/api/v1/access_token") ) { val tokenReq = original.newBuilder() .header("User-Agent", USER_AGENT) .build() return chain.proceed(tokenReq) } val authHeader = runBlocking { repository.ensureValidToken() repository.authorizationHeaderOrNull() } val newReqBuilder = original.newBuilder() .header("User-Agent", USER_AGENT) if (!authHeader.isNullOrBlank()) { newReqBuilder.header("Authorization", authHeader) } return chain.proceed(newReqBuilder.build()) } } ================================================ FILE: app/src/main/java/com/pineapple/app/network/interceptor/TokenUserAgentInterceptor.kt ================================================ package com.pineapple.app.network.interceptor import com.pineapple.app.network.repository.USER_AGENT import okhttp3.Interceptor import okhttp3.Response /** * Injects the User-Agent header into each request */ class TokenUserAgentInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val req = chain.request().newBuilder() .header("User-Agent", USER_AGENT) .build() return chain.proceed(req) } } ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/auth/AuthResponse.kt ================================================ package com.pineapple.app.network.model.auth import com.google.gson.annotations.SerializedName data class AuthResponse( @SerializedName("access_token") var accessToken: String, @SerializedName("token_type") var tokenType: String, @SerializedName("expires_in") var expires: Long, @SerializedName("scope") var scope: String, @SerializedName("refresh_token") var refreshToken: String? = null ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/cache/CommentWithUser.kt ================================================ package com.pineapple.app.network.model.cache import androidx.room.Embedded import androidx.room.Relation import com.pineapple.app.network.caching.entity.CommentEntity import com.pineapple.app.network.caching.entity.UserEntity data class CommentWithUser( @Embedded val comment: CommentEntity, @Relation( parentColumn = "author", entityColumn = "name" ) val user: UserEntity? ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/cache/PostwithUser.kt ================================================ package com.pineapple.app.network.model.cache import androidx.room.Embedded import androidx.room.Relation import com.pineapple.app.network.caching.entity.PostEntity import com.pineapple.app.network.caching.entity.UserEntity data class PostWithUser( @Embedded val post: PostEntity, @Relation( parentColumn = "author", entityColumn = "name" ) val user: UserEntity? ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/AboutAccount.kt ================================================ package com.pineapple.app.network.model.reddit data class AboutAccount( val subreddit: UserSubredditData, val snoovatar_img: String ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/AllAwarding.kt ================================================ package com.pineapple.app.network.model.reddit import com.google.gson.annotations.SerializedName data class AllAwarding( @SerializedName("giver_coin_reward") val giverCoinReward: Long? = null, @SerializedName("subreddit_id") val subredditID: Any? = null, @SerializedName("is_new") val isNew: Boolean, @SerializedName("days_of_drip_extension") val daysOfDripExtension: Long, @SerializedName("coin_price") val coinPrice: Long, val id: String, @SerializedName("penny_donate") val pennyDonate: Long? = null, @SerializedName("award_sub_type") val awardSubType: String, @SerializedName("coin_reward") val coinReward: Long, @SerializedName("icon_url") val iconURL: String, @SerializedName("days_of_premium") val daysOfPremium: Long, @SerializedName("tiers_by_required_awardings") val tiersByRequiredAwardings: Any? = null, @SerializedName("resized_icons") val resizedIcons: List, @SerializedName("icon_width") val iconWidth: Long, @SerializedName("static_icon_width") val staticIconWidth: Long, @SerializedName("start_date") val startDate: Any? = null, @SerializedName("is_enabled") val isEnabled: Boolean, @SerializedName("awardings_required_to_grant_benefits") val awardingsRequiredToGrantBenefits: Any? = null, val description: String, @SerializedName("end_date") val endDate: Any? = null, @SerializedName("subreddit_coin_reward") val subredditCoinReward: Long, val count: Long, @SerializedName("static_icon_height") val staticIconHeight: Long, val name: String, @SerializedName("resized_static_icons") val resizedStaticIcons: List, @SerializedName("icon_format") val iconFormat: String? = null, @SerializedName("icon_height") val iconHeight: Long, @SerializedName("penny_price") val pennyPrice: Long? = null, @SerializedName("award_type") val awardType: String, @SerializedName("static_icon_url") val staticIconURL: String ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/CommentData.kt ================================================ package com.pineapple.app.network.model.reddit import com.google.gson.JsonElement data class CommentPreData( var kind: String, var data: CommentData ) data class CommentData( var author: String?, var subreddit: String?, var id: String, var ups: Long?, var body: String?, var body_html: String, var permalink: String, var replies: JsonElement? = null, var created_utc: Double? = null, var saved: Boolean? = null, var likes: Boolean? = null ) data class CommentDataNull( var author: String, var subreddit: String, var id: String, var ups: Long, var body: String?, var body_html: String, var permalink: String, var link_title: String? = null ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/CommentListing.kt ================================================ package com.pineapple.app.network.model.reddit data class CommentListing( var kind: String, var data: ListingItem ) data class CommentListingNull( var kind: String, var data: ListingItem ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/CondensedUserAbout.kt ================================================ package com.pineapple.app.network.model.reddit data class CondensedUserAboutListing( var kind: String, var data: CondensedUserAbout ) data class CondensedUserAbout( var id: String, var snoovatar_img: String?, var icon_img: String?, var name: String?, var display_name_prefixed: String, var is_gold: Boolean, var total_karma: Long, var awardee_karma: Long, var link_karma: Long, var awarder_karma: Long, var comment_karma: Long, var has_verified_email: Boolean, var accept_chats: Boolean, var created_utc: Long, var accept_followers: Boolean, var accept_pms: Boolean, var verified: Boolean ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/FlairRichItem.kt ================================================ package com.pineapple.app.network.model.reddit data class FlairRichItem( var a: String?, var e: String, var u: String?, var t: String? ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/Gildings.kt ================================================ package com.pineapple.app.network.model.reddit import com.google.gson.annotations.SerializedName data class Gildings ( @SerializedName("gid_1") val gid1: Long ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/Image.kt ================================================ package com.pineapple.app.network.model.reddit data class Image ( val source: ResizedIcon, val resolutions: ArrayList ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/Listing.kt ================================================ package com.pineapple.app.network.model.reddit data class Listing( val kind: String, val data: ListingItem ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/ListingBase.kt ================================================ package com.pineapple.app.network.model.reddit data class ListingBase( var kind: String, var data: ListingItem ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/ListingItem.kt ================================================ package com.pineapple.app.network.model.reddit data class ListingItem( var after: String, var before: String, var dist: Int, var modhash: String, var children: List ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/PostData.kt ================================================ package com.pineapple.app.network.model.reddit import com.google.gson.JsonObject import com.google.gson.annotations.SerializedName typealias MediaEmbed = JsonObject data class PostData( @SerializedName("approved_at_utc") val approvedAtUTC: Any? = null, val subreddit: String? = null, val selftext: String? = null, @SerializedName("author_fullname") val authorFullname: String? = null, val saved: Boolean? = null, @SerializedName("mod_reason_title") val modReasonTitle: Any? = null, val gilded: Long? = null, val clicked: Boolean? = null, val title: String? = null, @SerializedName("link_flair_richtext") val linkFlairRichtext: List? = null, @SerializedName("subreddit_name_prefixed") val subredditNamePrefixed: String? = null, val hidden: Boolean? = null, val pwls: Long? = null, @SerializedName("link_flair_css_class") val linkFlairCSSClass: String? = null, val downs: Long? = null, @SerializedName("thumbnail_height") val thumbnailHeight: Long? = null, @SerializedName("top_awarded_type") val topAwardedType: Any? = null, @SerializedName("hide_score") val hideScore: Boolean? = null, val name: String? = null, val quarantine: Boolean? = null, @SerializedName("link_flair_text_color") val linkFlairTextColor: String? = null, @SerializedName("upvote_ratio") val upvoteRatio: Double? = null, @SerializedName("author_flair_background_color") val authorFlairBackgroundColor: Any? = null, @SerializedName("subreddit_type") val subredditType: String? = null, val ups: Long? = null, @SerializedName("total_awards_received") val totalAwardsReceived: Long? = null, @SerializedName("media_embed") val mediaEmbed: MediaEmbed? = null, @SerializedName("thumbnail_width") val thumbnailWidth: Long? = null, @SerializedName("author_flair_template_id") val authorFlairTemplateID: Any? = null, @SerializedName("is_original_content") val isOriginalContent: Boolean? = null, @SerializedName("user_reports") val userReports: List? = null, @SerializedName("secure_media") val secureMedia: SecureMedia? = null, @SerializedName("is_reddit_media_domain") val isRedditMediaDomain: Boolean? = null, @SerializedName("is_meta") val isMeta: Boolean? = null, val category: Any? = null, @SerializedName("secure_media_embed") val secureMediaEmbed: MediaEmbed? = null, @SerializedName("link_flair_text") val linkFlairText: String? = null, @SerializedName("can_mod_post") val canModPost: Boolean? = null, val score: Long? = null, @SerializedName("approved_by") val approvedBy: Any? = null, @SerializedName("is_created_from_ads_ui") val isCreatedFromAdsUI: Boolean? = null, @SerializedName("author_premium") val authorPremium: Boolean? = null, val thumbnail: String? = null, val edited: Any? = null, @SerializedName("author_flair_css_class") val authorFlairCSSClass: Any? = null, @SerializedName("author_flair_richtext") val authorFlairRichtext: List? = null, val gildings: Gildings? = null, @SerializedName("post_hint") val postHint: String? = null, @SerializedName("content_categories") val contentCategories: Any? = null, @SerializedName("is_self") val isSelf: Boolean? = null, @SerializedName("mod_note") val modNote: Any? = null, val created: Long? = null, @SerializedName("link_flair_type") val linkFlairType: String? = null, val wls: Long? = null, @SerializedName("removed_by_category") val removedByCategory: Any? = null, @SerializedName("banned_by") val bannedBy: Any? = null, @SerializedName("author_flair_type") val authorFlairType: String? = null, val domain: String? = null, @SerializedName("allow_live_comments") val allowLiveComments: Boolean? = null, @SerializedName("selftext_html") val selftextHTML: Any? = null, val likes: Boolean? = null, @SerializedName("suggested_sort") val suggestedSort: Any? = null, @SerializedName("banned_at_utc") val bannedAtUTC: Any? = null, @SerializedName("url_overridden_by_dest") val urlOverriddenByDest: String? = null, @SerializedName("view_count") val viewCount: Any? = null, val archived: Boolean? = null, @SerializedName("no_follow") val noFollow: Boolean? = null, @SerializedName("is_crosspostable") val isCrosspostable: Boolean? = null, val pinned: Boolean? = null, @SerializedName("over_18") val over18: Boolean? = null, val preview: Preview? = null, @SerializedName("all_awardings") val allAwardings: List? = null, val awarders: List? = null, @SerializedName("media_only") val mediaOnly: Boolean? = null, @SerializedName("can_gild") val canGild: Boolean? = null, val spoiler: Boolean? = null, val locked: Boolean? = null, @SerializedName("author_flair_text") val authorFlairText: Any? = null, @SerializedName("treatment_tags") val treatmentTags: List? = null, val visited: Boolean? = null, @SerializedName("removed_by") val removedBy: Any? = null, @SerializedName("num_reports") val numReports: Any? = null, val distinguished: Any? = null, @SerializedName("subreddit_id") val subredditID: String? = null, @SerializedName("author_is_blocked") val authorIsBlocked: Boolean? = null, @SerializedName("mod_reason_by") val modReasonBy: Any? = null, @SerializedName("removal_reason") val removalReason: Any? = null, @SerializedName("link_flair_background_color") val linkFlairBackgroundColor: String? = null, val id: String? = null, @SerializedName("is_robot_indexable") val isRobotIndexable: Boolean? = null, @SerializedName("report_reasons") val reportReasons: Any? = null, val author: String? = null, @SerializedName("discussion_type") val discussionType: Any? = null, @SerializedName("num_comments") val numComments: Long? = null, @SerializedName("send_replies") val sendReplies: Boolean? = null, @SerializedName("whitelist_status") val whitelistStatus: String? = null, @SerializedName("contest_mode") val contestMode: Boolean? = null, @SerializedName("mod_reports") val modReports: List? = null, @SerializedName("author_patreon_flair") val authorPatreonFlair: Boolean? = null, @SerializedName("author_flair_text_color") val authorFlairTextColor: Any? = null, val permalink: String? = null, @SerializedName("parent_whitelist_status") val parentWhitelistStatus: String? = null, val stickied: Boolean? = null, val url: String? = null, @SerializedName("subreddit_subscribers") val subredditSubscribers: Long? = null, @SerializedName("created_utc") val createdUTC: Long? = null, @SerializedName("num_crossposts") val numCrossposts: Long? = null, val media: Any? = null, @SerializedName("is_video") val isVideo: Boolean? = null ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/PostItem.kt ================================================ package com.pineapple.app.network.model.reddit data class PostItem( var kind: String, var data: PostData ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/PostListing.kt ================================================ package com.pineapple.app.network.model.reddit data class PostListing( var kind: String, var data: ListingItem ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/Preview.kt ================================================ package com.pineapple.app.network.model.reddit data class Preview( val images: ArrayList? ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/ResizedIcon.kt ================================================ package com.pineapple.app.network.model.reddit data class ResizedIcon ( val url: String, val width: Long, val height: Long ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/SecureMedia.kt ================================================ package com.pineapple.app.network.model.reddit data class SecureMedia( var reddit_video: RedditVideo ) data class RedditVideo( var bitrate_kbps: Long, var fallback_url: String, var height: Long, var width: Long, var hls_url: String, var is_gif: Boolean ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/SubredditData.kt ================================================ package com.pineapple.app.network.model.reddit import com.google.gson.annotations.SerializedName data class SubredditData( val title: String, @SerializedName("display_name") val displayName: String, @SerializedName("display_name_prefixed") val displayNamePrefixed: String, @SerializedName("description_html") val descriptionHtml: String, val description: String, val created: Long, val over18: Boolean, val url: String, @SerializedName("community_icon") val iconUrl: String, val subscribers: Long, val public_description: String ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/SubredditInfo.kt ================================================ package com.pineapple.app.network.model.reddit data class SubredditInfo( var data: SubredditData ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/SubredditItem.kt ================================================ package com.pineapple.app.network.model.reddit data class SubredditItem( val kind: String, val data: SubredditData ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/UserAbout.kt ================================================ package com.pineapple.app.network.model.reddit data class UserAboutListing( var kind: String, var data: UserAbout ) data class UserAbout( var id: String? = null, var snoovatar_img: String? = null, var icon_img: String? = null, var name: String? = null, var subreddit: UserSubredditData? = null, var is_gold: Boolean? = null, var total_karma: Long? = null, var awardee_karma: Long? = null, var link_karma: Long? = null, var awarder_karma: Long? = null, var comment_karma: Long? = null, var has_verified_email: Boolean? = null, var accept_chats: Boolean? = null, var created_utc: Long? = null, var accept_followers: Boolean? = null, var accept_pms: Boolean? = null, var verified: Boolean? = null ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/model/reddit/UserSubredditData.kt ================================================ package com.pineapple.app.network.model.reddit data class UserSubredditData( var banner_img: String, var display_name: String, var over_18: Boolean, var icon_img: String, var public_description: String, var subreddit_type: String, var user_is_subscriber: Boolean, var display_name_prefixed: String, var is_default_icon: Boolean ) ================================================ FILE: app/src/main/java/com/pineapple/app/network/paging/CommentsRemoteMediator.kt ================================================ package com.pineapple.app.network.paging import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import androidx.room.withTransaction import com.google.gson.Gson import com.google.gson.JsonObject import com.google.gson.reflect.TypeToken import com.pineapple.app.network.api.RedditApi import com.pineapple.app.network.caching.AppDatabase import com.pineapple.app.network.caching.entity.CommentEntity import com.pineapple.app.network.caching.entity.UserEntity import com.pineapple.app.network.model.cache.CommentWithUser import com.pineapple.app.network.model.reddit.CommentPreData import com.pineapple.app.network.model.reddit.Listing import java.util.concurrent.atomic.AtomicInteger @OptIn(ExperimentalPagingApi::class) class CommentsRemoteMediator( private val redditApi: RedditApi, private val db: AppDatabase, private val postId: String ) : RemoteMediator() { private val gson = Gson() override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult { return try { // For comments we generally only support REFRESH loads (comments are hierarchical) if (loadType == LoadType.PREPEND) { return MediatorResult.Success(endOfPaginationReached = true) } val response = redditApi.fetchCommentsByPostId(postId) // The first listing is the post itself (t3), the second is the comments (t1) val commentsListing = response.getOrNull(1) ?: return MediatorResult.Success(endOfPaginationReached = true) // We expect the second item to be a Listing val children = commentsListing.data.children val startIndex = db.commentDao().maxSortKeyForPost(postId)?.plus(1) ?: 0 val sortKeyCounter = AtomicInteger(startIndex) val out = mutableListOf() // IMPORTANT: Start depth at 0. Root comments are Depth 0. processComments(children, postId, null, 0, out, sortKeyCounter) db.withTransaction { if (out.isNotEmpty()) db.commentDao().upsertAll(out) // Insert placeholder users for authors we don't have locally to avoid blocking UI val authorNames = out.mapNotNull { it.author }.distinct() val existingUsers = authorNames.mapNotNull { db.userDao().getUser(it) }.associateBy { it.name } val missing = authorNames.filter { it !in existingUsers } if (missing.isNotEmpty()) { val placeholders = missing.map { name -> UserEntity(name = name, iconUrl = "", snoovatarUrl = "") } db.userDao().insertAll(placeholders) } } MediatorResult.Success(endOfPaginationReached = true) } catch (e: Exception) { MediatorResult.Error(e) } } private fun processComments( children: List, postId: String, parentId: String?, depth: Int, out: MutableList, sortKeyCounter: AtomicInteger ) { for (child in children) { if (child.kind == "t1") { val d = child.data val id = d.id if (id.isEmpty()) continue val entityId = "t1_$id" val author = d.author val body = d.body val bodyHtml = d.body_html val ups = try { d.ups?.toInt() ?: 0 } catch (_: Throwable) { 0 } val created = d.created_utc?.toLong() val saved = d.saved val likes = d.likes val permalink = d.permalink val sortKey = sortKeyCounter.getAndIncrement() // Parse replies to calculate count and recurse var replyChildren: List = emptyList() d.replies?.let { repliesElement -> if (repliesElement is JsonObject) { try { val type = object : TypeToken>() {}.type val listing = gson.fromJson>(repliesElement, type) replyChildren = listing.data.children } catch (e: Exception) { // Ignore parsing errors for replies } } } val replyCount = replyChildren.count { it.kind == "t1" } out.add( CommentEntity( id = entityId, postId = postId, parentId = parentId, author = author, body = body, bodyHtml = bodyHtml, ups = ups, sortKey = sortKey, depth = depth, replyCount = replyCount, createdUtc = created, saved = saved, likes = likes, permalink = permalink ) ) if (replyChildren.isNotEmpty()) { // Recurse: Ensure we increment depth processComments(replyChildren, postId, entityId, depth + 1, out, sortKeyCounter) } } } } } ================================================ FILE: app/src/main/java/com/pineapple/app/network/paging/PagingRepository.kt ================================================ package com.pineapple.app.network.paging import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import com.pineapple.app.network.api.RedditApi import com.pineapple.app.network.caching.AppDatabase import com.pineapple.app.network.model.cache.PostWithUser import com.pineapple.app.network.model.cache.CommentWithUser import javax.inject.Inject @OptIn(ExperimentalPagingApi::class) class PagingRepository @Inject constructor( private val db: AppDatabase, private val redditApi: RedditApi ) { fun postsPager( subreddit: String, sort: String, time: String ): Pager { return Pager( config = PagingConfig( pageSize = 25, enablePlaceholders = false ), remoteMediator = PostsRemoteMediator( redditApi = redditApi, db = db, subreddit = subreddit, sort = sort, time = time ), pagingSourceFactory = { db.postDao().pagingSourceWithUser() } ) } fun searchPostsPager(query: String, sort: String? = null, time: String? = null): Pager { return Pager( config = PagingConfig( pageSize = 25, enablePlaceholders = false ), remoteMediator = SearchRemoteMediator( redditApi = redditApi, db = db, query = query, sort = sort, time = time ), pagingSourceFactory = { db.postDao().pagingSourceForSearchQuery(query) } ) } fun commentsPager(postId: String): Pager { return Pager( config = PagingConfig( pageSize = 25, enablePlaceholders = false ), remoteMediator = CommentsRemoteMediator( redditApi = redditApi, db = db, postId = postId ), pagingSourceFactory = { db.commentDao().pagingSourceForPost(postId) } ) } } ================================================ FILE: app/src/main/java/com/pineapple/app/network/paging/PostsRemoteMediator.kt ================================================ package com.pineapple.app.network.paging import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import androidx.room.withTransaction import com.pineapple.app.network.api.RedditApi import com.pineapple.app.network.caching.AppDatabase import com.pineapple.app.network.caching.entity.PostEntity import com.pineapple.app.network.caching.entity.RemoteKeyEntity import com.pineapple.app.network.caching.entity.UserEntity import com.pineapple.app.network.model.cache.PostWithUser @OptIn(ExperimentalPagingApi::class) class PostsRemoteMediator( private val redditApi: RedditApi, private val db: AppDatabase, private val subreddit: String, private val sort: String, private val time: String ) : RemoteMediator() { override suspend fun load( loadType: LoadType, state: PagingState ): MediatorResult { return try { val pageKeyData = when (loadType) { LoadType.REFRESH -> null LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true) LoadType.APPEND -> { val lastItem = state.lastItemOrNull() if (lastItem == null) { null } else { db.remoteKeyDao().remoteKeysPostId(lastItem.post.id)?.nextKey } } } val response = redditApi.fetchSubreddit( name = subreddit, sort = sort, time = time, after = pageKeyData, rawJson = 1, limit = state.config.pageSize ) val posts = response.data.children val endOfPaginationReached = posts.isEmpty() val startIndex = when (loadType) { LoadType.REFRESH -> 0 LoadType.APPEND -> { db.postDao().maxSortKey() ?: 0 } else -> 0 } val entities = posts.mapIndexed { index, item -> val d = item.data val apiIndex = startIndex + index val source = d.preview?.images?.firstOrNull()?.source val previewUrl = source?.url?.replace("amp;", "") val previewWidth = source?.width val previewHeight = source?.height val normalizedId = d.name ?: "t3_${d.id}" PostEntity( id = normalizedId, title = d.title.orEmpty(), author = d.author, subreddit = d.subreddit, createdUtc = d.createdUTC ?: 0L, ups = d.ups?.toInt(), thumbnail = d.thumbnail, permalink = d.permalink.orEmpty(), url = d.url, previewImageUrl = previewUrl, previewWidth = previewWidth, previewHeight = previewHeight, sortKey = apiIndex, saved = d.saved, likes = d.likes, selftext = d.selftext ) } val after = response.data.after val keys = entities.map { RemoteKeyEntity( postId = it.id, prevKey = null, nextKey = after ) } val authorNames = entities.mapNotNull { it.author }.distinct() db.withTransaction { if (loadType == LoadType.REFRESH) { db.remoteKeyDao().clearRemoteKeys() db.postDao().clearAll() // optional: db.userDao().clearAll() if you want wipe } db.postDao().insertAll(entities) db.remoteKeyDao().insertAll(keys) val existingUsers = authorNames.mapNotNull { db.userDao().getUser(it) } .associateBy { it.name } val missingAuthors = authorNames.filter { it !in existingUsers } val newUsers = missingAuthors.mapNotNull { author -> try { val about = redditApi.fetchUserInfo(author) UserEntity( name = about.data.name.toString(), iconUrl = about.data.icon_img, snoovatarUrl = about.data.snoovatar_img ) } catch (_: Exception) { null } } if (newUsers.isNotEmpty()) { db.userDao().insertAll(newUsers) } } MediatorResult.Success(endOfPaginationReached = endOfPaginationReached) } catch (e: Exception) { MediatorResult.Error(e) } } } ================================================ FILE: app/src/main/java/com/pineapple/app/network/paging/SearchRemoteMediator.kt ================================================ package com.pineapple.app.network.paging import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import androidx.room.withTransaction import com.pineapple.app.network.api.RedditApi import com.pineapple.app.network.caching.AppDatabase import com.pineapple.app.network.caching.entity.PostEntity import com.pineapple.app.network.caching.entity.RemoteKeyEntity import com.pineapple.app.network.caching.entity.SearchRemoteKeyEntity import com.pineapple.app.network.caching.entity.SearchResultEntity import com.pineapple.app.network.caching.entity.UserEntity import com.pineapple.app.network.model.cache.PostWithUser private const val SEARCH_SORT_OFFSET = 1_000_000 @OptIn(ExperimentalPagingApi::class) class SearchRemoteMediator( private val redditApi: RedditApi, private val db: AppDatabase, private val query: String, private val sort: String?, private val time: String? ) : RemoteMediator() { override suspend fun load( loadType: LoadType, state: PagingState ): MediatorResult { return try { val pageKeyData = when (loadType) { LoadType.REFRESH -> null LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true) LoadType.APPEND -> { val lastItem = state.lastItemOrNull() if (lastItem == null) null else db.searchRemoteKeyDao().remoteKeysPostId(query, lastItem.post.id) } } val response = redditApi.searchPosts( query = query, sort = sort, time = time, after = pageKeyData, rawJson = 1, limit = state.config.pageSize ) val posts = response.data.children val endOfPaginationReached = posts.isEmpty() val startIndex = when (loadType) { LoadType.REFRESH -> 0 LoadType.APPEND -> { // continue from max sortKey for search (relative) db.searchResultDao().getPostIdsForQuery(query).size } else -> 0 } val entities = posts.mapIndexed { index, item -> val d = item.data val apiIndex = startIndex + index val source = d.preview?.images?.firstOrNull()?.source val previewUrl = source?.url?.replace("amp;", "") // offset the sortKey so search-inserted posts don't collide with home feed ordering val postSortKey = apiIndex + SEARCH_SORT_OFFSET val normalizedId = d.name ?: "t3_${d.id}" PostEntity( id = normalizedId, title = d.title.orEmpty(), author = d.author, subreddit = d.subreddit, createdUtc = d.createdUTC ?: 0L, ups = d.ups?.toInt(), thumbnail = d.thumbnail, permalink = d.permalink.orEmpty(), url = d.url, previewImageUrl = previewUrl, previewWidth = source?.width, previewHeight = source?.height, sortKey = postSortKey, saved = d.saved, likes = d.likes, selftext = d.selftext ) } val after = response.data.after val keys = entities.map { SearchRemoteKeyEntity(query = query, postId = it.id, prevKey = null, nextKey = after) } val authorNames = entities.mapNotNull { it.author }.distinct() db.withTransaction { if (loadType == LoadType.REFRESH) { // clear only search-specific tables to avoid wiping main feed cache db.searchRemoteKeyDao().clearRemoteKeysForQuery(query) db.searchResultDao().clearQuery(query) } db.postDao().insertAll(entities) // insert mapping rows for this query so we can page search results separately val mappings = entities.mapIndexed { index, post -> SearchResultEntity(query = query, postId = post.id, sortKey = index + startIndex) } db.searchResultDao().insertAll(mappings) db.searchRemoteKeyDao().insertAll(keys) val existingUsers = authorNames.mapNotNull { db.userDao().getUser(it) } .associateBy { it.name } val missingAuthors = authorNames.filter { it !in existingUsers } val newUsers = missingAuthors.mapNotNull { author -> try { val about = redditApi.fetchUserInfo(author) UserEntity(name = about.data.name.toString(), iconUrl = about.data.icon_img, snoovatarUrl = about.data.snoovatar_img) } catch (_: Exception) { null } } if (newUsers.isNotEmpty()) db.userDao().insertAll(newUsers) } MediatorResult.Success(endOfPaginationReached = endOfPaginationReached) } catch (e: Exception) { MediatorResult.Error(e) } } } ================================================ FILE: app/src/main/java/com/pineapple/app/network/repository/RedditAuthRepository.kt ================================================ package com.pineapple.app.network.repository import com.pineapple.app.consts.MMKVKey import com.pineapple.app.network.api.RedditApi import com.pineapple.app.network.api.RedditTokenApi import com.pineapple.app.network.caching.AppDatabase import com.tencent.mmkv.MMKV import okhttp3.Credentials import javax.inject.Inject import javax.inject.Singleton const val USER_AGENT = "android:com.pineapple.app:v1.0-beta (TEST)" @Singleton class RedditAuthRepository @Inject constructor( private val tokenApi: RedditTokenApi, private val mmkv: MMKV, ) { private var _accessToken: String? = null private var _refreshToken: String? = null private var _storedClientId: String? = null private var _tokenType = "bearer" val accessToken: String? get() = _accessToken val clientId: String? get() = _storedClientId val isUserless: Boolean get() = mmkv.decodeBool(MMKVKey.USER_GUEST, true) val isAuthenticated: Boolean get() = _accessToken != null && !isTokenExpired() init { loadStoredTokens() if (isTokenExpired()) { _accessToken = null _refreshToken = null } } /** * Ensures we have a valid access token in memory/storage. * The interceptor will read [accessToken] and prepend the type. */ suspend fun ensureValidToken(clientId: String? = _storedClientId) { _storedClientId = clientId ?: _storedClientId if (_accessToken.isNullOrBlank() || isTokenExpired()) { if (_refreshToken != null && !isUserless) { refreshAccessToken() } else if (!isUserless || mmkv.decodeString(MMKVKey.API_LOGIN_AUTH_CODE) ?.isNotBlank() == true ) { authenticateUser() } else { authenticateUserless() } } } /** * Get an access token if we have gotten an auth code from Reddit OAuth login flow */ suspend fun authenticateUser() { val authCode = mmkv.decodeString(MMKVKey.API_LOGIN_AUTH_CODE) ?: throw Exception("No auth code available for user login") val response = tokenApi.authenticateUser( basicAuth = Credentials.basic(_storedClientId!!, ""), authCode = authCode ) if (response.isSuccessful) { val auth = response.body()!! saveTokens(auth.accessToken, auth.refreshToken, auth.expires, auth.tokenType) mmkv.encode(MMKVKey.USER_GUEST, false) } else { throw Exception("User auth failed: ${response.message()}") } } /** * Get an access token without logging in with Reddit */ suspend fun authenticateUserless( clientId: String? = _storedClientId, testingClientID: Boolean = false ) { _storedClientId = clientId val response = tokenApi.authenticateUserless( basicAuth = Credentials.basic(_storedClientId!!, "") ) if (response.isSuccessful) { if (!testingClientID) { val auth = response.body()!! saveTokens(auth.accessToken, auth.refreshToken, auth.expires, auth.tokenType) } } else { throw Exception("Userless auth failed: ${response.message()}") } } /** * Using a previously obtained refresh token, get a new access token that is valid */ private suspend fun refreshAccessToken() { val response = tokenApi.refreshAccessToken( basicAuth = Credentials.basic(_storedClientId!!, ""), refreshToken = _refreshToken!! ) if (response.isSuccessful) { val auth = response.body()!! saveTokens(auth.accessToken, auth.refreshToken, auth.expires, auth.tokenType) } else { _refreshToken = null authenticateUserless() } } /** * Update the MMKV table with our most up to date token and authentication information */ private fun saveTokens( accessToken: String, refreshToken: String?, expiresIn: Long, tokenType: String? ) { _accessToken = accessToken _refreshToken = refreshToken _tokenType = tokenType ?: "bearer" mmkv.encode(MMKVKey.ACCESS_TOKEN, accessToken) mmkv.encode(MMKVKey.REFRESH_TOKEN, refreshToken) mmkv.encode(MMKVKey.TOKEN_EXPIRES, System.currentTimeMillis() + expiresIn * 1000) mmkv.encode(MMKVKey.CLIENT_ID, _storedClientId) mmkv.encode(MMKVKey.TOKEN_TYPE, _tokenType) } /** * Load any stored tokens from MMKV into memory */ private fun loadStoredTokens() { _accessToken = mmkv.decodeString(MMKVKey.ACCESS_TOKEN) _refreshToken = mmkv.decodeString(MMKVKey.REFRESH_TOKEN) _storedClientId = mmkv.decodeString(MMKVKey.CLIENT_ID) _tokenType = mmkv.decodeString(MMKVKey.TOKEN_TYPE, "bearer") ?: "bearer" } /** * Check the time that our access token expires against the current time to determine * if we need to request a new one or refresh it */ private fun isTokenExpired(): Boolean { return System.currentTimeMillis() > mmkv.decodeLong(MMKVKey.TOKEN_EXPIRES, 0) } /** * Optional helper if the interceptor wants the full header value. */ fun authorizationHeaderOrNull(): String? = _accessToken?.let { "$_tokenType $it" } } ================================================ FILE: app/src/main/java/com/pineapple/app/network/repository/RedditRepository.kt ================================================ package com.pineapple.app.network.repository import com.pineapple.app.network.api.RedditApi import com.pineapple.app.network.caching.AppDatabase import com.pineapple.app.network.caching.entity.CommentEntity import com.pineapple.app.network.caching.entity.PostEntity import com.pineapple.app.network.caching.entity.SubredditEntity import com.pineapple.app.network.caching.entity.UserEntity import com.pineapple.app.network.model.cache.PostWithUser import com.pineapple.app.network.model.reddit.SubredditData import com.pineapple.app.network.model.reddit.UserAbout import com.pineapple.app.network.model.reddit.CommentPreData import com.pineapple.app.utilities.toSubredditEntity import com.tencent.mmkv.MMKV import kotlinx.coroutines.flow.Flow import javax.inject.Inject import javax.inject.Singleton import com.google.gson.JsonElement import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.pineapple.app.network.model.reddit.Listing import com.google.gson.JsonObject @Singleton class RedditRepository @Inject constructor( private val redditApi: RedditApi, private val mmkv: MMKV, db: AppDatabase ) { private val subredditDao = db.subredditDao() private val postDao = db.postDao() private val userDao = db.userDao() private val commentDao = db.commentDao() private val gson = Gson() fun observePostWithUser(postId: String): Flow = postDao.getPostWithUserFlow("t3_$postId") /** * Refresh replies for a given comment by fetching the post's comments and extracting replies * that belong to the supplied commentId. Inserts reply CommentEntity rows with parentId set. * Returns the number of replies parsed/inserted, or -1 on error. */ suspend fun refreshRepliesForComment(postId: String, commentId: String): Int = withContext(Dispatchers.IO) { try { val response = redditApi.fetchCommentsByPostId(postId) // defensively extract children list from the second element of the response val commentChildren = run { val second = response.getOrNull(1) when (second) { is com.pineapple.app.network.model.reddit.Listing<*> -> (second.data.children as? List<*>) ?: emptyList() is Map<*, *> -> ((second["data"] as? Map<*, *>)?.get("children") as? List<*>) ?: emptyList() else -> emptyList() } } // compute a starting sortKey once to avoid suspending calls during traversal var sortCounter = commentDao.maxSortKeyForPost(postId)?.plus(1) ?: 0 // traverse the commentChildren and collect all nested replies that are direct children of the desired comment val out = mutableListOf() fun traverseAndCollect(item: Any?, parentFullname: String?) { if (item == null) return when (item) { is com.pineapple.app.network.model.reddit.ListingItem<*> -> { val inner = item.children inner.forEach { cpAny -> traverseAndCollect(cpAny, parentFullname) } } is CommentPreData -> { val d = item.data val thisFull = "t1_${d.id}" // Process nested replies if present var replyChildren: List = emptyList() d.replies?.let { je -> if (je.isJsonObject) { try { val type = object : TypeToken>() {}.type val listing = gson.fromJson>(je, type) replyChildren = listing.data.children replyChildren.forEach { traverseAndCollect(it, thisFull) } } catch (e: Exception) { // ignore } } } } is Map<*, *> -> { val data = item["data"] as? Map<*, *> if (data == null) return val id = data["id"] as? String ?: return val thisFull = "t1_$id" val parentIdRaw = data["parent_id"] as? String val parentFull = parentIdRaw // Parse replies to calculate count var replyCount = 0 val repliesAny = data["replies"] if (repliesAny is Map<*, *>) { val rdata = (repliesAny["data"] as? Map<*, *>)?.get("children") as? List<*> replyCount = rdata?.size ?: 0 } // if the parent matches the target comment's fullname (t1_$commentId), collect this as a reply if (parentFull == "t1_$commentId") { val author = data["author"] as? String val body = data["body"] as? String val bodyHtml = data["body_html"] as? String val ups = try { ((data["ups"] as? Number)?.toLong() ?: 0L).toInt() } catch (_: Throwable) { 0 } val created = (data["created_utc"] as? Number)?.toLong() val saved = data["saved"] as? Boolean val likes = data["likes"] as? Boolean val permalink = data["permalink"] as? String val sortKey = sortCounter++ out.add( CommentEntity( id = thisFull, postId = postId, parentId = "t1_$commentId", author = author, body = body, bodyHtml = bodyHtml, ups = ups, sortKey = sortKey, depth = 1, replyCount = replyCount, createdUtc = created, saved = saved, likes = likes, permalink = permalink ) ) } // traverse nested replies if present if (repliesAny is Map<*, *>) { val rdata = (repliesAny["data"] as? Map<*, *>)?.get("children") as? List<*> rdata?.forEach { traverseAndCollect(it, thisFull) } } } is JsonElement -> { if (item.isJsonObject) { val obj = item.asJsonObject val dataObj = obj.getAsJsonObject("data") val id = dataObj.get("id")?.asString ?: return val parentRaw = dataObj.get("parent_id")?.asString var replyCount = 0 val repliesElement = dataObj.get("replies") if (repliesElement != null && repliesElement.isJsonObject) { val repliesObj = repliesElement.asJsonObject val repliesData = repliesObj.getAsJsonObject("data") val repliesChildren = repliesData?.getAsJsonArray("children") replyCount = repliesChildren?.size() ?: 0 } if (parentRaw == "t1_$commentId") { val author = dataObj.get("author")?.asString val body = dataObj.get("body")?.asString val bodyHtml = dataObj.get("body_html")?.asString val ups = try { dataObj.get("ups")?.asInt ?: 0 } catch (_: Throwable) { 0 } val created = dataObj.get("created_utc")?.asLong val saved = dataObj.get("saved")?.asBoolean val likes = dataObj.get("likes")?.asBoolean val permalink = dataObj.get("permalink")?.asString val sortKey = sortCounter++ out.add( CommentEntity( id = "t1_$id", postId = postId, parentId = "t1_$commentId", author = author, body = body, bodyHtml = bodyHtml, ups = ups, sortKey = sortKey, depth = 1, replyCount = replyCount, createdUtc = created, saved = saved, likes = likes, permalink = permalink ) ) } // traverse nested children listing if present val data = obj.getAsJsonObject("data") val children = data?.getAsJsonArray("children") children?.forEach { traverseAndCollect(it, "t1_$id") } } } else -> { // unknown shape - ignore } } } commentChildren.forEach { traverseAndCollect(it, null) } if (out.isNotEmpty()) { commentDao.upsertAll(out) } // return number of replies parsed/inserted return@withContext out.size } catch (_: Exception) { // swallow } return@withContext -1 } suspend fun refreshPostAndAuthor(postId: String) { // Try to find cached post first val cached = postDao.getPost("t3_$postId") // Decide how to call the API to get fresh data val raw = try { if (cached == null) { // If we don't have subreddit/slug info, fetch by id endpoint redditApi.fetchPostById(postId) } else { val subreddit = cached.subreddit ?: "" val splitPerma = cached.permalink.split("/") val slug = splitPerma.getOrNull(splitPerma.size - 2) ?: "" redditApi.fetchPost( subreddit = subreddit, postID = postId, post = slug ) } } catch (_: Exception) { // If API fails and we have cached data, don't crash — just return if (cached == null) return else null } if (raw == null) return // Parse response into your PostEntity and update Room val postListing = raw.firstOrNull() ?: return val freshPostData = postListing.data.children.firstOrNull()?.children?.firstOrNull() ?: return // Determine a sortKey: preserve cached if present, otherwise append to end val sortKey = cached?.sortKey ?: ((postDao.maxSortKey() ?: 0) + 1) val freshEntity = PostEntity( id = freshPostData.name ?: "t3_$postId", title = freshPostData.title.orEmpty(), author = freshPostData.author, subreddit = freshPostData.subreddit, createdUtc = (freshPostData.createdUTC ?: 0.0).toLong(), ups = freshPostData.ups?.toInt() ?: cached?.ups?.toInt(), thumbnail = freshPostData.thumbnail ?: cached?.thumbnail, permalink = freshPostData.permalink ?: cached?.permalink ?: "", url = freshPostData.url ?: cached?.url, previewImageUrl = freshPostData.preview?.images?.firstOrNull()?.source?.url ?.replace("amp;", "") ?: cached?.previewImageUrl, previewWidth = freshPostData.preview?.images?.firstOrNull()?.source?.width ?: cached?.previewWidth, previewHeight = freshPostData.preview?.images?.firstOrNull()?.source?.height ?: cached?.previewHeight, sortKey = sortKey, saved = freshPostData.saved ?: cached?.saved, likes = freshPostData.likes ?: cached?.likes, selftext = freshPostData.selftext ?: cached?.selftext ) postDao.upsert(freshEntity) // 2) Refresh author info val authorName = freshEntity.author ?: return val userAbout = try { redditApi.fetchUserInfo(user = authorName) } catch (_: Exception) { null } // Map to UserEntity and upsert into userDao if (userAbout != null) { val about = userAbout.data val userEntity = UserEntity( name = about.name ?: authorName, iconUrl = about.icon_img ?: "", snoovatarUrl = about.snoovatar_img ?: "" ) userDao.insertAll(listOf(userEntity)) } } // New: Update bookmark state via API and persist to cache suspend fun savePost(postIdNoPrefix: String) { val fullId = "t3_$postIdNoPrefix" try { redditApi.savePost(fullId) } catch (_: Exception) { // ignore API errors for now } val cached = postDao.getPost(fullId) if (cached != null) { val updated = cached.copy(saved = true) postDao.upsert(updated) } } suspend fun unsavePost(postIdNoPrefix: String) { val fullId = "t3_$postIdNoPrefix" try { redditApi.unsavePost(fullId) } catch (_: Exception) { // ignore API errors for now } val cached = postDao.getPost(fullId) if (cached != null) { val updated = cached.copy(saved = false) postDao.upsert(updated) } } suspend fun castVoteAndCache(postIdNoPrefix: String, direction: Int, prefix: String = "t3_") { val fullId = "$prefix$postIdNoPrefix" try { redditApi.castVote(fullId, direction) } catch (_: Exception) { // ignore API errors } // determine target dao/entity based on fullname prefix when { fullId.startsWith("t3_") -> { val cached = postDao.getPost(fullId) ?: return val prevLikes = cached.likes val prevValue = when (prevLikes) { true -> 1 false -> -1 null -> 0 } val newValue = when (direction) { 1 -> 1 -1 -> -1 else -> 0 } val delta = newValue - prevValue val newUps = (cached.ups ?: 0) + delta val newLikes = when (direction) { 1 -> true -1 -> false else -> null } val updated = cached.copy(likes = newLikes, ups = newUps) postDao.upsert(updated) } fullId.startsWith("t1_") -> { val cached = commentDao.getComment(fullId) ?: return val prevLikes = cached.comment.likes val prevValue = when (prevLikes) { true -> 1 false -> -1 null -> 0 } val newValue = when (direction) { 1 -> 1 -1 -> -1 else -> 0 } val delta = newValue - prevValue val newUps = (cached.comment.ups ?: 0) + delta val newLikes = when (direction) { 1 -> true -1 -> false else -> null } val updated = cached.comment.copy(likes = newLikes, ups = newUps) commentDao.upsert(updated) } } } fun observePopularSubreddits(): Flow> = subredditDao.getPopularSubreddits() fun observeSubscribedSubreddits(): Flow> = subredditDao.getSubscribedSubreddits() suspend fun refreshPopularSubreddits(force: Boolean = false) { if (!force && !shouldRefreshPopularSubreddits()) return val listing = redditApi.fetchTopSubreddits(limit = 50) val entities = listing.data.children.map { it.toSubredditEntity(isSubscribed = false) } subredditDao.upsertAll(entities) mmkv.putLong("popular_subreddits_last_fetch", System.currentTimeMillis()) } suspend fun refreshSubscribedSubreddits(force: Boolean = false) { if (!force && !shouldRefreshSubscribedSubreddits()) return val listing = redditApi.fetchSubscribedSubreddits(limit = 100) val entities = listing.data.children.map { it.toSubredditEntity(isSubscribed = true) } subredditDao.markAllUnsubscribed() subredditDao.upsertAll(entities) mmkv.putLong("subscribed_subreddits_last_fetch", System.currentTimeMillis()) } private fun shouldRefreshPopularSubreddits(): Boolean { val last = mmkv.getLong("popular_subreddits_last_fetch", 0L) return System.currentTimeMillis() - last > 3 * 60 * 60 * 1000 // 3h } private fun shouldRefreshSubscribedSubreddits(): Boolean { val last = mmkv.getLong("subscribed_subreddits_last_fetch", 0L) return System.currentTimeMillis() - last > 30 * 60 * 1000 // 30min, up to you } // Suggest top communities: return the SubredditData model directly suspend fun suggestCommunities(query: String, limit: Int = 3): List { return try { val resp = redditApi.searchCommunities(query = query, limit = limit) resp.data.children.take(limit).map { it.data } } catch (_: Exception) { emptyList() } } // Suggest top users: return the UserAbout model directly suspend fun suggestUsers(query: String, limit: Int = 3): List { return try { val resp = redditApi.searchUsers(query = query, limit = limit) resp.data.children.take(limit).map { it.data } } catch (_: Exception) { emptyList() } } suspend fun refreshCommentsForPost(postId: String) { try { val response = redditApi.fetchCommentsByPostId(postId) // defensively extract children list from the second element of the response val commentChildren = run { val second = response.getOrNull(1) when (second) { is com.pineapple.app.network.model.reddit.Listing<*> -> (second.data.children as? List<*>) ?: emptyList() is Map<*, *> -> ((second["data"] as? Map<*, *>)?.get("children") as? List<*>) ?: emptyList() else -> emptyList() } } val startIndex = commentDao.maxSortKeyForPost(postId)?.plus(1) ?: 0 val out = mutableListOf() var counter = startIndex // parse a variety of possible shapes for individual comment items commentChildren.forEach { itemAny -> when (itemAny) { is com.pineapple.app.network.model.reddit.ListingItem<*> -> { val inner = itemAny.children inner.forEach { cpAny -> val cp = cpAny as? CommentPreData ?: return@forEach if (cp.kind != "t1") return@forEach val d = cp.data val id = d.id.ifEmpty { return@forEach } val author = d.author val body = d.body val bodyHtml = d.body_html val ups = try { d.ups?.toInt() ?: 0 } catch (_: Throwable) { 0 } val created = d.created_utc?.toLong() val saved = d.saved val likes = d.likes val permalink = d.permalink val sortKey = counter++ // Parse replies to calculate count var replyCount = 0 d.replies?.let { je -> if (je.isJsonObject) { try { val type = object : TypeToken>() {}.type val listing = gson.fromJson>(je, type) val replyChildren = listing.data.children replyCount = replyChildren.count { it.kind == "t1" } } catch (e: Exception) { // ignore } } } out.add( CommentEntity( id = "t1_$id", postId = postId, parentId = null, author = author, body = body, bodyHtml = bodyHtml, ups = ups, sortKey = sortKey, depth = 0, replyCount = replyCount, createdUtc = created, saved = saved, likes = likes, permalink = permalink ) ) } } is CommentPreData -> { val cp = itemAny if (cp.kind != "t1") return@forEach val d = cp.data val id = d.id.ifEmpty { return@forEach } val author = d.author val body = d.body val bodyHtml = d.body_html val ups = try { d.ups?.toInt() ?: 0 } catch (_: Throwable) { 0 } val created = d.created_utc?.toLong() val saved = d.saved val likes = d.likes val permalink = d.permalink val sortKey = counter++ // Parse replies to calculate count var replyCount = 0 d.replies?.let { je -> if (je.isJsonObject) { try { val type = object : TypeToken>() {}.type val listing = gson.fromJson>(je, type) val replyChildren = listing.data.children replyCount = replyChildren.count { it.kind == "t1" } } catch (e: Exception) { // ignore } } } out.add( CommentEntity( id = "t1_$id", postId = postId, parentId = null, author = author, body = body, bodyHtml = bodyHtml, ups = ups, sortKey = sortKey, depth = 0, replyCount = replyCount, createdUtc = created, saved = saved, likes = likes, permalink = permalink ) ) } is Map<*, *> -> { val kind = itemAny["kind"] as? String val data = itemAny["data"] as? Map<*, *> if (kind != "t1" || data == null) return@forEach val id = data["id"] as? String ?: return@forEach val author = data["author"] as? String val body = data["body"] as? String val bodyHtml = data["body_html"] as? String ?: "" val ups = try { ((data["ups"] as? Number)?.toLong() ?: 0L).toInt() } catch (_: Throwable) { 0 } val created = (data["created_utc"] as? Number)?.toLong() val saved = data["saved"] as? Boolean val likes = data["likes"] as? Boolean val permalink = data["permalink"] as? String val sortKey = counter++ // Parse replies to calculate count var replyCount = 0 val repliesAny = data["replies"] if (repliesAny is Map<*, *>) { val rdata = (repliesAny["data"] as? Map<*, *>)?.get("children") as? List<*> replyCount = rdata?.size ?: 0 } out.add( CommentEntity( id = "t1_$id", postId = postId, parentId = null, author = author, body = body, bodyHtml = bodyHtml, ups = ups, sortKey = sortKey, depth = 0, replyCount = replyCount, createdUtc = created, saved = saved, likes = likes, permalink = permalink ) ) } else -> { // unknown shape - skip } } } // persist if (out.isNotEmpty()) { commentDao.upsertAll(out) } // Insert placeholder users to avoid fetching each user synchronously (improves performance) val authorNames = out.mapNotNull { it.author }.distinct() val existingUsers = authorNames.mapNotNull { userDao.getUser(it) }.associateBy { it.name } val missing = authorNames.filter { it !in existingUsers } if (missing.isNotEmpty()) { val placeholders = missing.map { name -> UserEntity(name = name, iconUrl = "", snoovatarUrl = "") } userDao.insertAll(placeholders) // Removed background prefetch of up to 20 profiles. // Adopting on-demand fetch: UI components should request full user info (avatars) when rows are visible. } } catch (_: Exception) { // swallow - minimal behavior } } /** * Fetch a user's about info from the API and cache it locally. * This is intended for on-demand fetching (e.g. when a comment row becomes visible). */ suspend fun fetchAndCacheUser(username: String) { if (username.isBlank()) return // if user already exists and has an icon, skip val existing = userDao.getUser(username) if (existing != null && !existing.iconUrl.isNullOrEmpty()) return try { val about = redditApi.fetchUserInfo(username) val u = about.data val userEntity = UserEntity( name = u.name ?: username, iconUrl = u.icon_img ?: "", snoovatarUrl = u.snoovatar_img ?: "" ) userDao.insertAll(listOf(userEntity)) } catch (_: Exception) { // minimal: ignore failures; UI can retry or show placeholder } } fun observeRepliesForComment(parentCommentFullId: String) = commentDao.getRepliesForCommentFlow(parentCommentFullId) } ================================================ FILE: app/src/main/java/com/pineapple/app/network/serialization/RedditRepliesAdapter.kt ================================================ package com.pineapple.app.network.serialization import com.google.gson.* import com.pineapple.app.network.model.reddit.CommentDataNull import java.lang.reflect.Type class RedditRepliesAdapter : JsonDeserializer { override fun deserialize( json: JsonElement, typeOfT: Type, context: JsonDeserializationContext ): CommentDataNull? { // If it's a primitive (like the string ""), return null if (json.isJsonPrimitive) { return null } // If it's an object, parse it normally return context.deserialize(json, CommentDataNull::class.java) } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/components/ButtonComponents.kt ================================================ @file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package com.pineapple.app.ui.components import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalIconToggleButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonColors import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconToggleButtonColors import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.Dp import com.pineapple.app.ui.theme.FullCornerRadius import com.pineapple.app.ui.theme.MediumCornerRadius /** * Tonal icon toggle button that animates its shape based off of the checked status, using * Material 3 Expressive motion physics * @param checked Whether the button is checked or not * @param onCheckedChange Lambda that is triggered when the button is clicked * @param modifier [Modifier] to be applied to the button * @param checkedRadius Corner radius when the button is checked * @param uncheckedRadius Corner radius when the button is unchecked * @param checkedIcon Icon to be displayed when the button is checked * @param uncheckedIcon Icon to be displayed when the button is unchecked * @param contentDescription Content description for the icon (both states) */ @Composable fun AnimatedTonalToggleIconButton( checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, checkedRadius: Dp = FullCornerRadius, uncheckedRadius: Dp = MediumCornerRadius, checkedIcon: Painter, uncheckedIcon: Painter, contentDescription: String, colors: IconToggleButtonColors = IconButtonDefaults.filledTonalIconToggleButtonColors() ) { val targetRadius = if (checked) checkedRadius else uncheckedRadius val shapeRadius by animateDpAsState( targetValue = targetRadius, animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(), label = "shapeRadius" ) FilledTonalIconToggleButton( checked = checked, onCheckedChange = onCheckedChange, shape = RoundedCornerShape(shapeRadius), modifier = modifier, colors = colors ) { Icon( painter = if (checked) checkedIcon else uncheckedIcon, contentDescription = contentDescription ) } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/components/CardComponents.kt ================================================ @file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package com.pineapple.app.ui.components import android.content.Intent import androidx.compose.foundation.combinedClickable 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.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.pineapple.app.R import com.pineapple.app.network.model.cache.CommentWithUser import com.pineapple.app.network.model.reddit.PostData import com.pineapple.app.network.model.reddit.UserAboutListing import com.pineapple.app.ui.theme.FullCornerRadius import com.pineapple.app.ui.theme.MediumCornerRadius import com.pineapple.app.utilities.convertUnixToRelativeTime import com.pineapple.app.utilities.prettyNumber /** * A compact card representing a post to be used in list views * @param postData The data of the post to be displayed * @param modifier The modifier to be applied to the card * @param userInfo User info used to display the author's avatar * @param onClick Lambda to be invoked when the card is clicked * @param onMoreClick Lambda to be invoked when the more options button is clicked * @param onSaveClick Lambda to be invoked when the save button is clicked * @param onUpvote Lambda to be invoked when the upvote button is clicked * @param onDownvote Lambda to be invoked when the downvote button is clicked */ @Composable fun PostCard( postData: PostData, modifier: Modifier = Modifier, userInfo: UserAboutListing? = null, onClick: () -> Unit, onMoreClick: () -> Unit, onSaveClick: (Boolean, () -> Unit) -> Unit, onUpvote: (Boolean, () -> Unit) -> Unit, onDownvote: (Boolean, () -> Unit) -> Unit ) { val context = LocalContext.current val haptics = LocalHapticFeedback.current var bookmarkedState by rememberSaveable { mutableStateOf(postData.saved) } var upvoteState by rememberSaveable { mutableStateOf(postData.likes == true) } var downvoteState by rememberSaveable { mutableStateOf(postData.likes == false) } val imageData = postData.preview?.images?.get(0)?.source val width = imageData?.width?.toFloat() ?: 0f val height = imageData?.height?.toFloat() ?: 0f val imageUrl = imageData?.url?.replace("amp;", "")?.ifEmpty { postData.url } val computedAspectRatio = if (width > 0f && height > 0f) { (width / height).coerceIn(0.2f, 4f) } else null Card( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLow ), modifier = modifier.fillMaxWidth() .clip(MaterialTheme.shapes.medium) .combinedClickable( enabled = true, onLongClick = { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) onMoreClick() }, onClick = onClick ), ) { Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 13.dp, vertical = 13.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { userInfo?.let { AsyncImage( model = it.data.snoovatar_img?.ifBlank { null } ?: it.data.icon_img, contentDescription = null, modifier = Modifier.clip(CircleShape) .size(35.dp) ) } Column(modifier = Modifier.padding(start = 10.dp)) { postData.author?.let { Text( text = "u/$it", style = MaterialTheme.typography.titleSmall ) } postData.subredditNamePrefixed?.let { Text( text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface ) } } } postData.createdUTC?.let { Text( text = it.convertUnixToRelativeTime(), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.outline ) } } postData.title?.let { Text( text = it, modifier = Modifier.padding(start = 13.dp, end = 13.dp, bottom = 5.dp), style = MaterialTheme.typography.bodyLarge ) } if (imageUrl !== null && imageUrl.isNotEmpty()) { var aspectRatio by rememberSaveable(postData.id + "_ratio") { mutableStateOf(computedAspectRatio) } aspectRatio?.let { MeasuredAsyncImage( imageUrl = imageUrl, aspectRatio = it, modifier = Modifier .fillMaxWidth() .padding(vertical = 5.dp, horizontal = 13.dp) .clip(MaterialTheme.shapes.medium) ) } } Row( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() .padding(horizontal = 13.dp, vertical = 10.dp) ) { Row { FilledTonalIconButton( onClick = { onMoreClick() }, shape = MaterialTheme.shapes.medium, modifier = Modifier .padding(end = 3.dp) .width(33.dp) ) { Icon( painter = painterResource(R.drawable.ic_more_vert), contentDescription = stringResource(R.string.ic_more_vert_cdesc) ) } FilledTonalIconButton( onClick = { postData.permalink?.let { url -> val sendIntent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" putExtra(Intent.EXTRA_TEXT, "https://reddit.com$url") } val shareIntent = Intent.createChooser(sendIntent, "Share post") context.startActivity(shareIntent) } }, shape = MaterialTheme.shapes.medium ) { Icon( painter = painterResource(R.drawable.ic_share), contentDescription = stringResource(R.string.ic_share_cdesc) ) } AnimatedTonalToggleIconButton( checked = bookmarkedState == true, onCheckedChange = { onSaveClick(it) { bookmarkedState = it } }, checkedIcon = painterResource(R.drawable.ic_bookmark_filled), uncheckedIcon = painterResource(R.drawable.ic_bookmark), contentDescription = stringResource(R.string.ic_bookmark_cdesc) ) } Row(verticalAlignment = Alignment.CenterVertically) { AnimatedTonalToggleIconButton( checked = downvoteState, onCheckedChange = { onDownvote(it) { downvoteState = it upvoteState = false } }, checkedIcon = painterResource(R.drawable.ic_downvote), uncheckedIcon = painterResource(R.drawable.ic_downvote), contentDescription = stringResource(R.string.ic_downvote_cdesc), modifier = Modifier.width(33.dp), uncheckedRadius = 30.dp, checkedRadius = MediumCornerRadius ) postData.ups?.toInt()?.prettyNumber()?.let { Text( text = it, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp) ) } AnimatedTonalToggleIconButton( checked = upvoteState, onCheckedChange = { onUpvote(it) { upvoteState = it downvoteState = false } }, checkedIcon = painterResource(R.drawable.ic_upvote), uncheckedIcon = painterResource(R.drawable.ic_upvote), contentDescription = stringResource(R.string.ic_upvote_cdesc), modifier = Modifier.width(33.dp), uncheckedRadius = 30.dp, checkedRadius = MediumCornerRadius ) } } } } @Composable fun CommentCard( commentWithUser: CommentWithUser?, modifier: Modifier = Modifier, showingTrailingButtons: Boolean = true, onMoreClick: () -> Unit = { }, onUpvote: (Boolean, () -> Unit) -> Unit = { _, _, -> }, onDownvote: (Boolean, () -> Unit) -> Unit = { _, _, -> }, containerColor: Color = MaterialTheme.colorScheme.surfaceContainer ) { val author = commentWithUser?.comment?.author var downvoteState by rememberSaveable { mutableStateOf(commentWithUser?.comment?.likes == false) } var upvoteState by rememberSaveable { mutableStateOf(commentWithUser?.comment?.likes == true) } Column(modifier) { Row { commentWithUser?.user?.let { AsyncImage( model = it.snoovatarUrl?.ifBlank { null } ?: it.iconUrl, contentDescription = null, placeholder = painterResource(R.drawable.generic_avatar), modifier = Modifier .clip(CircleShape) .size(17.dp) ) } Column { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() ) { Row { Text( text = author?.let { "u/$it" } ?: "u/[deleted]", style = MaterialTheme.typography.labelSmall, modifier = Modifier .padding(start = 7.dp) .align(Alignment.CenterVertically) ) commentWithUser?.comment?.createdUtc?.let { created -> val rel = try { created.convertUnixToRelativeTime() } catch (_: Exception) { "" } if (rel.isNotEmpty()) { Text( text = rel, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline, modifier = Modifier.padding( start = 7.dp ) ) } } } } } } Row(modifier = Modifier.padding(top = 7.dp)) { var cardHeightPx by remember { mutableIntStateOf(0) } val density = androidx.compose.ui.platform.LocalDensity.current val requiredPx = with(density) { (40.dp + 5.dp + 40.dp).toPx().toInt() } Box(modifier = Modifier.weight(1f, false)) { Column { Card( modifier = Modifier.clip(MaterialTheme.shapes.medium) .combinedClickable( enabled = true, onClick = { }, onLongClick = onMoreClick ).onSizeChanged { cardHeightPx = it.height }, shape = MaterialTheme.shapes.medium, colors = CardDefaults.cardColors( containerColor = containerColor ) ) { Text( text = commentWithUser?.comment?.body.toString() .trimIndent().trimStart(), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(10.dp) ) } } } if (showingTrailingButtons) { if (cardHeightPx >= requiredPx) { Column( modifier = Modifier.padding(start = 5.dp), verticalArrangement = Arrangement.spacedBy(5.dp) ) { AnimatedTonalToggleIconButton( checked = upvoteState, onCheckedChange = { checked -> onUpvote(checked) { upvoteState = checked if (checked) { downvoteState = false } } }, checkedIcon = painterResource(R.drawable.ic_upvote), uncheckedIcon = painterResource(R.drawable.ic_upvote), contentDescription = stringResource(R.string.ic_upvote_cdesc), modifier = Modifier.size(30.dp, 40.dp), uncheckedRadius = MediumCornerRadius, checkedRadius = FullCornerRadius, colors = IconButtonDefaults.filledTonalIconToggleButtonColors( containerColor = MaterialTheme.colorScheme.surfaceContainer, contentColor = MaterialTheme.colorScheme.onSurfaceVariant, checkedContentColor = MaterialTheme.colorScheme.onPrimary ) ) AnimatedTonalToggleIconButton( checked = downvoteState, onCheckedChange = { checked -> onDownvote(checked) { downvoteState = checked if (checked) { upvoteState = false } } }, checkedIcon = painterResource(R.drawable.ic_downvote), uncheckedIcon = painterResource(R.drawable.ic_downvote), contentDescription = stringResource(R.string.ic_downvote_cdesc), modifier = Modifier.size(30.dp, 40.dp), uncheckedRadius = MediumCornerRadius, checkedRadius = FullCornerRadius, colors = IconButtonDefaults.filledTonalIconToggleButtonColors( containerColor = MaterialTheme.colorScheme.surfaceContainer, contentColor = MaterialTheme.colorScheme.onSurfaceVariant, checkedContentColor = MaterialTheme.colorScheme.onPrimary ) ) } } else { FilledTonalIconButton( onClick = { onMoreClick() }, modifier = Modifier .padding(start = 5.dp) .size(30.dp, 40.dp), colors = IconButtonDefaults.filledTonalIconButtonColors( containerColor = MaterialTheme.colorScheme.surfaceContainer ), shape = MaterialTheme.shapes.medium ) { Icon( painter = painterResource(R.drawable.ic_more_vert), contentDescription = stringResource(R.string.ic_more_vert_cdesc) ) } } } } } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/components/ListComponents.kt ================================================ @file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package com.pineapple.app.ui.components import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.pineapple.app.ui.theme.ExtraLargeCornerRadius import com.pineapple.app.ui.theme.ExtraSmallCornerRadius /** * A representation of a list item in the [TonalActionSectionList] */ data class TonalActionSectionItem( val text: String, val icon: Painter, val contentDescription: String, val onCLick: () -> Unit = { }, val iconSize: Dp = 24.dp, val shouldTintIcon: Boolean = true ) /** * List of clickable options styled as tonal cards, with rounding applied to first and last * items but not inner elements to replicate the Material 3 style used in the system settings app * @param items The list of [TonalActionSectionItem] to display * @param modifier The Modifier to be applied to this component * @param singleSelect Whether only a single item can be selected at a time * @param selectedIndexInitial The index of the initially selected item, if [singleSelect] is true * @param onSelectChange Callback invoked when the selected item changes, providing the new index * and corresponding [TonalActionSectionItem] (in that order) */ @Composable fun TonalActionSectionList( items: List, modifier: Modifier = Modifier, singleSelect: Boolean = false, selectedIndexInitial: Int = 0, onSelectChange: (Int, TonalActionSectionItem) -> Unit = { _, _ -> }, containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, listItemContainerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh, listItemContentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant ) { var selectedIndex by rememberSaveable { mutableIntStateOf(selectedIndexInitial) } Surface( modifier = modifier, shape = MaterialTheme.shapes.large, color = containerColor ) { Column { items.forEachIndexed { index, item -> val isSelected = singleSelect && selectedIndex == index val containerColor by animateColorAsState( targetValue = if (isSelected) { MaterialTheme.colorScheme.primary } else { listItemContainerColor }, label = "containerColor", animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec() ) val contentColor by animateColorAsState( targetValue = if (isSelected) { MaterialTheme.colorScheme.onPrimary } else { listItemContentColor }, label = "contentColor", animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec() ) val cornerRadius by animateDpAsState( targetValue = if (isSelected) ExtraLargeCornerRadius else ExtraSmallCornerRadius, label = "cornerRadius", animationSpec = MaterialTheme.motionScheme.defaultSpatialSpec() ) val animatedShape = RoundedCornerShape(cornerRadius) ListItem( headlineContent = { Text(item.text) }, leadingContent = { if (item.shouldTintIcon) { Icon( painter = item.icon, contentDescription = item.contentDescription, tint = contentColor ) } else { Image( painter = item.icon, contentDescription = item.contentDescription, modifier = Modifier.clip(CircleShape) .size(item.iconSize) ) } }, modifier = Modifier .padding(bottom = if (index == items.lastIndex) 0.dp else 3.dp) .clip(animatedShape) .clickable { if (singleSelect) { if (selectedIndex != index) { selectedIndex = index onSelectChange(index, item) } } item.onCLick() }, colors = ListItemDefaults.colors( containerColor = containerColor, contentColor = contentColor ) ) } } } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/components/MediaComponents.kt ================================================ package com.pineapple.app.ui.components import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter import coil3.compose.rememberAsyncImagePainter import coil3.request.CachePolicy import coil3.request.ImageRequest import coil3.request.crossfade import com.pineapple.app.R /** * Wrapper to the AsyncImage composable intended for images that are used in scrolling * layouts, which keeps a fixed size even before the image is loaded to eliminate jank * caused by changing layout sizes. * @param imageUrl The URL of the image to load. * @param aspectRatio The aspect ratio (width / height) to use for the image. * @param modifier The modifier to be applied to the image. * @param contentDescription The content description for the image. */ @Composable fun MeasuredAsyncImage( imageUrl: String, aspectRatio: Float?, modifier: Modifier = Modifier, contentDescription: String? = null ) { val context = LocalContext.current val painter = rememberAsyncImagePainter( model = ImageRequest.Builder(context) .data(imageUrl) .crossfade(true) .memoryCachePolicy(CachePolicy.ENABLED) .diskCachePolicy(CachePolicy.ENABLED) .build() ) val ratio = aspectRatio ?: (16f / 9f) // Keep the AnimatedContent transition, but apply the external modifier to the inner Box AnimatedContent( targetState = painter.state, transitionSpec = { fadeIn(animationSpec = tween(250)) togetherWith fadeOut(animationSpec = tween(150)) }, contentAlignment = Alignment.TopCenter, label = "ImageLoad" ) { state -> // Apply the caller-provided modifier to the measured container so size is stable Box( modifier = modifier .aspectRatio(ratio), contentAlignment = Alignment.TopCenter ) { when (state.collectAsState().value) { is AsyncImagePainter.State.Loading -> { // Placeholder image that fills the container AsyncImage( model = R.drawable.async_image_placeholder, contentDescription = contentDescription, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() ) } is AsyncImagePainter.State.Success -> { // Loaded image fills the container immediately Image( painter = painter, contentDescription = contentDescription, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() ) } else -> { Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surfaceContainerLowest) ) } } } } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/modal/CommentDetailSheet.kt ================================================ @file:OptIn(ExperimentalMaterial3Api::class) package com.pineapple.app.ui.modal import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text 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.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.pineapple.app.R import com.pineapple.app.network.model.cache.CommentWithUser import com.pineapple.app.ui.components.AnimatedTonalToggleIconButton import com.pineapple.app.ui.components.CommentCard import com.pineapple.app.ui.theme.MediumCornerRadius import com.pineapple.app.utilities.prettyNumber @Composable fun CommentDetailSheet( commentWithUser: CommentWithUser, onDismissRequest: () -> Unit, onDownvote: (Boolean, () -> Unit) -> Unit, onUpvote: (Boolean, () -> Unit) -> Unit, onSaveClick: (Boolean, () -> Unit) -> Unit, onViewUserClick: () -> Unit ) { var upvoteState by remember { mutableStateOf(commentWithUser.comment.likes == true) } var downvoteState by remember { mutableStateOf(commentWithUser.comment.likes == false) } var bookmarkedState by remember { mutableStateOf(commentWithUser.comment.saved) } val context = LocalContext.current ModalBottomSheet( onDismissRequest = onDismissRequest, containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) { Column { CommentCard( commentWithUser = commentWithUser, showingTrailingButtons = false, containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = Modifier.padding(horizontal = 15.dp) ) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.padding(start = 12.dp, end = 15.dp, top = 10.dp, bottom = 10.dp) .fillMaxWidth() ) { Row { FilledTonalIconButton( onClick = { commentWithUser.comment.permalink?.let { url -> val sendIntent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" putExtra(Intent.EXTRA_TEXT, "https://reddit.com$url") } val shareIntent = Intent.createChooser(sendIntent, "Share post") context.startActivity(shareIntent) } }, shape = MaterialTheme.shapes.medium ) { Icon( painter = painterResource(R.drawable.ic_share), contentDescription = stringResource(R.string.ic_share_cdesc) ) } AnimatedTonalToggleIconButton( checked = bookmarkedState == true, onCheckedChange = { onSaveClick(it) { bookmarkedState = it } }, checkedIcon = painterResource(R.drawable.ic_bookmark_filled), uncheckedIcon = painterResource(R.drawable.ic_bookmark), contentDescription = stringResource(R.string.ic_bookmark_cdesc) ) FilledTonalIconButton( onClick = onViewUserClick, shape = MaterialTheme.shapes.medium ) { Icon( painter = painterResource(R.drawable.ic_person), contentDescription = stringResource(R.string.ic_person_cdesc) ) } FilledTonalIconButton( onClick = { commentWithUser.comment.body?.let { bodyText -> val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText( "Comment Body", bodyText ) clipboard.setPrimaryClip(clip) } // Maybe only this for devices that don't have the clipboard popup // Toast.makeText(context, R.string.post_copied_comment, Toast.LENGTH_SHORT).show() onDismissRequest() }, shape = MaterialTheme.shapes.medium ) { Icon( painter = painterResource(R.drawable.ic_copy), contentDescription = stringResource(R.string.ic_copy_cdesc) ) } } Row(verticalAlignment = Alignment.CenterVertically) { AnimatedTonalToggleIconButton( checked = downvoteState, onCheckedChange = { onDownvote(it) { downvoteState = it upvoteState = false } }, checkedIcon = painterResource(R.drawable.ic_downvote), uncheckedIcon = painterResource(R.drawable.ic_downvote), contentDescription = stringResource(R.string.ic_downvote_cdesc), modifier = Modifier.width(33.dp), uncheckedRadius = 30.dp, checkedRadius = MediumCornerRadius ) commentWithUser.comment.ups?.toInt()?.prettyNumber()?.let { Text( text = it, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp) ) } AnimatedTonalToggleIconButton( checked = upvoteState, onCheckedChange = { onUpvote(it) { upvoteState = it downvoteState = false } }, checkedIcon = painterResource(R.drawable.ic_upvote), uncheckedIcon = painterResource(R.drawable.ic_upvote), contentDescription = stringResource(R.string.ic_upvote_cdesc), modifier = Modifier.width(33.dp), uncheckedRadius = 30.dp, checkedRadius = MediumCornerRadius ) } } } } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/modal/CommentRepliesSheet.kt ================================================ package com.pineapple.app.ui.modal import androidx.compose.runtime.Composable @Composable fun CommentRepliesSheet() { } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/modal/PostOptionSheet.kt ================================================ @file:OptIn(ExperimentalMaterial3Api::class) package com.pineapple.app.ui.modal import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.pineapple.app.R import com.pineapple.app.network.model.reddit.PostData import com.pineapple.app.ui.components.TonalActionSectionItem import com.pineapple.app.ui.components.TonalActionSectionList /** * Modal bottom sheet displaying extended options, intended to be called from a post card * @param postData Data of the post for which options are being displayed * @param onDismissRequest Callback when the sheet is dismissed * @param onViewUser Callback to view the post author's profile * @param onViewCommunity Callback to view the post's community * @param onOpenExternal Callback to open the post in an external browser * @param onReport Callback to report the post */ @Composable fun PostOptionSheet( postData: PostData, onDismissRequest: () -> Unit, onViewUser: () -> Unit, onViewCommunity: () -> Unit, onOpenExternal: () -> Unit, onReport: () -> Unit ) { ModalBottomSheet( onDismissRequest = onDismissRequest, containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) { TonalActionSectionList( items = listOf( TonalActionSectionItem( text = "Go to ${postData.subredditNamePrefixed}", icon = painterResource(id = R.drawable.ic_community), contentDescription = stringResource(R.string.ic_community_cdesc), onCLick = onViewCommunity ), TonalActionSectionItem( text = "View u/${postData.author}", icon = painterResource(id = R.drawable.ic_person), contentDescription = stringResource(R.string.ic_person_cdesc), onCLick = onViewUser ), TonalActionSectionItem( text = "Open in browser", icon = painterResource(id = R.drawable.ic_open_external), contentDescription = stringResource(R.string.ic_open_external_cdesc), onCLick = onOpenExternal ), TonalActionSectionItem( text = "Report post", icon = painterResource(id = R.drawable.ic_flag), contentDescription = stringResource(R.string.ic_flag_cdesc), onCLick = onReport ) ), modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 15.dp) ) } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/modal/SortPostSheet.kt ================================================ @file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) package com.pineapple.app.ui.modal import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.pineapple.app.R import com.pineapple.app.consts.PostFilterSort import com.pineapple.app.consts.PostFilterTime import com.pineapple.app.ui.components.TonalActionSectionItem import com.pineapple.app.ui.components.TonalActionSectionList /** * Modal bottom sheet that allows users to filter and sort a list of posts in the same style * that is available in reddit (hot, new, top etc. and by day, week, month, etc.) * @param currentTimeSelection The currently selected time filter * @param currentSortSelection The currently selected sort filter * @param onDismissRequest Callback invoked when the sheet is dismissed, passing in the time and * sort selections (in that order) * @see [PostFilterSort] and [PostFilterTime] */ @Composable fun SortPostSheet( currentTimeSelection: String, currentSortSelection: String, onDismissRequest: (String, String) -> Unit ) { var selectedSortType by rememberSaveable { mutableStateOf(currentSortSelection) } var selectedSortTime by rememberSaveable { mutableStateOf(currentTimeSelection) } val showExtended = selectedSortType == PostFilterSort.SORT_TOP || selectedSortType == PostFilterSort.SORT_CONTROVERSIAL ModalBottomSheet( onDismissRequest = { onDismissRequest(selectedSortTime, selectedSortType) }, containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) { Column(modifier = Modifier.padding(horizontal = 20.dp)) { Text( text = stringResource(R.string.sort_sheet_sort_header), style = MaterialTheme.typography.bodyMedium, ) TonalActionSectionList( items = listOf( TonalActionSectionItem( text = stringResource(R.string.sort_sheet_hot), icon = painterResource(R.drawable.ic_fire), contentDescription = stringResource(R.string.ic_fire_cdesc) ), TonalActionSectionItem( text = stringResource(R.string.sort_sheet_new), icon = painterResource(R.drawable.ic_shine), contentDescription = stringResource(R.string.ic_shine_cdesc) ), TonalActionSectionItem( text = stringResource(R.string.sort_sheet_rising), icon = painterResource(R.drawable.ic_trending), contentDescription = stringResource(R.string.ic_trending_cdesc) ), TonalActionSectionItem( text = stringResource(R.string.sort_sheet_controversial), icon = painterResource(R.drawable.ic_angry), contentDescription = stringResource(R.string.ic_angry_cdesc) ), TonalActionSectionItem( text = stringResource(R.string.sort_sheet_top), icon = painterResource(R.drawable.ic_arrow_up), contentDescription = stringResource(R.string.ic_arrow_up_cdesc) ) ), singleSelect = true, selectedIndexInitial = when (currentSortSelection) { PostFilterSort.SORT_HOT -> 0 PostFilterSort.SORT_NEW -> 1 PostFilterSort.SORT_RISING -> 2 PostFilterSort.SORT_CONTROVERSIAL -> 3 PostFilterSort.SORT_TOP -> 4 else -> 0 }, onSelectChange = { index, item -> selectedSortType = when (index) { 0 -> { selectedSortTime = PostFilterTime.TIME_DAY PostFilterSort.SORT_HOT } 1 -> { selectedSortTime = PostFilterTime.TIME_DAY PostFilterSort.SORT_NEW } 2 -> { selectedSortTime = PostFilterTime.TIME_DAY PostFilterSort.SORT_RISING } 3 -> PostFilterSort.SORT_CONTROVERSIAL 4 -> PostFilterSort.SORT_TOP else -> PostFilterSort.SORT_HOT } }, modifier = Modifier.padding( top = 15.dp, bottom = animateDpAsState(targetValue = if (showExtended) 0.dp else 15.dp).value ) ) AnimatedVisibility( visible = showExtended, modifier = Modifier.fillMaxWidth(), enter = fadeIn(animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec()) + expandVertically(animationSpec = MaterialTheme.motionScheme.defaultSpatialSpec()), exit = fadeOut(animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec()) + shrinkVertically(animationSpec = MaterialTheme.motionScheme.defaultSpatialSpec()) ) { Column(modifier = Modifier.padding(bottom = 15.dp)) { Text( text = stringResource(R.string.sort_sheet_time_header), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 20.dp) ) TonalActionSectionList( items = listOf( TonalActionSectionItem( text = stringResource(R.string.sort_sheet_day), icon = painterResource(R.drawable.ic_calendar_day), contentDescription = stringResource(R.string.ic_calendar_day_cdesc) ), TonalActionSectionItem( text = stringResource(R.string.sort_sheet_week), icon = painterResource(R.drawable.ic_week), contentDescription = stringResource(R.string.ic_week_cdesc) ), TonalActionSectionItem( text = stringResource(R.string.sort_sheet_month), icon = painterResource(R.drawable.ic_calendar_month), contentDescription = stringResource(R.string.ic_calendar_month_cdesc) ), TonalActionSectionItem( text = stringResource(R.string.sort_sheet_year), icon = painterResource(R.drawable.ic_hourglass), contentDescription = stringResource(R.string.ic_hourglass_cdesc) ), TonalActionSectionItem( text = stringResource(R.string.sort_sheet_all), icon = painterResource(R.drawable.ic_history), contentDescription = stringResource(R.string.ic_history_cdesc) ) ), singleSelect = true, selectedIndexInitial = when (currentTimeSelection) { PostFilterTime.TIME_DAY -> 0 PostFilterTime.TIME_WEEK -> 1 PostFilterTime.TIME_MONTH -> 2 PostFilterTime.TIME_YEAR -> 3 PostFilterTime.TIME_ALL -> 4 else -> 0 }, onSelectChange = { index, item -> selectedSortTime = when (index) { 0 -> PostFilterTime.TIME_DAY 1 -> PostFilterTime.TIME_WEEK 2 -> PostFilterTime.TIME_MONTH 3 -> PostFilterTime.TIME_YEAR 4 -> PostFilterTime.TIME_ALL else -> PostFilterTime.TIME_DAY } }, modifier = Modifier.padding(top = 15.dp) ) } } } } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/state/AuthViewState.kt ================================================ package com.pineapple.app.ui.state /** * Object to reflect a state of the reddit authentication process */ sealed class AuthViewState { object Idle : AuthViewState() object Loading : AuthViewState() object Success : AuthViewState() data class Error(val message: String) : AuthViewState() } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/theme/Shape.kt ================================================ package com.pineapple.app.ui.theme import androidx.compose.ui.unit.dp val ExtraSmallCornerRadius = 4.dp val SmallCornerRadius = 8.dp val MediumCornerRadius = 12.dp val LargeCornerRadius = 16.dp val ExtraLargeCornerRadius = 28.dp val FullCornerRadius = 100.dp ================================================ FILE: app/src/main/java/com/pineapple/app/ui/theme/Theme.kt ================================================ package com.pineapple.app.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MotionScheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun PineappleTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, content: @Composable () -> Unit ) { val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } darkTheme -> darkColorScheme() else -> lightColorScheme() } MaterialTheme( colorScheme = colorScheme, // typography = PineappleTypography, motionScheme = MotionScheme.expressive(), content = content ) } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/theme/Type.kt ================================================ package com.pineapple.app.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import com.pineapple.app.R val GoogleSans = FontFamily( Font(R.font.google_sans_regular, weight = FontWeight.Normal), Font(R.font.google_sans_medium, weight = FontWeight.Medium), Font(R.font.google_sans_semibold, weight = FontWeight.SemiBold), Font(R.font.google_sans_bold, weight = FontWeight.Bold) ) val PineappleTypography = Typography( displayLarge = Typography().displayLarge.copy(fontFamily = GoogleSans), displayMedium = Typography().displayMedium.copy(fontFamily = GoogleSans), displaySmall = Typography().displaySmall.copy(fontFamily = GoogleSans), headlineLarge = Typography().headlineLarge.copy(fontFamily = GoogleSans), headlineMedium = Typography().headlineMedium.copy(fontFamily = GoogleSans), headlineSmall = Typography().headlineSmall.copy(fontFamily = GoogleSans), titleLarge = Typography().titleLarge.copy(fontFamily = GoogleSans), titleMedium = Typography().titleMedium.copy(fontFamily = GoogleSans), titleSmall = Typography().titleSmall.copy(fontFamily = GoogleSans), bodyLarge = Typography().bodyLarge.copy(fontFamily = GoogleSans), bodyMedium = Typography().bodyMedium.copy(fontFamily = GoogleSans), bodySmall = Typography().bodySmall.copy(fontFamily = GoogleSans), labelLarge = Typography().labelLarge.copy(fontFamily = GoogleSans), labelMedium = Typography().labelMedium.copy(fontFamily = GoogleSans), labelSmall = Typography().labelSmall.copy(fontFamily = GoogleSans) ) ================================================ FILE: app/src/main/java/com/pineapple/app/ui/view/AccountPage.kt ================================================ package com.pineapple.app.ui.view import androidx.compose.runtime.Composable @Composable fun AccountPage() { } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/view/BrowsePage.kt ================================================ package com.pineapple.app.ui.view import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.paging.LoadState import androidx.paging.compose.collectAsLazyPagingItems import com.pineapple.app.consts.NavDestinationKey import com.pineapple.app.network.model.reddit.PostData import com.pineapple.app.ui.components.PostCard import com.pineapple.app.ui.viewmodel.BrowseViewModel import com.pineapple.app.utilities.toPostData import com.pineapple.app.utilities.toUserAboutListing @Composable fun BrowsePage( onRequestUserAuth: () -> Unit, onRequestPostDetailSheet: (PostData) -> Unit, navController: NavController ) { val viewModel: BrowseViewModel = hiltViewModel() val pagingItems = viewModel.pagedPosts.collectAsLazyPagingItems() val pullRefreshState = rememberPullToRefreshState() LaunchedEffect(pagingItems.loadState.refresh) { val refresh = pagingItems.loadState.refresh if (refresh !is LoadState.Loading && viewModel.shouldScrollToTopAfterRefresh) { viewModel.postListState.animateScrollToItem(0) } } PullToRefreshBox( onRefresh = { pagingItems.refresh() }, isRefreshing = false, state = pullRefreshState, modifier = Modifier.fillMaxSize() ) { Column(modifier = Modifier.fillMaxSize()) { LazyColumn( modifier = Modifier.fillMaxSize(), state = viewModel.postListState ) { items( count = pagingItems.itemCount, key = { index -> pagingItems[index]?.post?.id ?: index } ) { index -> val item = pagingItems[index] ?: return@items val postData = item.post.toPostData() val userInfo = item.user?.toUserAboutListing() PostCard( postData = postData, modifier = Modifier.padding( vertical = 5.dp, horizontal = 10.dp ), userInfo = userInfo, onClick = { viewModel.shouldScrollToTopAfterRefresh = false android.util.Log.e("BrowsePage", "navigating to post detail for id=${postData.id}") navController.navigate("${NavDestinationKey.PostView}/${postData.id}") }, onMoreClick = { onRequestPostDetailSheet(postData) }, onSaveClick = { newState, onSuccess -> if (!viewModel.isUserless) { postData.id?.let { onSuccess() viewModel.updatePostFavorite(newState, it) } } else { onRequestUserAuth() } }, onUpvote = { intention, onSuccess -> if (!viewModel.isUserless) { postData.id?.let { postID -> onSuccess() viewModel.updatePostVote( postId = postID, direction = if (intention) 1 else 0 ) } } else { onRequestUserAuth() } }, onDownvote = { intention, onSuccess -> if (!viewModel.isUserless) { postData.id?.let { postID -> onSuccess() viewModel.updatePostVote( postId = postID, direction = if (intention) -1 else 0 ) } } else { onRequestUserAuth() } } ) } } } } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/view/ChatPage.kt ================================================ package com.pineapple.app.ui.view import androidx.compose.runtime.Composable @Composable fun ChatPage() { } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/view/CommunityView.kt ================================================ package com.pineapple.app.ui.view import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.navigation.NavController import com.pineapple.app.ui.theme.PineappleTheme @Composable fun CommunityView(navController: NavController, community: String) { PineappleTheme { Text(community) } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/view/HomeView.kt ================================================ @file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) package com.pineapple.app.ui.view import android.content.Intent import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandIn import androidx.compose.animation.shrinkOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.paging.LoadState import androidx.paging.compose.collectAsLazyPagingItems import coil3.compose.AsyncImage import com.pineapple.app.R import com.pineapple.app.consts.NavDestinationKey import com.pineapple.app.consts.PageDestinationKey import com.pineapple.app.ui.modal.PostOptionSheet import com.pineapple.app.ui.modal.SortPostSheet import com.pineapple.app.ui.theme.PineappleTheme import com.pineapple.app.ui.viewmodel.BrowseViewModel import com.pineapple.app.ui.viewmodel.HomeViewModel import kotlinx.coroutines.launch @Composable fun HomeView(navController: NavController) { val viewModel: HomeViewModel = hiltViewModel() val browseViewModel: BrowseViewModel = hiltViewModel() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() val pagingItems = browseViewModel.pagedPosts.collectAsLazyPagingItems() val topSubreddits = viewModel.topSubreddits.collectAsState(initial = emptyList()) val subscribedSubreddits = viewModel.subscribedSubreddits.collectAsState(initial = emptyList()) PineappleTheme { ModalNavigationDrawer( drawerState = drawerState, drawerContent = { ModalDrawerSheet(modifier = Modifier.fillMaxWidth(0.7F)) { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Icon( painter = painterResource(R.drawable.ic_pineapple_logo), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 20.dp, start = 20.dp) .height(100.dp) ) Column( modifier = Modifier.padding(start = 15.dp, end = 15.dp, top = 30.dp) ) { NavigationDrawerItem( label = { Text(stringResource(R.string.home_nav_home)) }, icon = { Icon( painter = painterResource(R.drawable.ic_browse), contentDescription = stringResource(R.string.ic_browse_cdesc) ) }, selected = viewModel.currentNavPage == PageDestinationKey.BROWSE, onClick = { viewModel.currentNavPage = PageDestinationKey.BROWSE scope.launch { drawerState.close() } } ) NavigationDrawerItem( label = { Text(stringResource(R.string.home_nav_account)) }, icon = { Icon( painter = painterResource(R.drawable.ic_person), contentDescription = stringResource(R.string.ic_person_cdesc) ) }, selected = viewModel.currentNavPage == PageDestinationKey.ACCOUNT, onClick = { viewModel.currentNavPage = PageDestinationKey.ACCOUNT scope.launch { drawerState.close() } }, modifier = Modifier.padding(top = 2.dp) ) NavigationDrawerItem( label = { Text(stringResource(R.string.home_drawer_settings)) }, icon = { Icon( painter = painterResource(R.drawable.ic_settings), contentDescription = stringResource(R.string.ic_settings_cdesc) ) }, selected = false, onClick = { scope.launch { drawerState.close() } }, modifier = Modifier.padding(top = 2.dp) ) } val isShowingPopular = viewModel.isUserless || subscribedSubreddits.value.isEmpty() val subreddits = if (isShowingPopular) topSubreddits.value else subscribedSubreddits.value Text( text = if (isShowingPopular) { stringResource(R.string.home_drawer_communities_uless) } else { stringResource(R.string.home_drawer_communities) }, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 20.dp, start = 20.dp) ) Column(modifier = Modifier.padding(horizontal = 15.dp, vertical = 15.dp)) { subreddits.forEach { subreddit -> NavigationDrawerItem( label = { Text("r/${subreddit.name}") }, icon = { if (subreddit.iconUrl.isNotEmpty()) { AsyncImage( model = subreddit.iconUrl, contentDescription = null, modifier = Modifier.clip(CircleShape).size(25.dp) ) } else { Box( modifier = Modifier.clip(CircleShape) .size(25.dp) .background(MaterialTheme.colorScheme.primaryContainer) ) { Icon( painter = painterResource(R.drawable.ic_community), contentDescription = stringResource(R.string.ic_community_cdesc), modifier = Modifier.align(Alignment.Center) .size(18.dp) ) } } }, selected = false, onClick = { scope.launch { drawerState.close() } }, modifier = Modifier.padding(top = 2.dp) ) } } } } } ) { Scaffold( bottomBar = { NavigationBar { NavigationBarItem( selected = viewModel.currentNavPage == PageDestinationKey.BROWSE, onClick = { viewModel.currentNavPage = PageDestinationKey.BROWSE }, icon = { Icon( painter = painterResource(R.drawable.ic_browse), contentDescription = stringResource(R.string.ic_browse_cdesc) ) }, label = { Text(stringResource(R.string.home_nav_home)) } ) NavigationBarItem( selected = viewModel.currentNavPage == PageDestinationKey.SEARCH, onClick = { viewModel.currentNavPage = PageDestinationKey.SEARCH }, icon = { Icon( painter = painterResource(R.drawable.ic_search), contentDescription = stringResource(R.string.ic_search_cdesc) ) }, label = { Text(stringResource(R.string.home_nav_search)) } ) NavigationBarItem( selected = viewModel.currentNavPage == PageDestinationKey.CHATS, onClick = { viewModel.currentNavPage = PageDestinationKey.CHATS }, icon = { Icon( painter = painterResource(R.drawable.ic_forum), contentDescription = stringResource(R.string.ic_chats_cdesc) ) }, label = { Text(stringResource(R.string.home_nav_chats)) } ) NavigationBarItem( selected = viewModel.currentNavPage == PageDestinationKey.ACCOUNT, onClick = { viewModel.currentNavPage = PageDestinationKey.ACCOUNT }, icon = { Icon( painter = painterResource(R.drawable.ic_person), contentDescription = stringResource(R.string.ic_person_cdesc) ) }, label = { Text(stringResource(R.string.home_nav_account)) } ) } }, topBar = { AnimatedContent( targetState = viewModel.currentNavPage != PageDestinationKey.SEARCH, modifier = Modifier.fillMaxWidth() ) { showAppBar -> if (showAppBar) { Column { CenterAlignedTopAppBar( title = { AnimatedContent(targetState = viewModel.currentNavPage) { page -> when (page) { PageDestinationKey.BROWSE -> { Text(stringResource(R.string.home_title)) } PageDestinationKey.CHATS -> { Text(stringResource(R.string.home_nav_chats)) } PageDestinationKey.ACCOUNT -> { Text(stringResource(R.string.home_nav_account)) } } } }, navigationIcon = { IconButton( onClick = { scope.launch { drawerState.open() } } ) { Icon( painter = painterResource(R.drawable.ic_menu), contentDescription = stringResource(R.string.ic_menu_cdesc) ) } }, actions = { AnimatedContent( targetState = viewModel.currentNavPage == PageDestinationKey.BROWSE ) { show -> if (show) { IconButton( onClick = { viewModel.showPostFilterSheet = true } ) { Icon( painter = painterResource(R.drawable.ic_filter), contentDescription = stringResource(R.string.ic_filter_cdesc) ) } } } }, scrollBehavior = scrollBehavior ) AnimatedVisibility( visible = pagingItems.loadState.refresh is LoadState.Loading, modifier = Modifier.fillMaxWidth() .padding(horizontal = 5.dp) ) { LinearProgressIndicator() } } } } }, snackbarHost = { SnackbarHost(viewModel.snackbarState) }, floatingActionButton = { if (viewModel.currentNavPage == PageDestinationKey.BROWSE){ FloatingActionButton( onClick = { }, shape = MaterialTheme.shapes.large, modifier = Modifier.size(65.dp) ) { Icon( painter = painterResource(R.drawable.ic_plus), contentDescription = stringResource(R.string.ic_plus_cdesc) ) } } }, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) ) { paddingValues -> AnimatedContent( modifier = Modifier.padding(paddingValues).fillMaxSize(), targetState = viewModel.currentNavPage ) { page -> when (page) { PageDestinationKey.BROWSE -> { BrowsePage( onRequestUserAuth = { viewModel.encourageUserAuthSnackbar() }, onRequestPostDetailSheet = { postData -> viewModel.openPostOptionSheet(postData) }, navController = navController ) } PageDestinationKey.SEARCH -> SearchPage(navController) PageDestinationKey.CHATS -> ChatPage() PageDestinationKey.ACCOUNT -> AccountPage() } } } } if (viewModel.showPostFilterSheet) { SortPostSheet( onDismissRequest = { time, sort -> viewModel.apply { showPostFilterSheet = false browseViewModel.updateFilters(sort, time) } }, currentSortSelection = browseViewModel.currentFilterSort, currentTimeSelection = browseViewModel.currentFilterTime ) } if (viewModel.showPostOptionSheet) { viewModel.currentPostOptionData?.let { postData -> PostOptionSheet( postData = postData, onDismissRequest = { viewModel.showPostOptionSheet = false }, onViewUser = { viewModel.showPostOptionSheet = false navController.navigate("${NavDestinationKey.UserView}/${postData.author}") }, onViewCommunity = { viewModel.showPostOptionSheet = false navController.navigate("${NavDestinationKey.CommunityView}/${postData.subreddit}") }, onReport = { Intent(Intent.ACTION_VIEW).apply { this.data = ("https://www.reddit.com/report" + "?url=https://www.reddit.com${postData.permalink}").toUri() navController.context.startActivity(this) } }, onOpenExternal = { Intent(Intent.ACTION_VIEW).apply { this.data = "https://www.reddit.com${postData.permalink}".toUri() navController.context.startActivity(this) } } ) } } } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/view/KeyProviderView.kt ================================================ package com.pineapple.app.ui.view import android.content.Intent import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import com.pineapple.app.ui.state.AuthViewState import com.pineapple.app.R import com.pineapple.app.consts.MMKVKey import com.pineapple.app.consts.NavDestinationKey import com.pineapple.app.consts.OnboardingLoginType import com.pineapple.app.ui.theme.PineappleTheme import com.pineapple.app.ui.viewmodel.KeyProviderViewModel import java.util.UUID @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun KeyProviderView(navController: NavController, loginType: String) { val viewModel: KeyProviderViewModel = hiltViewModel() val viewState = viewModel.viewState.collectAsState() LaunchedEffect(viewState.value) { if (viewState.value is AuthViewState.Success) { when (loginType) { OnboardingLoginType.Guest -> { viewModel.mmkv.encode(MMKVKey.ONBOARDING_COMPLETE, true) navController.navigate(NavDestinationKey.HomeView) } OnboardingLoginType.RedditAuth -> { viewModel.launchRedditAuthFlow(navController.context) } } } } PineappleTheme { AnimatedContent(viewState.value is AuthViewState.Loading) { loading -> if (!loading) { Scaffold( topBar = { LargeTopAppBar( title = { Text( text = stringResource(R.string.provide_key_title_text), style = MaterialTheme.typography.displaySmall ) }, navigationIcon = { IconButton(onClick = { navController.popBackStack() }) { Icon( painter = painterResource(R.drawable.ic_back), contentDescription = stringResource(R.string.ic_back_cdesc) ) } } ) }, floatingActionButton = { FloatingActionButton( onClick = { viewModel.submitClientSecret() }, containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer, elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), shape = MaterialTheme.shapes.extraLarge ) { Icon( painter = painterResource(R.drawable.ic_forward), contentDescription = stringResource(R.string.ic_forward_cdesc), modifier = Modifier .padding(30.dp) .size(26.dp) ) } } ) { paddingValues -> Column( modifier = Modifier.padding(paddingValues) ) { Text( text = stringResource(R.string.provide_key_subtitle_text), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(vertical = 10.dp, horizontal = 20.dp) ) TextField( value = viewModel.clientSecretTextFieldValue, onValueChange = { viewModel.clientSecretTextFieldValue = it }, label = { Text(stringResource(R.string.provide_key_entry_hint)) }, modifier = Modifier .padding(top = 30.dp, start = 20.dp, end = 20.dp) .fillMaxWidth(), singleLine = true, supportingText = { if (viewState.value is AuthViewState.Error) { Text( text = (viewState.value as AuthViewState.Error).message ) } }, isError = viewState.value is AuthViewState.Error ) TextButton( onClick = { navController.context.startActivity( Intent(Intent.ACTION_VIEW, "https://reddit.com/prefs/apps".toUri()) ) }, modifier = Modifier.padding(top = 25.dp, start = 10.dp) ) { Icon( painter = painterResource(R.drawable.ic_reddit), contentDescription = stringResource(R.string.ic_reddit_cdesc) ) Text( text = stringResource(R.string.provide_key_dev_button), modifier = Modifier.padding(start = 15.dp) ) } TextButton( onClick = { // Link to some markdown file in the github }, modifier = Modifier.padding(start = 10.dp) ) { Icon( painter = painterResource(R.drawable.ic_help), contentDescription = stringResource(R.string.ic_help_cdesc) ) Text( text = stringResource(R.string.provide_key_what_button), modifier = Modifier.padding(start = 15.dp) ) } } } } else { Box(modifier = Modifier.fillMaxSize()) { LoadingIndicator( modifier = Modifier .align(Alignment.Center) .size(100.dp) ) } } } } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/view/PostView.kt ================================================ @file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) package com.pineapple.app.ui.view import android.content.Intent import androidx.compose.animation.AnimatedContent 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.fillMaxHeight 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.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.paging.compose.collectAsLazyPagingItems import coil3.compose.AsyncImage import com.pineapple.app.R import com.pineapple.app.consts.NavDestinationKey import com.pineapple.app.ui.components.AnimatedTonalToggleIconButton import com.pineapple.app.ui.components.CommentCard import com.pineapple.app.ui.components.MeasuredAsyncImage import com.pineapple.app.ui.modal.CommentDetailSheet import com.pineapple.app.ui.modal.PostOptionSheet import com.pineapple.app.ui.theme.MediumCornerRadius import com.pineapple.app.ui.theme.PineappleTheme import com.pineapple.app.ui.viewmodel.BrowseViewModel import com.pineapple.app.ui.viewmodel.PostViewModel import com.pineapple.app.utilities.convertUnixToRelativeTime import com.pineapple.app.utilities.prettyNumber import com.pineapple.app.utilities.toPostData @Composable fun PostView(navController: NavController, postID: String) { val viewModel: PostViewModel = hiltViewModel() val browseViewModel: BrowseViewModel = hiltViewModel() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val postWithUser = viewModel.postState.collectAsState() val comments = viewModel.comments.collectAsLazyPagingItems() val isLoading = viewModel.isLoading.collectAsState() val context = LocalContext.current LaunchedEffect(postID) { viewModel.loadPost(postID) } PineappleTheme { Scaffold( topBar = { TopAppBar( title = { }, navigationIcon = { IconButton( onClick = { navController.popBackStack() } ) { Icon( painter = painterResource(R.drawable.ic_back), contentDescription = stringResource(R.string.ic_back_cdesc) ) } }, actions = { IconButton( onClick = { viewModel.showingMoreSheet = true } ) { Icon( painter = painterResource(R.drawable.ic_more_vert), contentDescription = stringResource(R.string.ic_more_vert_cdesc) ) } }, scrollBehavior = scrollBehavior ) }, snackbarHost = { SnackbarHost(viewModel.snackbarState) }, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) ) { paddingValues -> Column(modifier = Modifier.padding(paddingValues)) { AnimatedContent(isLoading.value, label = "Post Loading Animation") { if (it) { Box(Modifier.fillMaxSize()) { LoadingIndicator( Modifier .size(75.dp) .align(Alignment.Center) ) } } else { postWithUser.value?.let { post -> LazyColumn( modifier = Modifier .fillMaxWidth() ) { item { Column { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 15.dp) .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceContainerLow), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Row(verticalAlignment = Alignment.CenterVertically) { post.user?.let { AsyncImage( model = it.snoovatarUrl?.ifBlank { null } ?: it.iconUrl, contentDescription = null, placeholder = painterResource(R.drawable.generic_avatar), modifier = Modifier .padding(15.dp) .clip(CircleShape) .size(35.dp) ) } Column { post.post.author?.let { Text( text = "u/$it", style = MaterialTheme.typography.titleSmall ) } post.post.subreddit?.let { Text( text = "r/$it", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface ) } } } Row(modifier = Modifier.padding(end = 15.dp)) { FilledTonalIconButton( onClick = { navController.navigate("${NavDestinationKey.CommunityView}/${post.post.subreddit}") }, modifier = Modifier.width(33.dp) ) { Icon( painter = painterResource(R.drawable.ic_community), contentDescription = stringResource(R.string.ic_community_cdesc) ) } FilledTonalIconButton( onClick = { navController.navigate("${NavDestinationKey.UserView}/${post.post.author}") }, modifier = Modifier .padding(start = 10.dp) .width(33.dp) ) { Icon( painter = painterResource(R.drawable.ic_person), contentDescription = stringResource(R.string.ic_person_cdesc) ) } } } Text( text = post.post.title, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding( top = 15.dp, start = 15.dp, end = 15.dp ) ) val width = post.post.previewWidth?.toFloat() ?: 0f val height = post.post.previewHeight?.toFloat() ?: 0f val imageUrl = post.post.previewImageUrl?.replace("amp;", "") ?.ifEmpty { post.post.url } val computedAspectRatio = if (width > 0f && height > 0f) { (width / height).coerceIn(0.2f, 4f) } else null if (imageUrl !== null && imageUrl.isNotEmpty()) { var aspectRatio by rememberSaveable(post.post.id + "_ratio") { mutableStateOf(computedAspectRatio) } aspectRatio?.let { MeasuredAsyncImage( imageUrl = imageUrl, aspectRatio = it, modifier = Modifier .fillMaxWidth() .padding(horizontal = 15.dp) .padding(top = 15.dp) .clip(MaterialTheme.shapes.medium) ) } } if (!post.post.selftext.isNullOrEmpty()) { Text( text = post.post.selftext, modifier = Modifier.padding(15.dp), style = MaterialTheme.typography.bodyLarge ) } Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 15.dp) .padding(top = if (post.post.selftext.isNullOrEmpty()) 15.dp else 0.dp) .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceContainerLow), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { var upvoteState by remember { mutableStateOf(post.post.likes == true) } var downvoteState by remember { mutableStateOf(post.post.likes == false) } var saveState by remember { mutableStateOf(post.post.saved == true) } Row( modifier = Modifier.padding( start = 5.dp, top = 5.dp, bottom = 5.dp ) ) { FilledTonalIconButton( onClick = { post.post.permalink.let { url -> val sendIntent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" putExtra( Intent.EXTRA_TEXT, "https://reddit.com$url" ) } val shareIntent = Intent.createChooser( sendIntent, "Share post" ) context.startActivity(shareIntent) } }, shape = MaterialTheme.shapes.medium ) { Icon( painter = painterResource(R.drawable.ic_share), contentDescription = stringResource(R.string.ic_share_cdesc) ) } AnimatedTonalToggleIconButton( checked = saveState, onCheckedChange = { checked -> if (viewModel.isUserless) { viewModel.encourageUserAuthSnackbar() } else { saveState = checked val id = post.post.id.removePrefix("t3_") browseViewModel.updatePostFavorite( checked, id ) } }, checkedIcon = painterResource(R.drawable.ic_bookmark_filled), uncheckedIcon = painterResource(R.drawable.ic_bookmark), contentDescription = stringResource(R.string.ic_bookmark_cdesc) ) } Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 10.dp) ) { AnimatedTonalToggleIconButton( checked = downvoteState, onCheckedChange = { checked -> if (viewModel.isUserless) { viewModel.encourageUserAuthSnackbar() } else { downvoteState = checked if (upvoteState) { upvoteState = false } val id = post.post.id.removePrefix("t3_") val dir = if (checked) -1 else 0 viewModel.updateVote(dir, id) } }, checkedIcon = painterResource(R.drawable.ic_downvote), uncheckedIcon = painterResource(R.drawable.ic_downvote), contentDescription = stringResource(R.string.ic_downvote_cdesc), modifier = Modifier.width(33.dp), uncheckedRadius = 30.dp, checkedRadius = MediumCornerRadius ) post.post.ups?.toInt()?.prettyNumber()?.let { Text( text = it, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp) ) } AnimatedTonalToggleIconButton( checked = upvoteState, onCheckedChange = { checked -> if (viewModel.isUserless) { viewModel.encourageUserAuthSnackbar() } else { upvoteState = checked if (downvoteState) { downvoteState = false } val id = post.post.id.removePrefix("t3_") val dir = if (checked) 1 else 0 viewModel.updateVote(dir, id) } }, checkedIcon = painterResource(R.drawable.ic_upvote), uncheckedIcon = painterResource(R.drawable.ic_upvote), contentDescription = stringResource(R.string.ic_upvote_cdesc), modifier = Modifier.width(33.dp), uncheckedRadius = 30.dp, checkedRadius = MediumCornerRadius ) } } } } itemsIndexed(comments.itemSnapshotList) { _, commentWithUser -> val author = commentWithUser?.comment?.author LaunchedEffect(author) { viewModel.fetchUserOnVisible(author) } val depth = commentWithUser?.comment?.depth ?: 0 val indentPerLevel = 10.dp val barWidth = 2.dp Box(modifier = Modifier.fillMaxWidth()) { if (depth > 0) { Row( modifier = Modifier .matchParentSize() .padding(start = 15.dp) ) { for (i in 0 until depth) { Box( modifier = Modifier .fillMaxHeight() .width(barWidth) .background(MaterialTheme.colorScheme.surfaceContainerHighest) ) Box(modifier = Modifier.width(indentPerLevel - barWidth)) } } } CommentCard( commentWithUser = commentWithUser, onMoreClick = { viewModel.apply { commentToShowMoreSheet = commentWithUser showingCommentMoreSheet = true } }, onUpvote = { upvoted, onSuccess -> if (viewModel.isUserless) { viewModel.encourageUserAuthSnackbar() } else { onSuccess() commentWithUser?.comment?.id?.removePrefix("t1_")?.let { viewModel.updateVote( postId = it, direction = if (upvoted) 1 else 0 ) } } }, onDownvote = { downvoted, onSuccess -> if (viewModel.isUserless) { viewModel.encourageUserAuthSnackbar() } else { onSuccess() commentWithUser?.comment?.id?.removePrefix("t1_")?.let { viewModel.updateVote( postId = it, direction = if (downvoted) -1 else 0 ) } } }, modifier = Modifier.padding( start = 15.dp + (depth * indentPerLevel.value).dp, end = 15.dp, top = 10.dp, bottom = 10.dp ) ) } } } } } } } } if (viewModel.showingCommentMoreSheet) { viewModel.commentToShowMoreSheet?.let { CommentDetailSheet( commentWithUser = it, onDismissRequest = { viewModel.showingCommentMoreSheet = false }, onDownvote = { downvoted, onSuccess -> if (viewModel.isUserless) { viewModel.apply { showingCommentMoreSheet = false encourageUserAuthSnackbar() } } else { onSuccess() it.comment.id.let { viewModel.updateVote( postId = it, direction = if (downvoted) -1 else 0 ) } } }, onUpvote = { upvoted, onSuccess -> if (viewModel.isUserless) { viewModel.apply { showingCommentMoreSheet = false encourageUserAuthSnackbar() } } else { onSuccess() it.comment.id.removePrefix("t3_").let { viewModel.updateVote( postId = it, direction = if (upvoted) 1 else 0 ) } } }, onSaveClick = { saved, onSuccess -> if (viewModel.isUserless) { viewModel.apply { showingCommentMoreSheet = false encourageUserAuthSnackbar() } } else { val id = it.comment.id.removePrefix("t3_") browseViewModel.updatePostFavorite(saved, id) onSuccess() } }, onViewUserClick = { viewModel.showingCommentMoreSheet = false navController.navigate("${NavDestinationKey.UserView}/${it.comment.author}") } ) } } if (viewModel.showingMoreSheet) { postWithUser.value?.let { PostOptionSheet( postData = it.post.toPostData(), onDismissRequest = { viewModel.showingMoreSheet = false }, onViewUser = { navController.navigate("${NavDestinationKey.UserView}/${it.post.author}") }, onViewCommunity = { navController.navigate("${NavDestinationKey.CommunityView}/${it.post.subreddit}") }, onOpenExternal = { Intent(Intent.ACTION_VIEW).apply { this.data = "https://www.reddit.com${it.post.permalink}".toUri() navController.context.startActivity(this) } }, onReport = { Intent(Intent.ACTION_VIEW).apply { this.data = ("https://www.reddit.com/report" + "?url=https://www.reddit.com${it.post.permalink}").toUri() navController.context.startActivity(this) } } ) } } } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/view/SearchPage.kt ================================================ @file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) package com.pineapple.app.ui.view import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.coerceAtLeast import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.paging.LoadState import androidx.paging.compose.collectAsLazyPagingItems import coil3.compose.rememberAsyncImagePainter import coil3.request.CachePolicy import coil3.request.ImageRequest import com.pineapple.app.R import com.pineapple.app.consts.NavDestinationKey import com.pineapple.app.ui.components.TonalActionSectionItem import com.pineapple.app.ui.components.TonalActionSectionList import com.pineapple.app.ui.components.PostCard import com.pineapple.app.ui.theme.PineappleTheme import com.pineapple.app.ui.viewmodel.SearchViewModel import com.pineapple.app.utilities.toPostData import com.pineapple.app.utilities.toUserAboutListing import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.painter.Painter @Composable fun SearchPage(navController: NavController) { val viewModel: SearchViewModel = hiltViewModel() val context = LocalContext.current val searchResults = viewModel.searchResults.collectAsLazyPagingItems() val subredditSuggestions by viewModel.subredditSuggestions.collectAsState() val userSuggestions by viewModel.userSuggestions.collectAsState() val motionScheme = MaterialTheme.motionScheme val searchFieldPadding by animateDpAsState( targetValue = if (viewModel.expandedSearchField) 0.dp else 15.dp, animationSpec = motionScheme.fastSpatialSpec(), label = "search_padding" ) PineappleTheme { Column { Column( modifier = Modifier .fillMaxWidth() .padding( top = searchFieldPadding.coerceAtLeast(0.dp), start = searchFieldPadding.coerceAtLeast(0.dp), end = searchFieldPadding.coerceAtLeast(0.dp) ) ) { SearchBar( inputField = { SearchBarDefaults.InputField( query = viewModel.searchFieldValue, // when the user types and clears the field, clear the active search results onQueryChange = { newText -> viewModel.updateQueryText(newText) if (newText.isBlank()) { viewModel.clearSearchQuery() } }, onSearch = { viewModel.submitSearch() }, expanded = viewModel.expandedSearchField, onExpandedChange = { viewModel.expandedSearchField = it }, placeholder = { Text(stringResource(R.string.search_placeholder)) }, leadingIcon = { AnimatedContent(viewModel.expandedSearchField) { expanded -> if (expanded) { IconButton( onClick = { viewModel.expandedSearchField = false viewModel.updateQueryText("") viewModel.clearSearchQuery() } ) { Icon( painter = painterResource(R.drawable.ic_back), contentDescription = stringResource(R.string.ic_back_cdesc) ) } } else { Icon( painter = painterResource(R.drawable.ic_search), contentDescription = stringResource(R.string.ic_search_cdesc) ) } } }, trailingIcon = { AnimatedContent( targetState = viewModel.searchFieldValue.isNotEmpty(), transitionSpec = { scaleIn(motionScheme.fastSpatialSpec()) .togetherWith(scaleOut(motionScheme.fastSpatialSpec())) } ) { show -> if (show) { IconButton( onClick = { viewModel.updateQueryText("") viewModel.clearSearchQuery() } ) { Icon( painter = painterResource(R.drawable.ic_close), contentDescription = stringResource(R.string.ic_close_cdesc) ) } } } } ) }, expanded = viewModel.expandedSearchField, onExpandedChange = { viewModel.expandedSearchField = it }, modifier = Modifier.fillMaxWidth(), content = { AnimatedContent( targetState = subredditSuggestions.isNotEmpty(), modifier = Modifier.fillMaxWidth() ) { suggestions -> if (suggestions) { Column { val items = subredditSuggestions.map { sd -> val name = sd.displayName val iconUrl = sd.iconUrl val painter: Painter = if (iconUrl.isBlank() || iconUrl.contains( "default", ignoreCase = true ) ) { painterResource(R.drawable.generic_community) } else { rememberAsyncImagePainter( ImageRequest.Builder(context) .data(iconUrl) .diskCachePolicy(CachePolicy.ENABLED) .memoryCachePolicy(CachePolicy.ENABLED) .build() ) } TonalActionSectionItem( text = "r/$name", icon = painter, contentDescription = "Subreddit $name", onCLick = { viewModel.updateQueryText(name) viewModel.submitSearch() }, shouldTintIcon = false ) } Text( text = stringResource(R.string.search_communities), style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding( top = 15.dp, start = 15.dp, bottom = 10.dp ), color = MaterialTheme.colorScheme.onSurfaceVariant ) TonalActionSectionList( items = items, modifier = Modifier.padding(horizontal = 15.dp), containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, listItemContainerColor = MaterialTheme.colorScheme.surfaceContainerLow ) } } } AnimatedContent( targetState = userSuggestions.isNotEmpty(), modifier = Modifier.fillMaxWidth() ) { suggestions -> if (suggestions) { Column { val items = userSuggestions.map { ua -> val name = ua.name ?: "" val iconUrl = ua.icon_img ?: ua.snoovatar_img val isDefaultIcon = ua.subreddit?.is_default_icon ?: false val painter = if (isDefaultIcon || iconUrl.isNullOrBlank()) { painterResource(R.drawable.generic_avatar) } else { rememberAsyncImagePainter( ImageRequest.Builder(context) .data(iconUrl) .diskCachePolicy(CachePolicy.ENABLED) .memoryCachePolicy(CachePolicy.ENABLED) .build() ) } TonalActionSectionItem( text = "u/$name", icon = painter, contentDescription = "User $name", onCLick = { viewModel.updateQueryText(name) viewModel.submitSearch() }, shouldTintIcon = false ) } Text( text = stringResource(R.string.search_users), style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding( top = 15.dp, start = 15.dp, bottom = 10.dp ), color = MaterialTheme.colorScheme.onSurfaceVariant ) TonalActionSectionList( items = items, modifier = Modifier.padding(horizontal = 15.dp), containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, listItemContainerColor = MaterialTheme.colorScheme.surfaceContainerLow ) } } } } ) } AnimatedContent(searchResults) { result -> if (result.loadState.refresh is LoadState.Loading && viewModel.searchFieldValue.isNotEmpty() ) { Box(Modifier.fillMaxSize()) { LoadingIndicator(Modifier.size(75.dp).align(Alignment.Center)) } } else if (result.itemCount != 0) { LazyColumn( modifier = Modifier.padding(horizontal = 10.dp), state = viewModel.postListState ) { itemsIndexed(result.itemSnapshotList.items) { index, item -> PostCard( postData = item.post.toPostData(), modifier = Modifier.padding(top = 10.dp), userInfo = item.user?.toUserAboutListing(), onClick = { // pass id without the t3_ prefix (PostView expects raw id) val rawId = item.post.id.removePrefix("t3_") navController.navigate("${NavDestinationKey.PostView}/$rawId") }, onMoreClick = { }, onSaveClick = { _, _ -> }, onUpvote = { _, _ -> }, onDownvote = { _, _ -> }, ) } } } } } } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/view/UserView.kt ================================================ package com.pineapple.app.ui.view import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.navigation.NavController import com.pineapple.app.ui.theme.PineappleTheme @Composable fun UserView(navController: NavController, user: String) { PineappleTheme { Text(user) } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/view/WelcomeView.kt ================================================ package com.pineapple.app.ui.view import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.pineapple.app.R import com.pineapple.app.consts.NavDestinationKey import com.pineapple.app.consts.OnboardingLoginType import com.pineapple.app.ui.theme.PineappleTheme @Composable fun WelcomeView(navController: NavController) { PineappleTheme { Surface { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween, modifier = Modifier.systemBarsPadding().fillMaxSize() ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(start = 30.dp, end = 30.dp, top = 40.dp) ) { Icon( painter = painterResource(R.drawable.ic_pineapple_logo), contentDescription = stringResource(R.string.ic_pineapple_logo_cdesc), tint = MaterialTheme.colorScheme.primary ) Text( text = stringResource(R.string.welcome_app_name), style = MaterialTheme.typography.displayMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(top = 20.dp) ) Text( text = stringResource(R.string.welcome_slogan_text), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(top = 10.dp), textAlign = TextAlign.Center ) } Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 30.dp) ) { Button( onClick = { navController.navigate( "${NavDestinationKey.KeyProviderView}/${OnboardingLoginType.RedditAuth}" ) }, shape = MaterialTheme.shapes.large ) { Text( text = stringResource(R.string.welcome_sign_in_button), modifier = Modifier.padding(10.dp), style = MaterialTheme.typography.titleMedium ) } Button( onClick = { navController.navigate( "${NavDestinationKey.KeyProviderView}/${OnboardingLoginType.Guest}" ) }, shape = MaterialTheme.shapes.medium, colors = ButtonDefaults.filledTonalButtonColors(), modifier = Modifier.padding(top = 10.dp) ) { Text( text = stringResource(R.string.welcome_continue_as_guest_button), style = MaterialTheme.typography.labelLarge ) } } } } } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/viewmodel/BrowseViewModel.kt ================================================ @file:OptIn(ExperimentalCoroutinesApi::class) package com.pineapple.app.ui.viewmodel import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.cachedIn import com.pineapple.app.consts.MMKVKey import com.pineapple.app.consts.PostFilterSort import com.pineapple.app.consts.PostFilterTime import com.pineapple.app.network.paging.PagingRepository import com.pineapple.app.network.repository.RedditRepository import com.tencent.mmkv.MMKV import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class BrowseViewModel @Inject constructor( private val postRepository: PagingRepository, private val mmkv: MMKV, private val redditRepository: RedditRepository ) : ViewModel() { val isUserless by lazy { mmkv.getBoolean(MMKVKey.USER_GUEST, true) } var currentFilterTime by mutableStateOf(PostFilterTime.TIME_DAY) var currentFilterSort by mutableStateOf(PostFilterSort.SORT_HOT) var shouldScrollToTopAfterRefresh by mutableStateOf(false) val postListState = LazyListState() fun updateFilters(sort: String, time: String) { if (sort != currentFilterSort || time != currentFilterTime) { currentFilterSort = sort currentFilterTime = time shouldScrollToTopAfterRefresh = true } } val pagedPosts = snapshotFlow { currentFilterSort to currentFilterTime } .flatMapLatest { (sort, time) -> postRepository .postsPager( subreddit = "all", sort = sort, time = time ).flow } .cachedIn(viewModelScope) /** * Post an update to the reddit API that reflects the bookmark state of a post * Also update local cache via repository so UI reacts immediately * @param save: True if we save the post, false otherwise * @param postID: The ID of the post to update (without prefix) */ fun updatePostFavorite(save: Boolean, postID: String) { viewModelScope.launch { if (save) { redditRepository.savePost(postID) } else { redditRepository.unsavePost(postID) } } } /** * Post an update to the reddit API that reflects whether a post is up/downvoted * Also update local cache via repository so UI reacts immediately * @param direction: 1 for upvote, -1 for downvote, 0 for no vote or to remove vote * @param postId: The ID of the post to update (without prefix) */ fun updatePostVote(direction: Int, postId: String) { viewModelScope.launch { redditRepository.castVoteAndCache(postId, direction) } } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/viewmodel/HomeViewModel.kt ================================================ @file:OptIn(ExperimentalCoroutinesApi::class) package com.pineapple.app.ui.viewmodel import android.content.Context import android.content.Intent import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.pineapple.app.R import com.pineapple.app.consts.MMKVKey import com.pineapple.app.consts.PageDestinationKey import com.pineapple.app.network.model.reddit.PostData import com.pineapple.app.network.paging.PagingRepository import com.pineapple.app.network.repository.RedditRepository import com.tencent.mmkv.MMKV import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import java.util.UUID import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( @ApplicationContext private val context: Context, private val repository: RedditRepository, private val mmkv: MMKV ) : ViewModel() { val snackbarState = SnackbarHostState() var showPostFilterSheet by mutableStateOf(false) var showPostOptionSheet by mutableStateOf(false) var currentPostOptionData by mutableStateOf(null) var currentNavPage by mutableIntStateOf(PageDestinationKey.BROWSE) val isUserless by lazy { mmkv.getBoolean(MMKVKey.USER_GUEST, true) } val topSubreddits = repository.observePopularSubreddits() val subscribedSubreddits = repository.observeSubscribedSubreddits() init { viewModelScope.launch { repository.refreshPopularSubreddits() if (!isUserless) { repository.refreshSubscribedSubreddits() } } } /** * Open the post overflow bottom sheet menu */ fun openPostOptionSheet(postData: PostData) { currentPostOptionData = postData showPostOptionSheet = true } /** * Open a snackbar notifying the user that the action they are trying to perform requires * authentication, with a button allowing them to log in if they want to */ fun encourageUserAuthSnackbar() { viewModelScope.launch { val result = snackbarState.showSnackbar( message = context.getString(R.string.home_snackbar_auth_text), actionLabel = context.getString(R.string.home_snackbar_auth_login), duration = SnackbarDuration.Long ) if (result == SnackbarResult.ActionPerformed) { launchRedditAuthFlow(context) } } } /** * Open the reddit authentication flow in the default browser, which will return to the app * via a deep link once completed, containing the code in a query parameter. (handled in NavHost) */ fun launchRedditAuthFlow(context: Context) { Intent(Intent.ACTION_VIEW).apply { data = ("https://www.reddit.com/api/v1/authorize.compact" + "?client_id=${mmkv.decodeString(MMKVKey.CLIENT_ID)}" + "&response_type=code" + "&state=${UUID.randomUUID()}" + "&redirect_uri=pineapple://login" + "&duration=permanent" + "&scope=identity edit flair history modconfig modflair modlog " + "modposts, modwiki mysubreddits privatemessages read report save " + "submit subscribe vote wikiedit wikiread" ).toUri() flags = Intent.FLAG_ACTIVITY_NEW_TASK context.startActivity(this) } } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/viewmodel/KeyProviderViewModel.kt ================================================ package com.pineapple.app.ui.viewmodel import android.content.Context import android.content.Intent import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.text.input.TextFieldValue import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.pineapple.app.ui.state.AuthViewState import com.pineapple.app.consts.MMKVKey import com.pineapple.app.network.repository.RedditAuthRepository import com.pineapple.app.network.repository.RedditRepository import com.tencent.mmkv.MMKV import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.util.UUID import javax.inject.Inject @HiltViewModel class KeyProviderViewModel @Inject constructor( private val repository: RedditAuthRepository, val mmkv: MMKV ) : ViewModel() { var clientSecretTextFieldValue by mutableStateOf(TextFieldValue()) private val internalViewState = MutableStateFlow(AuthViewState.Idle) val viewState: StateFlow = internalViewState.asStateFlow() /** * Attempt to get an authentication token (userless) to see if the provided client ID * is a valid one. If the user chose to be a guest, this is the end of the authentication * flow, however if they wanted to login, they will need to authenticate via Reddit OAuth next. */ fun submitClientSecret() { viewModelScope.launch { internalViewState.value = AuthViewState.Loading try { repository.authenticateUserless( clientId = clientSecretTextFieldValue.text, testingClientID = true ) mmkv.putString(MMKVKey.CLIENT_ID, clientSecretTextFieldValue.text) internalViewState.value = AuthViewState.Success } catch (_: Exception) { internalViewState.value = AuthViewState.Error("Invalid client secret") } } } /** * Open the reddit authentication flow in the default browser, which will return to the app * via a deep link once completed, containing the code in a query parameter. (handled in NavHost) */ fun launchRedditAuthFlow(context: Context) { Intent(Intent.ACTION_VIEW).apply { data = ("https://www.reddit.com/api/v1/authorize.compact" + "?client_id=${mmkv.decodeString(MMKVKey.CLIENT_ID)}" + "&response_type=code" + "&state=${UUID.randomUUID()}" + "&redirect_uri=pineapple://login" + "&duration=permanent" + "&scope=identity edit flair history modconfig modflair modlog " + "modposts, modwiki mysubreddits privatemessages read report save " + "submit subscribe vote wikiedit wikiread" ).toUri() flags = Intent.FLAG_ACTIVITY_NEW_TASK context.startActivity(this) } } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/viewmodel/PostViewModel.kt ================================================ package com.pineapple.app.ui.viewmodel import android.content.Context import android.content.Intent import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import com.pineapple.app.R import com.pineapple.app.consts.MMKVKey import com.pineapple.app.network.model.cache.CommentWithUser import com.pineapple.app.network.model.cache.PostWithUser import com.pineapple.app.network.paging.PagingRepository import com.pineapple.app.network.repository.RedditRepository import com.tencent.mmkv.MMKV import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.collectLatest import java.util.Collections import java.util.UUID import javax.inject.Inject @HiltViewModel class PostViewModel @Inject constructor( @ApplicationContext private val context: Context, private val repository: RedditRepository, private val pagingRepository: PagingRepository, private val mmkv: MMKV ) : ViewModel() { val postState = MutableStateFlow(null) val isLoading = MutableStateFlow(true) // replyCounts maps comment full-id (e.g. "t1_abc") to cached count (or -1 if unknown) private val _replyCounts = MutableStateFlow>(emptyMap()) val replyCounts: StateFlow> = _replyCounts /** Observe replies for a parent comment as stored in Room (commentDao) */ fun observeRepliesForComment(parentCommentFullId: String): Flow> { return repository.observeRepliesForComment(parentCommentFullId) } /** * Load replies for a specific comment: request network fetch which will update Room * and return the number of replies parsed; UI can observe `observeRepliesForComment` for inserted replies. */ fun loadRepliesForComment(postId: String, commentIdNoPrefix: String) { val full = if (commentIdNoPrefix.startsWith("t1_")) commentIdNoPrefix else "t1_$commentIdNoPrefix" viewModelScope.launch { try { // refreshRepliesForComment returns the number of replies parsed and inserted val parsed = repository.refreshRepliesForComment(postId, commentIdNoPrefix) if (parsed >= 0) { _replyCounts.value = _replyCounts.value + (full to parsed) } } catch (_: Throwable) { // swallow minimal } } } var showingMoreSheet by mutableStateOf(false) var showingCommentReplySheet by mutableStateOf(false) var commentToShowReplySheet by mutableStateOf(null) var showingCommentMoreSheet by mutableStateOf(false) var commentToShowMoreSheet by mutableStateOf(null) val isUserless by lazy { mmkv.getBoolean(MMKVKey.USER_GUEST, true) } // Comments exposed as a StateFlow of PagingData so UI can `collectAsState()` easily private val _comments = MutableStateFlow>(PagingData.empty()) val comments = _comments private var commentsJob: Job? = null // Deduplicate in-flight user fetches to avoid duplicate network calls during fast scrolls private val fetchingUsers = Collections.synchronizedSet(mutableSetOf()) val snackbarState = SnackbarHostState() fun observePost(postId: String) { viewModelScope.launch { repository.observePostWithUser(postId) .collect { postWithUser -> postState.value = postWithUser if (postWithUser != null) { isLoading.value = false } } } } fun refresh(postId: String) { viewModelScope.launch { try { isLoading.value = true repository.refreshPostAndAuthor(postId) } finally { isLoading.value = false } } } fun loadPost(postId: String) { // Called from LaunchedEffect(postID) observePost(postId) refresh(postId) // start collecting paged comments for this post so UI can observe `comments` startCommentsForPost(postId) } // Start collecting comments PagingData into a StateFlow (cancels previous collection) fun startCommentsForPost(postId: String) { commentsJob?.cancel() commentsJob = viewModelScope.launch { pagingRepository.commentsPager(postId).flow .cachedIn(viewModelScope) .collectLatest { pagingData -> _comments.value = pagingData } } } // Trigger a refresh to fetch comments and cache them fun refreshComments(postId: String) { viewModelScope.launch { repository.refreshCommentsForPost(postId) } } /** * Public helper: called by the UI when a comment row becomes visible. * Dedupes in-flight requests by username and launches an IO coroutine to fetch and cache the user. */ fun fetchUserOnVisible(username: String?) { if (username.isNullOrBlank()) return val first = fetchingUsers.add(username) if (!first) return viewModelScope.launch(Dispatchers.IO) { try { repository.fetchAndCacheUser(username) } catch (_: Throwable) { // swallow - minimal behavior (no logging) } finally { fetchingUsers.remove(username) } } } override fun onCleared() { super.onCleared() commentsJob?.cancel() } /** * Open a snackbar notifying the user that the action they are trying to perform requires * authentication, with a button allowing them to log in if they want to */ fun encourageUserAuthSnackbar() { viewModelScope.launch { val result = snackbarState.showSnackbar( message = context.getString(R.string.home_snackbar_auth_text), actionLabel = context.getString(R.string.home_snackbar_auth_login), duration = SnackbarDuration.Long ) if (result == SnackbarResult.ActionPerformed) { launchRedditAuthFlow(context) } } } /** * Open the reddit authentication flow in the default browser, which will return to the app * via a deep link once completed, containing the code in a query parameter. (handled in NavHost) */ fun launchRedditAuthFlow(context: Context) { Intent(Intent.ACTION_VIEW).apply { data = ("https://www.reddit.com/api/v1/authorize.compact" + "?client_id=${mmkv.decodeString(MMKVKey.CLIENT_ID)}" + "&response_type=code" + "&state=${UUID.randomUUID()}" + "&redirect_uri=pineapple://login" + "&duration=permanent" + "&scope=identity edit flair history modconfig modflair modlog " + "modposts, modwiki mysubreddits privatemessages read report save " + "submit subscribe vote wikiedit wikiread" ).toUri() flags = Intent.FLAG_ACTIVITY_NEW_TASK context.startActivity(this) } } /** * Post an update to the reddit API that reflects whether a post/comment is up/downvoted * Also update local cache via repository so UI reacts immediately * @param direction: 1 for upvote, -1 for downvote, 0 for no vote or to remove vote * @param postId: The ID of the post to update (without prefix) */ fun updateVote(direction: Int, postId: String) { viewModelScope.launch { repository.castVoteAndCache(postId, direction, prefix = "") } } } ================================================ FILE: app/src/main/java/com/pineapple/app/ui/viewmodel/SearchViewModel.kt ================================================ @file:OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class) package com.pineapple.app.ui.viewmodel import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import com.pineapple.app.network.model.cache.PostWithUser import com.pineapple.app.network.model.reddit.SubredditData import com.pineapple.app.network.model.reddit.UserAbout import com.pineapple.app.network.paging.PagingRepository import com.pineapple.app.network.repository.RedditRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SearchViewModel @Inject constructor( private val pagingRepository: PagingRepository, private val redditRepository: RedditRepository ) : ViewModel() { var expandedSearchField by mutableStateOf(false) var searchFieldValue by mutableStateOf("") private val queryState = MutableStateFlow("") val postListState = LazyListState() // Suggestions: use model types for clarity val subredditSuggestions = MutableStateFlow>(emptyList()) val userSuggestions = MutableStateFlow>(emptyList()) init { // fetch suggestions when queryState changes with a short debounce viewModelScope.launch { queryState .debounce(300) .distinctUntilChanged() .collect { query -> if (query.isBlank()) { subredditSuggestions.value = emptyList() userSuggestions.value = emptyList() } else { // fetch suggestions (no caching) try { val subs = redditRepository.suggestCommunities(query, limit = 3) subredditSuggestions.value = subs } catch (_: Exception) { subredditSuggestions.value = emptyList() } try { val users = redditRepository.suggestUsers(query, limit = 3) userSuggestions.value = users } catch (_: Exception) { userSuggestions.value = emptyList() } } } } } val searchResults = queryState .flatMapLatest { query -> if (query.isBlank()) { flowOf(PagingData.empty()) } else { pagingRepository.searchPostsPager(query = query).flow } } .cachedIn(viewModelScope) // Called when user types in the search field - updates visible text and active query fun updateQueryText(newText: String) { searchFieldValue = newText queryState.value = newText } fun submitSearch() { expandedSearchField = false queryState.value = searchFieldValue postListState.requestScrollToItem(0) } // Clear the active search query fun clearSearchQuery() { queryState.value = "" } } ================================================ FILE: app/src/main/java/com/pineapple/app/utilities/NumberUtilities.kt ================================================ package com.pineapple.app.utilities import java.util.Locale import kotlin.math.ln import kotlin.math.pow /** * Convert a unix timestamp (UTC) to a pretty-formatted time relative to now (e.g., "5m" or "16h") */ fun Long.convertUnixToRelativeTime() : String { val longTime = this * 1000L val currentTime = System.currentTimeMillis() val secondMs = 1000L val minuteMs = 60L * secondMs val hourMs = 60L * minuteMs val dayMs = 24L * hourMs val weekMs = 7L * dayMs val monthMs = 30L * dayMs val yearMs = 12L * monthMs val timeDifference = currentTime - longTime return when { timeDifference < minuteMs -> "${timeDifference / secondMs}s" timeDifference < 60L * minuteMs -> "${timeDifference / minuteMs}m" timeDifference < 24L * hourMs -> "${timeDifference / hourMs}h" timeDifference < 30L * dayMs -> "${timeDifference / dayMs}d" timeDifference < 7L * weekMs -> "${timeDifference / weekMs}w" timeDifference < 12L * monthMs -> "${timeDifference / monthMs}mo" timeDifference > yearMs -> "${timeDifference / yearMs}y" else -> "" } } /** * Convert an integer to a social media style pretty-formatted number (e.g., 1500 -> "1.5K") */ fun Int.prettyNumber() : String { if (this < 1000) return "" + this val exp = (ln(this.toDouble()) / ln(1000.0)).toInt() return java.lang.String.format( Locale.ENGLISH, "%.1f%c", (this / 1000.0.pow(exp.toDouble())), "KMGTPE"[exp - 1] ) } ================================================ FILE: app/src/main/java/com/pineapple/app/utilities/TypeUtilities.kt ================================================ package com.pineapple.app.utilities import com.pineapple.app.network.caching.entity.PostEntity import com.pineapple.app.network.caching.entity.SubredditEntity import com.pineapple.app.network.caching.entity.UserEntity import com.pineapple.app.network.model.reddit.Image import com.pineapple.app.network.model.reddit.PostData import com.pineapple.app.network.model.reddit.Preview import com.pineapple.app.network.model.reddit.ResizedIcon import com.pineapple.app.network.model.reddit.SubredditItem import com.pineapple.app.network.model.reddit.UserAbout import com.pineapple.app.network.model.reddit.UserAboutListing /** * Convert a cached PostEntity to a PostData object */ fun PostEntity.toPostData(): PostData { val source = if (previewImageUrl != null && previewWidth != null && previewHeight != null) { ResizedIcon( url = previewImageUrl, width = previewWidth, height = previewHeight ) } else null val preview = source?.let { Preview( images = arrayListOf( Image( source = it, resolutions = arrayListOf() ) ) as ArrayList? ) } return PostData( id = id.removePrefix("t3_"), name = if (id.startsWith("t3_")) id else "t3_$id", title = title, author = author, subreddit = subreddit, subredditNamePrefixed = subreddit?.let { "r/$it" }, createdUTC = createdUtc, ups = ups?.toLong(), thumbnail = thumbnail, permalink = permalink, url = url ?: previewImageUrl, preview = preview, saved = saved, likes = likes, selftext = selftext ) } /** * Convert a cached UserEntity to a UserAboutListing object */ fun UserEntity.toUserAboutListing(): UserAboutListing { return UserAboutListing( // could be problematic in future but as of now we do not use kind kind = "", data = UserAbout( name = name, icon_img = iconUrl, snoovatar_img = snoovatarUrl ) ) } fun SubredditItem.toSubredditEntity(isSubscribed: Boolean): SubredditEntity { val d = this.data return SubredditEntity( id = d.url, name = d.displayName, title = d.title, iconUrl = d.iconUrl, subscribers = d.subscribers, isNsfw = d.over18 == true, isSubscribed = isSubscribed ) } ================================================ FILE: app/src/main/res/drawable/async_image_placeholder.xml ================================================ ================================================ FILE: app/src/main/res/drawable/generic_avatar.xml ================================================ ================================================ FILE: app/src/main/res/drawable/generic_community.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_angry.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_arrow_up.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_back.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bookmark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bookmark_filled.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_browse.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_calendar_day.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_calendar_month.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_check.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_close.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_community.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_copy.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_downvote.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_filter.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_fire.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_flag.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_forum.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_forward.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_help.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_history.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_hourglass.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_monochrome.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_menu.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_more_vert.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_open_external.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_person.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_pineapple_logo.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_plus.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_reddit.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_search.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_settings.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_share.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_shine.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_trending.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_upvote.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_week.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night/async_image_placeholder.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night-v34/async_image_placeholder.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night-v34/generic_avatar.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night-v34/generic_community.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v34/async_image_placeholder.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v34/generic_avatar.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v34/generic_community.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: app/src/main/res/values/colors.xml ================================================ #FFBB86FC #FF6200EE #FF3700B3 #FF03DAC5 #FF018786 #FF000000 #FFFFFFFF ================================================ FILE: app/src/main/res/values/strings.xml ================================================ Pineapple Back Reddit Help Forward Filter Menu More Upvote Downvote Save Share Flag Person Open Community Check Angry Up Fire Trending Shine Week Month Day History Hourglass Browse Search Chats Plus Settings Close Copy Pineapple A Material version of the frontpage of the internet Sign in with Reddit Continue as guest First, we\'ll need your client ID In order to keep this app free, each user must supply their own Reddit API client ID Client secret Reddit Developer Dashboard What\'s this? Invalid client secret! Home You must log in to perform this action Log in Browse Search Chats Account Settings My communities Popular communities Search Users Communities Posts Copied comment Sort posts by Time range Hot New Rising Controversial Top Day Week Month Year All time ================================================ FILE: app/src/main/res/values/themes.xml ================================================