Full Code of galaxygoldfish/pineapple for AI

main 198b3ca05c34 cached
179 files
349.0 KB
85.6k tokens
1 requests
Download .txt
Showing preview only (399K chars total). Download the full file or copy to clipboard to get everything.
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
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="AndroidProjectSystem">
    <option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
  </component>
</project>

================================================
FILE: .idea/codeStyles/Project.xml
================================================
<component name="ProjectCodeStyleConfiguration">
  <code_scheme name="Project" version="173">
    <JetCodeStyleSettings>
      <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
    </JetCodeStyleSettings>
    <codeStyleSettings language="XML">
      <option name="FORCE_REARRANGE_MODE" value="1" />
      <indentOptions>
        <option name="CONTINUATION_INDENT_SIZE" value="4" />
      </indentOptions>
      <arrangement>
        <rules>
          <section>
            <rule>
              <match>
                <AND>
                  <NAME>xmlns:android</NAME>
                  <XML_ATTRIBUTE />
                  <XML_NAMESPACE>^$</XML_NAMESPACE>
                </AND>
              </match>
            </rule>
          </section>
          <section>
            <rule>
              <match>
                <AND>
                  <NAME>xmlns:.*</NAME>
                  <XML_ATTRIBUTE />
                  <XML_NAMESPACE>^$</XML_NAMESPACE>
                </AND>
              </match>
              <order>BY_NAME</order>
            </rule>
          </section>
          <section>
            <rule>
              <match>
                <AND>
                  <NAME>.*:id</NAME>
                  <XML_ATTRIBUTE />
                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
                </AND>
              </match>
            </rule>
          </section>
          <section>
            <rule>
              <match>
                <AND>
                  <NAME>.*:name</NAME>
                  <XML_ATTRIBUTE />
                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
                </AND>
              </match>
            </rule>
          </section>
          <section>
            <rule>
              <match>
                <AND>
                  <NAME>name</NAME>
                  <XML_ATTRIBUTE />
                  <XML_NAMESPACE>^$</XML_NAMESPACE>
                </AND>
              </match>
            </rule>
          </section>
          <section>
            <rule>
              <match>
                <AND>
                  <NAME>style</NAME>
                  <XML_ATTRIBUTE />
                  <XML_NAMESPACE>^$</XML_NAMESPACE>
                </AND>
              </match>
            </rule>
          </section>
          <section>
            <rule>
              <match>
                <AND>
                  <NAME>.*</NAME>
                  <XML_ATTRIBUTE />
                  <XML_NAMESPACE>^$</XML_NAMESPACE>
                </AND>
              </match>
              <order>BY_NAME</order>
            </rule>
          </section>
          <section>
            <rule>
              <match>
                <AND>
                  <NAME>.*</NAME>
                  <XML_ATTRIBUTE />
                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
                </AND>
              </match>
              <order>ANDROID_ATTRIBUTE_ORDER</order>
            </rule>
          </section>
          <section>
            <rule>
              <match>
                <AND>
                  <NAME>.*</NAME>
                  <XML_ATTRIBUTE />
                  <XML_NAMESPACE>.*</XML_NAMESPACE>
                </AND>
              </match>
              <order>BY_NAME</order>
            </rule>
          </section>
        </rules>
      </arrangement>
    </codeStyleSettings>
    <codeStyleSettings language="kotlin">
      <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
    </codeStyleSettings>
  </code_scheme>
</component>

================================================
FILE: .idea/codeStyles/codeStyleConfig.xml
================================================
<component name="ProjectCodeStyleConfiguration">
  <state>
    <option name="USE_PER_PROJECT_SETTINGS" value="true" />
  </state>
</component>

================================================
FILE: .idea/compiler.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="CompilerConfiguration">
    <bytecodeTargetLevel target="21" />
  </component>
</project>

================================================
FILE: .idea/copilot.data.migration.agent.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="AgentMigrationStateService">
    <option name="migrationStatus" value="COMPLETED" />
  </component>
</project>

================================================
FILE: .idea/copilot.data.migration.ask2agent.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="Ask2AgentMigrationStateService">
    <option name="migrationStatus" value="COMPLETED" />
  </component>
</project>

================================================
FILE: .idea/copilot.data.migration.edit.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="EditMigrationStateService">
    <option name="migrationStatus" value="COMPLETED" />
  </component>
</project>

================================================
FILE: .idea/deploymentTargetSelector.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="deploymentTargetSelector">
    <selectionStates>
      <SelectionState runConfigName="app">
        <option name="selectionMode" value="DROPDOWN" />
      </SelectionState>
    </selectionStates>
  </component>
</project>

================================================
FILE: .idea/gradle.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="GradleMigrationSettings" migrationVersion="1" />
  <component name="GradleSettings">
    <option name="linkedExternalProjectsSettings">
      <GradleProjectSettings>
        <option name="testRunner" value="CHOOSE_PER_TEST" />
        <option name="externalProjectPath" value="$PROJECT_DIR$" />
        <option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
        <option name="modules">
          <set>
            <option value="$PROJECT_DIR$" />
            <option value="$PROJECT_DIR$/app" />
          </set>
        </option>
      </GradleProjectSettings>
    </option>
  </component>
</project>

