Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ plugins {
// without a version (declaring one conflicts: "already on the classpath").
id("org.jetbrains.kotlin.kapt")
alias(libs.plugins.sentry)
alias(libs.plugins.hilt)
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -164,8 +165,8 @@ android {
applicationId = "com.pinakes.app"
minSdk = 26
targetSdk = 35
versionCode = 4
versionName = "1.2.0"
versionCode = 5
versionName = "1.2.1"

vectorDrawables {
useSupportLibrary = true
Expand Down Expand Up @@ -309,6 +310,11 @@ dependencies {
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.work.runtime.ktx)

// Hilt — dependency injection (trial: Home slice migrated, ServiceLocator bridges the rest).
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
implementation(libs.androidx.hilt.navigation.compose)

// Unit tests (JVM + Robolectric for Room DAO).
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
Expand Down
30 changes: 19 additions & 11 deletions app/src/main/java/com/pinakes/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,42 @@ import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.pinakes.app.ui.common.LocalServices
import kotlinx.coroutines.launch
import com.pinakes.app.data.repository.AuthRepository
import com.pinakes.app.data.store.SessionStore
import com.pinakes.app.data.store.ThemeStore
import com.pinakes.app.ui.navigation.PinakesNavHost
import com.pinakes.app.ui.theme.PinakesTheme
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch

// AppCompatActivity is required for per-app locales (AppCompatDelegate
// .setApplicationLocales) to be applied to this Activity's configuration.
// @AndroidEntryPoint enables Hilt field injection here AND lets hosted Composables
// obtain Hilt ViewModels via hiltViewModel().
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

@Inject lateinit var session: SessionStore
@Inject lateinit var theme: ThemeStore
@Inject lateinit var authRepository: AuthRepository

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val services = (application as PinakesApplication).services
// Best-effort re-fetch of /health on app start so a server-side CATALOGUE-ONLY MODE
// change is picked up. Failure keeps the last-known flags (never blocks the UI).
if (services.session.hasInstance()) {
lifecycleScope.launch { services.authRepository.refreshHealth() }
if (session.hasInstance()) {
lifecycleScope.launch { authRepository.refreshHealth() }
}
setContent {
// Read the persisted theme as state so switching it in Profile applies live.
val themeMode by services.theme.mode.collectAsStateWithLifecycle()
CompositionLocalProvider(LocalServices provides services) {
PinakesTheme(mode = themeMode) {
PinakesNavHost()
}
val themeMode by theme.mode.collectAsStateWithLifecycle()
PinakesTheme(mode = themeMode) {
PinakesNavHost()
}
}
}
Expand Down
23 changes: 14 additions & 9 deletions app/src/main/java/com/pinakes/app/PinakesApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,22 @@ import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import com.pinakes.app.data.sync.CatalogSyncWorker
import com.pinakes.app.di.ServiceLocator
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp
import io.sentry.android.core.SentryAndroid
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch

/** Application entry point; owns the single [ServiceLocator] and the Coil image loader. */
/**
* Application entry point (`@HiltAndroidApp`) plus the Coil image loader. All dependencies
* live in Hilt ([com.pinakes.app.di.AppModule]); code that can't be constructor-injected
* (this Application and the WorkManager worker) reaches them through a Hilt EntryPoint.
*/
@HiltAndroidApp
class PinakesApplication : Application(), ImageLoaderFactory {

lateinit var services: ServiceLocator
private set

private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

override fun onCreate() {
Expand All @@ -39,14 +42,16 @@ class PinakesApplication : Application(), ImageLoaderFactory {
options.tracesSampleRate = 0.0 // crash reporting only — no performance tracing
}

services = ServiceLocator(this)

// Refresh the cached catalog every time the app comes to the foreground, so the
// offline catalog stays current without a network round-trip on every screen.
// Reaches the Hilt singletons via the worker's EntryPoint (same session + repository).
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
if (!services.session.isLoggedIn()) return
appScope.launch { services.catalogRepository.refreshCatalog() }
val deps = EntryPointAccessors.fromApplication(
this@PinakesApplication, CatalogSyncWorker.Deps::class.java,
)
if (!deps.session().isLoggedIn()) return
appScope.launch { deps.catalogRepository().refreshCatalog() }
}
})

Expand Down
32 changes: 22 additions & 10 deletions app/src/main/java/com/pinakes/app/data/sync/CatalogSyncWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,44 @@ import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.pinakes.app.PinakesApplication
import com.pinakes.app.data.network.ApiResult
import com.pinakes.app.data.repository.CatalogRepository
import com.pinakes.app.data.store.SessionStore
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import java.util.concurrent.TimeUnit

/**
* Periodic background refresh of the offline catalog snapshot. Complements the
* foreground refresh in [PinakesApplication]: even if the user doesn't open the app,
* WorkManager keeps the cached "Available now" shelf reasonably fresh so the catalog
* works offline without hammering the server.
* foreground refresh in [com.pinakes.app.PinakesApplication]: even if the user doesn't
* open the app, WorkManager keeps the cached "Available now" shelf reasonably fresh so the
* catalog works offline without hammering the server.
*
* No-ops when the user is logged out; asks WorkManager to retry on a network failure.
* Uses the default WorkerFactory (this worker only needs the standard
* `(Context, WorkerParameters)` constructor) and reaches dependencies through the
* Application's [com.pinakes.app.di.ServiceLocator].
* Uses the default WorkerFactory and reaches its dependencies through a Hilt [EntryPoint]
* (the worker is created by WorkManager, not by Hilt, so it can't be constructor-injected).
*/
class CatalogSyncWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {

/** Bridge from WorkManager-created workers into the Hilt singleton graph. */
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Deps {
fun session(): SessionStore
fun catalogRepository(): CatalogRepository
}

override suspend fun doWork(): Result {
val app = applicationContext as? PinakesApplication ?: return Result.success()
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
// Nothing to sync for a signed-out user; succeed so the periodic chain continues.
if (!app.services.session.isLoggedIn()) return Result.success()
if (!deps.session().isLoggedIn()) return Result.success()

return when (app.services.catalogRepository.refreshCatalog()) {
return when (deps.catalogRepository().refreshCatalog()) {
is ApiResult.Success -> Result.success()
is ApiResult.Failure -> Result.retry()
}
Expand Down
72 changes: 72 additions & 0 deletions app/src/main/java/com/pinakes/app/di/AppModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.pinakes.app.di

import android.content.Context
import com.pinakes.app.data.local.AppDatabase
import com.pinakes.app.data.local.CatalogDao
import com.pinakes.app.data.network.NetworkModule
import com.pinakes.app.data.repository.AuthRepository
import com.pinakes.app.data.repository.CatalogRepository
import com.pinakes.app.data.repository.LibraryRepository
import com.pinakes.app.data.repository.MessagesRepository
import com.pinakes.app.data.repository.NotificationsRepository
import com.pinakes.app.data.repository.ProfileRepository
import com.pinakes.app.data.repository.WishlistRepository
import com.pinakes.app.data.store.FeatureStore
import com.pinakes.app.data.store.SessionStore
import com.pinakes.app.data.store.ThemeStore
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

/**
* The single Hilt module: builds the app-wide singletons (stores, network, DB, repositories)
* — the same graph the old manual ServiceLocator used to construct, now owned by Hilt.
*/
@Module
@InstallIn(SingletonComponent::class)
object AppModule {

@Provides @Singleton
fun session(@ApplicationContext context: Context): SessionStore = SessionStore(context)

@Provides @Singleton
fun theme(@ApplicationContext context: Context): ThemeStore = ThemeStore(context)

@Provides @Singleton
fun features(@ApplicationContext context: Context): FeatureStore = FeatureStore(context)

@Provides @Singleton
fun network(session: SessionStore): NetworkModule = NetworkModule(session)

@Provides @Singleton
fun database(@ApplicationContext context: Context): AppDatabase = AppDatabase.get(context)

@Provides @Singleton
fun catalogDao(database: AppDatabase): CatalogDao = database.catalogDao()

@Provides @Singleton
fun catalogRepository(network: NetworkModule, dao: CatalogDao): CatalogRepository =
CatalogRepository(network, dao)

@Provides @Singleton
fun authRepository(network: NetworkModule, session: SessionStore, features: FeatureStore): AuthRepository =
AuthRepository(network, session, features)

@Provides @Singleton
fun libraryRepository(network: NetworkModule): LibraryRepository = LibraryRepository(network)

@Provides @Singleton
fun wishlistRepository(network: NetworkModule): WishlistRepository = WishlistRepository(network)

@Provides @Singleton
fun profileRepository(network: NetworkModule): ProfileRepository = ProfileRepository(network)

@Provides @Singleton
fun notificationsRepository(network: NetworkModule): NotificationsRepository = NotificationsRepository(network)

@Provides @Singleton
fun messagesRepository(network: NetworkModule): MessagesRepository = MessagesRepository(network)
}
44 changes: 0 additions & 44 deletions app/src/main/java/com/pinakes/app/di/ServiceLocator.kt

This file was deleted.

32 changes: 32 additions & 0 deletions app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.pinakes.app.ui.common

import androidx.lifecycle.ViewModel
import com.pinakes.app.data.store.FeatureStore
import com.pinakes.app.data.store.SessionStore
import com.pinakes.app.data.store.ThemeStore
import com.pinakes.app.ui.theme.ThemeMode
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

/**
* App-wide reactive state that several Composables need regardless of the current screen:
* the auth state (start destination), the instance feature flags (catalogue-only gating)
* and the theme mode. Replaces the old `LocalServices` CompositionLocal — any Composable
* under an `@AndroidEntryPoint` Activity gets it with `hiltViewModel()`.
*
* The underlying stores are Hilt singletons, so every [AppViewModel] instance re-exposes the
* SAME StateFlows — reads stay consistent across screens and [setThemeMode] is seen everywhere.
*/
@HiltViewModel
class AppViewModel @Inject constructor(
session: SessionStore,
featureStore: FeatureStore,
private val themeStore: ThemeStore,
) : ViewModel() {

val authState = session.authState
val features = featureStore.features
val themeMode = themeStore.mode

fun setThemeMode(mode: ThemeMode) = themeStore.setMode(mode)
}
12 changes: 0 additions & 12 deletions app/src/main/java/com/pinakes/app/ui/common/LocalServices.kt

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.pinakes.app.R
import com.pinakes.app.ui.common.LocalServices
import com.pinakes.app.ui.common.AppViewModel
import com.pinakes.app.ui.components.PinakesListTopBar
import com.pinakes.app.ui.components.PinakesTab
import com.pinakes.app.ui.components.PinakesBottomBar
Expand All @@ -38,8 +39,8 @@ fun MainScaffold(
onOpenNotifications: () -> Unit,
onOpenContact: () -> Unit,
) {
val services = LocalServices.current
val features by services.features.features.collectAsStateWithLifecycle()
val app: AppViewModel = hiltViewModel()
val features by app.features.collectAsStateWithLifecycle()

var tab by rememberSaveable { mutableStateOf(PinakesTab.Home) }

Expand Down
Loading
Loading