diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c3cb013..d8e78cd 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) } // --------------------------------------------------------------------------- @@ -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 @@ -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..70c030c 100644 --- a/app/src/main/java/com/pinakes/app/MainActivity.kt +++ b/app/src/main/java/com/pinakes/app/MainActivity.kt @@ -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() } } } diff --git a/app/src/main/java/com/pinakes/app/PinakesApplication.kt b/app/src/main/java/com/pinakes/app/PinakesApplication.kt index 948ad83..e2b3352 100644 --- a/app/src/main/java/com/pinakes/app/PinakesApplication.kt +++ b/app/src/main/java/com/pinakes/app/PinakesApplication.kt @@ -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() { @@ -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() } } }) 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 new file mode 100644 index 0000000..f399f87 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/di/AppModule.kt @@ -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) +} 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..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 @@ -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 @@ -107,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/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..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 @@ -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 @@ -86,19 +86,11 @@ import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable 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 16b3edb..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 @@ -27,10 +27,10 @@ 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.common.AppViewModel import com.pinakes.app.ui.components.BookCard import com.pinakes.app.ui.components.BookCardSkeleton import com.pinakes.app.ui.components.EmptyState @@ -48,11 +48,11 @@ fun HomeScreen( onBookClick: (Int) -> Unit, onBrowseCatalog: () -> Unit, ) { - val services = LocalServices.current - val features by services.features.features.collectAsStateWithLifecycle() - val vm: HomeViewModel = viewModel( - factory = HomeViewModel.Factory(services.catalogRepository, services.session) - ) + // 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 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/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/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 - } } 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" }