From 861d70477dc52e3e1c5eb3523ce4fb84d4eef0cd Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 30 Jun 2026 16:10:25 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(di):=20Hilt=20trial=20=E2=80=94=20infr?= =?UTF-8?q?a=20+=20Home=20slice=20migrated=20(ServiceLocator=20bridges=20t?= =?UTF-8?q?he=20rest)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A working proof-of-concept of the "root-cause" DI fix (the testability lever), done incrementally so it's safe to land before the full migration. - Hilt wired: @HiltAndroidApp (PinakesApplication), @AndroidEntryPoint (MainActivity), hilt-android + hilt-android-compiler (kapt) + hilt-navigation-compose. - AppModule: an incremental-migration BRIDGE — it exposes to Hilt the SAME singletons the manual ServiceLocator already owns (session/stores/network/repositories), so a Hilt-injected ViewModel and a ServiceLocator-built one share one instance each (no auth-state / cache desync while the migration is half-done). - Home slice migrated end-to-end: HomeViewModel is now @HiltViewModel @Inject (its hand-written ViewModelProvider.Factory is deleted); HomeScreen builds it with hiltViewModel(). The other 9 screens are untouched and keep using LocalServices. Why this matters: once every screen is on Hilt, the AppModule bridges become real @Provides (or @Inject constructors), ServiceLocator is deleted, and repositories become unit-testable by swapping fakes via @TestInstallIn — which unblocks the CatalogRepository network tests (the original goal). Verified: assembleDebug builds (kapt validates the full Hilt graph — every binding resolves); testDebugUnitTest 37/37 green. NOTE: needs a device smoke-test (launch → Home renders) before merge — Hilt graph correctness is compile-checked, runtime wiring is not. --- app/build.gradle.kts | 6 +++ .../main/java/com/pinakes/app/MainActivity.kt | 3 ++ .../com/pinakes/app/PinakesApplication.kt | 10 +++- .../main/java/com/pinakes/app/di/AppModule.kt | 50 +++++++++++++++++++ .../pinakes/app/ui/screens/home/HomeScreen.kt | 8 +-- .../app/ui/screens/home/HomeViewModel.kt | 15 ++---- build.gradle.kts | 1 + gradle/libs.versions.toml | 6 +++ 8 files changed, 83 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/pinakes/app/di/AppModule.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c3cb013..d3a349a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) } // --------------------------------------------------------------------------- @@ -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) diff --git a/app/src/main/java/com/pinakes/app/MainActivity.kt b/app/src/main/java/com/pinakes/app/MainActivity.kt index b95f87f..b83381b 100644 --- a/app/src/main/java/com/pinakes/app/MainActivity.kt +++ b/app/src/main/java/com/pinakes/app/MainActivity.kt @@ -12,9 +12,12 @@ import com.pinakes.app.ui.common.LocalServices import kotlinx.coroutines.launch import com.pinakes.app.ui.navigation.PinakesNavHost import com.pinakes.app.ui.theme.PinakesTheme +import dagger.hilt.android.AndroidEntryPoint // AppCompatActivity is required for per-app locales (AppCompatDelegate // .setApplicationLocales) to be applied to this Activity's configuration. +// @AndroidEntryPoint lets Composables hosted here obtain Hilt ViewModels via hiltViewModel(). +@AndroidEntryPoint class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/com/pinakes/app/PinakesApplication.kt b/app/src/main/java/com/pinakes/app/PinakesApplication.kt index 948ad83..21a5480 100644 --- a/app/src/main/java/com/pinakes/app/PinakesApplication.kt +++ b/app/src/main/java/com/pinakes/app/PinakesApplication.kt @@ -9,13 +9,21 @@ import coil.ImageLoaderFactory import coil.disk.DiskCache import com.pinakes.app.data.sync.CatalogSyncWorker import com.pinakes.app.di.ServiceLocator +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; owns the single [ServiceLocator] and the Coil image loader. + * + * `@HiltAndroidApp` enables Hilt. During the incremental migration the ServiceLocator is + * kept and [com.pinakes.app.di.AppModule] bridges Hilt to its singletons, so migrated and + * not-yet-migrated screens share the same instances. + */ +@HiltAndroidApp class PinakesApplication : Application(), ImageLoaderFactory { lateinit var services: ServiceLocator diff --git a/app/src/main/java/com/pinakes/app/di/AppModule.kt b/app/src/main/java/com/pinakes/app/di/AppModule.kt new file mode 100644 index 0000000..8a49380 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/di/AppModule.kt @@ -0,0 +1,50 @@ +package com.pinakes.app.di + +import android.app.Application +import com.pinakes.app.PinakesApplication +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.components.SingletonComponent +import javax.inject.Singleton + +/** + * Hilt module — **incremental-migration bridge**. It exposes to Hilt the SAME singleton + * instances the manual [ServiceLocator] already owns, so a Hilt-injected ViewModel and a + * ServiceLocator-built one share one instance each (no auth-state / cache desync while the + * migration is half-done). + * + * Once every screen is on Hilt, replace these bridges with real `@Provides` that construct + * the dependencies (or `@Inject` constructors) and delete [ServiceLocator]. Hilt provides + * the [Application] binding out of the box (from `@HiltAndroidApp`). + */ +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + private fun services(app: Application) = (app as PinakesApplication).services + + @Provides @Singleton fun session(app: Application): SessionStore = services(app).session + @Provides @Singleton fun theme(app: Application): ThemeStore = services(app).theme + @Provides @Singleton fun features(app: Application): FeatureStore = services(app).features + @Provides @Singleton fun network(app: Application): NetworkModule = services(app).network + + @Provides @Singleton fun catalogRepository(app: Application): CatalogRepository = services(app).catalogRepository + @Provides @Singleton fun authRepository(app: Application): AuthRepository = services(app).authRepository + @Provides @Singleton fun libraryRepository(app: Application): LibraryRepository = services(app).libraryRepository + @Provides @Singleton fun wishlistRepository(app: Application): WishlistRepository = services(app).wishlistRepository + @Provides @Singleton fun profileRepository(app: Application): ProfileRepository = services(app).profileRepository + @Provides @Singleton fun notificationsRepository(app: Application): NotificationsRepository = services(app).notificationsRepository + @Provides @Singleton fun messagesRepository(app: Application): MessagesRepository = services(app).messagesRepository +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt index 16b3edb..4530326 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt @@ -27,8 +27,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.pinakes.app.R import com.pinakes.app.ui.common.LocalServices import com.pinakes.app.ui.components.BookCard @@ -48,11 +48,11 @@ fun HomeScreen( onBookClick: (Int) -> Unit, onBrowseCatalog: () -> Unit, ) { + // Feature flags still come from the ServiceLocator (coexistence); the ViewModel is now + // created by Hilt via hiltViewModel() instead of a hand-written ViewModelProvider.Factory. val services = LocalServices.current val features by services.features.features.collectAsStateWithLifecycle() - val vm: HomeViewModel = viewModel( - factory = HomeViewModel.Factory(services.catalogRepository, services.session) - ) + val vm: HomeViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() val catalogueMode = features.catalogueMode diff --git a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt index 0b66df1..acbc767 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt @@ -1,12 +1,13 @@ package com.pinakes.app.ui.screens.home import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.pinakes.app.data.model.BookSummary import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.repository.CatalogRepository import com.pinakes.app.data.store.SessionStore +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -24,7 +25,8 @@ data class HomeUiState( val isEmpty: Boolean get() = !loading && error == null && available.isEmpty() } -class HomeViewModel( +@HiltViewModel +class HomeViewModel @Inject constructor( private val catalog: CatalogRepository, private val session: SessionStore, ) : ViewModel() { @@ -76,13 +78,4 @@ class HomeViewModel( } fun retry() = refresh() - - class Factory( - private val catalog: CatalogRepository, - private val session: SessionStore, - ) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = - HomeViewModel(catalog, session) as T - } } diff --git a/build.gradle.kts b/build.gradle.kts index 09375a1..f823a7e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,4 +4,5 @@ plugins { alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.sentry) apply false + alias(libs.plugins.hilt) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 24c31da..4b4d608 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,8 @@ media3 = "1.4.1" room = "2.6.1" work = "2.9.1" sentry = "4.14.1" +hilt = "2.52" +hiltNavigationCompose = "1.2.0" junit = "4.13.2" coroutinesTest = "1.8.1" robolectric = "4.13" @@ -54,6 +56,9 @@ androidx-room-compiler = { group = "androidx.room", name = "room-compiler", vers androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } junit = { group = "junit", name = "junit", version.ref = "junit" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } @@ -65,3 +70,4 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } sentry = { id = "io.sentry.android.gradle", version.ref = "sentry" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } From 440dd3bfa8ff37ca072a2e4ea97d28528d2e8bea Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 30 Jun 2026 16:51:21 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(di):=20complete=20the=20Hilt=20migrati?= =?UTF-8?q?on=20=E2=80=94=20delete=20ServiceLocator,=20all=2012=20ViewMode?= =?UTF-8?q?ls=20injected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes the Hilt trial into a full migration. The manual ServiceLocator + LocalServices CompositionLocal are gone; Hilt owns the whole dependency graph. - AppModule: real @Provides @Singleton for every store/network/db/dao/repository (no longer a bridge to ServiceLocator). - All 12 ViewModels are @HiltViewModel @Inject and their hand-written ViewModelProvider .Factory classes are deleted; every screen builds its VM with hiltViewModel(). BookDetailViewModel reads its bookId nav-arg from an injected SavedStateHandle. - App-wide state (auth state, feature flags, theme) moves to a new AppViewModel (@HiltViewModel) — replaces LocalServices. NavHost/MainScaffold use hiltViewModel(); MainActivity field-injects SessionStore/ThemeStore/AuthRepository. - Code that can't be constructor-injected reaches Hilt via an EntryPoint: the CatalogSyncWorker (WorkManager-created) and the Application's foreground-refresh observer. - Deleted di/ServiceLocator.kt and ui/common/LocalServices.kt. Why it matters: repositories/ViewModels are now unit-testable by swapping fakes via @TestInstallIn (the original goal — unblocks the CatalogRepository network tests). Verified: assembleDebug builds — kapt validates the ENTIRE Hilt graph (every binding across all 12 ViewModels + EntryPoints resolves); testDebugUnitTest 37/37 green. NOTE: still needs a device smoke-test (launch → each tab/screen renders, theme switch, book detail) before merge — Hilt graph correctness is compile-checked, runtime is not. --- .../main/java/com/pinakes/app/MainActivity.kt | 29 ++++---- .../com/pinakes/app/PinakesApplication.kt | 23 +++---- .../app/data/sync/CatalogSyncWorker.kt | 32 ++++++--- .../main/java/com/pinakes/app/di/AppModule.kt | 66 ++++++++++++------- .../java/com/pinakes/app/di/ServiceLocator.kt | 44 ------------- .../com/pinakes/app/ui/common/AppViewModel.kt | 32 +++++++++ .../pinakes/app/ui/common/LocalServices.kt | 12 ---- .../pinakes/app/ui/navigation/MainScaffold.kt | 7 +- .../app/ui/navigation/PinakesNavHost.kt | 7 +- .../app/ui/screens/contact/ContactScreen.kt | 10 +-- .../ui/screens/contact/ContactViewModel.kt | 11 ++-- .../app/ui/screens/detail/BookDetailScreen.kt | 17 ++--- .../ui/screens/detail/BookDetailViewModel.kt | 24 +++---- .../pinakes/app/ui/screens/home/HomeScreen.kt | 8 +-- .../app/ui/screens/library/LibraryScreen.kt | 6 +- .../ui/screens/library/LibraryViewModel.kt | 11 ++-- .../ui/screens/login/ForgotPasswordScreen.kt | 18 ++--- .../app/ui/screens/login/LoginScreen.kt | 12 ++-- .../app/ui/screens/login/LoginViewModel.kt | 15 ++--- .../app/ui/screens/login/RegisterScreen.kt | 22 +++---- .../notifications/NotificationsScreen.kt | 10 +-- .../notifications/NotificationsViewModel.kt | 11 ++-- .../ui/screens/onboarding/OnboardingScreen.kt | 6 +- .../screens/onboarding/OnboardingViewModel.kt | 12 ++-- .../app/ui/screens/profile/ProfileScreen.kt | 18 +++-- .../ui/screens/profile/ProfileViewModel.kt | 14 ++-- .../app/ui/screens/search/SearchScreen.kt | 6 +- .../app/ui/screens/search/SearchViewModel.kt | 11 ++-- .../app/ui/screens/wishlist/WishlistScreen.kt | 6 +- .../ui/screens/wishlist/WishlistViewModel.kt | 11 ++-- 30 files changed, 230 insertions(+), 281 deletions(-) delete mode 100644 app/src/main/java/com/pinakes/app/di/ServiceLocator.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt delete mode 100644 app/src/main/java/com/pinakes/app/ui/common/LocalServices.kt diff --git a/app/src/main/java/com/pinakes/app/MainActivity.kt b/app/src/main/java/com/pinakes/app/MainActivity.kt index b83381b..70c030c 100644 --- a/app/src/main/java/com/pinakes/app/MainActivity.kt +++ b/app/src/main/java/com/pinakes/app/MainActivity.kt @@ -4,37 +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 lets Composables hosted here obtain Hilt ViewModels via hiltViewModel(). +// @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() } } } diff --git a/app/src/main/java/com/pinakes/app/PinakesApplication.kt b/app/src/main/java/com/pinakes/app/PinakesApplication.kt index 21a5480..e2b3352 100644 --- a/app/src/main/java/com/pinakes/app/PinakesApplication.kt +++ b/app/src/main/java/com/pinakes/app/PinakesApplication.kt @@ -8,7 +8,7 @@ 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 @@ -17,18 +17,13 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch /** - * Application entry point; owns the single [ServiceLocator] and the Coil image loader. - * - * `@HiltAndroidApp` enables Hilt. During the incremental migration the ServiceLocator is - * kept and [com.pinakes.app.di.AppModule] bridges Hilt to its singletons, so migrated and - * not-yet-migrated screens share the same instances. + * 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() { @@ -47,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() } } }) diff --git a/app/src/main/java/com/pinakes/app/data/sync/CatalogSyncWorker.kt b/app/src/main/java/com/pinakes/app/data/sync/CatalogSyncWorker.kt index beab3b8..7f67ff6 100644 --- a/app/src/main/java/com/pinakes/app/data/sync/CatalogSyncWorker.kt +++ b/app/src/main/java/com/pinakes/app/data/sync/CatalogSyncWorker.kt @@ -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() } diff --git a/app/src/main/java/com/pinakes/app/di/AppModule.kt b/app/src/main/java/com/pinakes/app/di/AppModule.kt index 8a49380..f399f87 100644 --- a/app/src/main/java/com/pinakes/app/di/AppModule.kt +++ b/app/src/main/java/com/pinakes/app/di/AppModule.kt @@ -1,7 +1,8 @@ package com.pinakes.app.di -import android.app.Application -import com.pinakes.app.PinakesApplication +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 @@ -16,35 +17,56 @@ 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 /** - * Hilt module — **incremental-migration bridge**. It exposes to Hilt the SAME singleton - * instances the manual [ServiceLocator] already owns, so a Hilt-injected ViewModel and a - * ServiceLocator-built one share one instance each (no auth-state / cache desync while the - * migration is half-done). - * - * Once every screen is on Hilt, replace these bridges with real `@Provides` that construct - * the dependencies (or `@Inject` constructors) and delete [ServiceLocator]. Hilt provides - * the [Application] binding out of the box (from `@HiltAndroidApp`). + * 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 { - private fun services(app: Application) = (app as PinakesApplication).services + @Provides @Singleton + fun session(@ApplicationContext context: Context): SessionStore = SessionStore(context) - @Provides @Singleton fun session(app: Application): SessionStore = services(app).session - @Provides @Singleton fun theme(app: Application): ThemeStore = services(app).theme - @Provides @Singleton fun features(app: Application): FeatureStore = services(app).features - @Provides @Singleton fun network(app: Application): NetworkModule = services(app).network + @Provides @Singleton + fun theme(@ApplicationContext context: Context): ThemeStore = ThemeStore(context) - @Provides @Singleton fun catalogRepository(app: Application): CatalogRepository = services(app).catalogRepository - @Provides @Singleton fun authRepository(app: Application): AuthRepository = services(app).authRepository - @Provides @Singleton fun libraryRepository(app: Application): LibraryRepository = services(app).libraryRepository - @Provides @Singleton fun wishlistRepository(app: Application): WishlistRepository = services(app).wishlistRepository - @Provides @Singleton fun profileRepository(app: Application): ProfileRepository = services(app).profileRepository - @Provides @Singleton fun notificationsRepository(app: Application): NotificationsRepository = services(app).notificationsRepository - @Provides @Singleton fun messagesRepository(app: Application): MessagesRepository = services(app).messagesRepository + @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) } diff --git a/app/src/main/java/com/pinakes/app/di/ServiceLocator.kt b/app/src/main/java/com/pinakes/app/di/ServiceLocator.kt deleted file mode 100644 index 15acb81..0000000 --- a/app/src/main/java/com/pinakes/app/di/ServiceLocator.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.pinakes.app.di - -import android.content.Context -import com.pinakes.app.data.local.AppDatabase -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 - -/** - * Manual dependency container (no DI framework, per the build spec). Holds the single - * [SessionStore] and [NetworkModule] and lazily builds the repositories on top of them. - * - * Instantiated once in [com.pinakes.app.PinakesApplication] and read by ViewModels. - */ -class ServiceLocator(context: Context) { - - val session: SessionStore = SessionStore(context.applicationContext) - - val theme: ThemeStore = ThemeStore(context.applicationContext) - - /** Reactive instance feature flags (CATALOGUE-ONLY MODE gating). App-wide. */ - val features: FeatureStore = FeatureStore(context.applicationContext) - - val network: NetworkModule = NetworkModule(session) - - /** Local cache DB (offline catalog snapshot). */ - val database: AppDatabase = AppDatabase.get(context.applicationContext) - - val authRepository: AuthRepository by lazy { AuthRepository(network, session, features) } - val catalogRepository: CatalogRepository by lazy { CatalogRepository(network, database.catalogDao()) } - val libraryRepository: LibraryRepository by lazy { LibraryRepository(network) } - val wishlistRepository: WishlistRepository by lazy { WishlistRepository(network) } - val profileRepository: ProfileRepository by lazy { ProfileRepository(network) } - val notificationsRepository: NotificationsRepository by lazy { NotificationsRepository(network) } - val messagesRepository: MessagesRepository by lazy { MessagesRepository(network) } -} diff --git a/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt b/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt new file mode 100644 index 0000000..545942c --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt @@ -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) +} diff --git a/app/src/main/java/com/pinakes/app/ui/common/LocalServices.kt b/app/src/main/java/com/pinakes/app/ui/common/LocalServices.kt deleted file mode 100644 index 5f536ad..0000000 --- a/app/src/main/java/com/pinakes/app/ui/common/LocalServices.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.pinakes.app.ui.common - -import androidx.compose.runtime.staticCompositionLocalOf -import com.pinakes.app.di.ServiceLocator - -/** - * Provides the singleton [ServiceLocator] down the Compose tree so ViewModel factories can - * reach the repositories without a DI framework (the build spec allows manual DI). - */ -val LocalServices = staticCompositionLocalOf { - error("ServiceLocator not provided") -} diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt b/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt index 3550629..81ec338 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt @@ -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 @@ -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) } diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt index 03f9c39..1d5f476 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt @@ -15,8 +15,9 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import androidx.navigation.NavType +import androidx.hilt.navigation.compose.hiltViewModel import com.pinakes.app.data.store.AuthState -import com.pinakes.app.ui.common.LocalServices +import com.pinakes.app.ui.common.AppViewModel import com.pinakes.app.ui.screens.contact.ContactScreen import com.pinakes.app.ui.screens.detail.BookDetailScreen import com.pinakes.app.ui.screens.login.ForgotPasswordScreen @@ -31,8 +32,8 @@ import com.pinakes.app.ui.screens.onboarding.OnboardingScreen */ @Composable fun PinakesNavHost(navController: NavHostController = rememberNavController()) { - val services = LocalServices.current - val authState by services.session.authState.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val authState by app.authState.collectAsStateWithLifecycle() val start = when (authState) { AuthState.NeedsOnboarding -> Routes.ONBOARDING diff --git a/app/src/main/java/com/pinakes/app/ui/screens/contact/ContactScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/contact/ContactScreen.kt index 5c5073f..41587aa 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/contact/ContactScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/contact/ContactScreen.kt @@ -21,10 +21,10 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel 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.EmptyState import com.pinakes.app.ui.components.PinakesTextField import com.pinakes.app.ui.components.PinakesTopBar @@ -34,8 +34,8 @@ import com.pinakes.app.ui.theme.Spacing @OptIn(ExperimentalMaterial3Api::class) @Composable fun ContactScreen(onNavigateUp: () -> Unit) { - val services = LocalServices.current - val features by services.features.features.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val features by app.features.collectAsStateWithLifecycle() if (!features.messages) { Scaffold( topBar = { PinakesTopBar(title = stringResource(R.string.title_message_library), onNavigateUp = onNavigateUp) }, @@ -50,7 +50,7 @@ fun ContactScreen(onNavigateUp: () -> Unit) { return } - val vm: ContactViewModel = viewModel(factory = ContactViewModel.Factory(services.messagesRepository)) + val vm: ContactViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() Scaffold( diff --git a/app/src/main/java/com/pinakes/app/ui/screens/contact/ContactViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/contact/ContactViewModel.kt index 61014ca..e4b3f40 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/contact/ContactViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/contact/ContactViewModel.kt @@ -1,11 +1,12 @@ package com.pinakes.app.ui.screens.contact import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.repository.MessagesRepository import com.pinakes.app.R +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -23,7 +24,8 @@ data class ContactUiState( val canSend: Boolean get() = subject.isNotBlank() && body.isNotBlank() && !sending } -class ContactViewModel(private val messages: MessagesRepository) : ViewModel() { +@HiltViewModel +class ContactViewModel @Inject constructor(private val messages: MessagesRepository) : ViewModel() { private val _state = MutableStateFlow(ContactUiState()) val state: StateFlow = _state.asStateFlow() @@ -45,9 +47,4 @@ class ContactViewModel(private val messages: MessagesRepository) : ViewModel() { } } } - - class Factory(private val messages: MessagesRepository) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = ContactViewModel(messages) as T - } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt index ed0e270..6a1858e 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt @@ -54,14 +54,14 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.SubcomposeAsyncImage import com.pinakes.app.R import com.pinakes.app.data.model.AvailabilityCalendar import com.pinakes.app.data.model.BookDetail import com.pinakes.app.data.model.PersonalHistory -import com.pinakes.app.ui.common.LocalServices +import com.pinakes.app.ui.common.AppViewModel import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.common.resolvedMessage import com.pinakes.app.ui.components.AudioPlayer @@ -89,16 +89,9 @@ fun BookDetailScreen( bookId: Int, onNavigateUp: () -> Unit, ) { - val services = LocalServices.current - val features by services.features.features.collectAsStateWithLifecycle() - val vm: BookDetailViewModel = viewModel( - factory = BookDetailViewModel.Factory( - bookId, - services.catalogRepository, - services.libraryRepository, - services.wishlistRepository, - ) - ) + val app: AppViewModel = hiltViewModel() + val features by app.features.collectAsStateWithLifecycle() + val vm: BookDetailViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() val snackbarHost = remember { SnackbarHostState() } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt index 0511b01..cfc5893 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt @@ -1,7 +1,7 @@ package com.pinakes.app.ui.screens.detail +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.pinakes.app.data.model.AvailabilityCalendar import com.pinakes.app.data.model.BookDetail @@ -12,6 +12,9 @@ import com.pinakes.app.data.repository.LibraryRepository import com.pinakes.app.data.repository.WishlistRepository import com.pinakes.app.R import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.navigation.Routes +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -36,13 +39,17 @@ data class BookDetailUiState( val showLoanSheet: Boolean = false, ) -class BookDetailViewModel( - private val bookId: Int, +@HiltViewModel +class BookDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, private val catalog: CatalogRepository, private val library: LibraryRepository, private val wishlist: WishlistRepository, ) : ViewModel() { + // The book id arrives as a navigation argument; Hilt populates SavedStateHandle from it. + private val bookId: Int = savedStateHandle.get(Routes.ARG_BOOK_ID) ?: 0 + private val _state = MutableStateFlow(BookDetailUiState()) val state: StateFlow = _state.asStateFlow() @@ -152,15 +159,4 @@ class BookDetailViewModel( fun consumeSnackbar() = _state.update { it.copy(snackbar = null, snackbarRes = null) } fun consumeRequestedDate() = _state.update { it.copy(requestedDate = null) } - - class Factory( - private val bookId: Int, - private val catalog: CatalogRepository, - private val library: LibraryRepository, - private val wishlist: WishlistRepository, - ) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = - BookDetailViewModel(bookId, catalog, library, wishlist) as T - } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt index 4530326..3ceee92 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.text.font.FontWeight 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.BookCard import com.pinakes.app.ui.components.BookCardSkeleton import com.pinakes.app.ui.components.EmptyState @@ -48,10 +48,10 @@ fun HomeScreen( onBookClick: (Int) -> Unit, onBrowseCatalog: () -> Unit, ) { - // Feature flags still come from the ServiceLocator (coexistence); the ViewModel is now + // Feature flags come from the Hilt-provided AppViewModel; the screen ViewModel is also // created by Hilt via hiltViewModel() instead of a hand-written ViewModelProvider.Factory. - val services = LocalServices.current - val features by services.features.features.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val features by app.features.collectAsStateWithLifecycle() val vm: HomeViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() val catalogueMode = features.catalogueMode diff --git a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt index ad242b5..64c0589 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt @@ -28,13 +28,12 @@ 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 androidx.lifecycle.viewmodel.compose.viewModel import com.pinakes.app.R import com.pinakes.app.data.model.LoanItem import com.pinakes.app.data.model.ReservationItem import com.pinakes.app.ui.common.DateFormat -import com.pinakes.app.ui.common.LocalServices import com.pinakes.app.ui.common.StatusMapping import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.common.resolvedMessage @@ -48,8 +47,7 @@ import com.pinakes.app.ui.theme.Spacing @OptIn(ExperimentalMaterial3Api::class) @Composable fun LibraryScreen(onBookClick: (Int) -> Unit) { - val services = LocalServices.current - val vm: LibraryViewModel = viewModel(factory = LibraryViewModel.Factory(services.libraryRepository)) + val vm: LibraryViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() var tab by rememberSaveable { mutableIntStateOf(0) } var confirmCancelId by remember { mutableStateOf(null) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryViewModel.kt index b5b989d..5d972b4 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryViewModel.kt @@ -1,7 +1,6 @@ package com.pinakes.app.ui.screens.library import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.pinakes.app.data.model.LoansData import com.pinakes.app.data.model.ReservationItem @@ -10,6 +9,8 @@ import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.data.repository.LibraryRepository import com.pinakes.app.R import com.pinakes.app.ui.common.UiState +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow @@ -31,7 +32,8 @@ data class LibraryUiState( val snackbarRes: Int? = null, ) -class LibraryViewModel(private val library: LibraryRepository) : ViewModel() { +@HiltViewModel +class LibraryViewModel @Inject constructor(private val library: LibraryRepository) : ViewModel() { private val _state = MutableStateFlow(LibraryUiState()) val state: StateFlow = _state.asStateFlow() @@ -99,9 +101,4 @@ class LibraryViewModel(private val library: LibraryRepository) : ViewModel() { } fun consumeSnackbar() = _state.update { it.copy(snackbar = null, snackbarRes = null) } - - class Factory(private val library: LibraryRepository) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = LibraryViewModel(library) as T - } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/login/ForgotPasswordScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/login/ForgotPasswordScreen.kt index 4767888..5368c8d 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/login/ForgotPasswordScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/login/ForgotPasswordScreen.kt @@ -24,19 +24,19 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel import com.pinakes.app.R import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.repository.AuthRepository -import com.pinakes.app.ui.common.LocalServices import com.pinakes.app.ui.components.PinakesTextButton import com.pinakes.app.ui.components.PinakesTextField import com.pinakes.app.ui.components.PrimaryButton import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -51,7 +51,8 @@ data class ForgotPasswordUiState( val errorRes: Int? = null, ) -class ForgotPasswordViewModel(private val auth: AuthRepository) : ViewModel() { +@HiltViewModel +class ForgotPasswordViewModel @Inject constructor(private val auth: AuthRepository) : ViewModel() { private val _state = MutableStateFlow(ForgotPasswordUiState()) val state: StateFlow = _state.asStateFlow() @@ -79,18 +80,11 @@ class ForgotPasswordViewModel(private val auth: AuthRepository) : ViewModel() { } } } - - class Factory(private val auth: AuthRepository) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = - ForgotPasswordViewModel(auth) as T - } } @Composable fun ForgotPasswordScreen(onBackToLogin: () -> Unit) { - val services = LocalServices.current - val vm: ForgotPasswordViewModel = viewModel(factory = ForgotPasswordViewModel.Factory(services.authRepository)) + val vm: ForgotPasswordViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() val form = Modifier.fillMaxWidth().widthIn(max = 420.dp) val errorMessage = state.error ?: state.errorRes?.let { stringResource(it) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/login/LoginScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/login/LoginScreen.kt index 3fd992a..5d9dfdd 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/login/LoginScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/login/LoginScreen.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel 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.PasswordField import com.pinakes.app.ui.components.PinakesTextButton import com.pinakes.app.ui.components.PinakesTextField @@ -49,12 +49,10 @@ fun LoginScreen( onRegister: () -> Unit, onForgotPassword: () -> Unit, ) { - val services = LocalServices.current - val vm: LoginViewModel = viewModel( - factory = LoginViewModel.Factory(services.authRepository, services.session) - ) + val app: AppViewModel = hiltViewModel() + val vm: LoginViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() - val features by services.features.features.collectAsStateWithLifecycle() + val features by app.features.collectAsStateWithLifecycle() val errorMessage = state.error ?: state.errorRes?.let { res -> if (state.errorArg != null) stringResource(res, state.errorArg!!) else stringResource(res) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/login/LoginViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/login/LoginViewModel.kt index d81fa14..88f4d24 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/login/LoginViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/login/LoginViewModel.kt @@ -1,13 +1,14 @@ package com.pinakes.app.ui.screens.login import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.data.repository.AuthRepository import com.pinakes.app.data.store.SessionStore import com.pinakes.app.R +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -25,7 +26,8 @@ data class LoginUiState( val instanceOrigin: String = "", ) -class LoginViewModel( +@HiltViewModel +class LoginViewModel @Inject constructor( private val auth: AuthRepository, session: SessionStore, ) : ViewModel() { @@ -86,13 +88,4 @@ class LoginViewModel( if (failure.message.isNotBlank()) state.copy(error = failure.message, errorRes = null, errorArg = null) else state.copy(error = null, errorRes = R.string.login_error_generic, errorArg = null) } - - class Factory( - private val auth: AuthRepository, - private val session: SessionStore, - ) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = - LoginViewModel(auth, session) as T - } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/login/RegisterScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/login/RegisterScreen.kt index bf90dd0..6571383 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/login/RegisterScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/login/RegisterScreen.kt @@ -26,20 +26,21 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel import com.pinakes.app.R import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.repository.AuthRepository -import com.pinakes.app.ui.common.LocalServices +import com.pinakes.app.ui.common.AppViewModel import com.pinakes.app.ui.components.PasswordField import com.pinakes.app.ui.components.PinakesTextButton import com.pinakes.app.ui.components.PinakesTextField import com.pinakes.app.ui.components.PrimaryButton import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -61,7 +62,8 @@ data class RegisterUiState( val errorRes: Int? = null, ) -class RegisterViewModel(private val auth: AuthRepository) : ViewModel() { +@HiltViewModel +class RegisterViewModel @Inject constructor(private val auth: AuthRepository) : ViewModel() { private val _state = MutableStateFlow(RegisterUiState()) val state: StateFlow = _state.asStateFlow() @@ -124,19 +126,13 @@ class RegisterViewModel(private val auth: AuthRepository) : ViewModel() { } } } - - class Factory(private val auth: AuthRepository) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = - RegisterViewModel(auth) as T - } } @Composable fun RegisterScreen(onBackToLogin: () -> Unit) { - val services = LocalServices.current - val features by services.features.features.collectAsStateWithLifecycle() - val vm: RegisterViewModel = viewModel(factory = RegisterViewModel.Factory(services.authRepository)) + val app: AppViewModel = hiltViewModel() + val features by app.features.collectAsStateWithLifecycle() + val vm: RegisterViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() val form = Modifier.fillMaxWidth().widthIn(max = 420.dp) val errorMessage = state.error ?: state.errorRes?.let { stringResource(it) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/notifications/NotificationsScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/notifications/NotificationsScreen.kt index d47b149..d66a8a0 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/notifications/NotificationsScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/notifications/NotificationsScreen.kt @@ -37,12 +37,12 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.pinakes.app.R import com.pinakes.app.data.model.NotificationItem +import com.pinakes.app.ui.common.AppViewModel import com.pinakes.app.ui.common.DateFormat -import com.pinakes.app.ui.common.LocalServices import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.common.resolvedMessage import com.pinakes.app.ui.components.EmptyState @@ -54,8 +54,8 @@ import com.pinakes.app.ui.theme.Spacing @OptIn(ExperimentalMaterial3Api::class) @Composable fun NotificationsScreen(onNavigateUp: () -> Unit) { - val services = LocalServices.current - val features by services.features.features.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val features by app.features.collectAsStateWithLifecycle() if (!features.notifications) { Scaffold( topBar = { PinakesTopBar(title = stringResource(R.string.title_notifications), onNavigateUp = onNavigateUp) }, @@ -70,7 +70,7 @@ fun NotificationsScreen(onNavigateUp: () -> Unit) { return } - val vm: NotificationsViewModel = viewModel(factory = NotificationsViewModel.Factory(services.notificationsRepository)) + val vm: NotificationsViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() Scaffold( diff --git a/app/src/main/java/com/pinakes/app/ui/screens/notifications/NotificationsViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/notifications/NotificationsViewModel.kt index f748438..c2910c3 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/notifications/NotificationsViewModel.kt @@ -1,13 +1,14 @@ package com.pinakes.app.ui.screens.notifications import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.pinakes.app.data.model.NotificationItem import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.repository.NotificationsRepository import com.pinakes.app.R import com.pinakes.app.ui.common.UiState +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -19,7 +20,8 @@ data class NotificationsUiState( val refreshing: Boolean = false, ) -class NotificationsViewModel(private val repo: NotificationsRepository) : ViewModel() { +@HiltViewModel +class NotificationsViewModel @Inject constructor(private val repo: NotificationsRepository) : ViewModel() { private val _state = MutableStateFlow(NotificationsUiState()) val state: StateFlow = _state.asStateFlow() @@ -46,9 +48,4 @@ class NotificationsViewModel(private val repo: NotificationsRepository) : ViewMo } } } - - class Factory(private val repo: NotificationsRepository) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = NotificationsViewModel(repo) as T - } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingScreen.kt index 8f02bd3..9c0f1b2 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingScreen.kt @@ -34,11 +34,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.pinakes.app.R +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.SubcomposeAsyncImage import com.pinakes.app.data.repository.HealthDiscovery -import com.pinakes.app.ui.common.LocalServices import com.pinakes.app.ui.components.PrimaryButton import com.pinakes.app.ui.components.UrlField import com.pinakes.app.ui.theme.AvailableOnContainerDark @@ -51,8 +50,7 @@ import com.pinakes.app.ui.theme.Spacing */ @Composable fun OnboardingScreen(onContinue: () -> Unit) { - val services = LocalServices.current - val vm: OnboardingViewModel = viewModel(factory = OnboardingViewModel.Factory(services.authRepository)) + val vm: OnboardingViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt index 66f29ba..e478931 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt @@ -1,12 +1,13 @@ package com.pinakes.app.ui.screens.onboarding import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.repository.AuthRepository import com.pinakes.app.data.repository.HealthDiscovery import com.pinakes.app.R +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -25,7 +26,8 @@ data class OnboardingUiState( * Onboarding: the user types an instance URL, we call `/health` and surface the library * identity + transport/app-access warnings before they continue. */ -class OnboardingViewModel(private val auth: AuthRepository) : ViewModel() { +@HiltViewModel +class OnboardingViewModel @Inject constructor(private val auth: AuthRepository) : ViewModel() { private val _state = MutableStateFlow(OnboardingUiState()) val state: StateFlow = _state.asStateFlow() @@ -66,10 +68,4 @@ class OnboardingViewModel(private val auth: AuthRepository) : ViewModel() { if (failure.message.isNotBlank()) state.copy(error = failure.message, errorRes = null) else state.copy(error = null, errorRes = R.string.onboarding_error_generic) } - - class Factory(private val auth: AuthRepository) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = - OnboardingViewModel(auth) as T - } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt index 69db99b..c1c0bbe 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt @@ -50,13 +50,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.pinakes.app.R import com.pinakes.app.data.model.DeviceItem import com.pinakes.app.data.model.UserProfile +import com.pinakes.app.ui.common.AppViewModel import com.pinakes.app.ui.common.DateFormat -import com.pinakes.app.ui.common.LocalServices import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.common.resolvedMessage import com.pinakes.app.ui.components.ErrorState @@ -74,12 +74,10 @@ fun ProfileScreen( onOpenNotifications: () -> Unit, onOpenContact: () -> Unit, ) { - val services = LocalServices.current - val vm: ProfileViewModel = viewModel( - factory = ProfileViewModel.Factory(services.profileRepository, services.authRepository) - ) + val app: AppViewModel = hiltViewModel() + val vm: ProfileViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() - val features by services.features.features.collectAsStateWithLifecycle() + val features by app.features.collectAsStateWithLifecycle() val snackbarHost = remember { SnackbarHostState() } val snackbarMessage = state.snackbar ?: state.snackbarRes?.let { stringResource(it) } @@ -324,8 +322,8 @@ private fun LanguageSection() { */ @Composable private fun ThemeSection() { - val services = LocalServices.current - val mode by services.theme.mode.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val mode by app.themeMode.collectAsStateWithLifecycle() var dialogOpen by remember { mutableStateOf(false) } val options = listOf( @@ -366,7 +364,7 @@ private fun ThemeSection() { Modifier .fillMaxWidth() .clickable { - services.theme.setMode(value) + app.setThemeMode(value) dialogOpen = false } .padding(vertical = Spacing.sm), diff --git a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt index 702954d..4c145e1 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt @@ -1,7 +1,6 @@ package com.pinakes.app.ui.screens.profile import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.pinakes.app.data.model.DeviceItem import com.pinakes.app.data.model.UserProfile @@ -11,6 +10,8 @@ import com.pinakes.app.data.repository.AuthRepository import com.pinakes.app.data.repository.ProfileRepository import com.pinakes.app.R import com.pinakes.app.ui.common.UiState +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -39,7 +40,8 @@ data class ProfileUiState( val snackbarRes: Int? = null, ) -class ProfileViewModel( +@HiltViewModel +class ProfileViewModel @Inject constructor( private val profile: ProfileRepository, private val auth: AuthRepository, ) : ViewModel() { @@ -150,12 +152,4 @@ class ProfileViewModel( } fun consumeSnackbar() = _state.update { it.copy(snackbar = null, snackbarRes = null) } - - class Factory( - private val profile: ProfileRepository, - private val auth: AuthRepository, - ) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = ProfileViewModel(profile, auth) as T - } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt index efbc7c5..77841c7 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt @@ -34,11 +34,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.pinakes.app.R -import androidx.lifecycle.viewmodel.compose.viewModel import com.pinakes.app.data.model.BookSummary -import com.pinakes.app.ui.common.LocalServices import com.pinakes.app.ui.components.AvailabilityStatus import com.pinakes.app.ui.components.BookCard import com.pinakes.app.ui.components.BookCardSkeleton @@ -50,8 +49,7 @@ import com.pinakes.app.ui.theme.Spacing @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchScreen(onBookClick: (Int) -> Unit) { - val services = LocalServices.current - val vm: SearchViewModel = viewModel(factory = SearchViewModel.Factory(services.catalogRepository)) + val vm: SearchViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() val listState = rememberLazyListState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt index 83a956e..8a708f2 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt @@ -2,7 +2,6 @@ package com.pinakes.app.ui.screens.search import androidx.annotation.StringRes import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.pinakes.app.R import com.pinakes.app.data.model.BookSummary @@ -10,6 +9,8 @@ import com.pinakes.app.data.model.GenreNode import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.repository.CatalogRepository import com.pinakes.app.data.repository.SearchFilters +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -64,7 +65,8 @@ val SearchLanguageOptions: List = listOf( LanguageOption("lat", R.string.lang_latin), ) -class SearchViewModel(private val catalog: CatalogRepository) : ViewModel() { +@HiltViewModel +class SearchViewModel @Inject constructor(private val catalog: CatalogRepository) : ViewModel() { private val _state = MutableStateFlow(SearchUiState()) val state: StateFlow = _state.asStateFlow() @@ -184,9 +186,4 @@ class SearchViewModel(private val catalog: CatalogRepository) : ViewModel() { } } } - - class Factory(private val catalog: CatalogRepository) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = SearchViewModel(catalog) as T - } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistScreen.kt index ded35c1..db68f8b 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistScreen.kt @@ -23,10 +23,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.pinakes.app.R -import com.pinakes.app.ui.common.LocalServices import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.common.resolvedMessage import com.pinakes.app.ui.components.AvailabilityStatus @@ -39,8 +38,7 @@ import com.pinakes.app.ui.theme.Spacing @OptIn(ExperimentalMaterial3Api::class) @Composable fun WishlistScreen(onBookClick: (Int) -> Unit) { - val services = LocalServices.current - val vm: WishlistViewModel = viewModel(factory = WishlistViewModel.Factory(services.wishlistRepository)) + val vm: WishlistViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() val snackbarHost = remember { SnackbarHostState() } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistViewModel.kt index 94e4e86..c6d1a68 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistViewModel.kt @@ -1,13 +1,14 @@ package com.pinakes.app.ui.screens.wishlist import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.pinakes.app.data.model.WishlistItem import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.repository.WishlistRepository import com.pinakes.app.R import com.pinakes.app.ui.common.UiState +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -22,7 +23,8 @@ data class WishlistUiState( val snackbarRes: Int? = null, ) -class WishlistViewModel(private val wishlist: WishlistRepository) : ViewModel() { +@HiltViewModel +class WishlistViewModel @Inject constructor(private val wishlist: WishlistRepository) : ViewModel() { private val _state = MutableStateFlow(WishlistUiState()) val state: StateFlow = _state.asStateFlow() @@ -75,9 +77,4 @@ class WishlistViewModel(private val wishlist: WishlistRepository) : ViewModel() } fun consumeSnackbar() = _state.update { it.copy(snackbar = null, snackbarRes = null) } - - class Factory(private val wishlist: WishlistRepository) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = WishlistViewModel(wishlist) as T - } } From 61e287b68bd248582e75d2c06dd11b1150a7447e Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 30 Jun 2026 18:09:11 +0200 Subject: [PATCH 3/4] fix: remove dead bookId parameter from BookDetailScreen (code review F004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix groups (committed): - [FG-1] F004 — BookDetailScreen.kt, PinakesNavHost.kt: verified The Hilt migration moved book-id reading into BookDetailViewModel via SavedStateHandle, leaving the bookId parameter on BookDetailScreen and its nav-host extraction as dead pass-through. Removed both; kept the navArgument(ARG_BOOK_ID) declaration that feeds SavedStateHandle. Post-fix review: 1/1 groups verified complete; 0 partial; 0 reverted. --- .../java/com/pinakes/app/ui/navigation/PinakesNavHost.kt | 5 ++--- .../com/pinakes/app/ui/screens/detail/BookDetailScreen.kt | 1 - 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt index 1d5f476..ea0c2e5 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt @@ -108,9 +108,8 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { arguments = listOf(navArgument(Routes.ARG_BOOK_ID) { type = NavType.IntType }), enterTransition = slideIn, popExitTransition = slideOut, - ) { backStack -> - val bookId = backStack.arguments?.getInt(Routes.ARG_BOOK_ID) ?: 0 - BookDetailScreen(bookId = bookId, onNavigateUp = { navController.popBackStack() }) + ) { + BookDetailScreen(onNavigateUp = { navController.popBackStack() }) } composable( diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt index 6a1858e..89bb3cd 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt @@ -86,7 +86,6 @@ import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable fun BookDetailScreen( - bookId: Int, onNavigateUp: () -> Unit, ) { val app: AppViewModel = hiltViewModel() From 5a6486f512ebb0cbe1208c7b5913c42a2bfeed2f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 30 Jun 2026 19:21:53 +0200 Subject: [PATCH 4/4] chore: bump version to 1.2.1 (versionCode 5) New release including the Hilt DI migration and the F004 dead-parameter cleanup. --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d3a349a..d8e78cd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -165,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