diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 3f3a58e4..6af3b540 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -13,9 +13,6 @@ - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index daa45ce4..04bba921 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -4,15 +4,19 @@ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 59216037..45f87c6f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -125,6 +125,21 @@ android { } } + /** + * Code shrinking and obfuscation are intentionally disabled for the `release` build. + * + * This application embeds Android Studio–like tooling (editor/runtime features) that rely on: + * - reflection + * - dynamic class loading + * - stable class and method names + * + * Enabling `isMinifyEnabled` or `isShrinkResources` may break these mechanisms, + * leading to runtime instability or non-functional tooling inside the app. + * + * Note: + * If minification is ever enabled, extensive keep rules will be required to preserve + * all dynamically accessed APIs and internal tooling components. + */ buildTypes { release { val signingFile = rootProject.file("signing.properties") @@ -133,8 +148,8 @@ android { } else { null } - isMinifyEnabled = true - isShrinkResources = true + isMinifyEnabled = false + isShrinkResources = false proguardFiles(getDefaultProguardFile(name = "proguard-android-optimize.txt"), "proguard-rules.pro") if (hasGoogleServicesConfig) { configure { @@ -170,7 +185,7 @@ android { dependencies { // App Core - implementation(dependencyNotation = "com.github.MihaiCristianCondrea:App-Toolkit-for-Android:2.0.8") { + implementation(dependencyNotation = "com.github.MihaiCristianCondrea:App-Toolkit-for-Android:2.0.10") { isTransitive = true } diff --git a/app/src/androidTest/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/ui/IdeOnboardingScreenTest.kt b/app/src/androidTest/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/ui/IdeOnboardingScreenTest.kt new file mode 100644 index 00000000..7374fbc3 --- /dev/null +++ b/app/src/androidTest/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/ui/IdeOnboardingScreenTest.kt @@ -0,0 +1,124 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.ui + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.d4rk.android.libs.apptoolkit.core.ui.window.AppWindowWidthSizeClass +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioOnboardingStep +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioProjectForm +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.reducer.IdeOnboardingReducer +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.validation.IdeProjectFormValidator +import com.d4rk.androidtutorials.app.codestudio.onboarding.ui.contract.CodeStudioOnboardingAction +import com.d4rk.androidtutorials.app.codestudio.onboarding.ui.state.CodeStudioOnboardingUiState +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +// FIXME: FIX THE UNIT TESTS +@RunWith(AndroidJUnit4::class) +class IdeOnboardingScreenTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun stepNavigation_nextAndBack_updatesStepCounter() { + composeRule.setContent { + var state by mutableStateOf(CodeStudioOnboardingUiState()) + CodeStudioOnboardingScreen( + state = state, + windowWidthSizeClass = AppWindowWidthSizeClass.Compact, + paddingValues = androidx.compose.foundation.layout.PaddingValues(), + onAction = { action -> state = IdeOnboardingReducer.reduce(state, action).state }, + onRequestPermissionCapability = {}, + onRunEnvironmentChecks = {}, + ) + } + + composeRule.onNodeWithText("Step 1 of 7").assertIsDisplayed() + composeRule.onNodeWithText("Next").performClick() + composeRule.onNodeWithText("Step 2 of 7").assertIsDisplayed() + composeRule.onNodeWithText("Next").performClick() + composeRule.onNodeWithText("Step 3 of 7").assertIsDisplayed() + composeRule.onNodeWithText("Back").performClick() + composeRule.onNodeWithText("Step 2 of 7").assertIsDisplayed() + } + + @Test + fun nextDisabled_whenPermissionStepNotGranted() { + composeRule.setContent { + val state = CodeStudioOnboardingUiState(currentStep = CodeStudioOnboardingStep.PERMISSIONS) + IdeOnboardingScreen( + state = state, + windowWidthSizeClass = AppWindowWidthSizeClass.Compact, + paddingValues = androidx.compose.foundation.layout.PaddingValues(), + onAction = {}, + onRequestPermissionCapability = {}, + onRunEnvironmentChecks = {}, + ) + } + + composeRule.onNodeWithText("Next").assertIsNotEnabled() + } + + @Test + fun errorMessage_isRenderedWithLiveRegionCard() { + composeRule.setContent { + val state = CodeStudioOnboardingUiState(errorMessage = "Project cannot be created yet.") + IdeOnboardingScreen( + state = state, + windowWidthSizeClass = AppWindowWidthSizeClass.Compact, + paddingValues = androidx.compose.foundation.layout.PaddingValues(), + onAction = {}, + onRequestPermissionCapability = {}, + onRunEnvironmentChecks = {}, + ) + } + + composeRule.onNodeWithText("Project cannot be created yet.").assertIsDisplayed() + } + + @Test + fun createButton_enabledOnValidReview_andDispatchesAction() { + var lastAction: CodeStudioOnboardingAction? = null + + composeRule.setContent { + val form = CodeStudioProjectForm( + projectName = "MyProject", + packageName = "com.example.project", + saveLocation = "/tmp", + selectedTemplateId = "empty_activity", + ) + val state = CodeStudioOnboardingUiState( + currentStep = CodeStudioOnboardingStep.REVIEW_AND_CREATE, + projectForm = form, + validationResult = IdeProjectFormValidator.validate(form), + isPermissionGranted = true, + isEnvironmentReady = true, + ) + IdeOnboardingScreen( + state = state, + windowWidthSizeClass = AppWindowWidthSizeClass.Compact, + paddingValues = androidx.compose.foundation.layout.PaddingValues(), + onAction = { action -> lastAction = action }, + onRequestPermissionCapability = {}, + onRunEnvironmentChecks = {}, + ) + } + + composeRule.onNodeWithText("Create Project").assertIsEnabled().performClick() + composeRule.runOnIdle { + assertEquals(CodeStudioOnboardingAction.CreateProjectClicked, lastAction) + } + composeRule.onNodeWithText("Next").assertDoesNotExist() + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 50bb27b3..bf524445 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,6 +20,8 @@ android:installLocation="auto"> + + + + + + + + Unit, +) { + val horizontalPadding = if (windowWidthSizeClass >= AppWindowWidthSizeClass.Medium) 56.dp else 24.dp + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = horizontalPadding, vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(id = R.string.ide_start_app_name), + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.SemiBold, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Icons.Outlined.Code, + contentDescription = null, + tint = Color.White, + modifier = Modifier + .size(78.dp) + .clip(CircleShape) + .background(Color(0xFF0D0D0D)) + .padding(22.dp), + ) + Text(stringResource(id = R.string.ide_start_get_started), style = MaterialTheme.typography.headlineMedium) + Text(stringResource(id = R.string.ide_start_subtitle), style = MaterialTheme.typography.titleLarge) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 36.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + StartMenuRow(icon = { Icon(Icons.Filled.Add, contentDescription = null) }, title = stringResource(id = R.string.ide_start_create_project), onClick = onStartOnboarding) + StartMenuRow(icon = { Icon(Icons.Filled.Folder, contentDescription = null) }, title = stringResource(id = R.string.ide_start_open_existing), onClick = onStartOnboarding) + StartMenuRow(icon = { Icon(Icons.Outlined.Android, contentDescription = null) }, title = stringResource(id = R.string.ide_start_clone_git), onClick = {}) + } + } +} + +@Composable +private fun StartMenuRow( + icon: @Composable () -> Unit, + title: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(18.dp), + ) { + icon() + Text(title, style = MaterialTheme.typography.headlineSmall) + } +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/dashboard/ui/navigation/CodeStudioDashboardEntryBuilder.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/dashboard/ui/navigation/CodeStudioDashboardEntryBuilder.kt new file mode 100644 index 00000000..cba9b002 --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/dashboard/ui/navigation/CodeStudioDashboardEntryBuilder.kt @@ -0,0 +1,22 @@ +/* + * Copyright (©) 2026 Mihai-Cristian Condrea + */ + +package com.d4rk.androidtutorials.app.codestudio.dashboard.ui.navigation + +import com.d4rk.android.libs.apptoolkit.core.ui.navigation.NavigationEntryBuilder +import com.d4rk.androidtutorials.app.codestudio.dashboard.ui.CodeStudioDashboardRoute +import com.d4rk.androidtutorials.app.main.ui.views.navigation.AppNavigationEntryContext +import com.d4rk.androidtutorials.app.main.utils.constants.AppNavKey +import com.d4rk.androidtutorials.app.main.utils.constants.IdeRoute + +fun codeStudioDashboardEntryBuilder( + context: AppNavigationEntryContext, +): NavigationEntryBuilder = { + entry { + CodeStudioDashboardRoute( + paddingValues = context.paddingValues, + windowWidthSizeClass = context.windowWidthSizeClass, + ) + } +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/README.md b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/README.md new file mode 100644 index 00000000..42a04a1e --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/README.md @@ -0,0 +1,22 @@ +# IDE Onboarding Feature Scope + +This package contains the onboarding and setup experience for the new IDE flow. + +## What belongs to onboarding-only + +- Route registration and navigation entry point for the IDE tab. +- Compose UI that guides users through setup and project-creation onboarding. +- Onboarding state, actions, events, and view model orchestration. +- Data contracts needed to persist onboarding draft/setup information. + +## What is deferred to the future actual IDE integration + +- Full code editor activity and tool windows. +- Project explorer and file editing runtime. +- Build/run/debug lifecycle and terminal integration. +- Full AndroidIDE runtime services port. + +## AndroidIDE-dev as validation source + +`resources/AndroidIDE-dev` is used as the validation/reference source for behavior and structure. +The onboarding implementation in this app should mirror key UX and flow principles while remaining decoupled from direct module integration at this stage. diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/data/permissions/CodeStudioPermissionCoordinator.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/data/permissions/CodeStudioPermissionCoordinator.kt new file mode 100644 index 00000000..6ad8d207 --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/data/permissions/CodeStudioPermissionCoordinator.kt @@ -0,0 +1,106 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.data.permissions + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import androidx.core.content.ContextCompat +import com.d4rk.androidtutorials.R + +class CodeStudioPermissionCoordinator { + + fun getRequirements(context: Context): List { + val requirements = mutableListOf() + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { + requirements += IdePermissionRequirement( + capability = IdePermissionCapability.LEGACY_STORAGE, + titleRes = R.string.ide_permission_storage_title, + descriptionRes = R.string.ide_permission_storage_description, + status = legacyStorageStatus(context), + isRequired = false, + requestPermissions = legacyStoragePermissions(), + needsSettingsIntent = false, + ) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + requirements += IdePermissionRequirement( + capability = IdePermissionCapability.MANAGE_ALL_FILES, + titleRes = R.string.ide_permission_manage_files_title, + descriptionRes = R.string.ide_permission_manage_files_description, + status = if (Environment.isExternalStorageManager()) IdePermissionStatus.GRANTED else IdePermissionStatus.DENIED, + isRequired = false, + needsSettingsIntent = true, + ) + } + + requirements += IdePermissionRequirement( + capability = IdePermissionCapability.INSTALL_UNKNOWN_APPS, + titleRes = R.string.ide_permission_install_unknown_apps_title, + descriptionRes = R.string.ide_permission_install_unknown_apps_description, + status = installUnknownAppsStatus(context), + isRequired = false, + needsSettingsIntent = true, + ) + + return requirements + } + + fun settingsIntent(context: Context, capability: IdePermissionCapability): Intent = when (capability) { + IdePermissionCapability.MANAGE_ALL_FILES -> Intent( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION + } else { + Settings.ACTION_APPLICATION_DETAILS_SETTINGS + } + ).apply { + data = Uri.fromParts("package", context.packageName, null) + } + + IdePermissionCapability.INSTALL_UNKNOWN_APPS -> Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = Uri.fromParts("package", context.packageName, null) + } + + IdePermissionCapability.LEGACY_STORAGE -> Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + } + + private fun legacyStorageStatus(context: Context): IdePermissionStatus { + val permissions = legacyStoragePermissions() + if (permissions.isEmpty()) return IdePermissionStatus.GRANTED + + val allGranted = permissions.all { permission -> + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } + return if (allGranted) IdePermissionStatus.GRANTED else IdePermissionStatus.DENIED + } + + private fun installUnknownAppsStatus(context: Context): IdePermissionStatus { + val hasManifestPermission = context.hasManifestPermission(android.Manifest.permission.REQUEST_INSTALL_PACKAGES) + if (!hasManifestPermission) return IdePermissionStatus.DENIED + + return try { + if (context.packageManager.canRequestPackageInstalls()) IdePermissionStatus.GRANTED else IdePermissionStatus.DENIED + } catch (_: SecurityException) { + IdePermissionStatus.DENIED + } + } + + private fun Context.hasManifestPermission(permission: String): Boolean = try { + val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackageInfo(packageName , PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong())) + } else { + @Suppress("DEPRECATION") + packageManager.getPackageInfo(packageName, PackageManager.GET_PERMISSIONS) + } + + packageInfo.requestedPermissions?.contains(permission) == true + } catch (_: Throwable) { + false + } +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/data/permissions/CodeStudioPermissionModels.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/data/permissions/CodeStudioPermissionModels.kt new file mode 100644 index 00000000..0a01974d --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/data/permissions/CodeStudioPermissionModels.kt @@ -0,0 +1,37 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.data.permissions + +import android.Manifest +import android.os.Build +import androidx.annotation.StringRes + +enum class IdePermissionCapability { + LEGACY_STORAGE, + MANAGE_ALL_FILES, + INSTALL_UNKNOWN_APPS, +} + +enum class IdePermissionStatus { + GRANTED, + DENIED, + PERMANENTLY_DENIED, +} + +data class IdePermissionRequirement( + val capability: IdePermissionCapability , + @get:StringRes val titleRes: Int , + @get:StringRes val descriptionRes: Int , + val status: IdePermissionStatus , + val isRequired: Boolean = true , + val requestPermissions: List = emptyList() , + val needsSettingsIntent: Boolean = false , +) + +internal fun legacyStoragePermissions(): List = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + emptyList() + } else { + listOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + ) + } diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/data/repository/CodeStudioOnboardingDraftRepository.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/data/repository/CodeStudioOnboardingDraftRepository.kt new file mode 100644 index 00000000..5f4b1382 --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/data/repository/CodeStudioOnboardingDraftRepository.kt @@ -0,0 +1,94 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.data.repository + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioOnboardingDraft +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioOnboardingStep +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioProjectForm +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioProjectLanguage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.ideOnboardingDataStore by preferencesDataStore(name = "ide_onboarding_prefs") + +interface IdeOnboardingDraftRepository { + fun observeDraft(): Flow + + suspend fun saveDraft(draft: CodeStudioOnboardingDraft) + + suspend fun clearDraft() +} + +class DataStoreIdeOnboardingDraftRepository( + private val context: Context, +) : IdeOnboardingDraftRepository { + + override fun observeDraft(): Flow = context.ideOnboardingDataStore.data.map { prefs -> + prefs.toDraft() + } + + override suspend fun saveDraft(draft: CodeStudioOnboardingDraft) { + context.ideOnboardingDataStore.edit { prefs -> + prefs[Keys.step] = draft.step.name + prefs[Keys.projectName] = draft.form.projectName + prefs[Keys.packageName] = draft.form.packageName + prefs[Keys.language] = draft.form.language.name + prefs[Keys.minSdk] = draft.form.minSdk + prefs[Keys.targetSdk] = draft.form.targetSdk + prefs[Keys.saveLocation] = draft.form.saveLocation + prefs[Keys.templateId] = draft.form.selectedTemplateId + prefs[Keys.permissionGranted] = draft.isPermissionGranted + prefs[Keys.environmentReady] = draft.isEnvironmentReady + prefs[Keys.lastPreflightTimestamp] = System.currentTimeMillis() + } + } + + override suspend fun clearDraft() { + context.ideOnboardingDataStore.edit { it.clear() } + } + + private fun Preferences.toDraft(): CodeStudioOnboardingDraft { + val step = this[Keys.step] + ?.let { runCatching { CodeStudioOnboardingStep.valueOf(it) }.getOrNull() } + ?: CodeStudioOnboardingStep.WELCOME + + val language = this[Keys.language] + ?.let { runCatching { CodeStudioProjectLanguage.valueOf(it) }.getOrNull() } + ?: CodeStudioProjectLanguage.KOTLIN + + return CodeStudioOnboardingDraft( + step = step, + form = CodeStudioProjectForm( + projectName = this[Keys.projectName].orEmpty(), + packageName = this[Keys.packageName].orEmpty(), + language = language, + minSdk = this[Keys.minSdk] ?: 26, + targetSdk = this[Keys.targetSdk] ?: 36, + saveLocation = this[Keys.saveLocation].orEmpty(), + selectedTemplateId = this[Keys.templateId].orEmpty(), + ), + isPermissionGranted = this[Keys.permissionGranted] ?: false, + isEnvironmentReady = this[Keys.environmentReady] ?: false, + ) + } + + private object Keys { + val step = stringPreferencesKey("ide_onboarding_step") + val projectName = stringPreferencesKey("ide_onboarding_project_name") + val packageName = stringPreferencesKey("ide_onboarding_package_name") + val language = stringPreferencesKey("ide_onboarding_language") + val minSdk = intPreferencesKey("ide_onboarding_min_sdk") + val targetSdk = intPreferencesKey("ide_onboarding_target_sdk") + val saveLocation = stringPreferencesKey("ide_onboarding_save_location") + val templateId = stringPreferencesKey("ide_onboarding_template_id") + val permissionGranted = booleanPreferencesKey("ide_onboarding_permission_granted") + val environmentReady = booleanPreferencesKey("ide_onboarding_environment_ready") + val lastPreflightTimestamp = longPreferencesKey("ide_onboarding_last_preflight_timestamp") + } +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/data/template/CodeStudioTemplateCatalog.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/data/template/CodeStudioTemplateCatalog.kt new file mode 100644 index 00000000..6ee8c838 --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/data/template/CodeStudioTemplateCatalog.kt @@ -0,0 +1,68 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.data.template + +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioProjectLanguage + +/** + * Local template metadata abstraction used by onboarding. + * + * Deferred fields: preview images, full widget schemas, and recipe execution logic + * are intentionally excluded to keep this module decoupled from AndroidIDE runtime. + */ +interface IdeTemplateCatalog { + fun getTemplates(): List + + fun getTemplateById(id: String): IdeTemplateMetadata? +} + +data class IdeTemplateMetadata( + val id: String, + val name: String, + val description: String, + val supportedLanguages: Set, + val supportsCpp: Boolean = false, +) + +class AndroidIdeReferenceTemplateCatalog : IdeTemplateCatalog { + private val templates = listOf( + IdeTemplateMetadata( + id = "no_activity", + name = "No Activity", + description = "Project without an Activity entrypoint.", + supportedLanguages = setOf(CodeStudioProjectLanguage.KOTLIN, CodeStudioProjectLanguage.JAVA), + ), + IdeTemplateMetadata( + id = "empty_activity", + name = "Empty project", + description = "Minimal Android app scaffold.", + supportedLanguages = setOf(CodeStudioProjectLanguage.KOTLIN, CodeStudioProjectLanguage.JAVA), + ), + IdeTemplateMetadata( + id = "basic_activity", + name = "Basic Project", + description = "Starter app with app bar and FAB.", + supportedLanguages = setOf(CodeStudioProjectLanguage.KOTLIN, CodeStudioProjectLanguage.JAVA), + ), + IdeTemplateMetadata( + id = "navigation_drawer", + name = "Navigation drawer", + description = "App scaffold with navigation drawer.", + supportedLanguages = setOf(CodeStudioProjectLanguage.KOTLIN, CodeStudioProjectLanguage.JAVA), + ), + IdeTemplateMetadata( + id = "bottom_navigation", + name = "Bottom Navigation", + description = "App shell with bottom navigation.", + supportedLanguages = setOf(CodeStudioProjectLanguage.KOTLIN, CodeStudioProjectLanguage.JAVA), + ), + IdeTemplateMetadata( + id = "tabbed_activity", + name = "Tabbed Activity", + description = "Activity with tabbed UI structure.", + supportedLanguages = setOf(CodeStudioProjectLanguage.KOTLIN, CodeStudioProjectLanguage.JAVA), + ), + ) + + override fun getTemplates(): List = templates + + override fun getTemplateById(id: String): IdeTemplateMetadata? = templates.firstOrNull { it.id == id } +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/data/template/CodeStudioTemplateSelectionMapper.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/data/template/CodeStudioTemplateSelectionMapper.kt new file mode 100644 index 00000000..1673b287 --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/data/template/CodeStudioTemplateSelectionMapper.kt @@ -0,0 +1,35 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.data.template + +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioProjectForm + +/** + * Maps onboarding form state to a minimal template request payload. + * + * References used for alignment: + * - AndroidIDE `NewProjectDetails` (name/package/sdk/language/savePath) + * - AndroidIDE `ProjectTemplate` capabilities (java/kotlin/cpp) + * + * Deferred fields: + * - C++ flags, advanced widget parameters, template recipe internals. + */ +data class IdeTemplateSelection( + val templateId: String, + val projectName: String, + val packageName: String, + val minSdk: Int, + val targetSdk: Int, + val language: String, + val savePath: String, +) + +object IdeTemplateSelectionMapper { + fun map(form: CodeStudioProjectForm): IdeTemplateSelection = IdeTemplateSelection( + templateId = form.selectedTemplateId, + projectName = form.projectName, + packageName = form.packageName, + minSdk = form.minSdk, + targetSdk = form.targetSdk, + language = form.language.name.lowercase(), + savePath = form.saveLocation, + ) +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/model/CodeStudioOnboardingDraft.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/model/CodeStudioOnboardingDraft.kt new file mode 100644 index 00000000..5a44a04e --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/model/CodeStudioOnboardingDraft.kt @@ -0,0 +1,9 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model + +data class CodeStudioOnboardingDraft( + val step: CodeStudioOnboardingStep = CodeStudioOnboardingStep.WELCOME, + val form: CodeStudioProjectForm = CodeStudioProjectForm(), + val isPermissionGranted: Boolean = false, + val isEnvironmentReady: Boolean = false, + val lastPreflightTimestamp: Long? = null, +) diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/model/CodeStudioOnboardingStep.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/model/CodeStudioOnboardingStep.kt new file mode 100644 index 00000000..e88f0ee7 --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/model/CodeStudioOnboardingStep.kt @@ -0,0 +1,24 @@ +/* + * Copyright (©) 2026 Mihai-Cristian Condrea + */ + +package com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model + +enum class CodeStudioOnboardingStep { + WELCOME, + PERMISSIONS, + PROJECT_BASICS, + TEMPLATE_SELECTION, + ENVIRONMENT_CHECKS, + REVIEW_AND_CREATE, + COMPLETED, +} + +private val ideOnboardingSteps = CodeStudioOnboardingStep.values() // FIXME: 'Enum.values()' is recommended to be replaced by 'Enum.entries' since 1.9 +val ideOnboardingStepCount: Int = ideOnboardingSteps.size + +fun CodeStudioOnboardingStep.next(): CodeStudioOnboardingStep = + ideOnboardingSteps.getOrElse(ordinal + 1) { this } + +fun CodeStudioOnboardingStep.previous(): CodeStudioOnboardingStep = + ideOnboardingSteps.getOrElse(ordinal - 1) { this } diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/model/CodeStudioProjectForm.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/model/CodeStudioProjectForm.kt new file mode 100644 index 00000000..01f819ae --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/model/CodeStudioProjectForm.kt @@ -0,0 +1,15 @@ +/* + * Copyright (©) 2026 Mihai-Cristian Condrea + */ + +package com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model + +data class CodeStudioProjectForm( + val projectName: String = "", + val packageName: String = "", + val language: CodeStudioProjectLanguage = CodeStudioProjectLanguage.KOTLIN, + val minSdk: Int = 26, + val targetSdk: Int = 36, + val saveLocation: String = "", + val selectedTemplateId: String = "", +) diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/model/CodeStudioProjectLanguage.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/model/CodeStudioProjectLanguage.kt new file mode 100644 index 00000000..7903689f --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/model/CodeStudioProjectLanguage.kt @@ -0,0 +1,10 @@ +/* + * Copyright (©) 2026 Mihai-Cristian Condrea + */ + +package com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model + +enum class CodeStudioProjectLanguage { + KOTLIN, + JAVA, +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/model/environment/EnvironmentCheckModels.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/model/environment/EnvironmentCheckModels.kt new file mode 100644 index 00000000..4179ab1c --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/model/environment/EnvironmentCheckModels.kt @@ -0,0 +1,23 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.environment + +enum class EnvironmentCheckStatus { + PENDING, + RUNNING, // FIXME: RUNNING is never used + PASSED, + FAILED, +} + +enum class EnvironmentCheckType { + PROJECT_DIRECTORY_WRITABLE, + TOOLCHAIN_METADATA, + TEMPLATE_ASSETS, +} + +data class EnvironmentCheckItem( + val type: EnvironmentCheckType, + val title: String, + val description: String, + val status: EnvironmentCheckStatus = EnvironmentCheckStatus.PENDING, + val detail: String? = null, + val isCritical: Boolean = true, +) diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/reducer/CodeStudioOnboardingReducer.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/reducer/CodeStudioOnboardingReducer.kt new file mode 100644 index 00000000..30d8f80a --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/reducer/CodeStudioOnboardingReducer.kt @@ -0,0 +1,160 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.domain.reducer + +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.permissions.IdePermissionStatus +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioOnboardingStep +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioProjectForm +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.next +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.previous +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.environment.EnvironmentCheckStatus +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.validation.IdeProjectFormValidator +import com.d4rk.androidtutorials.app.codestudio.onboarding.ui.contract.CodeStudioOnboardingAction +import com.d4rk.androidtutorials.app.codestudio.onboarding.ui.contract.CodeStudioOnboardingEvent +import com.d4rk.androidtutorials.app.codestudio.onboarding.ui.state.CodeStudioOnboardingUiState + +data class IdeOnboardingReduceResult( + val state: CodeStudioOnboardingUiState, + val event: CodeStudioOnboardingEvent? = null, +) + +object IdeOnboardingReducer { + + fun reduce( + state: CodeStudioOnboardingUiState, + action: CodeStudioOnboardingAction, + ): IdeOnboardingReduceResult = when (action) { + CodeStudioOnboardingAction.BackClicked -> IdeOnboardingReduceResult( + state = state.copy(currentStep = state.currentStep.previous(), errorMessage = null) + ) + + CodeStudioOnboardingAction.NextClicked -> moveForward(state) + CodeStudioOnboardingAction.CreateProjectClicked -> { + if (!state.canCreateProject || state.currentStep != CodeStudioOnboardingStep.REVIEW_AND_CREATE) { + IdeOnboardingReduceResult(state.copy(errorMessage = "Project cannot be created yet.")) + } else { + IdeOnboardingReduceResult( + state.copy( + isCreatingProject = true, + projectCreationProgress = null, + errorMessage = null, + ) + ) + } + } + + CodeStudioOnboardingAction.RunEnvironmentChecks -> IdeOnboardingReduceResult( + state.copy(isEnvironmentChecking = true, errorMessage = null) + ) + + CodeStudioOnboardingAction.ResetOnboarding -> IdeOnboardingReduceResult( + CodeStudioOnboardingUiState( + templates = state.templates, + permissionRequirements = state.permissionRequirements, + environmentChecks = state.environmentChecks, + ) + ) + + is CodeStudioOnboardingAction.RestoreDraft -> { + val form = action.draft.form + val validation = IdeProjectFormValidator.validate(form) + IdeOnboardingReduceResult( + state.copy( + currentStep = action.draft.step, + projectForm = form, + validationResult = validation, + isPermissionGranted = state.isPermissionGranted || action.draft.isPermissionGranted, + isEnvironmentReady = action.draft.isEnvironmentReady, + ) + ) + } + + is CodeStudioOnboardingAction.PermissionRequirementsUpdated -> { + val allGranted = action.requirements + .filter { it.isRequired } + .all { it.status == IdePermissionStatus.GRANTED } + IdeOnboardingReduceResult( + state.copy( + permissionRequirements = action.requirements, + isPermissionGranted = allGranted, + errorMessage = null, + ) + ) + } + + is CodeStudioOnboardingAction.PermissionCapabilityRequested -> IdeOnboardingReduceResult( + state = state, + event = CodeStudioOnboardingEvent.RequestPermissionCapability(action.capability), + ) + + is CodeStudioOnboardingAction.PermissionResultChanged -> IdeOnboardingReduceResult( + state.copy(isPermissionGranted = action.isGranted, errorMessage = null) + ) + + is CodeStudioOnboardingAction.EnvironmentChecksUpdated -> { + val ready = action.checks.filter { it.isCritical }.all { it.status == EnvironmentCheckStatus.PASSED } + IdeOnboardingReduceResult( + state.copy( + environmentChecks = action.checks, + isEnvironmentReady = ready, + isEnvironmentChecking = false, + errorMessage = null, + ) + ) + } + + is CodeStudioOnboardingAction.TemplatesLoaded -> IdeOnboardingReduceResult(state.copy(templates = action.templates)) + is CodeStudioOnboardingAction.ProjectCreationProgressUpdated -> IdeOnboardingReduceResult( + state.copy(projectCreationProgress = action.progress, isCreatingProject = true) + ) + + is CodeStudioOnboardingAction.ProjectCreationFinished -> IdeOnboardingReduceResult( + state.copy( + isCreatingProject = false, + currentStep = if (action.success) CodeStudioOnboardingStep.COMPLETED else state.currentStep, + errorMessage = if (action.success) null else action.message, + ) + ) + + is CodeStudioOnboardingAction.ProjectNameChanged -> updateForm(state) { copy(projectName = action.value) } + is CodeStudioOnboardingAction.PackageNameChanged -> updateForm(state) { copy(packageName = action.value) } + is CodeStudioOnboardingAction.LanguageChanged -> updateForm(state) { copy(language = action.value) } + is CodeStudioOnboardingAction.MinSdkChanged -> updateForm(state) { copy(minSdk = action.value) } + is CodeStudioOnboardingAction.TargetSdkChanged -> updateForm(state) { copy(targetSdk = action.value) } + is CodeStudioOnboardingAction.SaveLocationChanged -> updateForm(state) { copy(saveLocation = action.value) } + is CodeStudioOnboardingAction.TemplateSelected -> updateForm(state) { copy(selectedTemplateId = action.templateId) } + + CodeStudioOnboardingAction.ClearError -> IdeOnboardingReduceResult(state.copy(errorMessage = null)) + } + + private fun moveForward(state: CodeStudioOnboardingUiState): IdeOnboardingReduceResult { + if (!canMoveForward(state)) { + return IdeOnboardingReduceResult(state.copy(errorMessage = "Complete required fields before continuing.")) + } + + return IdeOnboardingReduceResult(state.copy(currentStep = state.currentStep.next(), errorMessage = null)) + } + + private fun canMoveForward(state: CodeStudioOnboardingUiState): Boolean = when (state.currentStep) { + CodeStudioOnboardingStep.WELCOME -> true + CodeStudioOnboardingStep.PERMISSIONS -> state.isPermissionGranted + CodeStudioOnboardingStep.PROJECT_BASICS -> state.validationResult.isValid + CodeStudioOnboardingStep.TEMPLATE_SELECTION -> state.projectForm.selectedTemplateId.isNotBlank() + CodeStudioOnboardingStep.ENVIRONMENT_CHECKS -> state.isEnvironmentReady + CodeStudioOnboardingStep.REVIEW_AND_CREATE -> state.canCreateProject && !state.isCreatingProject + CodeStudioOnboardingStep.COMPLETED -> false + } + + private fun updateForm( + state: CodeStudioOnboardingUiState, + transform: CodeStudioProjectForm.() -> CodeStudioProjectForm, + ): IdeOnboardingReduceResult { + val updatedForm = state.projectForm.transform() + val validation = IdeProjectFormValidator.validate(updatedForm) + return IdeOnboardingReduceResult( + state = state.copy( + projectForm = updatedForm, + validationResult = validation, + errorMessage = null, + ) + ) + } +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/usecase/CreateIdeProjectUseCase.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/usecase/CreateIdeProjectUseCase.kt new file mode 100644 index 00000000..112cd411 --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/usecase/CreateIdeProjectUseCase.kt @@ -0,0 +1,71 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.domain.usecase + +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.template.IdeTemplateSelection +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import java.io.File +import java.util.UUID + +enum class ProjectCreationStage { + INITIALIZING, + GENERATING_FILES, + FINALIZING, +} + +data class ProjectCreationProgress( + val stage: ProjectCreationStage, + val percent: Int, +) + +sealed interface CreateIdeProjectResult { + data class Success( + val projectId: String, + val projectPath: String, + val templateId: String, + ) : CreateIdeProjectResult +} + +class CreateIdeProjectUseCase { + + fun execute(selection: IdeTemplateSelection): Flow = flow { + emit(ProjectCreationProgress(ProjectCreationStage.INITIALIZING, 10)) + delay(150) + + val rootDir = File(selection.savePath) + if ((!rootDir.exists() && !rootDir.mkdirs()) || !rootDir.canWrite()) { + throw IllegalStateException("Selected directory is not writable.") + } + + val projectDir = File(rootDir, selection.projectName) + if (!projectDir.exists() && !projectDir.mkdirs()) { + throw IllegalStateException("Cannot create project directory.") + } + + emit(ProjectCreationProgress(ProjectCreationStage.GENERATING_FILES, 55)) + delay(150) + + File(projectDir, "README.md").writeText( + """ + # ${selection.projectName} + Template: ${selection.templateId} + Package: ${selection.packageName} + Language: ${selection.language} + MinSDK: ${selection.minSdk} + TargetSDK: ${selection.targetSdk} + """.trimIndent() + ) + + emit(ProjectCreationProgress(ProjectCreationStage.FINALIZING, 90)) + delay(100) + } + + fun buildSuccessResult(selection: IdeTemplateSelection): CreateIdeProjectResult.Success { + val projectPath = File(selection.savePath, selection.projectName).absolutePath + return CreateIdeProjectResult.Success( + projectId = UUID.randomUUID().toString(), + projectPath = projectPath, + templateId = selection.templateId, + ) + } +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/validation/CodeStudioEnvironmentPreflightChecker.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/validation/CodeStudioEnvironmentPreflightChecker.kt new file mode 100644 index 00000000..3837d3e5 --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/validation/CodeStudioEnvironmentPreflightChecker.kt @@ -0,0 +1,91 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.domain.validation + +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.template.IdeTemplateCatalog +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.environment.EnvironmentCheckItem +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.environment.EnvironmentCheckStatus +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.environment.EnvironmentCheckType +import java.io.File + +class CodeStudioEnvironmentPreflightChecker( + private val templateCatalog: IdeTemplateCatalog, + private val isContentUriWritable: (String) -> Boolean = { false }, +) { + + fun initialChecks(): List = listOf( + EnvironmentCheckItem( + type = EnvironmentCheckType.PROJECT_DIRECTORY_WRITABLE, + title = "Project directory", + description = "Verify selected location exists and is writable.", + ), + EnvironmentCheckItem( + type = EnvironmentCheckType.TOOLCHAIN_METADATA, + title = "Toolchain metadata", + description = "Verify minimum Java/Gradle metadata is available.", + ), + EnvironmentCheckItem( + type = EnvironmentCheckType.TEMPLATE_ASSETS, + title = "Template assets", + description = "Verify template catalog is available for onboarding.", + ), + ) + + fun runChecks(saveLocation: String): List { + return listOf( + checkWritableDirectory(saveLocation), + checkToolchainMetadata(), + checkTemplateAssets(), + ) + } + + private fun checkWritableDirectory(saveLocation: String): EnvironmentCheckItem { + if (saveLocation.isBlank()) { + return EnvironmentCheckItem( + type = EnvironmentCheckType.PROJECT_DIRECTORY_WRITABLE, + title = "Project directory", + description = "Verify selected location exists and is writable.", + status = EnvironmentCheckStatus.FAILED, + detail = "No save location selected.", + ) + } + + val writable = if (saveLocation.startsWith("content://")) { + isContentUriWritable(saveLocation) + } else { + val file = File(saveLocation) + (file.exists() || file.mkdirs()) && file.canWrite() + } + return EnvironmentCheckItem( + type = EnvironmentCheckType.PROJECT_DIRECTORY_WRITABLE, + title = "Project directory", + description = "Verify selected location exists and is writable.", + status = if (writable) EnvironmentCheckStatus.PASSED else EnvironmentCheckStatus.FAILED, + detail = if (writable) "Directory is writable." else "Cannot write into selected directory.", + ) + } + + private fun checkToolchainMetadata(): EnvironmentCheckItem { + val javaVersion = System.getProperty("java.version").orEmpty() + val hasMetadata = javaVersion.isNotBlank() + + return EnvironmentCheckItem( + type = EnvironmentCheckType.TOOLCHAIN_METADATA, + title = "Toolchain metadata", + description = "Verify minimum Java/Gradle metadata is available.", + status = if (hasMetadata) EnvironmentCheckStatus.PASSED else EnvironmentCheckStatus.FAILED, + detail = if (hasMetadata) "Java runtime: $javaVersion" else "Java metadata unavailable.", + ) + } + + private fun checkTemplateAssets(): EnvironmentCheckItem { + val templates = templateCatalog.getTemplates() + val hasTemplates = templates.isNotEmpty() + + return EnvironmentCheckItem( + type = EnvironmentCheckType.TEMPLATE_ASSETS, + title = "Template assets", + description = "Verify template catalog is available for onboarding.", + status = if (hasTemplates) EnvironmentCheckStatus.PASSED else EnvironmentCheckStatus.FAILED, + detail = if (hasTemplates) "${templates.size} templates available." else "No templates found.", + ) + } +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/validation/CodeStudioProjectFormValidator.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/validation/CodeStudioProjectFormValidator.kt new file mode 100644 index 00000000..b4e37c5f --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/validation/CodeStudioProjectFormValidator.kt @@ -0,0 +1,77 @@ +/* + * Copyright (©) 2026 Mihai-Cristian Condrea + */ + +package com.d4rk.androidtutorials.app.codestudio.onboarding.domain.validation + +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioProjectForm + +data class IdeProjectFormValidationResult( + val projectNameError: String? = null, + val packageNameError: String? = null, + val minSdkError: String? = null, + val targetSdkError: String? = null, + val saveLocationError: String? = null, + val globalErrors: List = emptyList(), +) { + val isValid: Boolean + get() = projectNameError == null && + packageNameError == null && + minSdkError == null && + targetSdkError == null && + saveLocationError == null && + globalErrors.isEmpty() +} + +object IdeProjectFormValidator { + private val invalidProjectNameChars = Regex("[\\\\/:*?\"<>|]") + private val packageRegex = Regex("^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$") + private val reservedPackageNames = setOf("android", "java", "kotlin") + + fun validate(form: CodeStudioProjectForm): IdeProjectFormValidationResult { + val projectNameError = when { + form.projectName.isBlank() -> "Project name is required." + invalidProjectNameChars.containsMatchIn(form.projectName) -> + "Project name contains invalid filesystem characters." + else -> null + } + + val packageNameError = when { + form.packageName.isBlank() -> "Package name is required." + !packageRegex.matches(form.packageName) -> "Package name format is invalid." + form.packageName.split(".").any { it in reservedPackageNames } -> + "Package name contains reserved identifiers." + else -> null + } + + val minSdkError = when { + form.minSdk < 21 -> "Min SDK must be at least 21." + form.minSdk > form.targetSdk -> "Min SDK cannot be greater than target SDK." + else -> null + } + + val targetSdkError = when { + form.targetSdk < form.minSdk -> "Target SDK must be greater than or equal to Min SDK." + form.targetSdk > 36 -> "Target SDK cannot be greater than 36 in onboarding." + else -> null + } + + val saveLocationError = + if (form.saveLocation.isBlank()) "Save location is required." else null + + val globalErrors = buildList { + if (form.projectName.equals("app", ignoreCase = true)) { + add("Project name 'app' is reserved in this onboarding flow.") + } + } + + return IdeProjectFormValidationResult( + projectNameError = projectNameError, + packageNameError = packageNameError, + minSdkError = minSdkError, + targetSdkError = targetSdkError, + saveLocationError = saveLocationError, + globalErrors = globalErrors, + ) + } +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/ui/CodeStudioOnboardingViewModel.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/ui/CodeStudioOnboardingViewModel.kt new file mode 100644 index 00000000..342f4b8b --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/ui/CodeStudioOnboardingViewModel.kt @@ -0,0 +1,177 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.ui + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.documentfile.provider.DocumentFile +import com.d4rk.androidtutorials.R +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.permissions.IdePermissionCapability +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.permissions.CodeStudioPermissionCoordinator +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.repository.DataStoreIdeOnboardingDraftRepository +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.repository.IdeOnboardingDraftRepository +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.template.AndroidIdeReferenceTemplateCatalog +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.template.IdeTemplateSelectionMapper +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioOnboardingDraft +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.reducer.IdeOnboardingReducer +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.usecase.CreateIdeProjectResult +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.usecase.CreateIdeProjectUseCase +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.validation.CodeStudioEnvironmentPreflightChecker +import com.d4rk.androidtutorials.app.codestudio.onboarding.ui.contract.CodeStudioOnboardingAction +import com.d4rk.androidtutorials.app.codestudio.onboarding.ui.contract.CodeStudioOnboardingEvent +import com.d4rk.androidtutorials.app.codestudio.onboarding.ui.state.CodeStudioOnboardingUiState +import com.d4rk.androidtutorials.app.codestudio.common.runtime.navigation.IdeLaunchPayload +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class CodeStudioOnboardingViewModel : ViewModel() { + private val templateCatalog = AndroidIdeReferenceTemplateCatalog() + private val permissionCoordinator = CodeStudioPermissionCoordinator() + private val preflightChecker = CodeStudioEnvironmentPreflightChecker( + templateCatalog = templateCatalog, + isContentUriWritable = ::isPersistedTreeUriWritable, + ) + private val createProjectUseCase = CreateIdeProjectUseCase() + + private val _uiState = MutableStateFlow( + CodeStudioOnboardingUiState( + environmentChecks = preflightChecker.initialChecks(), + templates = templateCatalog.getTemplates(), + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + private var restoreCompleted = false + private var appContext: Context? = null + private var draftRepository: IdeOnboardingDraftRepository? = null + + fun onAction(action: CodeStudioOnboardingAction) { + if (action is CodeStudioOnboardingAction.CreateProjectClicked) { + createProjectIfAllowed() + return + } + + if (action is CodeStudioOnboardingAction.ResetOnboarding) { + viewModelScope.launch { draftRepository?.clearDraft() } + } + + val result = IdeOnboardingReducer.reduce(_uiState.value, action) + _uiState.value = result.state + + result.event?.let { event -> + viewModelScope.launch { _events.emit(event) } + } + + persistDraft() + } + + fun initializePersistence(context: Context) { + if (draftRepository == null) { + draftRepository = DataStoreIdeOnboardingDraftRepository(context.applicationContext) + } + appContext = context.applicationContext + if (restoreCompleted) return + + viewModelScope.launch { + val draft = draftRepository?.observeDraft()?.first() ?: CodeStudioOnboardingDraft() + onAction(CodeStudioOnboardingAction.RestoreDraft(draft)) + restoreCompleted = true + } + } + + fun refreshPermissions(context: Context) { + onAction(CodeStudioOnboardingAction.PermissionRequirementsUpdated(permissionCoordinator.getRequirements(context))) + } + + fun requestPermissionCapability(capability: IdePermissionCapability) { + onAction(CodeStudioOnboardingAction.PermissionCapabilityRequested(capability)) + } + + fun runEnvironmentChecks() { + onAction(CodeStudioOnboardingAction.RunEnvironmentChecks) + + viewModelScope.launch(Dispatchers.IO) { + val checks = preflightChecker.runChecks(saveLocation = _uiState.value.projectForm.saveLocation) + onAction(CodeStudioOnboardingAction.EnvironmentChecksUpdated(checks)) + } + } + + private fun isPersistedTreeUriWritable(location: String): Boolean { + val context = appContext ?: return false + val uri = Uri.parse(location) + val hasPersistedWritePermission = context.contentResolver.persistedUriPermissions.any { permission -> + permission.uri == uri && permission.isWritePermission + } + if (!hasPersistedWritePermission) return false + + val document = DocumentFile.fromTreeUri(context, uri) ?: return false + return document.exists() && document.canWrite() + } + + private fun createProjectIfAllowed() { + val state = _uiState.value + if (state.isCreatingProject) return + + val start = IdeOnboardingReducer.reduce(state, CodeStudioOnboardingAction.CreateProjectClicked) + _uiState.value = start.state + if (!start.state.isCreatingProject) return + + viewModelScope.launch(Dispatchers.IO) { + val selection = IdeTemplateSelectionMapper.map(_uiState.value.projectForm) + + try { + createProjectUseCase.execute(selection).collect { progress -> + onAction(CodeStudioOnboardingAction.ProjectCreationProgressUpdated(progress)) + } + + val success: CreateIdeProjectResult.Success = createProjectUseCase.buildSuccessResult(selection) + onAction(CodeStudioOnboardingAction.ProjectCreationFinished(success = true)) + _events.emit( + CodeStudioOnboardingEvent.NavigateToIde( + payload = IdeLaunchPayload( + projectId = success.projectId, + projectPath = success.projectPath, + templateId = success.templateId, + ) + ) + ) + } catch (error: Throwable) { + val fallback = appContext?.getString(R.string.ide_creation_failure_generic) + ?: "Project creation failed. Retry after fixing configuration." + val errorMessage = error.message?.takeIf { it.isNotBlank() } + onAction( + CodeStudioOnboardingAction.ProjectCreationFinished( + success = false, + message = if (errorMessage != null) "$fallback $errorMessage" else fallback, + ) + ) + } + } + } + + private fun persistDraft() { + val repo = draftRepository ?: return + val current = _uiState.value + viewModelScope.launch { + repo.saveDraft( + CodeStudioOnboardingDraft( + step = current.currentStep, + form = current.projectForm, + isPermissionGranted = current.isPermissionGranted, + isEnvironmentReady = current.isEnvironmentReady, + lastPreflightTimestamp = System.currentTimeMillis(), + ) + ) + } + } +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/ui/contract/CodeStudioOnboardingAction.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/ui/contract/CodeStudioOnboardingAction.kt new file mode 100644 index 00000000..0c00a03c --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/ui/contract/CodeStudioOnboardingAction.kt @@ -0,0 +1,35 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.ui.contract + +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.permissions.IdePermissionCapability +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.permissions.IdePermissionRequirement +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.template.IdeTemplateMetadata +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioOnboardingDraft +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioProjectLanguage +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.environment.EnvironmentCheckItem +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.usecase.ProjectCreationProgress + +sealed interface CodeStudioOnboardingAction { + data object BackClicked : CodeStudioOnboardingAction + data object NextClicked : CodeStudioOnboardingAction + data object CreateProjectClicked : CodeStudioOnboardingAction + data object ClearError : CodeStudioOnboardingAction + data object RunEnvironmentChecks : CodeStudioOnboardingAction + data object ResetOnboarding : CodeStudioOnboardingAction + + data class RestoreDraft(val draft: CodeStudioOnboardingDraft) : CodeStudioOnboardingAction + data class PermissionResultChanged(val isGranted: Boolean) : CodeStudioOnboardingAction + data class PermissionRequirementsUpdated(val requirements: List) : CodeStudioOnboardingAction + data class PermissionCapabilityRequested(val capability: IdePermissionCapability) : CodeStudioOnboardingAction + data class EnvironmentChecksUpdated(val checks: List) : CodeStudioOnboardingAction + data class TemplatesLoaded(val templates: List) : CodeStudioOnboardingAction + data class ProjectCreationProgressUpdated(val progress: ProjectCreationProgress) : CodeStudioOnboardingAction + data class ProjectCreationFinished(val success: Boolean, val message: String? = null) : CodeStudioOnboardingAction + + data class ProjectNameChanged(val value: String) : CodeStudioOnboardingAction + data class PackageNameChanged(val value: String) : CodeStudioOnboardingAction + data class LanguageChanged(val value: CodeStudioProjectLanguage) : CodeStudioOnboardingAction + data class MinSdkChanged(val value: Int) : CodeStudioOnboardingAction + data class TargetSdkChanged(val value: Int) : CodeStudioOnboardingAction + data class SaveLocationChanged(val value: String) : CodeStudioOnboardingAction + data class TemplateSelected(val templateId: String) : CodeStudioOnboardingAction +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/ui/contract/CodeStudioOnboardingEvent.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/ui/contract/CodeStudioOnboardingEvent.kt new file mode 100644 index 00000000..11dfabf2 --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/ui/contract/CodeStudioOnboardingEvent.kt @@ -0,0 +1,9 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.ui.contract + +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.permissions.IdePermissionCapability +import com.d4rk.androidtutorials.app.codestudio.common.runtime.navigation.IdeLaunchPayload + +sealed interface CodeStudioOnboardingEvent { + data class NavigateToIde(val payload: IdeLaunchPayload) : CodeStudioOnboardingEvent + data class RequestPermissionCapability(val capability: IdePermissionCapability) : CodeStudioOnboardingEvent +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/ui/state/CodeStudioOnboardingUiState.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/ui/state/CodeStudioOnboardingUiState.kt new file mode 100644 index 00000000..1aa906e0 --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/ui/state/CodeStudioOnboardingUiState.kt @@ -0,0 +1,52 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.ui.state + +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.permissions.IdePermissionRequirement +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.permissions.IdePermissionStatus +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.template.IdeTemplateMetadata +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioOnboardingStep +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioProjectForm +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.ideOnboardingStepCount +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.environment.EnvironmentCheckItem +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.environment.EnvironmentCheckStatus +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.usecase.ProjectCreationProgress +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.validation.IdeProjectFormValidationResult + +data class CodeStudioOnboardingUiState( + val currentStep: CodeStudioOnboardingStep = CodeStudioOnboardingStep.WELCOME, + val projectForm: CodeStudioProjectForm = CodeStudioProjectForm(), + val validationResult: IdeProjectFormValidationResult = IdeProjectFormValidationResult(), + val permissionRequirements: List = emptyList(), + val environmentChecks: List = emptyList(), + val templates: List = emptyList(), + val isPermissionGranted: Boolean = false, + val isEnvironmentReady: Boolean = false, + val isEnvironmentChecking: Boolean = false, + val isCreatingProject: Boolean = false, + val projectCreationProgress: ProjectCreationProgress? = null, + val errorMessage: String? = null, +) { + val stepProgress: Float = (currentStep.ordinal + 1).toFloat() / ideOnboardingStepCount + + val canMoveNext: Boolean + get() = when (currentStep) { + CodeStudioOnboardingStep.WELCOME -> true + CodeStudioOnboardingStep.PERMISSIONS -> isPermissionGranted + CodeStudioOnboardingStep.PROJECT_BASICS -> validationResult.isValid + CodeStudioOnboardingStep.TEMPLATE_SELECTION -> projectForm.selectedTemplateId.isNotBlank() + CodeStudioOnboardingStep.ENVIRONMENT_CHECKS -> isEnvironmentReady + CodeStudioOnboardingStep.REVIEW_AND_CREATE -> canCreateProject + CodeStudioOnboardingStep.COMPLETED -> false + } + + val canMoveBack: Boolean + get() = !isCreatingProject && currentStep != CodeStudioOnboardingStep.WELCOME + + val canCreateProject: Boolean + get() = validationResult.isValid && isPermissionGranted && isEnvironmentReady + + val hasDeniedPermissions: Boolean + get() = permissionRequirements.any { it.status == IdePermissionStatus.DENIED || it.status == IdePermissionStatus.PERMANENTLY_DENIED } + + val hasFailedCriticalChecks: Boolean + get() = environmentChecks.any { it.isCritical && it.status == EnvironmentCheckStatus.FAILED } +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/ui/utils/ContextExt.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/ui/utils/ContextExt.kt new file mode 100644 index 00000000..d736dc58 --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/ui/utils/ContextExt.kt @@ -0,0 +1,11 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.ui.utils + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper + +tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/setup/navigation/CodeStudioSetupActivityContract.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/setup/navigation/CodeStudioSetupActivityContract.kt new file mode 100644 index 00000000..66241190 --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/setup/navigation/CodeStudioSetupActivityContract.kt @@ -0,0 +1,9 @@ +package com.d4rk.androidtutorials.app.codestudio.setup.navigation + +import android.content.Context +import android.content.Intent +import com.d4rk.androidtutorials.app.codestudio.setup.ui.CodeStudioSetupActivity + +object CodeStudioSetupActivityContract { + fun createIntent(context: Context): Intent = Intent(context, CodeStudioSetupActivity::class.java) +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/setup/ui/CodeStudioSetupActivity.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/setup/ui/CodeStudioSetupActivity.kt new file mode 100644 index 00000000..325814ca --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/setup/ui/CodeStudioSetupActivity.kt @@ -0,0 +1,21 @@ +package com.d4rk.androidtutorials.app.codestudio.setup.ui + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import com.d4rk.android.libs.apptoolkit.app.theme.ui.style.AppTheme +import com.d4rk.android.libs.apptoolkit.core.ui.window.rememberWindowWidthSizeClass + +class CodeStudioSetupActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + AppTheme { + CodeStudioSetupRoute( + windowWidthSizeClass = rememberWindowWidthSizeClass(), + onBackClicked = ::finish, + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/setup/ui/CodeStudioSetupScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/setup/ui/CodeStudioSetupScreen.kt new file mode 100644 index 00000000..94cb0193 --- /dev/null +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/codestudio/setup/ui/CodeStudioSetupScreen.kt @@ -0,0 +1,671 @@ +package com.d4rk.androidtutorials.app.codestudio.setup.ui + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.d4rk.android.libs.apptoolkit.core.ui.views.buttons.AnimatedIconButtonDirection +import com.d4rk.android.libs.apptoolkit.core.ui.views.navigation.LargeTopAppBarWithScaffold +import com.d4rk.android.libs.apptoolkit.core.ui.window.AppWindowWidthSizeClass +import com.d4rk.android.libs.apptoolkit.core.utils.constants.ui.SizeConstants +import com.d4rk.androidtutorials.R +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.permissions.IdePermissionCapability +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.permissions.CodeStudioPermissionCoordinator +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.permissions.IdePermissionStatus +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioOnboardingStep +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioProjectLanguage +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.environment.EnvironmentCheckStatus +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.ideOnboardingStepCount +import com.d4rk.androidtutorials.app.codestudio.onboarding.ui.contract.CodeStudioOnboardingAction +import com.d4rk.androidtutorials.app.codestudio.onboarding.ui.contract.CodeStudioOnboardingEvent +import com.d4rk.androidtutorials.app.codestudio.onboarding.ui.state.CodeStudioOnboardingUiState +import com.d4rk.androidtutorials.app.codestudio.onboarding.ui.CodeStudioOnboardingViewModel +import com.d4rk.androidtutorials.app.codestudio.common.runtime.navigation.IdeActivityContract +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CodeStudioSetupRoute( + windowWidthSizeClass: AppWindowWidthSizeClass, + onBackClicked: () -> Unit, + viewModel: CodeStudioOnboardingViewModel = viewModel(), // FIXME: Unstable parameter 'viewModel' prevents composable from being skippable +) { + val state: CodeStudioOnboardingUiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val permissionCoordinator = CodeStudioPermissionCoordinator() + val projectCreatedToastMessage = stringResource(id = R.string.ide_project_created_toast) + + val runtimePermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + ) { + viewModel.refreshPermissions(context) + } + + val settingsLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { + viewModel.refreshPermissions(context) + } + + val directoryPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree(), + ) { uri: Uri? -> + if (uri != null) { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(uri, flags) + viewModel.onAction(CodeStudioOnboardingAction.SaveLocationChanged(uri.toString())) + } + } + + LaunchedEffect(Unit) { + viewModel.initializePersistence(context) + viewModel.refreshPermissions(context) + viewModel.events.collect { event -> + when (event) { + is CodeStudioOnboardingEvent.NavigateToIde -> { + context.startActivity(IdeActivityContract.createIntent(context, event.payload)) + Toast.makeText(context, projectCreatedToastMessage, Toast.LENGTH_SHORT).show() + (context as? Activity)?.finish() + } + + is CodeStudioOnboardingEvent.RequestPermissionCapability -> { + when (event.capability) { + IdePermissionCapability.LEGACY_STORAGE -> { + val runtimePermissions: Array = permissionCoordinator + .getRequirements(context) + .firstOrNull { it.capability == IdePermissionCapability.LEGACY_STORAGE } + ?.requestPermissions + .orEmpty() + .toTypedArray() + runtimePermissionLauncher.launch(runtimePermissions) + } + + else -> settingsLauncher.launch(permissionCoordinator.settingsIntent(context, event.capability)) + } + } + } + } + } + + LargeTopAppBarWithScaffold( + title = stringResource(id = R.string.ide_onboarding_title), + onBackClicked = onBackClicked, + actions = { + AnimatedIconButtonDirection( + fromRight = true, + icon = Icons.Outlined.Refresh, + contentDescription = stringResource(id = R.string.ide_onboarding_reset), + onClick = { viewModel.onAction(CodeStudioOnboardingAction.ResetOnboarding) }, + iconSize = SizeConstants.TwentyFourSize, + ) + }, + ) { paddingValues -> + IdeOnboardingScreen( + state = state, + windowWidthSizeClass = windowWidthSizeClass, + paddingValues = paddingValues, + onAction = viewModel::onAction, + onRequestPermissionCapability = viewModel::requestPermissionCapability, + onRunEnvironmentChecks = viewModel::runEnvironmentChecks, + onPickLocation = { directoryPickerLauncher.launch(null) }, + ) + } +} + +@Composable +fun IdeOnboardingScreen( + state: CodeStudioOnboardingUiState, + windowWidthSizeClass: AppWindowWidthSizeClass, + paddingValues: androidx.compose.foundation.layout.PaddingValues, + onAction: (CodeStudioOnboardingAction) -> Unit, + onRequestPermissionCapability: (IdePermissionCapability) -> Unit, + onRunEnvironmentChecks: () -> Unit, + onPickLocation: () -> Unit = {}, +) { + val isWideLayout = windowWidthSizeClass >= AppWindowWidthSizeClass.Medium + val animatedStepProgress by animateFloatAsState( + targetValue = state.stepProgress, + animationSpec = tween(durationMillis = 450), + label = "ideOnboardingStepProgress", + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = if (isWideLayout) 24.dp else 16.dp, vertical = 16.dp), + ) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + tonalElevation = 2.dp, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = stringResource( + id = R.string.ide_onboarding_step_counter, + state.currentStep.ordinal + 1, + ideOnboardingStepCount, + ), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = state.currentStep.name + .lowercase(Locale.ROOT) + .split("_") + .joinToString(" ") { token -> token.replaceFirstChar { it.titlecase(Locale.ROOT) } }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + LinearProgressIndicator( + progress = { animatedStepProgress }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + state.projectCreationProgress?.let { progress -> + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(id = R.string.ide_creation_progress_title), fontWeight = FontWeight.SemiBold) + val stageText = when (progress.stage) { + com.d4rk.androidtutorials.app.codestudio.onboarding.domain.usecase.ProjectCreationStage.INITIALIZING -> stringResource(id = R.string.ide_creation_stage_initializing) + com.d4rk.androidtutorials.app.codestudio.onboarding.domain.usecase.ProjectCreationStage.GENERATING_FILES -> stringResource(id = R.string.ide_creation_stage_generating) + com.d4rk.androidtutorials.app.codestudio.onboarding.domain.usecase.ProjectCreationStage.FINALIZING -> stringResource(id = R.string.ide_creation_stage_finalizing) + } + Text(stageText) + LinearProgressIndicator(progress = { progress.percent / 100f }, modifier = Modifier.fillMaxWidth()) + } + } + } + + state.errorMessage?.let { + Card( + modifier = Modifier + .fillMaxWidth() + .semantics { liveRegion = LiveRegionMode.Assertive }, + ) { + Text(text = it, modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.error) + } + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + when (state.currentStep) { + CodeStudioOnboardingStep.WELCOME -> Text(stringResource(id = R.string.ide_onboarding_welcome_subtitle)) + CodeStudioOnboardingStep.PERMISSIONS -> StepPermissions(state, onRequestPermissionCapability) + CodeStudioOnboardingStep.PROJECT_BASICS -> StepProjectBasics(state, onAction, onPickLocation) + CodeStudioOnboardingStep.TEMPLATE_SELECTION -> StepTemplateSelection(state, onAction) + CodeStudioOnboardingStep.ENVIRONMENT_CHECKS -> StepEnvironmentChecks(state, onRunEnvironmentChecks) + CodeStudioOnboardingStep.REVIEW_AND_CREATE -> StepReviewAndCreate(state) + CodeStudioOnboardingStep.COMPLETED -> Text(stringResource(id = R.string.ide_onboarding_completed_message)) + } + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { onAction(CodeStudioOnboardingAction.BackClicked) }, + enabled = state.canMoveBack, + modifier = Modifier + .weight(1f) + .sizeIn(minHeight = 48.dp), + ) { Text(stringResource(id = R.string.ide_action_back)) } + + if (state.currentStep == CodeStudioOnboardingStep.REVIEW_AND_CREATE) { + Button( + onClick = { onAction(CodeStudioOnboardingAction.CreateProjectClicked) }, + enabled = state.canCreateProject && !state.isCreatingProject, + modifier = Modifier + .weight(1f) + .sizeIn(minHeight = 48.dp), + ) { + Text( + stringResource( + id = if (state.isCreatingProject) R.string.ide_action_creating else R.string.ide_action_create_project, + ) + ) + } + } else { + Button( + onClick = { onAction(CodeStudioOnboardingAction.NextClicked) }, + enabled = state.canMoveNext && !state.isCreatingProject, + modifier = Modifier + .weight(1f) + .sizeIn(minHeight = 48.dp), + ) { Text(stringResource(id = R.string.ide_action_next)) } + } + } + } +} + +@Composable +private fun StepPermissions( + state: CodeStudioOnboardingUiState, // FIXME: Parameter 'state' has runtime-determined stability + onRequestPermissionCapability: (IdePermissionCapability) -> Unit, +) { + Text(stringResource(id = R.string.ide_permissions_step_help)) + state.permissionRequirements.forEach { requirement -> + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(id = requirement.titleRes), fontWeight = FontWeight.SemiBold) + if (!requirement.isRequired) { + Text( + text = stringResource(id = R.string.ide_permissions_optional), + style = MaterialTheme.typography.labelSmall, + ) + } + Text(stringResource(id = requirement.descriptionRes), style = MaterialTheme.typography.bodySmall) + Text( + text = stringResource( + id = R.string.ide_permissions_status, + when (requirement.status) { + IdePermissionStatus.GRANTED -> stringResource(id = R.string.ide_permissions_status_granted) + IdePermissionStatus.DENIED -> stringResource(id = R.string.ide_permissions_status_denied) + IdePermissionStatus.PERMANENTLY_DENIED -> stringResource(id = R.string.ide_permissions_status_permanently_denied) + } + ) + ) + + if (requirement.status != IdePermissionStatus.GRANTED) { + AssistChip( + onClick = { onRequestPermissionCapability(requirement.capability) }, + label = { + Text( + stringResource( + id = if (requirement.needsSettingsIntent) R.string.ide_permissions_open_settings else R.string.ide_permissions_grant, + ) + ) + }, + modifier = Modifier.sizeIn(minHeight = 48.dp), + ) + + if (requirement.status == IdePermissionStatus.PERMANENTLY_DENIED) { + Text( + text = stringResource(id = R.string.ide_permissions_permanent_denied_help), + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun StepProjectBasics( + state: CodeStudioOnboardingUiState, + onAction: (CodeStudioOnboardingAction) -> Unit, + onPickLocation: () -> Unit, +) { + Text(stringResource(id = R.string.ide_project_basics_title), modifier = Modifier.semantics { heading() }) + + OutlinedTextField( + value = state.projectForm.projectName, + onValueChange = { onAction(CodeStudioOnboardingAction.ProjectNameChanged(it)) }, + label = { Text(stringResource(id = R.string.ide_field_project_name)) }, + isError = state.validationResult.projectNameError != null, + supportingText = { state.validationResult.projectNameError?.let { Text(it) } }, + shape = MaterialTheme.shapes.medium, + singleLine = true, + maxLines = 1, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.projectForm.packageName, + onValueChange = { onAction(CodeStudioOnboardingAction.PackageNameChanged(it)) }, + label = { Text(stringResource(id = R.string.ide_field_package_name)) }, + isError = state.validationResult.packageNameError != null, + supportingText = { state.validationResult.packageNameError?.let { Text(it) } }, + shape = MaterialTheme.shapes.medium, + singleLine = true, + maxLines = 1, + modifier = Modifier.fillMaxWidth(), + ) + + val saveLocationDisplay = remember(state.projectForm.saveLocation) { + formatSaveLocationForDisplay(state.projectForm.saveLocation) + } + OutlinedTextField( + value = saveLocationDisplay, + onValueChange = {}, + label = { Text(stringResource(id = R.string.ide_field_save_location)) }, + isError = state.validationResult.saveLocationError != null, + supportingText = { state.validationResult.saveLocationError?.let { Text(it) } }, + shape = MaterialTheme.shapes.medium, + singleLine = true, + maxLines = 1, + readOnly = true, + leadingIcon = { + AnimatedIconButtonDirection( + icon = Icons.Outlined.Folder, + contentDescription = stringResource(id = R.string.ide_pick_location), + onClick = onPickLocation, + iconSize = SizeConstants.TwentyFourSize, + ) + }, + modifier = Modifier + .fillMaxWidth() + .clickable { onPickLocation() }, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = state.projectForm.language == CodeStudioProjectLanguage.KOTLIN, + onClick = { onAction(CodeStudioOnboardingAction.LanguageChanged(CodeStudioProjectLanguage.KOTLIN)) }, + label = { Text(stringResource(id = R.string.ide_language_kotlin)) }, + modifier = Modifier.sizeIn(minHeight = 48.dp), + ) + FilterChip( + selected = state.projectForm.language == CodeStudioProjectLanguage.JAVA, + onClick = { onAction(CodeStudioOnboardingAction.LanguageChanged(CodeStudioProjectLanguage.JAVA)) }, + label = { Text(stringResource(id = R.string.ide_language_java)) }, + modifier = Modifier.sizeIn(minHeight = 48.dp), + ) + } + + val sdkOptions = remember(state.projectForm.minSdk) { + (state.projectForm.minSdk..36).toList() + } + var targetSdkExpanded by remember { mutableStateOf(false) } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = state.projectForm.minSdk.toString(), + onValueChange = { it.toIntOrNull()?.let { sdk -> onAction(CodeStudioOnboardingAction.MinSdkChanged(sdk)) } }, + label = { Text(stringResource(id = R.string.ide_field_min_sdk)) }, + isError = state.validationResult.minSdkError != null, + supportingText = { state.validationResult.minSdkError?.let { Text(it) } }, + shape = MaterialTheme.shapes.medium, + singleLine = true, + maxLines = 1, + modifier = Modifier.weight(1f), + ) + ExposedDropdownMenuBox( + expanded = targetSdkExpanded, + onExpandedChange = { targetSdkExpanded = !targetSdkExpanded }, + modifier = Modifier.weight(1f), + ) { + OutlinedTextField( + value = state.projectForm.targetSdk.toString(), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(id = R.string.ide_field_target_sdk)) }, + isError = state.validationResult.targetSdkError != null, + supportingText = { state.validationResult.targetSdkError?.let { Text(it) } }, + shape = MaterialTheme.shapes.medium, + singleLine = true, + maxLines = 1, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = targetSdkExpanded) }, + modifier = Modifier.menuAnchor( + type = ExposedDropdownMenuAnchorType.PrimaryEditable + ), + ) + ExposedDropdownMenu( + expanded = targetSdkExpanded, + onDismissRequest = { targetSdkExpanded = false }, + ) { + sdkOptions.forEach { sdk -> + DropdownMenuItem( + text = { Text(text = sdk.toString()) }, + onClick = { + onAction(CodeStudioOnboardingAction.TargetSdkChanged(sdk)) + targetSdkExpanded = false + }, + ) + } + } + } + } +} + +@Composable +private fun StepTemplateSelection( + state: CodeStudioOnboardingUiState, + onAction: (CodeStudioOnboardingAction) -> Unit, +) { + Text(stringResource(id = R.string.ide_new_project), style = MaterialTheme.typography.headlineMedium) + Text(stringResource(id = R.string.ide_template_step_help), style = MaterialTheme.typography.bodyMedium) + + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier + .fillMaxWidth() + .height(500.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(state.templates, key = { it.id }) { template -> + TemplatePreviewCard( + name = template.name, + selected = state.projectForm.selectedTemplateId == template.id, + onClick = { onAction(CodeStudioOnboardingAction.TemplateSelected(template.id)) }, + ) + } + } +} + +@Composable +private fun TemplatePreviewCard( + name: String, + selected: Boolean, + onClick: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(10.dp), + border = BorderStroke( + width = if (selected) 2.dp else 1.dp, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + ), + tonalElevation = if (selected) 2.dp else 0.dp, + ) { + Column(modifier = Modifier.padding(10.dp)) { + Surface( + color = Color(0xFF009688), + modifier = Modifier + .fillMaxWidth() + .height(20.dp), + ) {} + androidx.compose.foundation.layout.Box( + modifier = Modifier + .fillMaxWidth() + .height(92.dp) + .background(Color(0xFFF1F1F1)), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Text(text = "●", color = MaterialTheme.colorScheme.primary) + } + } + } + } + Text( + text = name, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } +} + +@Composable +private fun StepEnvironmentChecks( + state: CodeStudioOnboardingUiState, + onRunEnvironmentChecks: () -> Unit, +) { + Button( + onClick = onRunEnvironmentChecks, + enabled = !state.isEnvironmentChecking, + modifier = Modifier.sizeIn(minHeight = 48.dp), + ) { + Text(stringResource(id = if (state.isEnvironmentChecking) R.string.ide_checks_running else R.string.ide_checks_run)) + } + + state.environmentChecks.forEach { check -> + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(check.title, fontWeight = FontWeight.SemiBold) + Text(check.description, style = MaterialTheme.typography.bodySmall) + Text(stringResource(id = R.string.ide_checks_status, check.status.name)) + check.detail?.let { + Text( + it, + color = if (check.status == EnvironmentCheckStatus.FAILED) + MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} + +@Composable +private fun StepReviewAndCreate(state: CodeStudioOnboardingUiState) { + Text(stringResource(id = R.string.ide_review_title), modifier = Modifier.semantics { heading() }) + Text(stringResource(id = R.string.ide_review_name, state.projectForm.projectName)) + Text(stringResource(id = R.string.ide_review_package, state.projectForm.packageName)) + Text(stringResource(id = R.string.ide_review_language, state.projectForm.language.name)) + Text(stringResource(id = R.string.ide_review_sdk, state.projectForm.minSdk, state.projectForm.targetSdk)) + Text(stringResource(id = R.string.ide_review_location, formatSaveLocationForDisplay(state.projectForm.saveLocation))) + Text(stringResource(id = R.string.ide_review_template, state.projectForm.selectedTemplateId)) + + state.validationResult.globalErrors.forEach { + Text(text = stringResource(id = R.string.ide_error_bullet, it), color = MaterialTheme.colorScheme.error) + } + if (state.hasFailedCriticalChecks) { + Text(stringResource(id = R.string.ide_review_critical_checks_failed), color = MaterialTheme.colorScheme.error) + } +} + +private fun formatSaveLocationForDisplay(location: String): String { + if (location.isBlank()) return location + if (!location.startsWith("content://")) return location + + val decoded = URLDecoder.decode(location, StandardCharsets.UTF_8) // FIXME: Call requires API level 33 (current min is 26): `java.net.URLDecoder#decode` + val treeDocumentPath = decoded.substringAfter("/tree/", missingDelimiterValue = decoded) + return treeDocumentPath + .replace("primary:", "Internal storage/") + .replace(":", "/") +} + +@Preview(showBackground = true) +@Composable +private fun IdeOnboardingScreenWelcomePreview() { + IdeOnboardingScreen( + state = CodeStudioOnboardingUiState(), + windowWidthSizeClass = AppWindowWidthSizeClass.Compact, + paddingValues = androidx.compose.foundation.layout.PaddingValues(), + onAction = {}, + onRequestPermissionCapability = {}, + onRunEnvironmentChecks = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun IdeOnboardingScreenMidStepPreview() { + IdeOnboardingScreen( + state = CodeStudioOnboardingUiState(currentStep = CodeStudioOnboardingStep.TEMPLATE_SELECTION), + windowWidthSizeClass = AppWindowWidthSizeClass.Medium, + paddingValues = androidx.compose.foundation.layout.PaddingValues(), + onAction = {}, + onRequestPermissionCapability = {}, + onRunEnvironmentChecks = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun IdeOnboardingScreenReviewPreview() { + IdeOnboardingScreen( + state = CodeStudioOnboardingUiState(currentStep = CodeStudioOnboardingStep.REVIEW_AND_CREATE), + windowWidthSizeClass = AppWindowWidthSizeClass.Compact, + paddingValues = androidx.compose.foundation.layout.PaddingValues(), + onAction = {}, + onRequestPermissionCapability = {}, + onRunEnvironmentChecks = {}, + ) +} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/MainActivity.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/MainActivity.kt index 168c9770..6131ee11 100644 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/MainActivity.kt @@ -37,7 +37,8 @@ import com.d4rk.android.libs.apptoolkit.core.utils.extensions.context.openActivi import com.d4rk.androidtutorials.app.main.ui.contract.MainAction import com.d4rk.androidtutorials.app.main.ui.contract.MainEvent import com.d4rk.androidtutorials.core.data.local.datastore.DataStore -import com.google.android.gms.ads.MobileAds +import com.google.android.libraries.ads.mobile.sdk.MobileAds +import com.google.android.libraries.ads.mobile.sdk.initialization.InitializationConfig import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -77,9 +78,15 @@ class MainActivity : AppCompatActivity() { lifecycleScope.launch { coroutineScope { val adsInitialization = - async(dispatchers.default) { MobileAds.initialize(this@MainActivity) {} } + async(dispatchers.default) { + MobileAds.initialize( + this@MainActivity , + InitializationConfig.Builder(getString(com.d4rk.android.libs.apptoolkit.R.string.ad_mob_app_id)) + .build() + ) {} + } val consentInitialization = - async(dispatchers.io) { applyInitialConsentUseCase.invoke() } + async(dispatchers.io) { applyInitialConsentUseCase.invoke() } awaitAll(adsInitialization, consentInitialization) } } diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/views/navigation/AppNavigationGraph.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/views/navigation/AppNavigationGraph.kt index ac9c1d60..03db0823 100644 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/views/navigation/AppNavigationGraph.kt +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/views/navigation/AppNavigationGraph.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Stable import com.d4rk.android.libs.apptoolkit.core.ui.navigation.NavigationEntryBuilder import com.d4rk.android.libs.apptoolkit.core.ui.window.AppWindowWidthSizeClass +import com.d4rk.androidtutorials.app.codestudio.dashboard.ui.navigation.codeStudioDashboardEntryBuilder import com.d4rk.androidtutorials.app.lessons.favorites.ui.navigation.favoritesEntryBuilder import com.d4rk.androidtutorials.app.lessons.listing.ui.navigation.listingEntryBuilder import com.d4rk.androidtutorials.app.main.utils.constants.AppNavKey @@ -50,6 +51,7 @@ private fun defaultAppNavigationEntryBuilders( context: AppNavigationEntryContext, ): List> = listOf( listingEntryBuilder(context), + codeStudioDashboardEntryBuilder(context), studioBotEntryBuilder(context), favoritesEntryBuilder(context), ) diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/utils/constants/NavigationRoutes.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/utils/constants/NavigationRoutes.kt index b764d8e5..4d44687c 100644 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/utils/constants/NavigationRoutes.kt +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/utils/constants/NavigationRoutes.kt @@ -21,33 +21,38 @@ import androidx.compose.runtime.Immutable import com.d4rk.android.libs.apptoolkit.core.ui.model.navigation.StableNavKey import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf -import kotlinx.serialization.Serializable +import kotlinx.parcelize.Parcelize @Immutable -@Serializable +@Parcelize sealed interface AppNavKey : StableNavKey -@Serializable +@Parcelize data object HomeRoute : AppNavKey -@Serializable +@Parcelize +data object IdeRoute : AppNavKey + +@Parcelize data object StudioBotRoute : AppNavKey -@Serializable +@Parcelize data object FavoritesRoute : AppNavKey object NavigationRoutes { const val ROUTE_HOME: String = "home" + const val ROUTE_IDE: String = "ide" const val ROUTE_STUDIO_BOT: String = "studio_bot" const val ROUTE_FAVORITES: String = "favorites" val topLevelRoutes: ImmutableSet = - persistentSetOf(HomeRoute, StudioBotRoute, FavoritesRoute) + persistentSetOf(HomeRoute, IdeRoute, StudioBotRoute, FavoritesRoute) } fun String.toNavKeyOrDefault(): AppNavKey = when (this) { + NavigationRoutes.ROUTE_IDE -> IdeRoute NavigationRoutes.ROUTE_STUDIO_BOT -> StudioBotRoute NavigationRoutes.ROUTE_FAVORITES -> FavoritesRoute else -> HomeRoute diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/utils/defaults/MainNavigationDefaults.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/utils/defaults/MainNavigationDefaults.kt index 2a19d248..89ae6fad 100644 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/utils/defaults/MainNavigationDefaults.kt +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/utils/defaults/MainNavigationDefaults.kt @@ -18,7 +18,9 @@ package com.d4rk.androidtutorials.app.main.utils.defaults import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Android import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.outlined.Android import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.rounded.AutoAwesome import androidx.compose.material.icons.rounded.Favorite @@ -29,6 +31,7 @@ import com.d4rk.androidtutorials.R import com.d4rk.androidtutorials.app.main.utils.constants.AppNavKey import com.d4rk.androidtutorials.app.main.utils.constants.FavoritesRoute import com.d4rk.androidtutorials.app.main.utils.constants.HomeRoute +import com.d4rk.androidtutorials.app.main.utils.constants.IdeRoute import com.d4rk.androidtutorials.app.main.utils.constants.StudioBotRoute import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -42,6 +45,12 @@ internal object MainNavigationDefaults { selectedIcon = Icons.Filled.Home, title = ToolkitR.string.home, ), + BottomBarItem( + route = IdeRoute , + icon = Icons.Outlined.Android , + selectedIcon = Icons.Filled.Android , + title = R.string.code_studio , + ), BottomBarItem( route = StudioBotRoute, icon = Icons.Sharp.AutoAwesome, diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppSettingsProvider.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppSettingsProvider.kt index 64bcab2c..52d73e16 100644 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppSettingsProvider.kt +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppSettingsProvider.kt @@ -34,18 +34,22 @@ import com.d4rk.android.libs.apptoolkit.app.settings.utils.interfaces.SettingsPr import com.d4rk.android.libs.apptoolkit.core.utils.extensions.context.openAppNotificationSettings import com.d4rk.androidtutorials.app.settings.settings.utils.constants.SettingsConstants -class AppSettingsProvider : SettingsProvider { - override fun provideSettingsConfig(context: Context): SettingsConfig { +class AppSettingsProvider( + context: Context, +) : SettingsProvider { + private val context: Context = context.applicationContext + + override fun provideSettingsConfig(): SettingsConfig { return SettingsConfig( title = context.getString(R.string.settings), categories = listOf( SettingsCategory( preferences = listOf( SettingsPreference( - key = SettingsConstants.KEY_SETTINGS_NOTIFICATION, - icon = Icons.Outlined.Notifications, - title = context.getString(R.string.notifications), - summary = context.getString(R.string.summary_preference_settings_notifications), + key = SettingsConstants.KEY_SETTINGS_NOTIFICATION , + icon = Icons.Outlined.Notifications , + title = context.getString(R.string.notifications) , + summary = context.getString(R.string.summary_preference_settings_notifications) , action = { val opened = context.openAppNotificationSettings() if (!opened) { @@ -55,7 +59,7 @@ class AppSettingsProvider : SettingsProvider { contentKey = SettingsContent.SECURITY_AND_PRIVACY, ) } - }, + } , ), SettingsPreference( key = SettingsContent.DISPLAY, @@ -118,4 +122,4 @@ class AppSettingsProvider : SettingsProvider { ), ) } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/StudiobotScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/StudiobotScreen.kt index 694f0158..5189f21e 100644 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/StudiobotScreen.kt +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/StudiobotScreen.kt @@ -20,6 +20,7 @@ package com.d4rk.androidtutorials.app.studiobot.ui import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -30,7 +31,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -48,7 +48,6 @@ fun StudiobotScreenRoute( val viewModel: StudiobotViewModel = koinViewModel() val screenState by viewModel.uiState.collectAsStateWithLifecycle() val uiState = screenState.data ?: StudiobotUiState() - val isTwoPane = LocalConfiguration.current.screenWidthDp >= 840 // FIXME: Using Configuration.screenWidthDp instead of LocalWindowInfo.current.containerSize if (!uiState.hasAcceptedTerms) { AlertDialog( @@ -66,49 +65,53 @@ fun StudiobotScreenRoute( val selectedConversationId = uiState.selectedConversationId - if (isTwoPane) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box(modifier = Modifier.weight(0.4f)) { - ConversationsScreen( - paddingValues = PaddingValues(0.dp), - selectedConversationId = selectedConversationId, - onConversationClick = { viewModel.onEvent(StudiobotEvent.SelectConversation(it)) }, - ) - } - Box(modifier = Modifier.weight(0.6f)) { - selectedConversationId?.let { conversationId -> - StudioBotScreen( + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val isTwoPane = maxWidth >= 840.dp + + if (isTwoPane) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box(modifier = Modifier.weight(0.4f)) { + ConversationsScreen( paddingValues = PaddingValues(0.dp), - conversationId = conversationId, + selectedConversationId = selectedConversationId, + onConversationClick = { viewModel.onEvent(StudiobotEvent.SelectConversation(it)) }, ) } + Box(modifier = Modifier.weight(0.6f)) { + selectedConversationId?.let { conversationId -> + StudioBotScreen( + paddingValues = PaddingValues(0.dp), + conversationId = conversationId, + ) + } + } } - } - } else { - val showChat = selectedConversationId != null - BackHandler(enabled = showChat) { - viewModel.onEvent(StudiobotEvent.ShowConversations) - } - - if (showChat) { - StudioBotScreen( - paddingValues = paddingValues, - conversationId = selectedConversationId, - onBackToConversations = { - viewModel.onEvent(StudiobotEvent.ShowConversations) - }, - ) } else { - ConversationsScreen( - paddingValues = paddingValues, - selectedConversationId = selectedConversationId, - onConversationClick = { viewModel.onEvent(StudiobotEvent.SelectConversation(it)) }, - ) + val showChat = selectedConversationId != null + BackHandler(enabled = showChat) { + viewModel.onEvent(StudiobotEvent.ShowConversations) + } + + if (showChat) { + StudioBotScreen( + paddingValues = paddingValues, + conversationId = selectedConversationId, + onBackToConversations = { + viewModel.onEvent(StudiobotEvent.ShowConversations) + }, + ) + } else { + ConversationsScreen( + paddingValues = paddingValues, + selectedConversationId = selectedConversationId, + onConversationClick = { viewModel.onEvent(StudiobotEvent.SelectConversation(it)) }, + ) + } } } } diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/AdsModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/AdsModule.kt index b07e4f4d..4373c678 100644 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/AdsModule.kt +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/AdsModule.kt @@ -25,7 +25,7 @@ import com.d4rk.android.libs.apptoolkit.app.ads.ui.AdsSettingsViewModel import com.d4rk.android.libs.apptoolkit.app.settings.utils.providers.BuildInfoProvider import com.d4rk.android.libs.apptoolkit.core.ui.model.ads.AdsConfig import com.d4rk.androidtutorials.core.utils.constants.ads.AdsConstants -import com.google.android.gms.ads.AdSize +import com.google.android.libraries.ads.mobile.sdk.banner.AdSize import org.koin.core.module.Module import org.koin.core.module.dsl.viewModel import org.koin.core.qualifier.named diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/LessonsModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/LessonsModule.kt index 49928671..f78ad8fb 100644 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/LessonsModule.kt +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/LessonsModule.kt @@ -129,14 +129,13 @@ private fun openFavoritesDatabase( private fun buildFavoritesDatabase(context: Context): FavoritesDatabase { val builder = Room.databaseBuilder( - context = context, - klass = FavoritesDatabase::class.java, - name = FAVORITES_DATABASE_NAME, - ).addMigrations(MIGRATION_1_2, MIGRATION_2_3) - .fallbackToDestructiveMigrationOnDowngrade() + context = context , + klass = FavoritesDatabase::class.java , + name = FAVORITES_DATABASE_NAME , + ).addMigrations(MIGRATION_1_2 , MIGRATION_2_3).fallbackToDestructiveMigrationOnDowngrade(false) if (ENABLE_GLOBAL_DESTRUCTIVE_MIGRATION_FALLBACK) { - builder.fallbackToDestructiveMigration() + builder.fallbackToDestructiveMigration(false) } return builder.build() diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/OnboardingModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/OnboardingModule.kt index 76f9eb4b..6d686fa8 100644 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/OnboardingModule.kt +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/OnboardingModule.kt @@ -41,7 +41,6 @@ val onboardingModule: Module = module { OnboardingViewModel( observeOnboardingCompletionUseCase = get(), completeOnboardingUseCase = get(), - requestConsentUseCase = get(), dispatchers = get(), firebaseController = get(), ) diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/AppToolkitCoreModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/AppToolkitCoreModule.kt index 2e6801f3..89c7d9a9 100644 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/AppToolkitCoreModule.kt +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/AppToolkitCoreModule.kt @@ -32,8 +32,6 @@ val appToolkitCoreModule: Module = module { viewModel { StartupViewModel( - requestConsentUseCase = get(), - dispatchers = get(), firebaseController = get() ) } diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/SettingsRootModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/SettingsRootModule.kt index a8fe8bb3..972ae809 100644 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/SettingsRootModule.kt +++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/SettingsRootModule.kt @@ -25,7 +25,7 @@ import org.koin.core.module.dsl.viewModel import org.koin.dsl.module val settingsRootModule: Module = module { - single { AppSettingsProvider() } + single { AppSettingsProvider(context = get()) } viewModel { SettingsViewModel( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ebf810d3..a7e6b677 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,6 +26,19 @@ No lessons found. Database error encountered. Please try the following steps:\n\n1. Restart the app.\n2. If the issue persists, clear app data from Settings > Apps > Android Studio Tutorials > Storage.\n3. Contact support if the problem continues. + Code Studio + + AndroidIDE + Get started + Start your new awesome project! + Create project + Open existing project + Clone git repository + Terminal + Preferences + Documentation + Sponsor + Pick location Studio Bot Type a message… Send @@ -70,4 +83,67 @@ You can check for updates to Android Studio Tutorials: Kotlin Edition by going to the settings menu in the app and selecting the \"Check for updates\" option. How can I support the development of Android Studio Tutorials: Kotlin Edition? ou can support the development of Android Studio Tutorials: Kotlin Edition by leaving a positive review on the Google Play Store, sharing the app with friends and colleagues, and supporting the developers through the \"Share\" option in the settings menu. + + IDE Onboarding + Step %1$d of %2$d + Welcome! Configure permissions, project setup, and environment checks. + Setup completed. Redirected to IDE runtime. + Reset + + Project created and handed off to IDE runtime. + Project creation in progress + + Permissions are API-level aware and requested only when required. + Status: %1$s + Granted + Denied + Permanently denied + Optional + Open settings + Grant + Permission permanently denied. Open app settings to grant manually. + + Storage access (Android 10 and lower) + Required to read and write project files in shared storage. + All files access (Android 11+) + Required for broad project file management across directories. + Install unknown apps + Needed only if onboarding later supports local APK install from IDE outputs. + + Project basics + Project name + Package name + Save location + Min SDK + Target SDK + Kotlin + Java + + New project + Choose an activity template (aligned with AndroidIDE). + + Run checks + Checking... + Status: %1$s + + Review project setup + • Name: %1$s + • Package: %1$s + • Language: %1$s + • SDK: %1$d - %2$d + • Location: %1$s + • Template: %1$s + • Critical environment checks failed. + + Back + Next + Create Project + Creating... + • %1$s + Preparing project generation... + Generating project files... + Finalizing project setup... + Project creation failed. Retry after fixing configuration. + + diff --git a/app/src/test/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/CreateIdeProjectUseCaseTest.kt b/app/src/test/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/CreateIdeProjectUseCaseTest.kt new file mode 100644 index 00000000..7b07fd1f --- /dev/null +++ b/app/src/test/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/CreateIdeProjectUseCaseTest.kt @@ -0,0 +1,34 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.domain + +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.template.IdeTemplateSelection +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.usecase.CreateIdeProjectUseCase +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class CreateIdeProjectUseCaseTest { + + @Test + fun `execute emits expected progress stages and creates project artifacts`() = runBlocking { + val root = createTempDir(prefix = "ide-onboarding-test") + val selection = IdeTemplateSelection( + templateId = "empty_activity", + projectName = "SampleProject", + packageName = "com.example.sample", + minSdk = 26, + targetSdk = 36, + language = "kotlin", + savePath = root.absolutePath, + ) + val useCase = CreateIdeProjectUseCase() + + val stages = useCase.execute(selection).toList().map { it.stage } + + assertEquals(3, stages.size) + assertTrue(File(root, "SampleProject/README.md").exists()) + root.deleteRecursively() + } +} diff --git a/app/src/test/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/IdeEnvironmentPreflightCheckerTest.kt b/app/src/test/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/IdeEnvironmentPreflightCheckerTest.kt new file mode 100644 index 00000000..7cd7b139 --- /dev/null +++ b/app/src/test/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/IdeEnvironmentPreflightCheckerTest.kt @@ -0,0 +1,56 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.domain + +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.template.AndroidIdeReferenceTemplateCatalog +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.environment.EnvironmentCheckStatus +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.environment.EnvironmentCheckType +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.validation.CodeStudioEnvironmentPreflightChecker +import org.junit.Assert.assertEquals +import org.junit.Test + +class IdeEnvironmentPreflightCheckerTest { + + @Test + fun `runChecks passes writable file system path`() { + val writableDirectory = createTempDir(prefix = "ide-checker-") + try { + val checker = CodeStudioEnvironmentPreflightChecker(AndroidIdeReferenceTemplateCatalog()) + + val directoryCheck = checker.runChecks(writableDirectory.absolutePath) + .first { it.type == EnvironmentCheckType.PROJECT_DIRECTORY_WRITABLE } + + assertEquals(EnvironmentCheckStatus.PASSED, directoryCheck.status) + assertEquals("Directory is writable.", directoryCheck.detail) + } finally { + writableDirectory.deleteRecursively() + } + } + + @Test + fun `runChecks uses content uri boundary checker for saf location`() { + var observedUri: String? = null + val checker = CodeStudioEnvironmentPreflightChecker( + templateCatalog = AndroidIdeReferenceTemplateCatalog(), + isContentUriWritable = { uri -> + observedUri = uri + uri == "content://com.example.documents/tree/projects" + }, + ) + + val directoryCheck = checker.runChecks("content://com.example.documents/tree/projects") + .first { it.type == EnvironmentCheckType.PROJECT_DIRECTORY_WRITABLE } + + assertEquals("content://com.example.documents/tree/projects", observedUri) + assertEquals(EnvironmentCheckStatus.PASSED, directoryCheck.status) + } + + @Test + fun `runChecks fails when location blank`() { + val checker = CodeStudioEnvironmentPreflightChecker(AndroidIdeReferenceTemplateCatalog()) + + val directoryCheck = checker.runChecks("") + .first { it.type == EnvironmentCheckType.PROJECT_DIRECTORY_WRITABLE } + + assertEquals(EnvironmentCheckStatus.FAILED, directoryCheck.status) + assertEquals("No save location selected.", directoryCheck.detail) + } +} diff --git a/app/src/test/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/IdeOnboardingReducerTest.kt b/app/src/test/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/IdeOnboardingReducerTest.kt new file mode 100644 index 00000000..c7ce53be --- /dev/null +++ b/app/src/test/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/IdeOnboardingReducerTest.kt @@ -0,0 +1,156 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.domain + +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioOnboardingStep +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioOnboardingDraft +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioProjectForm +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.reducer.IdeOnboardingReducer +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.validation.IdeProjectFormValidator +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.permissions.IdePermissionCapability +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.permissions.IdePermissionRequirement +import com.d4rk.androidtutorials.app.codestudio.onboarding.data.permissions.IdePermissionStatus +import com.d4rk.androidtutorials.app.codestudio.onboarding.ui.contract.CodeStudioOnboardingAction +import com.d4rk.androidtutorials.app.codestudio.onboarding.ui.state.CodeStudioOnboardingUiState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class IdeOnboardingReducerTest { + + @Test + fun `next from permissions blocked when permission not granted`() { + val state = CodeStudioOnboardingUiState(currentStep = CodeStudioOnboardingStep.PERMISSIONS) + + val result = IdeOnboardingReducer.reduce(state, CodeStudioOnboardingAction.NextClicked) + + assertEquals(CodeStudioOnboardingStep.PERMISSIONS, result.state.currentStep) + assertTrue(result.state.errorMessage != null) + } + + + @Test + fun `permission requirements update enables permission gate when all granted`() { + val requirements = listOf( + IdePermissionRequirement( + capability = IdePermissionCapability.INSTALL_UNKNOWN_APPS, + titleRes = 0, + descriptionRes = 0, + status = IdePermissionStatus.GRANTED, + ) + ) + + val result = IdeOnboardingReducer.reduce( + CodeStudioOnboardingUiState(currentStep = CodeStudioOnboardingStep.PERMISSIONS), + CodeStudioOnboardingAction.PermissionRequirementsUpdated(requirements), + ) + + assertTrue(result.state.isPermissionGranted) + } + + @Test + fun `next from project basics advances when form valid`() { + val form = CodeStudioProjectForm( + projectName = "MyProject", + packageName = "com.example.project", + saveLocation = "/tmp/projects", + ) + val state = CodeStudioOnboardingUiState( + currentStep = CodeStudioOnboardingStep.PROJECT_BASICS, + projectForm = form, + validationResult = IdeProjectFormValidator.validate(form), + ) + + val result = IdeOnboardingReducer.reduce(state, CodeStudioOnboardingAction.NextClicked) + + assertEquals(CodeStudioOnboardingStep.TEMPLATE_SELECTION, result.state.currentStep) + assertNull(result.state.errorMessage) + } + + @Test + fun `restore draft does not downgrade granted permission state`() { + val state = CodeStudioOnboardingUiState( + currentStep = CodeStudioOnboardingStep.PERMISSIONS, + isPermissionGranted = true, + ) + + val result = IdeOnboardingReducer.reduce( + state, + CodeStudioOnboardingAction.RestoreDraft( + CodeStudioOnboardingDraft( + step = CodeStudioOnboardingStep.PERMISSIONS, + isPermissionGranted = false, + ) + ), + ) + + assertTrue(result.state.isPermissionGranted) + } + + @Test + fun `create project sets creating state when review is valid`() { + val form = CodeStudioProjectForm( + projectName = "MyProject", + packageName = "com.example.project", + saveLocation = "/tmp/projects", + selectedTemplateId = "empty_activity", + ) + val state = CodeStudioOnboardingUiState( + currentStep = CodeStudioOnboardingStep.REVIEW_AND_CREATE, + projectForm = form, + validationResult = IdeProjectFormValidator.validate(form), + isPermissionGranted = true, + isEnvironmentReady = true, + ) + + val result = IdeOnboardingReducer.reduce(state, CodeStudioOnboardingAction.CreateProjectClicked) + + assertTrue(result.state.isCreatingProject) + } + + + @Test + fun `create action ignored when already creating`() { + val form = CodeStudioProjectForm( + projectName = "MyProject", + packageName = "com.example.project", + saveLocation = "/tmp/projects", + selectedTemplateId = "empty_activity", + ) + val state = CodeStudioOnboardingUiState( + currentStep = CodeStudioOnboardingStep.REVIEW_AND_CREATE, + projectForm = form, + validationResult = IdeProjectFormValidator.validate(form), + isPermissionGranted = true, + isEnvironmentReady = true, + isCreatingProject = true, + ) + + val result = IdeOnboardingReducer.reduce(state, CodeStudioOnboardingAction.CreateProjectClicked) + + assertTrue(result.state.isCreatingProject) + assertEquals("Project cannot be created yet.", result.state.errorMessage) + } + + @Test + fun `environment checks update readiness based on critical failures`() { + val state = CodeStudioOnboardingUiState(currentStep = CodeStudioOnboardingStep.ENVIRONMENT_CHECKS) + val checks = listOf( + com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.environment.EnvironmentCheckItem( + type = com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.environment.EnvironmentCheckType.PROJECT_DIRECTORY_WRITABLE, + title = "Project directory", + description = "", + status = com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.environment.EnvironmentCheckStatus.PASSED, + ), + com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.environment.EnvironmentCheckItem( + type = com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.environment.EnvironmentCheckType.TOOLCHAIN_METADATA, + title = "Toolchain metadata", + description = "", + status = com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.environment.EnvironmentCheckStatus.FAILED, + ), + ) + + val result = IdeOnboardingReducer.reduce(state, CodeStudioOnboardingAction.EnvironmentChecksUpdated(checks)) + + assertEquals(false, result.state.isEnvironmentReady) + } +} diff --git a/app/src/test/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/IdeProjectFormValidatorTest.kt b/app/src/test/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/IdeProjectFormValidatorTest.kt new file mode 100644 index 00000000..338554d2 --- /dev/null +++ b/app/src/test/kotlin/com/d4rk/androidtutorials/app/codestudio/onboarding/domain/IdeProjectFormValidatorTest.kt @@ -0,0 +1,36 @@ +package com.d4rk.androidtutorials.app.codestudio.onboarding.domain + +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.model.CodeStudioProjectForm +import com.d4rk.androidtutorials.app.codestudio.onboarding.domain.validation.IdeProjectFormValidator +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class IdeProjectFormValidatorTest { + + @Test + fun `validate returns invalid when required fields missing`() { + val result = IdeProjectFormValidator.validate(CodeStudioProjectForm()) + + assertFalse(result.isValid) + assertTrue(result.projectNameError != null) + assertTrue(result.packageNameError != null) + assertTrue(result.saveLocationError != null) + } + + @Test + fun `validate returns valid for well formed project`() { + val result = IdeProjectFormValidator.validate( + CodeStudioProjectForm( + projectName = "MyIdeProject", + packageName = "com.example.myideproject", + saveLocation = "/storage/emulated/0/AndroidIDEProjects", + selectedTemplateId = "empty_activity", + minSdk = 26, + targetSdk = 36, + ) + ) + + assertTrue(result.isValid) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f26b47ab..32237a7b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,16 +2,16 @@ androidGradlePlugin = "9.1.0" androidxRoom = "2.8.4" googleMobileServices = "4.4.4" -googleDevtoolsKsp = "2.3.4" +googleDevtoolsKsp = "2.3.6" firebaseCrashlyticsPlugin = "3.0.6" firebasePerformancePlugin = "2.0.2" mannodermausPlugin = "2.0.1" -kotlin = "2.3.10" +kotlin = "2.3.20" ktor = "3.4.1" -koin = "4.1.1" -aboutLibraries = "14.0.0-b02" +koin = "4.2.0" +aboutLibraries = "14.0.0-b03" composeCodeEditor = "2.0.3" -androidxComposeUiTestManifest = "1.10.4" +androidxComposeUiTestManifest = "1.10.6" androidxEspressoCore = "3.7.0" androidxTestTruth = "1.7.0" testJupiter = "6.0.3" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index aad7ba29..9c5c7848 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Sat Mar 07 16:33:42 EET 2026 +#Thu Mar 26 15:02:15 EET 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/resources/AndroidIDE-dev/composite-builds/build-deps/google-java-format/src/main/java/com/google/googlejavaformat/java/AutoValue_JavaInputAstVisitor_DeclarationModifiersAndTypeAnnotations.java b/resources/AndroidIDE-dev/composite-builds/build-deps/google-java-format/src/main/java/com/google/googlejavaformat/java/AutoValue_JavaInputAstVisitor_DeclarationModifiersAndTypeAnnotations.java deleted file mode 100644 index 3d25c786..00000000 --- a/resources/AndroidIDE-dev/composite-builds/build-deps/google-java-format/src/main/java/com/google/googlejavaformat/java/AutoValue_JavaInputAstVisitor_DeclarationModifiersAndTypeAnnotations.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.google.googlejavaformat.java; - -import com.google.common.collect.ImmutableList; -import openjdk.source.tree.AnnotationTree; - -import jdkx.annotation.processing.Generated; - -@Generated("com.google.auto.value.processor.AutoValueProcessor") -final class AutoValue_JavaInputAstVisitor_DeclarationModifiersAndTypeAnnotations - extends JavaInputAstVisitor.DeclarationModifiersAndTypeAnnotations { - - private final ImmutableList declarationModifiers; - - private final ImmutableList typeAnnotations; - - AutoValue_JavaInputAstVisitor_DeclarationModifiersAndTypeAnnotations( - ImmutableList declarationModifiers, - ImmutableList typeAnnotations) { - if (declarationModifiers == null) { - throw new NullPointerException("Null declarationModifiers"); - } - this.declarationModifiers = declarationModifiers; - if (typeAnnotations == null) { - throw new NullPointerException("Null typeAnnotations"); - } - this.typeAnnotations = typeAnnotations; - } - - @Override - public int hashCode() { - int h$ = 1; - h$ *= 1000003; - h$ ^= declarationModifiers.hashCode(); - h$ *= 1000003; - h$ ^= typeAnnotations.hashCode(); - return h$; - } - - @Override - public boolean equals(Object o) { - if (o == this) { - return true; - } - if (o instanceof JavaInputAstVisitor.DeclarationModifiersAndTypeAnnotations) { - JavaInputAstVisitor.DeclarationModifiersAndTypeAnnotations that = - (JavaInputAstVisitor.DeclarationModifiersAndTypeAnnotations) o; - return this.declarationModifiers.equals(that.declarationModifiers()) - && this.typeAnnotations.equals(that.typeAnnotations()); - } - return false; - } - - @Override - public String toString() { - return "DeclarationModifiersAndTypeAnnotations{" - + "declarationModifiers=" - + declarationModifiers - + ", " - + "typeAnnotations=" - + typeAnnotations - + "}"; - } - - @Override - ImmutableList typeAnnotations() { - return typeAnnotations; - } - - @Override - ImmutableList declarationModifiers() { - return declarationModifiers; - } -}