================================================
FILE: .idea/inspectionProfiles/Project_Default.xml
================================================
<component name="InspectionProjectProfileManager">
  <profile version="1.0">
    <option name="myName" value="Project Default" />
    <inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
      <option name="composableFile" value="true" />
    </inspection_tool>
    <inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
      <option name="composableFile" value="true" />
    </inspection_tool>
    <inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
      <option name="composableFile" value="true" />
    </inspection_tool>
    <inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
      <option name="composableFile" value="true" />
    </inspection_tool>
    <inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
      <option name="composableFile" value="true" />
    </inspection_tool>
    <inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
      <option name="composableFile" value="true" />
    </inspection_tool>
    <inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
      <option name="composableFile" value="true" />
    </inspection_tool>
    <inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
      <option name="composableFile" value="true" />
    </inspection_tool>
    <inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
      <option name="composableFile" value="true" />
    </inspection_tool>
    <inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
      <option name="composableFile" value="true" />
    </inspection_tool>
    <inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
      <option name="composableFile" value="true" />
    </inspection_tool>
    <inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
      <option name="composableFile" value="true" />
    </inspection_tool>
    <inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
      <option name="composableFile" value="true" />
    </inspection_tool>
    <inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
      <option name="composableFile" value="true" />
    </inspection_tool>
    <inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
      <option name="composableFile" value="true" />
    </inspection_tool>
  </profile>
</component>

================================================
FILE: .idea/kotlinc.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="KotlinJpsPluginSettings">
    <option name="version" value="2.0.21" />
  </component>
</project>

================================================
FILE: .idea/migrations.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="ProjectMigrations">
    <option name="MigrateToGradleLocalJavaHome">
      <set>
        <option value="$PROJECT_DIR$" />
      </set>
    </option>
  </component>
</project>

================================================
FILE: .idea/misc.xml
================================================
<project version="4">
  <component name="ExternalStorageConfigurationManager" enabled="true" />
  <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
    <output url="file://$PROJECT_DIR$/build/classes" />
  </component>
  <component name="ProjectType">
    <option name="id" value="Android" />
  </component>
</project>

================================================
FILE: .idea/runConfigurations.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="RunConfigurationProducerService">
    <option name="ignoredProducers">
      <set>
        <option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
        <option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
        <option value="com.intellij.execution.junit.PatternConfigurationProducer" />
        <option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
        <option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
        <option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
        <option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
        <option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
      </set>
    </option>
  </component>
</project>

================================================
FILE: .idea/vcs.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="VcsDirectoryMappings">
    <mapping directory="" vcs="Git" />
  </component>
</project>

================================================
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. 

<div>
  <img src="media/onboard.png" width=19% />
  <img src="media/clientid.png" width=19% />
  <img src="media/home.png" width=19% />
  <img src="media/filters.png" width=19% />
  <img src="media/comments.png" width=19% />

</div>


================================================
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
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:name=".PineappleApp"
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/welcome_app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.Pineapple"
        android:enableOnBackInvokedCallback="true"
        tools:targetApi="31">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:theme="@style/Theme.Pineapple">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="pineapple"
                    android:host="login" />
            </intent-filter>
        </activity>
    </application>

</manifest>

================================================
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<Listing<ListingItem<PostData>>>

    /**
     * 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<Listing<ListingItem<PostData>>>

    /**
     * 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<SubredditItem>

    /**
     * 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<SubredditItem>

    /**
     * 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<CondensedUserAboutListing>

    /**
     * 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<SubredditItem>
     */
    @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<SubredditItem>

    /**
     * Search for users by query. Returns ListingBase<UserAboutListing>
     */
    @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<UserAboutListing>

    /**
     * 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<Listing<CommentPreData>>


}

/**
 * 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<AuthResponse>

    /**
     * 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<AuthResponse>

    /**
     * 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<AuthResponse>

}

/**
 * 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<CommentEntity>)

    @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<Int, CommentWithUser>

    @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<List<CommentWithUser>>

    @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<Int, PostWithUser>

    // 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<Int, PostWithUser>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(posts: List<PostEntity>)

    @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<PostWithUser?>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsert(post: PostEntity)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsertAll(posts: List<PostEntity>)


}


================================================
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<RemoteKeyEntity>)

    @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<SearchRemoteKeyEntity>)

    @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<SearchResultEntity>)

    @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<String>

}


================================================
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<List<SubredditEntity>>

    @Query("SELECT * FROM subreddits WHERE isSubscribed = 1 ORDER BY name COLLATE NOCASE ASC")
    fun getSubscribedSubreddits(): Flow<List<SubredditEntity>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsertAll(subreddits: List<SubredditEntity>)

    @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<UserEntity>)
}

================================================
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<ResizedIcon>,

    @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<ResizedIcon>,

    @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<CommentPreData>
)

data class CommentListingNull(
    var kind: String,
    var data: ListingItem<Any>
)


================================================
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<ResizedIcon>
)



================================================
FILE: app/src/main/java/com/pineapple/app/network/model/reddit/Listing.kt
================================================
package com.pineapple.app.network.model.reddit

data class Listing<T>(
    val kind: String,
    val data: ListingItem<T>
)


================================================
FILE: app/src/main/java/com/pineapple/app/network/model/reddit/ListingBase.kt
================================================
package com.pineapple.app.network.model.reddit

data class ListingBase<T>(
    var kind: String,
    var data: ListingItem<T>
)


================================================
FILE: app/src/main/java/com/pineapple/app/network/model/reddit/ListingItem.kt
================================================
package com.pineapple.app.network.model.reddit

data class ListingItem<T>(
    var after: String,
    var before: String,
    var dist: Int,
    var modhash: String,
    var children: List<T>
)


================================================
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<FlairRichItem>? = 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<Any?>? = 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<Any?>? = 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<AllAwarding>? = null,

    val awarders: List<Any?>? = 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<Any?>? = 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<Any?>? = 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<PostItem>
)


================================================
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<Image>?
)


================================================
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<Int, CommentWithUser>() {

    private val gson = Gson()

    override suspend fun load(loadType: LoadType, state: PagingState<Int, CommentWithUser>): 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<CommentPreData>
            val children = commentsListing.data.children

            val startIndex = db.commentDao().maxSortKeyForPost(postId)?.plus(1) ?: 0
            val sortKeyCounter = AtomicInteger(startIndex)

            val out = mutableListOf<CommentEntity>()
            
            // 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<CommentPreData>,
        postId: String,
        parentId: String?,
        depth: Int,
        out: MutableList<CommentEntity>,
        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<CommentPreData> = emptyList()
                d.replies?.let { repliesElement ->
                    if (repliesElement is JsonObject) {
                        try {
                            val type = object : TypeToken<Listing<CommentPreData>>() {}.type
                            val listing = gson.fromJson<Listing<CommentPreData>>(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<Int, PostWithUser> {
        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<Int, PostWithUser> {
        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<Int, CommentWithUser> {
        return Pager<Int, CommentWithUser>(
             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<Int, PostWithUser>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, PostWithUser>
    ): 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<Int, PostWithUser>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, PostWithUser>
    ): 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<PostWithUser?> =
        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<Any>()
                }
            }

            // 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<CommentEntity>()

            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<CommentPreData> = emptyList()
                        d.replies?.let { je ->
                            if (je.isJsonObject) {
                                try {
                                    val type = object : TypeToken<Listing<CommentPreData>>() {}.type
                                    val listing = gson.fromJson<Listing<CommentPreData>>(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<List<SubredditEntity>> =
        subredditDao.getPopularSubreddits()

    fun observeSubscribedSubreddits(): Flow<List<SubredditEntity>> =
        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<SubredditData> {
        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<UserAbout> {
        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<Any>()
                }
            }

            val startIndex = commentDao.maxSortKeyForPost(postId)?.plus(1) ?: 0

            val out = mutableListOf<CommentEntity>()
            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<Listing<CommentPreData>>() {}.type
                                        val listing = gson.fromJson<Listing<CommentPreData>>(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<Listing<CommentPreData>>() {}.type
                                    val listing = gson.fromJson<Listing<CommentPreData>>(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<CommentDataNull?> {
    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<Float?>(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<TonalActionSectionItem>,
    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()
                            }
                        },
                        onUpv
Download .txt
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
Condensed preview — 179 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (383K chars).
[
  {
    "path": ".gitignore",
    "chars": 225,
    "preview": "*.iml\n.gradle\n/local.properties\n/.idea/caches\n/.idea/libraries\n/.idea/modules.xml\n/.idea/workspace.xml\n/.idea/navEditor."
  },
  {
    "path": ".idea/.gitignore",
    "chars": 47,
    "preview": "# Default ignored files\n/shelf/\n/workspace.xml\n"
  },
  {
    "path": ".idea/.name",
    "chars": 9,
    "preview": "Pineapple"
  },
  {
    "path": ".idea/AndroidProjectSystem.xml",
    "chars": 212,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"AndroidProjectSystem\">\n    <option name="
  },
  {
    "path": ".idea/codeStyles/Project.xml",
    "chars": 3622,
    "preview": "<component name=\"ProjectCodeStyleConfiguration\">\n  <code_scheme name=\"Project\" version=\"173\">\n    <JetCodeStyleSettings>"
  },
  {
    "path": ".idea/codeStyles/codeStyleConfig.xml",
    "chars": 142,
    "preview": "<component name=\"ProjectCodeStyleConfiguration\">\n  <state>\n    <option name=\"USE_PER_PROJECT_SETTINGS\" value=\"true\" />\n "
  },
  {
    "path": ".idea/compiler.xml",
    "chars": 169,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"CompilerConfiguration\">\n    <bytecodeTar"
  },
  {
    "path": ".idea/copilot.data.migration.agent.xml",
    "chars": 190,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"AgentMigrationStateService\">\n    <option"
  },
  {
    "path": ".idea/copilot.data.migration.ask2agent.xml",
    "chars": 194,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"Ask2AgentMigrationStateService\">\n    <op"
  },
  {
    "path": ".idea/copilot.data.migration.edit.xml",
    "chars": 189,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"EditMigrationStateService\">\n    <option "
  },
  {
    "path": ".idea/deploymentTargetSelector.xml",
    "chars": 301,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"deploymentTargetSelector\">\n    <selectio"
  },
  {
    "path": ".idea/gradle.xml",
    "chars": 690,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"GradleMigrationSettings\" migrationVersio"
  },
  {
    "path": ".idea/inspectionProfiles/Project_Default.xml",
    "chars": 3110,
    "preview": "<component name=\"InspectionProjectProfileManager\">\n  <profile version=\"1.0\">\n    <option name=\"myName\" value=\"Project De"
  },
  {
    "path": ".idea/kotlinc.xml",
    "chars": 176,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"KotlinJpsPluginSettings\">\n    <option na"
  },
  {
    "path": ".idea/migrations.xml",
    "chars": 254,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"ProjectMigrations\">\n    <option name=\"Mi"
  },
  {
    "path": ".idea/misc.xml",
    "chars": 409,
    "preview": "<project version=\"4\">\n  <component name=\"ExternalStorageConfigurationManager\" enabled=\"true\" />\n  <component name=\"Proje"
  },
  {
    "path": ".idea/runConfigurations.xml",
    "chars": 964,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"RunConfigurationProducerService\">\n    <o"
  },
  {
    "path": ".idea/vcs.xml",
    "chars": 167,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"VcsDirectoryMappings\">\n    <mapping dire"
  },
  {
    "path": "README.md",
    "chars": 605,
    "preview": "# Pineapple 🍍\n\nPineapple is an Android reddit client application developed with Jetpack Compose following the Material 3"
  },
  {
    "path": "app/.gitignore",
    "chars": 6,
    "preview": "/build"
  },
  {
    "path": "app/build.gradle.kts",
    "chars": 2439,
    "preview": "plugins {\n    alias(libs.plugins.android.application)\n    alias(libs.plugins.kotlin.android)\n    alias(libs.plugins.kotl"
  },
  {
    "path": "app/proguard-rules.pro",
    "chars": 750,
    "preview": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguar"
  },
  {
    "path": "app/src/androidTest/java/com/pineapple/app/ExampleInstrumentedTest.kt",
    "chars": 661,
    "preview": "package com.pineapple.app\n\nimport androidx.test.platform.app.InstrumentationRegistry\nimport androidx.test.ext.junit.runn"
  },
  {
    "path": "app/src/main/AndroidManifest.xml",
    "chars": 1520,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:to"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/MainActivity.kt",
    "chars": 5257,
    "preview": "package com.pineapple.app\n\nimport android.content.Intent\nimport android.os.Bundle\nimport androidx.activity.ComponentActi"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/PineappleApp.kt",
    "chars": 277,
    "preview": "package com.pineapple.app\n\nimport android.app.Application\nimport com.tencent.mmkv.MMKV\nimport dagger.hilt.android.HiltAn"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/consts/MMKVKey.kt",
    "chars": 507,
    "preview": "package com.pineapple.app.consts\n\n/**\n * Holds constants that represent all keys used in the MMKV preference table\n */\no"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/consts/NavDestinationKey.kt",
    "chars": 398,
    "preview": "package com.pineapple.app.consts\n\n/**\n * Holds constants used to define all navigation destination routes\n * used in the"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/consts/OnboardingLoginType.kt",
    "chars": 213,
    "preview": "package com.pineapple.app.consts\n\n/**\n * Represents the types of login methods available during onboarding.\n */\nobject O"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/consts/PageDestinationKey.kt",
    "chars": 288,
    "preview": "package com.pineapple.app.consts\n\n/**\n * Represents the different pages that can be navigated between in the [HomeView]\n"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/consts/PostFilterSort.kt",
    "chars": 386,
    "preview": "package com.pineapple.app.consts\n\n/**\n * Represent the available options for sorting posts in a list\n * These values rep"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/consts/PostFilterTime.kt",
    "chars": 383,
    "preview": "package com.pineapple.app.consts\n\n/**\n * Represents the options for time range supplied when filtering posts by top or c"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/di/DatabaseModule.kt",
    "chars": 973,
    "preview": "package com.pineapple.app.di\n\nimport android.content.Context\nimport androidx.room.Room\nimport com.pineapple.app.network."
  },
  {
    "path": "app/src/main/java/com/pineapple/app/di/MMKVModule.kt",
    "chars": 445,
    "preview": "package com.pineapple.app.di\n\nimport android.content.Context\nimport com.tencent.mmkv.MMKV\nimport dagger.Module\nimport da"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/di/NetworkModule.kt",
    "chars": 3531,
    "preview": "package com.pineapple.app.di\n\nimport com.google.gson.GsonBuilder\nimport com.pineapple.app.network.interceptor.AuthInterc"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/api/RedditApi.kt",
    "chars": 7106,
    "preview": "package com.pineapple.app.network.api\n\nimport com.pineapple.app.network.model.reddit.CommentPreData\nimport com.pineapple"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/api/RedditTokenApi.kt",
    "chars": 3325,
    "preview": "package com.pineapple.app.network.api\n\nimport com.pineapple.app.network.model.auth.AuthResponse\nimport retrofit2.Respons"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/caching/AppDatabase.kt",
    "chars": 1592,
    "preview": "package com.pineapple.app.network.caching\n\nimport androidx.room.Database\nimport androidx.room.RoomDatabase\nimport com.pi"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/caching/dao/CommentDao.kt",
    "chars": 1485,
    "preview": "package com.pineapple.app.network.caching.dao\n\nimport androidx.paging.PagingSource\nimport androidx.room.Dao\nimport andro"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/caching/dao/PostDao.kt",
    "chars": 1697,
    "preview": "package com.pineapple.app.network.caching.dao\n\nimport androidx.paging.PagingSource\nimport androidx.room.Dao\nimport andro"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/caching/dao/RemoteKeyDao.kt",
    "chars": 568,
    "preview": "package com.pineapple.app.network.caching.dao\n\nimport androidx.room.Dao\nimport androidx.room.Insert\nimport androidx.room"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/caching/dao/SearchRemoteKeyDao.kt",
    "chars": 675,
    "preview": "package com.pineapple.app.network.caching.dao\n\nimport androidx.room.Dao\nimport androidx.room.Insert\nimport androidx.room"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/caching/dao/SearchResultDao.kt",
    "chars": 630,
    "preview": "package com.pineapple.app.network.caching.dao\n\nimport androidx.room.Dao\nimport androidx.room.Insert\nimport androidx.room"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/caching/dao/SubredditDao.kt",
    "chars": 793,
    "preview": "package com.pineapple.app.network.caching.dao\n\nimport androidx.room.Dao\nimport androidx.room.Insert\nimport androidx.room"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/caching/dao/UserDao.kt",
    "chars": 462,
    "preview": "package com.pineapple.app.network.caching.dao\n\nimport androidx.room.Dao\nimport androidx.room.Insert\nimport androidx.room"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/caching/entity/CommentEntity.kt",
    "chars": 555,
    "preview": "package com.pineapple.app.network.caching.entity\n\nimport androidx.room.Entity\nimport androidx.room.PrimaryKey\n\n@Entity(t"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/caching/entity/PostEntity.kt",
    "chars": 606,
    "preview": "package com.pineapple.app.network.caching.entity\n\nimport androidx.room.Entity\nimport androidx.room.PrimaryKey\n\n@Entity(t"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/caching/entity/RemoteKeyEntity.kt",
    "chars": 262,
    "preview": "package com.pineapple.app.network.caching.entity\n\nimport androidx.room.Entity\nimport androidx.room.PrimaryKey\n\n@Entity(t"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/caching/entity/SearchRemoteKeyEntity.kt",
    "chars": 300,
    "preview": "package com.pineapple.app.network.caching.entity\n\nimport androidx.room.Entity\n\n@Entity(\n    tableName = \"search_remote_k"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/caching/entity/SearchResultEntity.kt",
    "chars": 263,
    "preview": "package com.pineapple.app.network.caching.entity\n\nimport androidx.room.Entity\n\n@Entity(\n    tableName = \"search_results\""
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/caching/entity/SubredditEntity.kt",
    "chars": 426,
    "preview": "package com.pineapple.app.network.caching.entity\n\nimport androidx.room.Entity\nimport androidx.room.PrimaryKey\n\n@Entity(t"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/caching/entity/UserEntity.kt",
    "chars": 254,
    "preview": "package com.pineapple.app.network.caching.entity\n\nimport androidx.room.Entity\nimport androidx.room.PrimaryKey\n\n@Entity(t"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/interceptor/AuthInterceptor.kt",
    "chars": 1413,
    "preview": "package com.pineapple.app.network.interceptor\n\nimport com.pineapple.app.network.repository.RedditAuthRepository\nimport c"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/interceptor/TokenUserAgentInterceptor.kt",
    "chars": 482,
    "preview": "package com.pineapple.app.network.interceptor\n\nimport com.pineapple.app.network.repository.USER_AGENT\nimport okhttp3.Int"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/auth/AuthResponse.kt",
    "chars": 432,
    "preview": "package com.pineapple.app.network.model.auth\n\nimport com.google.gson.annotations.SerializedName\n\ndata class AuthResponse"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/cache/CommentWithUser.kt",
    "chars": 412,
    "preview": "package com.pineapple.app.network.model.cache\n\nimport androidx.room.Embedded\nimport androidx.room.Relation\nimport com.pi"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/cache/PostwithUser.kt",
    "chars": 399,
    "preview": "package com.pineapple.app.network.model.cache\n\nimport androidx.room.Embedded\nimport androidx.room.Relation\nimport com.pi"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/AboutAccount.kt",
    "chars": 143,
    "preview": "package com.pineapple.app.network.model.reddit\n\ndata class AboutAccount(\n    val subreddit: UserSubredditData,\n    val s"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/AllAwarding.kt",
    "chars": 2103,
    "preview": "package com.pineapple.app.network.model.reddit\n\nimport com.google.gson.annotations.SerializedName\n\ndata class AllAwardin"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/CommentData.kt",
    "chars": 728,
    "preview": "package com.pineapple.app.network.model.reddit\n\nimport com.google.gson.JsonElement\n\ndata class CommentPreData(\n    var k"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/CommentListing.kt",
    "chars": 228,
    "preview": "package com.pineapple.app.network.model.reddit\n\ndata class CommentListing(\n    var kind: String,\n    var data: ListingIt"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/CondensedUserAbout.kt",
    "chars": 669,
    "preview": "package com.pineapple.app.network.model.reddit\n\ndata class CondensedUserAboutListing(\n    var kind: String,\n    var data"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/FlairRichItem.kt",
    "chars": 154,
    "preview": "package com.pineapple.app.network.model.reddit\n\ndata class FlairRichItem(\n    var a: String?,\n    var e: String,\n    var"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/Gildings.kt",
    "chars": 171,
    "preview": "package com.pineapple.app.network.model.reddit\n\nimport com.google.gson.annotations.SerializedName\n\ndata class Gildings ("
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/Image.kt",
    "chars": 143,
    "preview": "package com.pineapple.app.network.model.reddit\n\ndata class Image (\n    val source: ResizedIcon,\n    val resolutions: Arr"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/Listing.kt",
    "chars": 124,
    "preview": "package com.pineapple.app.network.model.reddit\n\ndata class Listing<T>(\n    val kind: String,\n    val data: ListingItem<T"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/ListingBase.kt",
    "chars": 128,
    "preview": "package com.pineapple.app.network.model.reddit\n\ndata class ListingBase<T>(\n    var kind: String,\n    var data: ListingIt"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/ListingItem.kt",
    "chars": 194,
    "preview": "package com.pineapple.app.network.model.reddit\n\ndata class ListingItem<T>(\n    var after: String,\n    var before: String"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/PostData.kt",
    "chars": 7239,
    "preview": "package com.pineapple.app.network.model.reddit\n\nimport com.google.gson.JsonObject\nimport com.google.gson.annotations.Ser"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/PostItem.kt",
    "chars": 116,
    "preview": "package com.pineapple.app.network.model.reddit\n\ndata class PostItem(\n    var kind: String,\n    var data: PostData\n)\n"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/PostListing.kt",
    "chars": 132,
    "preview": "package com.pineapple.app.network.model.reddit\n\ndata class PostListing(\n    var kind: String,\n    var data: ListingItem<"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/Preview.kt",
    "chars": 104,
    "preview": "package com.pineapple.app.network.model.reddit\n\ndata class Preview(\n    val images: ArrayList<Image>?\n)\n"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/ResizedIcon.kt",
    "chars": 138,
    "preview": "package com.pineapple.app.network.model.reddit\n\ndata class ResizedIcon (\n    val url: String,\n    val width: Long,\n    v"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/SecureMedia.kt",
    "chars": 284,
    "preview": "package com.pineapple.app.network.model.reddit\n\ndata class SecureMedia(\n    var reddit_video: RedditVideo\n)\n\ndata class "
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/SubredditData.kt",
    "chars": 593,
    "preview": "package com.pineapple.app.network.model.reddit\n\nimport com.google.gson.annotations.SerializedName\n\ndata class SubredditD"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/SubredditInfo.kt",
    "chars": 104,
    "preview": "package com.pineapple.app.network.model.reddit\n\ndata class SubredditInfo(\n    var data: SubredditData\n)\n"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/SubredditItem.kt",
    "chars": 126,
    "preview": "package com.pineapple.app.network.model.reddit\n\ndata class SubredditItem(\n    val kind: String,\n    val data: SubredditD"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/UserAbout.kt",
    "chars": 774,
    "preview": "package com.pineapple.app.network.model.reddit\n\ndata class UserAboutListing(\n    var kind: String,\n    var data: UserAbo"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/model/reddit/UserSubredditData.kt",
    "chars": 367,
    "preview": "package com.pineapple.app.network.model.reddit\n\ndata class UserSubredditData(\n    var banner_img: String,\n    var displa"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/paging/CommentsRemoteMediator.kt",
    "chars": 5704,
    "preview": "package com.pineapple.app.network.paging\n\nimport androidx.paging.ExperimentalPagingApi\nimport androidx.paging.LoadType\ni"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/paging/PagingRepository.kt",
    "chars": 2233,
    "preview": "package com.pineapple.app.network.paging\n\nimport androidx.paging.ExperimentalPagingApi\nimport androidx.paging.Pager\nimpo"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/paging/PostsRemoteMediator.kt",
    "chars": 5092,
    "preview": "package com.pineapple.app.network.paging\n\nimport androidx.paging.ExperimentalPagingApi\nimport androidx.paging.LoadType\ni"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/paging/SearchRemoteMediator.kt",
    "chars": 5550,
    "preview": "package com.pineapple.app.network.paging\n\nimport androidx.paging.ExperimentalPagingApi\nimport androidx.paging.LoadType\ni"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/repository/RedditAuthRepository.kt",
    "chars": 5459,
    "preview": "package com.pineapple.app.network.repository\n\nimport com.pineapple.app.consts.MMKVKey\nimport com.pineapple.app.network.a"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/repository/RedditRepository.kt",
    "chars": 30186,
    "preview": "package com.pineapple.app.network.repository\n\nimport com.pineapple.app.network.api.RedditApi\nimport com.pineapple.app.ne"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/network/serialization/RedditRepliesAdapter.kt",
    "chars": 644,
    "preview": "package com.pineapple.app.network.serialization\n\nimport com.google.gson.*\nimport com.pineapple.app.network.model.reddit."
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/components/ButtonComponents.kt",
    "chars": 2619,
    "preview": "@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)\n\npackage com.pineapple.app.ui.components\n\nimport androidx.compose"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/components/CardComponents.kt",
    "chars": 18898,
    "preview": "@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)\n\npackage com.pineapple.app.ui.components\n\nimport android.content."
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/components/ListComponents.kt",
    "chars": 6158,
    "preview": "@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)\n\npackage com.pineapple.app.ui.components\n\nimport androidx.compose"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/components/MediaComponents.kt",
    "chars": 3872,
    "preview": "package com.pineapple.app.ui.components\n\nimport androidx.compose.animation.AnimatedContent\nimport androidx.compose.anima"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/modal/CommentDetailSheet.kt",
    "chars": 8098,
    "preview": "@file:OptIn(ExperimentalMaterial3Api::class)\n\npackage com.pineapple.app.ui.modal\n\nimport android.content.ClipData\nimport"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/modal/CommentRepliesSheet.kt",
    "chars": 122,
    "preview": "package com.pineapple.app.ui.modal\n\nimport androidx.compose.runtime.Composable\n\n@Composable\nfun CommentRepliesSheet() {\n"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/modal/PostOptionSheet.kt",
    "chars": 2976,
    "preview": "@file:OptIn(ExperimentalMaterial3Api::class)\n\npackage com.pineapple.app.ui.modal\n\nimport androidx.compose.foundation.lay"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/modal/SortPostSheet.kt",
    "chars": 9813,
    "preview": "@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)\n\npackage com.pineapple.app.ui.mo"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/state/AuthViewState.kt",
    "chars": 308,
    "preview": "package com.pineapple.app.ui.state\n\n/**\n * Object to reflect a state of the reddit authentication process\n */\nsealed cla"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/theme/Shape.kt",
    "chars": 260,
    "preview": "package com.pineapple.app.ui.theme\n\nimport androidx.compose.ui.unit.dp\n\nval ExtraSmallCornerRadius = 4.dp\nval SmallCorne"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/theme/Theme.kt",
    "chars": 1317,
    "preview": "package com.pineapple.app.ui.theme\n\nimport android.os.Build\nimport androidx.compose.foundation.isSystemInDarkTheme\nimpor"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/theme/Type.kt",
    "chars": 1691,
    "preview": "package com.pineapple.app.ui.theme\n\nimport androidx.compose.material3.Typography\nimport androidx.compose.ui.text.font.Fo"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/view/AccountPage.kt",
    "chars": 113,
    "preview": "package com.pineapple.app.ui.view\n\nimport androidx.compose.runtime.Composable\n\n@Composable\nfun AccountPage() {\n\n}"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/view/BrowsePage.kt",
    "chars": 5114,
    "preview": "package com.pineapple.app.ui.view\n\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.l"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/view/ChatPage.kt",
    "chars": 110,
    "preview": "package com.pineapple.app.ui.view\n\nimport androidx.compose.runtime.Composable\n\n@Composable\nfun ChatPage() {\n\n}"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/view/CommunityView.kt",
    "chars": 341,
    "preview": "package com.pineapple.app.ui.view\n\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimp"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/view/HomeView.kt",
    "chars": 21800,
    "preview": "@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)\n\npackage com.pineapple.app.ui.vi"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/view/KeyProviderView.kt",
    "chars": 8418,
    "preview": "package com.pineapple.app.ui.view\n\nimport android.content.Intent\nimport androidx.compose.animation.AnimatedContent\nimpor"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/view/PostView.kt",
    "chars": 32110,
    "preview": "@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)\n\npackage com.pineapple.app.ui.vi"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/view/SearchPage.kt",
    "chars": 16149,
    "preview": "@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)\n\npackage com.pineapple.app.ui.vi"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/view/UserView.kt",
    "chars": 326,
    "preview": "package com.pineapple.app.ui.view\n\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimp"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/view/WelcomeView.kt",
    "chars": 4259,
    "preview": "package com.pineapple.app.ui.view\n\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundat"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/viewmodel/BrowseViewModel.kt",
    "chars": 3116,
    "preview": "@file:OptIn(ExperimentalCoroutinesApi::class)\n\npackage com.pineapple.app.ui.viewmodel\n\nimport androidx.compose.foundatio"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/viewmodel/HomeViewModel.kt",
    "chars": 4019,
    "preview": "@file:OptIn(ExperimentalCoroutinesApi::class)\n\npackage com.pineapple.app.ui.viewmodel\n\nimport android.content.Context\nim"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/viewmodel/KeyProviderViewModel.kt",
    "chars": 3231,
    "preview": "package com.pineapple.app.ui.viewmodel\n\nimport android.content.Context\nimport android.content.Intent\nimport androidx.com"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/viewmodel/PostViewModel.kt",
    "chars": 8538,
    "preview": "package com.pineapple.app.ui.viewmodel\n\nimport android.content.Context\nimport android.content.Intent\nimport androidx.com"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/ui/viewmodel/SearchViewModel.kt",
    "chars": 3694,
    "preview": "@file:OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)\n\npackage com.pineapple.app.ui.viewm"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/utilities/NumberUtilities.kt",
    "chars": 1509,
    "preview": "package com.pineapple.app.utilities\n\nimport java.util.Locale\nimport kotlin.math.ln\nimport kotlin.math.pow\n\n/**\n * Conver"
  },
  {
    "path": "app/src/main/java/com/pineapple/app/utilities/TypeUtilities.kt",
    "chars": 2487,
    "preview": "package com.pineapple.app.utilities\n\nimport com.pineapple.app.network.caching.entity.PostEntity\nimport com.pineapple.app"
  },
  {
    "path": "app/src/main/res/drawable/async_image_placeholder.xml",
    "chars": 372,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"203dp\"\n    android:height=\"100dp\"\n"
  },
  {
    "path": "app/src/main/res/drawable/generic_avatar.xml",
    "chars": 290,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<layer-list xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <!-- "
  },
  {
    "path": "app/src/main/res/drawable/generic_community.xml",
    "chars": 292,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<layer-list xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <!-- "
  },
  {
    "path": "app/src/main/res/drawable/ic_angry.xml",
    "chars": 877,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_arrow_up.xml",
    "chars": 336,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_back.xml",
    "chars": 374,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\" android:autoMirrored=\"true\" android:height=\"24dp\" and"
  },
  {
    "path": "app/src/main/res/drawable/ic_bookmark.xml",
    "chars": 437,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_bookmark_filled.xml",
    "chars": 354,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\" android:height=\"24dp\" android:tint=\"#000000\" android:"
  },
  {
    "path": "app/src/main/res/drawable/ic_browse.xml",
    "chars": 634,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_calendar_day.xml",
    "chars": 492,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_calendar_month.xml",
    "chars": 1134,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_check.xml",
    "chars": 321,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_close.xml",
    "chars": 374,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_community.xml",
    "chars": 1046,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_copy.xml",
    "chars": 501,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_downvote.xml",
    "chars": 362,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_filter.xml",
    "chars": 407,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_fire.xml",
    "chars": 796,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_flag.xml",
    "chars": 359,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\" android:height=\"24dp\" android:tint=\"#000000\" android:"
  },
  {
    "path": "app/src/main/res/drawable/ic_forum.xml",
    "chars": 545,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_forward.xml",
    "chars": 370,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\" android:autoMirrored=\"true\" android:height=\"24dp\" and"
  },
  {
    "path": "app/src/main/res/drawable/ic_help.xml",
    "chars": 1958,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_history.xml",
    "chars": 599,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_hourglass.xml",
    "chars": 568,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_launcher_background.xml",
    "chars": 284,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"108dp\"\n    android:height=\"108dp\"\n"
  },
  {
    "path": "app/src/main/res/drawable/ic_launcher_foreground.xml",
    "chars": 4523,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"108dp\"\n    android:height=\"108dp\"\n"
  },
  {
    "path": "app/src/main/res/drawable/ic_launcher_monochrome.xml",
    "chars": 1972,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"96dp\"\n    android:height=\"96dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_menu.xml",
    "chars": 341,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\" android:height=\"24dp\" android:tint=\"#000000\" android:"
  },
  {
    "path": "app/src/main/res/drawable/ic_more_vert.xml",
    "chars": 468,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\" android:height=\"24dp\" android:tint=\"#000000\" android:"
  },
  {
    "path": "app/src/main/res/drawable/ic_open_external.xml",
    "chars": 527,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_person.xml",
    "chars": 1336,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_pineapple_logo.xml",
    "chars": 3624,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"95dp\"\n    android:height=\"208dp\"\n "
  },
  {
    "path": "app/src/main/res/drawable/ic_plus.xml",
    "chars": 329,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_reddit.xml",
    "chars": 2168,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_search.xml",
    "chars": 528,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_settings.xml",
    "chars": 977,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_share.xml",
    "chars": 2129,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_shine.xml",
    "chars": 615,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_trending.xml",
    "chars": 361,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_upvote.xml",
    "chars": 366,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_week.xml",
    "chars": 481,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable-night/async_image_placeholder.xml",
    "chars": 372,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"203dp\"\n    android:height=\"100dp\"\n"
  },
  {
    "path": "app/src/main/res/drawable-night-v34/async_image_placeholder.xml",
    "chars": 412,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"203dp\"\n    android:height=\"100dp\"\n"
  },
  {
    "path": "app/src/main/res/drawable-night-v34/generic_avatar.xml",
    "chars": 1087,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"40dp\"\n    android:height=\"40dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable-night-v34/generic_community.xml",
    "chars": 1344,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"40dp\"\n    android:height=\"40dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable-v34/async_image_placeholder.xml",
    "chars": 413,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"203dp\"\n    android:height=\"100dp\"\n"
  },
  {
    "path": "app/src/main/res/drawable-v34/generic_avatar.xml",
    "chars": 1090,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"40dp\"\n    android:height=\"40dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable-v34/generic_community.xml",
    "chars": 1346,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"40dp\"\n    android:height=\"40dp\"\n  "
  },
  {
    "path": "app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml",
    "chars": 341,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <b"
  },
  {
    "path": "app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml",
    "chars": 340,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <b"
  },
  {
    "path": "app/src/main/res/values/colors.xml",
    "chars": 378,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n    <color name=\"purple_200\">#FFBB86FC</color>\n    <color name=\"purpl"
  },
  {
    "path": "app/src/main/res/values/strings.xml",
    "chars": 4291,
    "preview": "<resources>\n\n    <!-- Content descriptions -->\n    <string name=\"ic_pineapple_logo_cdesc\">Pineapple</string>\n    <string"
  },
  {
    "path": "app/src/main/res/values/themes.xml",
    "chars": 151,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n\n    <style name=\"Theme.Pineapple\" parent=\"android:Theme.Material.Lig"
  },
  {
    "path": "app/src/main/res/xml/backup_rules.xml",
    "chars": 478,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?><!--\n   Sample backup rules file; uncomment and customize as necessary.\n   See htt"
  },
  {
    "path": "app/src/main/res/xml/data_extraction_rules.xml",
    "chars": 551,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?><!--\n   Sample data extraction rules file; uncomment and customize as necessary.\n "
  },
  {
    "path": "app/src/test/java/com/pineapple/app/ExampleUnitTest.kt",
    "chars": 341,
    "preview": "package com.pineapple.app\n\nimport org.junit.Test\n\nimport org.junit.Assert.*\n\n/**\n * Example local unit test, which will "
  },
  {
    "path": "build.gradle.kts",
    "chars": 358,
    "preview": "// Top-level build file where you can add configuration options common to all sub-projects/modules.\nplugins {\n    alias("
  },
  {
    "path": "gradle/libs.versions.toml",
    "chars": 3745,
    "preview": "[versions]\nagp = \"8.13.2\"\ncoil = \"3.1.0\"\nconverterGson = \"3.0.0\"\nhiltAndroid = \"2.57.1\"\nkotlin = \"2.0.21\"\ncoreKtx = \"1.1"
  },
  {
    "path": "gradle/wrapper/gradle-wrapper.properties",
    "chars": 231,
    "preview": "#Thu Dec 18 12:06:56 PST 2025\ndistributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://"
  },
  {
    "path": "gradle.properties",
    "chars": 1346,
    "preview": "# Project-wide Gradle settings.\n# IDE (e.g. Android Studio) users:\n# Gradle settings configured through the IDE *will ov"
  },
  {
    "path": "gradlew",
    "chars": 5766,
    "preview": "#!/usr/bin/env sh\n\n#\n# Copyright 2015 the original author or authors.\n#\n# Licensed under the Apache License, Version 2.0"
  },
  {
    "path": "gradlew.bat",
    "chars": 2674,
    "preview": "@rem\n@rem Copyright 2015 the original author or authors.\n@rem\n@rem Licensed under the Apache License, Version 2.0 (the \""
  },
  {
    "path": "settings.gradle.kts",
    "chars": 563,
    "preview": "pluginManagement {\n    repositories {\n        google {\n            content {\n                includeGroupByRegex(\"com\\\\."
  }
]

// ... and 1 more files (download for full content)

About this extraction

This page contains the full source code of the galaxygoldfish/pineapple GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 179 files (349.0 KB), approximately 85.6k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!