From d1f8bebf47a7ab6241826b1ae0c65872bab3c7a5 Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Thu, 23 Jul 2026 11:13:37 +0900 Subject: [PATCH 01/21] =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EC=A6=88=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/.gitignore | 3 ++ .idea/.name | 1 + .idea/AndroidProjectSystem.xml | 6 +++ .idea/compiler.xml | 6 +++ .idea/deploymentTargetSelector.xml | 10 ++++ .idea/gradle.xml | 19 +++++++ .idea/inspectionProfiles/Project_Default.xml | 50 +++++++++++++++++++ .idea/markdown.xml | 8 +++ .idea/migrations.xml | 10 ++++ .idea/misc.xml | 9 ++++ .idea/runConfigurations.xml | 17 +++++++ .idea/vcs.xml | 6 +++ app/build.gradle.kts | 16 ++++-- .../example/android_2026_1/MainActivity.kt | 48 ++++++++++++++---- app/src/main/res/layout/activity_main.xml | 10 ---- app/src/main/res/values/strings.xml | 3 +- build.gradle.kts | 1 + gradle/libs.versions.toml | 25 ++++++---- gradle/wrapper/gradle-wrapper.properties | 4 +- 19 files changed, 215 insertions(+), 37 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/.name create mode 100644 .idea/AndroidProjectSystem.xml create mode 100644 .idea/compiler.xml create mode 100644 .idea/deploymentTargetSelector.xml create mode 100644 .idea/gradle.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/markdown.xml create mode 100644 .idea/migrations.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/runConfigurations.xml create mode 100644 .idea/vcs.xml delete mode 100644 app/src/main/res/layout/activity_main.xml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..cec0517 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +android_2026_1 \ No newline at end of file diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..f0c6ad0 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,50 @@ + + + + \ No newline at end of file diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000..c61ea33 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cc8ee59..9ec942c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,10 +1,11 @@ plugins { alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) } android { namespace = "com.example.android_2026_1" - compileSdk = 36 + compileSdk = 37 defaultConfig { applicationId = "com.example.android_2026_1" @@ -29,15 +30,22 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } + + buildFeatures { + compose = true + } } dependencies { implementation(libs.androidx.core.ktx) - implementation(libs.androidx.appcompat) implementation(libs.material) - implementation(libs.androidx.activity) - implementation(libs.androidx.constraintlayout) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.activity.compose) } \ No newline at end of file diff --git a/app/src/main/java/com/example/android_2026_1/MainActivity.kt b/app/src/main/java/com/example/android_2026_1/MainActivity.kt index ad2a6ca..c6198bb 100644 --- a/app/src/main/java/com/example/android_2026_1/MainActivity.kt +++ b/app/src/main/java/com/example/android_2026_1/MainActivity.kt @@ -1,20 +1,50 @@ package com.example.android_2026_1 import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource -class MainActivity : AppCompatActivity() { +class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - setContentView(R.layout.activity_main) - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets + setContent { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + MainScreen( + modifier = Modifier.padding(innerPadding), + onNewsClick = { + + } + ) + } + } + } +} + +@Composable +fun MainScreen( + modifier: Modifier = Modifier, + onNewsClick: () -> Unit +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Button(onClick = onNewsClick) { + Text(text = stringResource(R.string.btn_news)) } } } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 9affce0..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c4a5053..73ad06d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ - android_2026_1 + 스팀_api_테스트 + "게임 뉴스" \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 922f551..b2c8728 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.compose.compiler) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dd29a05..63c9d4c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,25 +1,28 @@ [versions] -kotlin = "2.3.21" -activity = "1.13.0" -agp = "9.0.1" -constraintlayout = "2.2.1" -coreKtx = "1.18.0" +activityCompose = "1.13.0" +composeBom = "2026.06.01" +kotlin = "2.4.10" +agp = "9.1.1" +coreKtx = "1.19.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -appcompat = "1.7.1" material = "1.14.0" [libraries] -androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } -androidx-activity = { module = "androidx.activity:activity", version.ref = "activity" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } -material = { module = "com.google.android.material:material", version.ref = "material" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } \ No newline at end of file +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 986dfe1..149c4ae 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,8 @@ #Fri May 15 03:28:10 KST 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 0ee4726dd085faa62cc8f7b08c4dae632312c330 Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Sun, 26 Jul 2026 18:48:39 +0900 Subject: [PATCH 02/21] =?UTF-8?q?=ED=8B=80=20=EC=99=84=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/deviceManager.xml | 13 + app/build.gradle.kts | 9 +- app/src/main/AndroidManifest.xml | 4 +- .../com/example/android_2026_1/AppLogger.kt | 9 + .../example/android_2026_1/MainActivity.kt | 36 +- .../com/example/android_2026_1/MainScreen.kt | 90 +++++ .../example/android_2026_1/MainViewModel.kt | 36 ++ .../com/example/android_2026_1/NewsScreen.kt | 356 ++++++++++++++++++ .../example/android_2026_1/NewsViewModel.kt | 144 +++++++ .../example/android_2026_1/ProfileScreen.kt | 245 ++++++++++++ .../android_2026_1/ProfileViewModel.kt | 173 +++++++++ .../com/example/android_2026_1/SteamApi.kt | 210 +++++++++++ app/src/main/res/values/strings.xml | 20 +- gradle/libs.versions.toml | 13 + 14 files changed, 1319 insertions(+), 39 deletions(-) create mode 100644 .idea/deviceManager.xml create mode 100644 app/src/main/java/com/example/android_2026_1/AppLogger.kt create mode 100644 app/src/main/java/com/example/android_2026_1/MainScreen.kt create mode 100644 app/src/main/java/com/example/android_2026_1/MainViewModel.kt create mode 100644 app/src/main/java/com/example/android_2026_1/NewsScreen.kt create mode 100644 app/src/main/java/com/example/android_2026_1/NewsViewModel.kt create mode 100644 app/src/main/java/com/example/android_2026_1/ProfileScreen.kt create mode 100644 app/src/main/java/com/example/android_2026_1/ProfileViewModel.kt create mode 100644 app/src/main/java/com/example/android_2026_1/SteamApi.kt diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9ec942c..e5018b4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,7 +10,7 @@ android { defaultConfig { applicationId = "com.example.android_2026_1" minSdk = 26 - targetSdk = 36 + targetSdk = 37 versionCode = 1 versionName = "1.0" @@ -48,4 +48,11 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) implementation(libs.androidx.activity.compose) + implementation(libs.retrofit) + implementation(libs.converter.gson) + implementation(libs.androidx.compose.material.icons.core) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.coil.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.navigation.compose) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e58d838..4ac1cc3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ - + + - MainScreen( - modifier = Modifier.padding(innerPadding), - onNewsClick = { - - } - ) - } - } - } -} - -@Composable -fun MainScreen( - modifier: Modifier = Modifier, - onNewsClick: () -> Unit -) { - Column( - modifier = modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Button(onClick = onNewsClick) { - Text(text = stringResource(R.string.btn_news)) + MainRoute() } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/android_2026_1/MainScreen.kt b/app/src/main/java/com/example/android_2026_1/MainScreen.kt new file mode 100644 index 0000000..440ed46 --- /dev/null +++ b/app/src/main/java/com/example/android_2026_1/MainScreen.kt @@ -0,0 +1,90 @@ +package com.example.android_2026_1 + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +fun MainRoute( + modifier: Modifier = Modifier, + viewModel: MainViewModel = viewModel() +) { + val uiState by viewModel.uiState.collectAsState() + + MainScreen( + uiState = uiState, + onEvent = viewModel::onEvent, + modifier = modifier + ) +} + +@Composable +fun MainScreen( + uiState: MainUiState, + onEvent: (MainEvent) -> Unit, + modifier: Modifier = Modifier +) { + when (uiState.currentScreen) { + is Screen.Home -> { + HomeScreen( + onNavigateToNews = { onEvent(MainEvent.NavigateTo(Screen.News)) }, + onNavigateToProfile = { onEvent(MainEvent.NavigateTo(Screen.Profile)) }, + modifier = modifier + ) + } + is Screen.News -> { + BackHandler { + onEvent(MainEvent.NavigateTo(Screen.Home)) + } + NewsRoute(modifier = modifier) + } + is Screen.Profile -> { + BackHandler { + onEvent(MainEvent.NavigateTo(Screen.Home)) + } + ProfileRoute(modifier = modifier) + } + } +} + +@Composable +private fun HomeScreen( + onNavigateToNews: () -> Unit, + onNavigateToProfile: () -> Unit, + modifier: Modifier = Modifier +) { + Scaffold(modifier = modifier.fillMaxSize()) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Button(onClick = onNavigateToNews) { + Text(text = stringResource(R.string.btn_news)) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Button(onClick = onNavigateToProfile) { + Text(text = stringResource(R.string.btn_profile)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android_2026_1/MainViewModel.kt b/app/src/main/java/com/example/android_2026_1/MainViewModel.kt new file mode 100644 index 0000000..6bc5b5a --- /dev/null +++ b/app/src/main/java/com/example/android_2026_1/MainViewModel.kt @@ -0,0 +1,36 @@ +package com.example.android_2026_1 + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +sealed interface Screen { + object Home : Screen + object News : Screen + object Profile : Screen +} + +data class MainUiState( + val currentScreen: Screen = Screen.Home +) + +sealed interface MainEvent { + data class NavigateTo(val screen: Screen) : MainEvent +} + +class MainViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(MainUiState()) + val uiState = _uiState.asStateFlow() + + fun onEvent(event: MainEvent) { + when (event) { + is MainEvent.NavigateTo -> navigateTo(event.screen) + } + } + + private fun navigateTo(screen: Screen) { + _uiState.update { it.copy(currentScreen = screen) } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android_2026_1/NewsScreen.kt b/app/src/main/java/com/example/android_2026_1/NewsScreen.kt new file mode 100644 index 0000000..23ad4a2 --- /dev/null +++ b/app/src/main/java/com/example/android_2026_1/NewsScreen.kt @@ -0,0 +1,356 @@ +package com.example.android_2026_1 + +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage + +@Composable +fun NewsRoute( + modifier: Modifier = Modifier, + viewModel: NewsViewModel = viewModel() +) { + val uiState by viewModel.uiState.collectAsState() + NewsScreen( + uiState = uiState, + onEvent = viewModel::onEvent, + modifier = modifier + ) +} + +@Composable +fun NewsScreen( + uiState: NewsUiState, + onEvent: (NewsEvent) -> Unit, + modifier: Modifier = Modifier +) { + var isSettingsOpen by rememberSaveable { mutableStateOf(false) } + var selectedNewsUrl by rememberSaveable { mutableStateOf(null) } + + Scaffold( + modifier = modifier.fillMaxSize(), + floatingActionButton = { + FloatingActionButton(onClick = { isSettingsOpen = true }) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = stringResource(R.string.cd_settings) + ) + } + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + NewsSearchBar( + text = uiState.text, + suggestions = uiState.suggestions, + onTextChange = { onEvent(NewsEvent.TextChanged(it)) }, + onSearch = { onEvent(NewsEvent.Search) }, + onSelect = { onEvent(NewsEvent.SelectItem(it)) } + ) + + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + NewsList( + newsList = uiState.newsList, + showImg = uiState.showImg, + onNewsClick = { selectedNewsUrl = it } + ) + } + } + } + + if (isSettingsOpen) { + SettingsDialog( + uiState = uiState, + onDismiss = { isSettingsOpen = false }, + onApply = { img, dev, ext, empty -> + onEvent(NewsEvent.UpdateSettings(img, dev, ext, empty)) + isSettingsOpen = false + } + ) + } + + selectedNewsUrl?.let { url -> + NewsWebViewDialog( + url = url, + onDismiss = { selectedNewsUrl = null } + ) + } +} + +@Composable +private fun NewsSearchBar( + text: String, + suggestions: List, + onTextChange: (String) -> Unit, + onSearch: () -> Unit, + onSelect: (String) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = text, + onValueChange = onTextChange, + modifier = Modifier.weight(1f), + placeholder = { Text(stringResource(R.string.search_hint)) }, + singleLine = true, + trailingIcon = { + if (text.isNotEmpty()) { + IconButton(onClick = { onTextChange("") }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_close) + ) + } + } + } + ) + IconButton(onClick = onSearch) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.cd_search) + ) + } + } + + if (suggestions.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(8.dp) + ) { + Column { + suggestions.take(5).forEach { item -> + Text( + text = item.name, + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(item.name) } + .padding(12.dp), + fontSize = 14.sp + ) + } + } + } + } + } +} + +@Composable +private fun NewsList( + newsList: List, + showImg: Boolean, + onNewsClick: (String) -> Unit +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(newsList) { item -> + NewsCard( + item = item, + showImg = showImg, + onClick = { onNewsClick(item.url) } + ) + } + } +} + +@Composable +private fun NewsCard( + item: NewsItem, + showImg: Boolean, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + val imgUrl = ImageUtils.extractImageUrl(item.contents) + if (showImg && imgUrl != null) { + AsyncImage( + model = imgUrl, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + } + Text( + text = item.title, + style = MaterialTheme.typography.titleMedium + ) + if (item.author.isNotEmpty()) { + Text( + text = item.author, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun SettingsDialog( + uiState: NewsUiState, + onDismiss: () -> Unit, + onApply: (Boolean, Boolean, Boolean, Boolean) -> Unit +) { + var img by remember { mutableStateOf(uiState.showImg) } + var dev by remember { mutableStateOf(uiState.dev) } + var ext by remember { mutableStateOf(uiState.external) } + var empty by remember { mutableStateOf(uiState.showEmpty) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(stringResource(R.string.show_images_label)) + Switch(checked = img, onCheckedChange = { img = it }) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = dev, onCheckedChange = { dev = it }) + Text(stringResource(R.string.news_type_dev)) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = ext, onCheckedChange = { ext = it }) + Text(stringResource(R.string.news_type_external)) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = empty, onCheckedChange = { empty = it }) + Text("기타 (빈 값)") + } + } + }, + confirmButton = { + TextButton(onClick = { onApply(img, dev, ext, empty) }) { + Text(stringResource(R.string.btn_apply)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.btn_cancel)) + } + } + ) +} + +@Composable +private fun NewsWebViewDialog( + url: String, + onDismiss: () -> Unit +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Card( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_close) + ) + } + } + AndroidView( + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + webViewClient = WebViewClient() + loadUrl(url) + } + }, + onRelease = { webView -> + webView.stopLoading() + webView.destroy() + }, + modifier = Modifier.fillMaxSize() + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android_2026_1/NewsViewModel.kt b/app/src/main/java/com/example/android_2026_1/NewsViewModel.kt new file mode 100644 index 0000000..a7986a9 --- /dev/null +++ b/app/src/main/java/com/example/android_2026_1/NewsViewModel.kt @@ -0,0 +1,144 @@ +package com.example.android_2026_1 + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class NewsUiState( + val text: String = "", + val suggestions: List = emptyList(), + val newsList: List = emptyList(), + val isLoading: Boolean = false, + val showImg: Boolean = true, + val dev: Boolean = true, + val external: Boolean = false, + val showEmpty: Boolean = true +) + +sealed interface NewsEvent { + data class TextChanged(val text: String) : NewsEvent + data class SelectItem(val name: String) : NewsEvent + object Search : NewsEvent + data class UpdateSettings( + val showImg: Boolean, + val dev: Boolean, + val external: Boolean, + val showEmpty: Boolean + ) : NewsEvent +} + +class NewsViewModel : ViewModel() { + + companion object { + private const val TAG = "NewsViewModel" + private const val FEED_DEV_ANNOUNCEMENT = "steam_community_announcements" + } + + private val _uiState = MutableStateFlow(NewsUiState()) + val uiState = _uiState.asStateFlow() + + private var rawNewsList = emptyList() + + fun onEvent(event: NewsEvent) { + when (event) { + is NewsEvent.TextChanged -> onTextChange(event.text) + is NewsEvent.SelectItem -> selectItem(event.name) + is NewsEvent.Search -> doSearch() + is NewsEvent.UpdateSettings -> updateSettings( + event.showImg, + event.dev, + event.external, + event.showEmpty + ) + } + } + + private fun onTextChange(newText: String) { + _uiState.update { it.copy(text = newText) } + if (newText.isEmpty()) { + _uiState.update { it.copy(suggestions = emptyList()) } + return + } + + viewModelScope.launch { + try { + val result = RetrofitClient.storeService.searchApps(newText) + _uiState.update { + it.copy(suggestions = result.items ?: emptyList()) + } + } catch (e: Exception) { + AppLogger.e(TAG, "Error searching apps", e) + _uiState.update { it.copy(suggestions = emptyList()) } + } + } + } + + private fun selectItem(name: String) { + _uiState.update { it.copy(text = name, suggestions = emptyList()) } + } + + private fun doSearch() { + val query = _uiState.value.text + if (query.isEmpty()) return + + _uiState.update { it.copy(isLoading = true, suggestions = emptyList()) } + + viewModelScope.launch { + val appId = findAppId(query) + rawNewsList = if (appId != null) { + loadNews(appId) + } else { + emptyList() + } + applyFilter() + } + } + + private suspend fun findAppId(query: String): Int? { + return try { + val response = RetrofitClient.storeService.searchApps(query) + val matchedGame = response.items?.firstOrNull() + matchedGame?.id + } catch (e: Exception) { + AppLogger.e(TAG, "Error finding appId", e) + null + } + } + + private suspend fun loadNews(appId: Int): List { + return try { + val response = RetrofitClient.newsService.getNewsForApp(appId) + response.appNews?.newsItems ?: emptyList() + } catch (e: Exception) { + AppLogger.e(TAG, "Error loading news", e) + emptyList() + } + } + + private fun applyFilter() { + val state = _uiState.value + val filtered = rawNewsList.filter { item -> + val isDev = item.feedname == FEED_DEV_ANNOUNCEMENT + val isEmpty = item.feedname.isNullOrEmpty() + val isExternal = !isEmpty && !isDev + + (state.dev && isDev) || (state.showEmpty && isEmpty) || (state.external && isExternal) + } + _uiState.update { it.copy(newsList = filtered, isLoading = false) } + } + + private fun updateSettings(showImg: Boolean, dev: Boolean, external: Boolean, showEmpty: Boolean) { + _uiState.update { + it.copy( + showImg = showImg, + dev = dev, + external = external, + showEmpty = showEmpty + ) + } + applyFilter() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android_2026_1/ProfileScreen.kt b/app/src/main/java/com/example/android_2026_1/ProfileScreen.kt new file mode 100644 index 0000000..61169d4 --- /dev/null +++ b/app/src/main/java/com/example/android_2026_1/ProfileScreen.kt @@ -0,0 +1,245 @@ +package com.example.android_2026_1 + +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage + +@Composable +fun ProfileRoute( + modifier: Modifier = Modifier, + viewModel: ProfileViewModel = viewModel() +) { + val uiState by viewModel.uiState.collectAsState() + ProfileScreen( + uiState = uiState, + onEvent = viewModel::onEvent, + modifier = modifier + ) +} + +@Composable +fun ProfileScreen( + uiState: ProfileUiState, + onEvent: (ProfileEvent) -> Unit, + modifier: Modifier = Modifier +) { + var showWebViewDialog by rememberSaveable { mutableStateOf(false) } + + Scaffold( + modifier = modifier.fillMaxSize() + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + ProfileSearchBar( + text = uiState.text, + onTextChange = { onEvent(ProfileEvent.TextChanged(it)) }, + onSearch = { onEvent(ProfileEvent.Search) } + ) + + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (uiState.userProfile != null) { + ProfileCard( + userProfile = uiState.userProfile, + onClick = { showWebViewDialog = true } + ) + } else if (uiState.errorMessageResId != null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(uiState.errorMessageResId), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + } + } + } + + if (showWebViewDialog && uiState.profileUrl != null) { + ProfileDetailDialog( + url = uiState.profileUrl, + onDismiss = { showWebViewDialog = false } + ) + } +} + +@Composable +private fun ProfileSearchBar( + text: String, + onTextChange: (String) -> Unit, + onSearch: () -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = text, + onValueChange = onTextChange, + modifier = Modifier.weight(1f), + placeholder = { Text(stringResource(R.string.profile_search_hint)) }, + singleLine = true, + trailingIcon = { + if (text.isNotEmpty()) { + IconButton(onClick = { onTextChange("") }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_close) + ) + } + } + } + ) + IconButton(onClick = onSearch) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.cd_search) + ) + } + } +} + +@Composable +private fun ProfileCard( + userProfile: UserProfile, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (userProfile.avatarUrl.isNotEmpty()) { + AsyncImage( + model = userProfile.avatarUrl, + contentDescription = null, + modifier = Modifier.size(64.dp), + contentScale = ContentScale.Crop + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = userProfile.name, + style = MaterialTheme.typography.titleMedium + ) + if (userProfile.stateMessage.isNotEmpty()) { + Text( + text = userProfile.stateMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (userProfile.summary.isNotEmpty()) { + Text( + text = userProfile.summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +private fun ProfileDetailDialog( + url: String, + onDismiss: () -> Unit +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Card( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_close) + ) + } + } + AndroidView( + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + webViewClient = WebViewClient() + loadUrl(url) + } + }, + onRelease = { webView -> + webView.stopLoading() + webView.destroy() + }, + modifier = Modifier.fillMaxSize() + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android_2026_1/ProfileViewModel.kt b/app/src/main/java/com/example/android_2026_1/ProfileViewModel.kt new file mode 100644 index 0000000..a542764 --- /dev/null +++ b/app/src/main/java/com/example/android_2026_1/ProfileViewModel.kt @@ -0,0 +1,173 @@ +package com.example.android_2026_1 + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.ResponseBody +import retrofit2.HttpException + +data class ProfileUiState( + val text: String = "", + val userProfile: UserProfile? = null, + val profileUrl: String? = null, + val isLoading: Boolean = false, + val errorMessageResId: Int? = null +) + +sealed interface ProfileEvent { + data class TextChanged(val text: String) : ProfileEvent + object Search : ProfileEvent +} + +class ProfileViewModel : ViewModel() { + + companion object { + private const val TAG = "ProfileViewModel" + private const val BASE_PROFILE_URL = "https://steamcommunity.com/profiles/" + private const val BASE_CUSTOM_ID_URL = "https://steamcommunity.com/id/" + } + + private val _uiState = MutableStateFlow(ProfileUiState()) + val uiState = _uiState.asStateFlow() + + fun onEvent(event: ProfileEvent) { + when (event) { + is ProfileEvent.TextChanged -> onTextChange(event.text) + is ProfileEvent.Search -> doSearch() + } + } + + private fun onTextChange(newText: String) { + _uiState.update { it.copy(text = newText) } + } + + private fun doSearch() { + val rawQuery = _uiState.value.text.trim() + if (rawQuery.isEmpty()) return + + val query = extractIdFromQuery(rawQuery) + + _uiState.update { + it.copy( + isLoading = true, + userProfile = null, + profileUrl = null, + errorMessageResId = null + ) + } + + viewModelScope.launch { + val result = fetchUserProfile(query) + _uiState.update { + it.copy( + isLoading = false, + userProfile = result.profile, + profileUrl = result.url, + errorMessageResId = result.errorMessageResId + ) + } + } + } + + private fun extractIdFromQuery(rawQuery: String): String { + var cleaned = rawQuery.trim().removeSuffix("/") + if (cleaned.contains("steamcommunity.com/id/")) { + cleaned = cleaned.substringAfter("steamcommunity.com/id/") + } else if (cleaned.contains("steamcommunity.com/profiles/")) { + cleaned = cleaned.substringAfter("steamcommunity.com/profiles/") + } + return cleaned.substringAfterLast("/") + } + + private data class FetchResult( + val profile: UserProfile? = null, + val url: String? = null, + val errorMessageResId: Int? = null + ) + + private suspend fun fetchUserProfile(query: String): FetchResult { + val isNumeric = query.all { it.isDigit() } + + if (isNumeric) { + val profileResult = searchByProfileId(query) + if (profileResult.isTooManyRequests) { + return FetchResult(errorMessageResId = R.string.error_too_many_requests) + } + if (profileResult.profile != null) { + val url = "$BASE_PROFILE_URL${profileResult.profile.steamId64 ?: query}" + return FetchResult(profile = profileResult.profile, url = url) + } + + val customResult = searchByCustomId(query) + if (customResult.isTooManyRequests) { + return FetchResult(errorMessageResId = R.string.error_too_many_requests) + } + if (customResult.profile != null) { + val url = "$BASE_CUSTOM_ID_URL$query" + return FetchResult(profile = customResult.profile, url = url) + } + } else { + val customResult = searchByCustomId(query) + if (customResult.isTooManyRequests) { + return FetchResult(errorMessageResId = R.string.error_too_many_requests) + } + if (customResult.profile != null) { + val url = if (customResult.profile.steamId64 != null) { + "$BASE_PROFILE_URL${customResult.profile.steamId64}" + } else { + "$BASE_CUSTOM_ID_URL$query" + } + return FetchResult(profile = customResult.profile, url = url) + } + + val profileResult = searchByProfileId(query) + if (profileResult.isTooManyRequests) { + return FetchResult(errorMessageResId = R.string.error_too_many_requests) + } + if (profileResult.profile != null) { + val url = "$BASE_PROFILE_URL${profileResult.profile.steamId64 ?: query}" + return FetchResult(profile = profileResult.profile, url = url) + } + } + + return FetchResult(errorMessageResId = R.string.user_not_found) + } + + private data class ApiResult( + val profile: UserProfile? = null, + val isTooManyRequests: Boolean = false + ) + + private suspend fun searchByProfileId(id: String): ApiResult = executeApiCall { + RetrofitClient.communityService.getUserByProfile(id) + } + + private suspend fun searchByCustomId(id: String): ApiResult = executeApiCall { + RetrofitClient.communityService.getUserById(id) + } + + private suspend fun executeApiCall( + apiCall: suspend () -> ResponseBody + ): ApiResult = withContext(Dispatchers.IO) { + try { + val response = apiCall() + val xmlString = response.string() + ApiResult(profile = XmlParserUtils.parseUserInfo(xmlString)) + } catch (e: HttpException) { + AppLogger.e(TAG, "Error fetching user profile", e) + if (e.code() == 429) { + ApiResult(isTooManyRequests = true) + } else { + ApiResult() + } + } catch (e: Exception) { + AppLogger.e(TAG, "Error fetching user profile", e) + ApiResult() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android_2026_1/SteamApi.kt b/app/src/main/java/com/example/android_2026_1/SteamApi.kt new file mode 100644 index 0000000..3f2d2ad --- /dev/null +++ b/app/src/main/java/com/example/android_2026_1/SteamApi.kt @@ -0,0 +1,210 @@ +package com.example.android_2026_1 + +import com.google.gson.annotations.SerializedName +import okhttp3.ResponseBody +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Path +import retrofit2.http.Query +import java.io.StringReader + +data class SteamNewsResponse( + @SerializedName(SteamApiService.KEY_APP_NEWS) val appNews: AppNews? +) + +data class AppNews( + @SerializedName(SteamApiService.KEY_NEWS_ITEMS) val newsItems: List? +) + +data class NewsItem( + @SerializedName(SteamApiService.KEY_TITLE) val title: String, + @SerializedName(SteamApiService.KEY_URL) val url: String, + @SerializedName(SteamApiService.KEY_AUTHOR) val author: String, + @SerializedName(SteamApiService.KEY_CONTENTS) val contents: String, + @SerializedName(SteamApiService.KEY_FEED_NAME) val feedname: String? +) + +data class SteamStoreSearchResponse( + @SerializedName(SteamApiService.KEY_ITEMS) val items: List? +) + +data class SteamAppItem( + @SerializedName(SteamApiService.KEY_ID) val id: Int, + @SerializedName(SteamApiService.KEY_NAME) val name: String +) + +data class WishlistGameItem( + @SerializedName(SteamApiService.KEY_NAME) val name: String?, + @SerializedName(SteamApiService.KEY_CAPSULE) val capsule: String?, + @SerializedName(SteamApiService.KEY_REVIEW_DESC) val reviewDesc: String?, + @SerializedName(SteamApiService.KEY_PRIORITY) val priority: Int?, + @SerializedName(SteamApiService.KEY_TAGS) val tags: List?, + @SerializedName(SteamApiService.KEY_SUBS) val subs: List?, + @SerializedName(SteamApiService.KEY_PRERELEASE) val prerelease: Int? +) + +data class WishlistSub( + @SerializedName(SteamApiService.KEY_PRICE) val price: Int?, + @SerializedName(SteamApiService.KEY_DISCOUNT_PCT) val discountPct: Int? +) + +interface SteamApiService { + @GET(ENDPOINT_NEWS) + suspend fun getNewsForApp( + @Query(PARAM_APP_ID) appId: Int, + @Query(PARAM_COUNT) count: Int = 100, + @Query(PARAM_FORMAT) format: String = DEFAULT_FORMAT + ): SteamNewsResponse + + companion object { + const val BASE_URL_API = "https://api.steampowered.com/" + const val BASE_URL_STORE = "https://store.steampowered.com/" + const val BASE_URL_COMMUNITY = "https://steamcommunity.com/" + + const val ENDPOINT_NEWS = "ISteamNews/GetNewsForApp/v0002/" + const val ENDPOINT_STORE_SEARCH = "api/storesearch/" + const val ENDPOINT_COMMUNITY_ID = "id/{id}/?xml=1" + const val ENDPOINT_COMMUNITY_PROFILES = "profiles/{id}/?xml=1" + const val ENDPOINT_WISHLIST_ID = "wishlist/id/{id}/wishlistdata/" + const val ENDPOINT_WISHLIST_PROFILES = "wishlist/profiles/{id}/wishlistdata/" + + const val PARAM_APP_ID = "appid" + const val PARAM_COUNT = "count" + const val PARAM_FORMAT = "format" + const val PARAM_TERM = "term" + const val PARAM_LANGUAGE = "l" + const val PARAM_COUNTRY = "cc" + const val PARAM_ID = "id" + + const val DEFAULT_FORMAT = "json" + const val DEFAULT_LANGUAGE = "english" + const val DEFAULT_COUNTRY = "US" + const val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + + const val KEY_APP_NEWS = "appnews" + const val KEY_NEWS_ITEMS = "newsitems" + const val KEY_TITLE = "title" + const val KEY_URL = "url" + const val KEY_AUTHOR = "author" + const val KEY_CONTENTS = "contents" + const val KEY_FEED_NAME = "feedname" + const val KEY_ITEMS = "items" + const val KEY_ID = "id" + const val KEY_NAME = "name" + const val KEY_CAPSULE = "capsule" + const val KEY_REVIEW_DESC = "review_desc" + const val KEY_PRIORITY = "priority" + const val KEY_TAGS = "tags" + const val KEY_SUBS = "subs" + const val KEY_PRERELEASE = "prerelease" + const val KEY_PRICE = "price" + const val KEY_DISCOUNT_PCT = "discount_pct" + + const val CLAN_IMAGE_REGEX = """\{STEAM_CLAN_IMAGE\}/([^\s"<\[]+)""" + const val CLAN_IMAGE_BASE_URL = "https://clan.akamai.steamstatic.com/images/" + } +} + +interface SteamStoreApiService { + @GET(SteamApiService.ENDPOINT_STORE_SEARCH) + suspend fun searchApps( + @Query(SteamApiService.PARAM_TERM) term: String, + @Query(SteamApiService.PARAM_LANGUAGE) l: String = SteamApiService.DEFAULT_LANGUAGE, + @Query(SteamApiService.PARAM_COUNTRY) cc: String = SteamApiService.DEFAULT_COUNTRY + ): SteamStoreSearchResponse + + @GET(SteamApiService.ENDPOINT_WISHLIST_ID) + suspend fun getWishlistById( + @Path(SteamApiService.PARAM_ID) id: String, + @Header("Cookie") cookie: String? = null, + @Header("User-Agent") userAgent: String = SteamApiService.DEFAULT_USER_AGENT + ): Map + + @GET(SteamApiService.ENDPOINT_WISHLIST_PROFILES) + suspend fun getWishlistByProfile( + @Path(SteamApiService.PARAM_ID) id: String, + @Header("Cookie") cookie: String? = null, + @Header("User-Agent") userAgent: String = SteamApiService.DEFAULT_USER_AGENT + ): Map +} + +interface SteamCommunityApiService { + @GET(SteamApiService.ENDPOINT_COMMUNITY_ID) + suspend fun getUserById( + @Path(SteamApiService.PARAM_ID) id: String, + @Header("Cookie") cookie: String? = null, + @Header("User-Agent") userAgent: String = SteamApiService.DEFAULT_USER_AGENT + ): ResponseBody + + @GET(SteamApiService.ENDPOINT_COMMUNITY_PROFILES) + suspend fun getUserByProfile( + @Path(SteamApiService.PARAM_ID) id: String, + @Header("Cookie") cookie: String? = null, + @Header("User-Agent") userAgent: String = SteamApiService.DEFAULT_USER_AGENT + ): ResponseBody +} + +object RetrofitClient { + private fun createRetrofit(baseUrl: String): Retrofit { + return Retrofit.Builder() + .baseUrl(baseUrl) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + val newsService: SteamApiService by lazy { + createRetrofit(SteamApiService.BASE_URL_API).create(SteamApiService::class.java) + } + + val storeService: SteamStoreApiService by lazy { + createRetrofit(SteamApiService.BASE_URL_STORE).create(SteamStoreApiService::class.java) + } + + val communityService: SteamCommunityApiService by lazy { + createRetrofit(SteamApiService.BASE_URL_COMMUNITY).create(SteamCommunityApiService::class.java) + } +} + +object ImageUtils { + private val clanImageRegex = Regex(SteamApiService.CLAN_IMAGE_REGEX) + + fun extractImageUrl(contents: String): String? { + val match = clanImageRegex.find(contents) + return match?.let { "${SteamApiService.CLAN_IMAGE_BASE_URL}${it.groupValues[1]}" } + } +} + +object XmlParserUtils { + fun parseUserInfo(xml: String): UserProfile? { + if (xml.contains("") || xml.isBlank()) return null + + return try { + val steamId64 = Regex("""(.*?)""").find(xml)?.groupValues?.get(1) + val name = Regex("""|(.*?)""", RegexOption.DOT_MATCHES_ALL) + .find(xml)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } + val avatar = Regex("""|(.*?)""", RegexOption.DOT_MATCHES_ALL) + .find(xml)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } + val state = Regex("""|(.*?)""", RegexOption.DOT_MATCHES_ALL) + .find(xml)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } + val summary = Regex("""|(.*?)""", RegexOption.DOT_MATCHES_ALL) + .find(xml)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } + + if (name.isNullOrEmpty()) return null + UserProfile(name, avatar ?: "", state ?: "", summary ?: "", steamId64) + } catch (e: Exception) { + null + } + } +} + +data class UserProfile( + val name: String, + val avatarUrl: String, + val stateMessage: String, + val summary: String = "", + val steamId64: String? = null +) \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 73ad06d..cb9618c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,22 @@ 스팀_api_테스트 - "게임 뉴스" + + 게임 뉴스 + 게임 검색… + 게임에 뉴스가 없음 + 설정 + 이미지 보이기 + 뉴스 종류 + 제작자 공지 + 외부 언론사 뉴스 + 취소 + 적용 + 검색 + 닫기 + 설정창 + + 유저 검색 + 스팀 ID 또는 커스텀 URL 입력… + 유저를 찾을 수 없습니다. + 요청이 너무 많습니다. 잠시 후 다시 시도해 주세요. \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 63c9d4c..e49be6a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,26 +1,39 @@ [versions] activityCompose = "1.13.0" +coilCompose = "2.6.0" composeBom = "2026.06.01" +converterGson = "2.9.0" kotlin = "2.4.10" agp = "9.1.1" coreKtx = "1.19.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" +lifecycleViewmodelCompose = "2.8.7" material = "1.14.0" +navigationCompose = "2.8.5" +retrofit = "3.0.0" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +androidx-compose-material-icons-core = { module = "androidx.compose.material:material-icons-core" } +androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } androidx-compose-material3 = { module = "androidx.compose.material3:material3" } androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" } +converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From e0982520ee7a0187911b71d03c2eef3f46353ec8 Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Sun, 26 Jul 2026 20:33:52 +0900 Subject: [PATCH 03/21] =?UTF-8?q?=ED=8F=B4=EB=8D=94=EB=A1=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20->=20=ED=8C=A8=ED=82=A4=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/android_2026_1/MainActivity.kt | 1 + .../android_2026_1/{ => data}/SteamApi.kt | 5 +---- .../{ => ui/main}/MainScreen.kt | 5 ++++- .../{ => ui/main}/MainViewModel.kt | 2 +- .../{ => ui/news}/NewsScreen.kt | 6 +++++- .../{ => ui/news}/NewsViewModel.kt | 6 +++++- .../{ => ui/profile}/ProfileScreen.kt | 4 +++- .../{ => ui/profile}/ProfileViewModel.kt | 20 +++++++++++++------ .../android_2026_1/{ => util}/AppLogger.kt | 2 +- 9 files changed, 35 insertions(+), 16 deletions(-) rename app/src/main/java/com/example/android_2026_1/{ => data}/SteamApi.kt (98%) rename app/src/main/java/com/example/android_2026_1/{ => ui/main}/MainScreen.kt (93%) rename app/src/main/java/com/example/android_2026_1/{ => ui/main}/MainViewModel.kt (95%) rename app/src/main/java/com/example/android_2026_1/{ => ui/news}/NewsScreen.kt (98%) rename app/src/main/java/com/example/android_2026_1/{ => ui/news}/NewsViewModel.kt (94%) rename app/src/main/java/com/example/android_2026_1/{ => ui/profile}/ProfileScreen.kt (98%) rename app/src/main/java/com/example/android_2026_1/{ => ui/profile}/ProfileViewModel.kt (88%) rename app/src/main/java/com/example/android_2026_1/{ => util}/AppLogger.kt (82%) diff --git a/app/src/main/java/com/example/android_2026_1/MainActivity.kt b/app/src/main/java/com/example/android_2026_1/MainActivity.kt index 05bf2d6..ee06d99 100644 --- a/app/src/main/java/com/example/android_2026_1/MainActivity.kt +++ b/app/src/main/java/com/example/android_2026_1/MainActivity.kt @@ -4,6 +4,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import com.example.android_2026_1.ui.main.MainRoute class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/com/example/android_2026_1/SteamApi.kt b/app/src/main/java/com/example/android_2026_1/data/SteamApi.kt similarity index 98% rename from app/src/main/java/com/example/android_2026_1/SteamApi.kt rename to app/src/main/java/com/example/android_2026_1/data/SteamApi.kt index 3f2d2ad..ee38a31 100644 --- a/app/src/main/java/com/example/android_2026_1/SteamApi.kt +++ b/app/src/main/java/com/example/android_2026_1/data/SteamApi.kt @@ -1,16 +1,13 @@ -package com.example.android_2026_1 +package com.example.android_2026_1.data import com.google.gson.annotations.SerializedName import okhttp3.ResponseBody -import org.xmlpull.v1.XmlPullParser -import org.xmlpull.v1.XmlPullParserFactory import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.Path import retrofit2.http.Query -import java.io.StringReader data class SteamNewsResponse( @SerializedName(SteamApiService.KEY_APP_NEWS) val appNews: AppNews? diff --git a/app/src/main/java/com/example/android_2026_1/MainScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt similarity index 93% rename from app/src/main/java/com/example/android_2026_1/MainScreen.kt rename to app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt index 440ed46..517a89e 100644 --- a/app/src/main/java/com/example/android_2026_1/MainScreen.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt @@ -1,4 +1,4 @@ -package com.example.android_2026_1 +package com.example.android_2026_1.ui.main import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement @@ -18,6 +18,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.android_2026_1.R +import com.example.android_2026_1.ui.news.NewsRoute +import com.example.android_2026_1.ui.profile.ProfileRoute @Composable fun MainRoute( diff --git a/app/src/main/java/com/example/android_2026_1/MainViewModel.kt b/app/src/main/java/com/example/android_2026_1/ui/main/MainViewModel.kt similarity index 95% rename from app/src/main/java/com/example/android_2026_1/MainViewModel.kt rename to app/src/main/java/com/example/android_2026_1/ui/main/MainViewModel.kt index 6bc5b5a..8292e41 100644 --- a/app/src/main/java/com/example/android_2026_1/MainViewModel.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/main/MainViewModel.kt @@ -1,4 +1,4 @@ -package com.example.android_2026_1 +package com.example.android_2026_1.ui.main import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/java/com/example/android_2026_1/NewsScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt similarity index 98% rename from app/src/main/java/com/example/android_2026_1/NewsScreen.kt rename to app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt index 23ad4a2..edbc714 100644 --- a/app/src/main/java/com/example/android_2026_1/NewsScreen.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt @@ -1,4 +1,4 @@ -package com.example.android_2026_1 +package com.example.android_2026_1.ui.news import android.webkit.WebView import android.webkit.WebViewClient @@ -51,6 +51,10 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage +import com.example.android_2026_1.data.ImageUtils +import com.example.android_2026_1.data.NewsItem +import com.example.android_2026_1.R +import com.example.android_2026_1.data.SteamAppItem @Composable fun NewsRoute( diff --git a/app/src/main/java/com/example/android_2026_1/NewsViewModel.kt b/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt similarity index 94% rename from app/src/main/java/com/example/android_2026_1/NewsViewModel.kt rename to app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt index a7986a9..2c37020 100644 --- a/app/src/main/java/com/example/android_2026_1/NewsViewModel.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt @@ -1,7 +1,11 @@ -package com.example.android_2026_1 +package com.example.android_2026_1.ui.news import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.example.android_2026_1.util.AppLogger +import com.example.android_2026_1.data.NewsItem +import com.example.android_2026_1.data.RetrofitClient +import com.example.android_2026_1.data.SteamAppItem import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update diff --git a/app/src/main/java/com/example/android_2026_1/ProfileScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/profile/ProfileScreen.kt similarity index 98% rename from app/src/main/java/com/example/android_2026_1/ProfileScreen.kt rename to app/src/main/java/com/example/android_2026_1/ui/profile/ProfileScreen.kt index 61169d4..6b23939 100644 --- a/app/src/main/java/com/example/android_2026_1/ProfileScreen.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/profile/ProfileScreen.kt @@ -1,4 +1,4 @@ -package com.example.android_2026_1 +package com.example.android_2026_1.ui.profile import android.webkit.WebView import android.webkit.WebViewClient @@ -39,6 +39,8 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage +import com.example.android_2026_1.R +import com.example.android_2026_1.data.UserProfile @Composable fun ProfileRoute( diff --git a/app/src/main/java/com/example/android_2026_1/ProfileViewModel.kt b/app/src/main/java/com/example/android_2026_1/ui/profile/ProfileViewModel.kt similarity index 88% rename from app/src/main/java/com/example/android_2026_1/ProfileViewModel.kt rename to app/src/main/java/com/example/android_2026_1/ui/profile/ProfileViewModel.kt index a542764..ee35432 100644 --- a/app/src/main/java/com/example/android_2026_1/ProfileViewModel.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/profile/ProfileViewModel.kt @@ -1,7 +1,12 @@ -package com.example.android_2026_1 +package com.example.android_2026_1.ui.profile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.example.android_2026_1.util.AppLogger +import com.example.android_2026_1.R +import com.example.android_2026_1.data.RetrofitClient +import com.example.android_2026_1.data.UserProfile +import com.example.android_2026_1.data.XmlParserUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -30,6 +35,9 @@ class ProfileViewModel : ViewModel() { private const val TAG = "ProfileViewModel" private const val BASE_PROFILE_URL = "https://steamcommunity.com/profiles/" private const val BASE_CUSTOM_ID_URL = "https://steamcommunity.com/id/" + private const val PATH_CUSTOM_ID = "steamcommunity.com/id/" + private const val PATH_PROFILE_ID = "steamcommunity.com/profiles/" + private const val HTTP_TOO_MANY_REQUESTS = 429 } private val _uiState = MutableStateFlow(ProfileUiState()) @@ -76,10 +84,10 @@ class ProfileViewModel : ViewModel() { private fun extractIdFromQuery(rawQuery: String): String { var cleaned = rawQuery.trim().removeSuffix("/") - if (cleaned.contains("steamcommunity.com/id/")) { - cleaned = cleaned.substringAfter("steamcommunity.com/id/") - } else if (cleaned.contains("steamcommunity.com/profiles/")) { - cleaned = cleaned.substringAfter("steamcommunity.com/profiles/") + if (cleaned.contains(PATH_CUSTOM_ID)) { + cleaned = cleaned.substringAfter(PATH_CUSTOM_ID) + } else if (cleaned.contains(PATH_PROFILE_ID)) { + cleaned = cleaned.substringAfter(PATH_PROFILE_ID) } return cleaned.substringAfterLast("/") } @@ -160,7 +168,7 @@ class ProfileViewModel : ViewModel() { ApiResult(profile = XmlParserUtils.parseUserInfo(xmlString)) } catch (e: HttpException) { AppLogger.e(TAG, "Error fetching user profile", e) - if (e.code() == 429) { + if (e.code() == HTTP_TOO_MANY_REQUESTS) { ApiResult(isTooManyRequests = true) } else { ApiResult() diff --git a/app/src/main/java/com/example/android_2026_1/AppLogger.kt b/app/src/main/java/com/example/android_2026_1/util/AppLogger.kt similarity index 82% rename from app/src/main/java/com/example/android_2026_1/AppLogger.kt rename to app/src/main/java/com/example/android_2026_1/util/AppLogger.kt index b4e2fb1..4f44a9e 100644 --- a/app/src/main/java/com/example/android_2026_1/AppLogger.kt +++ b/app/src/main/java/com/example/android_2026_1/util/AppLogger.kt @@ -1,4 +1,4 @@ -package com.example.android_2026_1 +package com.example.android_2026_1.util import android.util.Log From 152a0934019e8459cb09dbc6d73d951cc0bf88af Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Mon, 27 Jul 2026 12:35:43 +0900 Subject: [PATCH 04/21] =?UTF-8?q?=EA=B2=8C=EC=9E=84=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EA=B2=80=EC=83=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/android_2026_1/data/SteamApi.kt | 151 ++++++++++-- .../android_2026_1/ui/game/GameScreen.kt | 224 ++++++++++++++++++ .../android_2026_1/ui/game/GameViewModel.kt | 211 +++++++++++++++++ .../android_2026_1/ui/main/MainScreen.kt | 15 ++ .../android_2026_1/ui/main/MainViewModel.kt | 1 + app/src/main/res/values/strings.xml | 7 + 6 files changed, 594 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/example/android_2026_1/ui/game/GameScreen.kt create mode 100644 app/src/main/java/com/example/android_2026_1/ui/game/GameViewModel.kt diff --git a/app/src/main/java/com/example/android_2026_1/data/SteamApi.kt b/app/src/main/java/com/example/android_2026_1/data/SteamApi.kt index ee38a31..0e50406 100644 --- a/app/src/main/java/com/example/android_2026_1/data/SteamApi.kt +++ b/app/src/main/java/com/example/android_2026_1/data/SteamApi.kt @@ -66,20 +66,27 @@ interface SteamApiService { const val ENDPOINT_STORE_SEARCH = "api/storesearch/" const val ENDPOINT_COMMUNITY_ID = "id/{id}/?xml=1" const val ENDPOINT_COMMUNITY_PROFILES = "profiles/{id}/?xml=1" + const val ENDPOINT_COMMUNITY_GROUP = "groups/{groupName}/memberslistxml/?xml=1" const val ENDPOINT_WISHLIST_ID = "wishlist/id/{id}/wishlistdata/" const val ENDPOINT_WISHLIST_PROFILES = "wishlist/profiles/{id}/wishlistdata/" + const val ENDPOINT_USER_STATS = "ISteamUserStats/GetNumberOfCurrentPlayers/v1/" + const val ENDPOINT_APP_DETAILS = "api/appdetails" const val PARAM_APP_ID = "appid" + const val PARAM_APPIDS = "appids" const val PARAM_COUNT = "count" const val PARAM_FORMAT = "format" const val PARAM_TERM = "term" const val PARAM_LANGUAGE = "l" const val PARAM_COUNTRY = "cc" const val PARAM_ID = "id" + const val PARAM_GROUP_NAME = "groupName" const val DEFAULT_FORMAT = "json" const val DEFAULT_LANGUAGE = "english" const val DEFAULT_COUNTRY = "US" + const val DEFAULT_COUNTRY_KR = "KR" + const val DEFAULT_LANGUAGE_KOREAN = "korean" const val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" const val KEY_APP_NEWS = "appnews" @@ -100,9 +107,38 @@ interface SteamApiService { const val KEY_PRERELEASE = "prerelease" const val KEY_PRICE = "price" const val KEY_DISCOUNT_PCT = "discount_pct" + const val KEY_RESPONSE = "response" + const val KEY_PLAYER_COUNT = "player_count" + const val KEY_RESULT = "result" + const val KEY_SUCCESS = "success" + const val KEY_DATA = "data" + const val KEY_HEADER_IMAGE = "header_image" + const val KEY_SHORT_DESCRIPTION = "short_description" + const val KEY_PRICE_OVERVIEW = "price_overview" + const val KEY_IS_FREE = "is_free" + const val KEY_DEVELOPERS = "developers" + const val KEY_PUBLISHERS = "publishers" + const val KEY_GENRES = "genres" + const val KEY_CURRENCY = "currency" + const val KEY_INITIAL = "initial" + const val KEY_FINAL = "final" + const val KEY_DISCOUNT_PERCENT = "discount_percent" + const val KEY_INITIAL_FORMATTED = "initial_formatted" + const val KEY_FINAL_FORMATTED = "final_formatted" + const val KEY_DESCRIPTION = "description" const val CLAN_IMAGE_REGEX = """\{STEAM_CLAN_IMAGE\}/([^\s"<\[]+)""" const val CLAN_IMAGE_BASE_URL = "https://clan.akamai.steamstatic.com/images/" + + const val XML_ERROR_TAG = "" + const val XML_REGEX_STEAM_ID64 = """(.*?)""" + const val XML_REGEX_STEAM_ID = """|(.*?)""" + const val XML_REGEX_AVATAR = """|(.*?)""" + const val XML_REGEX_STATE = """|(.*?)""" + const val XML_REGEX_SUMMARY = """|(.*?)""" + + const val HEADER_COOKIE = "Cookie" + const val HEADER_USER_AGENT = "User-Agent" } } @@ -117,15 +153,15 @@ interface SteamStoreApiService { @GET(SteamApiService.ENDPOINT_WISHLIST_ID) suspend fun getWishlistById( @Path(SteamApiService.PARAM_ID) id: String, - @Header("Cookie") cookie: String? = null, - @Header("User-Agent") userAgent: String = SteamApiService.DEFAULT_USER_AGENT + @Header(SteamApiService.HEADER_COOKIE) cookie: String? = null, + @Header(SteamApiService.HEADER_USER_AGENT) userAgent: String = SteamApiService.DEFAULT_USER_AGENT ): Map @GET(SteamApiService.ENDPOINT_WISHLIST_PROFILES) suspend fun getWishlistByProfile( @Path(SteamApiService.PARAM_ID) id: String, - @Header("Cookie") cookie: String? = null, - @Header("User-Agent") userAgent: String = SteamApiService.DEFAULT_USER_AGENT + @Header(SteamApiService.HEADER_COOKIE) cookie: String? = null, + @Header(SteamApiService.HEADER_USER_AGENT) userAgent: String = SteamApiService.DEFAULT_USER_AGENT ): Map } @@ -133,15 +169,21 @@ interface SteamCommunityApiService { @GET(SteamApiService.ENDPOINT_COMMUNITY_ID) suspend fun getUserById( @Path(SteamApiService.PARAM_ID) id: String, - @Header("Cookie") cookie: String? = null, - @Header("User-Agent") userAgent: String = SteamApiService.DEFAULT_USER_AGENT + @Header(SteamApiService.HEADER_COOKIE) cookie: String? = null, + @Header(SteamApiService.HEADER_USER_AGENT) userAgent: String = SteamApiService.DEFAULT_USER_AGENT ): ResponseBody @GET(SteamApiService.ENDPOINT_COMMUNITY_PROFILES) suspend fun getUserByProfile( @Path(SteamApiService.PARAM_ID) id: String, - @Header("Cookie") cookie: String? = null, - @Header("User-Agent") userAgent: String = SteamApiService.DEFAULT_USER_AGENT + @Header(SteamApiService.HEADER_COOKIE) cookie: String? = null, + @Header(SteamApiService.HEADER_USER_AGENT) userAgent: String = SteamApiService.DEFAULT_USER_AGENT + ): ResponseBody + + @GET(SteamApiService.ENDPOINT_COMMUNITY_GROUP) + suspend fun getGroupMembers( + @Path(SteamApiService.PARAM_GROUP_NAME) groupName: String, + @Header(SteamApiService.HEADER_USER_AGENT) userAgent: String = SteamApiService.DEFAULT_USER_AGENT ): ResponseBody } @@ -177,17 +219,17 @@ object ImageUtils { object XmlParserUtils { fun parseUserInfo(xml: String): UserProfile? { - if (xml.contains("") || xml.isBlank()) return null + if (xml.contains(SteamApiService.XML_ERROR_TAG) || xml.isBlank()) return null return try { - val steamId64 = Regex("""(.*?)""").find(xml)?.groupValues?.get(1) - val name = Regex("""|(.*?)""", RegexOption.DOT_MATCHES_ALL) + val steamId64 = Regex(SteamApiService.XML_REGEX_STEAM_ID64).find(xml)?.groupValues?.get(1) + val name = Regex(SteamApiService.XML_REGEX_STEAM_ID, RegexOption.DOT_MATCHES_ALL) .find(xml)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } - val avatar = Regex("""|(.*?)""", RegexOption.DOT_MATCHES_ALL) + val avatar = Regex(SteamApiService.XML_REGEX_AVATAR, RegexOption.DOT_MATCHES_ALL) .find(xml)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } - val state = Regex("""|(.*?)""", RegexOption.DOT_MATCHES_ALL) + val state = Regex(SteamApiService.XML_REGEX_STATE, RegexOption.DOT_MATCHES_ALL) .find(xml)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } - val summary = Regex("""|(.*?)""", RegexOption.DOT_MATCHES_ALL) + val summary = Regex(SteamApiService.XML_REGEX_SUMMARY, RegexOption.DOT_MATCHES_ALL) .find(xml)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } if (name.isNullOrEmpty()) return null @@ -196,6 +238,12 @@ object XmlParserUtils { null } } + + fun parseGroupMemberIds(xml: String): List { + if (xml.contains(SteamApiService.XML_ERROR_TAG) || xml.isBlank()) return emptyList() + val matches = Regex(SteamApiService.XML_REGEX_STEAM_ID64).findAll(xml) + return matches.map { it.groupValues[1] }.toList() + } } data class UserProfile( @@ -204,4 +252,77 @@ data class UserProfile( val stateMessage: String, val summary: String = "", val steamId64: String? = null -) \ No newline at end of file +) + +data class SteamPlayerCountResponse( + @SerializedName(SteamApiService.KEY_RESPONSE) val response: PlayerCountData? +) + +data class PlayerCountData( + @SerializedName(SteamApiService.KEY_PLAYER_COUNT) val playerCount: Int?, + @SerializedName(SteamApiService.KEY_RESULT) val result: Int? +) + +data class SteamAppDetailsWrapper( + @SerializedName(SteamApiService.KEY_SUCCESS) val success: Boolean, + @SerializedName(SteamApiService.KEY_DATA) val data: SteamAppDetailsData? +) + +data class SteamAppDetailsData( + @SerializedName(SteamApiService.KEY_NAME) val name: String?, + @SerializedName(SteamApiService.KEY_HEADER_IMAGE) val headerImage: String?, + @SerializedName(SteamApiService.KEY_SHORT_DESCRIPTION) val shortDescription: String?, + @SerializedName(SteamApiService.KEY_PRICE_OVERVIEW) val priceOverview: SteamPriceOverview?, + @SerializedName(SteamApiService.KEY_IS_FREE) val isFree: Boolean?, + @SerializedName(SteamApiService.KEY_DEVELOPERS) val developers: List?, + @SerializedName(SteamApiService.KEY_PUBLISHERS) val publishers: List?, + @SerializedName(SteamApiService.KEY_GENRES) val genres: List? +) + +data class SteamPriceOverview( + @SerializedName(SteamApiService.KEY_CURRENCY) val currency: String?, + @SerializedName(SteamApiService.KEY_INITIAL) val initial: Int?, + @SerializedName(SteamApiService.KEY_FINAL) val finalPrice: Int?, + @SerializedName(SteamApiService.KEY_DISCOUNT_PERCENT) val discountPercent: Int?, + @SerializedName(SteamApiService.KEY_INITIAL_FORMATTED) val initialFormatted: String?, + @SerializedName(SteamApiService.KEY_FINAL_FORMATTED) val finalFormatted: String? +) + +data class SteamGenre( + @SerializedName(SteamApiService.KEY_ID) val id: String?, + @SerializedName(SteamApiService.KEY_DESCRIPTION) val description: String? +) + +interface SteamUserStatsApiService { + @GET(SteamApiService.ENDPOINT_USER_STATS) + suspend fun getNumberOfCurrentPlayers( + @Query(SteamApiService.PARAM_APP_ID) appId: Int + ): SteamPlayerCountResponse +} + +interface SteamAppDetailsApiService { + @GET(SteamApiService.ENDPOINT_APP_DETAILS) + suspend fun getAppDetails( + @Query(SteamApiService.PARAM_APPIDS) appId: Int, + @Query(SteamApiService.PARAM_COUNTRY) cc: String = SteamApiService.DEFAULT_COUNTRY_KR, + @Query(SteamApiService.PARAM_LANGUAGE) l: String = SteamApiService.DEFAULT_LANGUAGE_KOREAN + ): Map +} + +object SteamExtraClient { + val statsService: SteamUserStatsApiService by lazy { + Retrofit.Builder() + .baseUrl(SteamApiService.BASE_URL_API) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(SteamUserStatsApiService::class.java) + } + + val appDetailsService: SteamAppDetailsApiService by lazy { + Retrofit.Builder() + .baseUrl(SteamApiService.BASE_URL_STORE) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(SteamAppDetailsApiService::class.java) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android_2026_1/ui/game/GameScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/game/GameScreen.kt new file mode 100644 index 0000000..b8648cc --- /dev/null +++ b/app/src/main/java/com/example/android_2026_1/ui/game/GameScreen.kt @@ -0,0 +1,224 @@ +package com.example.android_2026_1.ui.game + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.example.android_2026_1.R +import com.example.android_2026_1.data.SteamAppItem + +@Composable +fun GameRoute( + modifier: Modifier = Modifier, + viewModel: GameViewModel = viewModel() +) { + val uiState by viewModel.uiState.collectAsState() + GameScreen( + uiState = uiState, + onEvent = viewModel::onEvent, + modifier = modifier + ) +} + +@Composable +fun GameScreen( + uiState: GameUiState, + onEvent: (GameEvent) -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + modifier = modifier.fillMaxSize() + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + GameSearchBar( + text = uiState.text, + suggestions = uiState.suggestions, + onTextChange = { onEvent(GameEvent.TextChanged(it)) }, + onSearch = { onEvent(GameEvent.Search) }, + onSelect = { onEvent(GameEvent.SelectItem(it)) } + ) + + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (uiState.gameTitle != null) { + item { + TopHeaderCard( + headerImage = uiState.headerImage, + title = uiState.gameTitle + ) + } + + items(uiState.detailCards) { cardItem -> + DetailInfoCard(item = cardItem) + } + } + } + } + } + } +} + +@Composable +private fun GameSearchBar( + text: String, + suggestions: List, + onTextChange: (String) -> Unit, + onSearch: () -> Unit, + onSelect: (String) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = text, + onValueChange = onTextChange, + modifier = Modifier.weight(1f), + placeholder = { Text(stringResource(R.string.search_hint)) }, + singleLine = true, + trailingIcon = { + if (text.isNotEmpty()) { + IconButton(onClick = { onTextChange(GameViewModel.EMPTY_STRING) }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_close) + ) + } + } + } + ) + IconButton(onClick = onSearch) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.cd_search) + ) + } + } + + if (suggestions.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(8.dp) + ) { + Column { + suggestions.take(5).forEach { item -> + Text( + text = item.name, + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(item.name) } + .padding(12.dp), + fontSize = 14.sp + ) + } + } + } + } + } +} + +@Composable +private fun TopHeaderCard( + headerImage: String?, + title: String +) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (headerImage != null) { + AsyncImage( + model = headerImage, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + } + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + } + } +} + +@Composable +private fun DetailInfoCard( + item: GameCardItem +) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = item.title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = item.content, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android_2026_1/ui/game/GameViewModel.kt b/app/src/main/java/com/example/android_2026_1/ui/game/GameViewModel.kt new file mode 100644 index 0000000..1542152 --- /dev/null +++ b/app/src/main/java/com/example/android_2026_1/ui/game/GameViewModel.kt @@ -0,0 +1,211 @@ +package com.example.android_2026_1.ui.game + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.android_2026_1.data.RetrofitClient +import com.example.android_2026_1.data.SteamAppDetailsData +import com.example.android_2026_1.data.SteamAppItem +import com.example.android_2026_1.data.SteamExtraClient +import com.example.android_2026_1.util.AppLogger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.text.NumberFormat +import java.util.Locale + +data class GameCardItem( + val title: String, + val content: String +) + +data class GameUiState( + val text: String = GameViewModel.EMPTY_STRING, + val suggestions: List = emptyList(), + val isLoading: Boolean = false, + val headerImage: String? = null, + val gameTitle: String? = null, + val detailCards: List = emptyList() +) + +sealed interface GameEvent { + data class TextChanged(val text: String) : GameEvent + data class SelectItem(val name: String) : GameEvent + object Search : GameEvent +} + +class GameViewModel : ViewModel() { + + companion object { + private const val TAG = "GameViewModel" + + const val EMPTY_STRING = "" + private const val NO_INFO = "정보 없음" + private const val PLAYER_UNIT = "명" + private const val FREE_TO_PLAY = "무료 플레이" + private const val NO_PRICE_INFO = "가격 정보 없음" + private const val NO_DESCRIPTION = "설명 없음" + private const val TITLE_PLAYER_COUNT = "실시간 동시 접속자 수" + private const val TITLE_PRICE = "가격 및 세일 정보" + private const val TITLE_DESCRIPTION = "게임 설명" + private const val TITLE_DEVELOPERS = "개발사" + private const val TITLE_PUBLISHERS = "배급사" + private const val TITLE_GENRES = "장르" + private const val DELIMITER_COMMA = ", " + + private const val LOG_ERROR_SEARCH_APPS = "Error searching apps" + private const val LOG_ERROR_LOADING_PLAYER_COUNT = "Error loading player count" + private const val LOG_ERROR_FETCHING_DETAILS = "Error fetching game details" + private const val LOG_ERROR_FINDING_APP_ID = "Error finding appId" + } + + private val _uiState = MutableStateFlow(GameUiState()) + val uiState = _uiState.asStateFlow() + + fun onEvent(event: GameEvent) { + when (event) { + is GameEvent.TextChanged -> onTextChange(event.text) + is GameEvent.SelectItem -> selectItem(event.name) + is GameEvent.Search -> doSearch() + } + } + + private fun onTextChange(newText: String) { + _uiState.update { it.copy(text = newText) } + if (newText.isEmpty()) { + _uiState.update { it.copy(suggestions = emptyList()) } + return + } + + viewModelScope.launch { + try { + val result = RetrofitClient.storeService.searchApps(newText) + _uiState.update { + it.copy(suggestions = result.items ?: emptyList()) + } + } catch (e: Exception) { + AppLogger.e(TAG, LOG_ERROR_SEARCH_APPS, e) + _uiState.update { it.copy(suggestions = emptyList()) } + } + } + } + + private fun selectItem(name: String) { + _uiState.update { it.copy(text = name, suggestions = emptyList()) } + } + + private fun doSearch() { + val query = _uiState.value.text + if (query.isEmpty()) return + + _uiState.update { it.copy(isLoading = true, suggestions = emptyList()) } + + viewModelScope.launch { + try { + val appId = findAppId(query) + if (appId == null) { + _uiState.update { + it.copy( + isLoading = false, + headerImage = null, + gameTitle = null, + detailCards = emptyList() + ) + } + return@launch + } + + val result = SteamExtraClient.appDetailsService.getAppDetails(appId) + val appDetails = result[appId.toString()] + val details = appDetails?.data + + var playerCount = NO_INFO + try { + val countResponse = SteamExtraClient.statsService.getNumberOfCurrentPlayers(appId) + val count = countResponse.response?.playerCount + if (count != null) { + playerCount = "${NumberFormat.getNumberInstance(Locale.US).format(count)}$PLAYER_UNIT" + } + } catch (e: Exception) { + AppLogger.e(TAG, LOG_ERROR_LOADING_PLAYER_COUNT, e) + } + + if (details != null) { + val cards = buildCards(details, playerCount) + _uiState.update { + it.copy( + isLoading = false, + headerImage = details.headerImage, + gameTitle = details.name ?: query, + detailCards = cards + ) + } + } else { + _uiState.update { + it.copy( + isLoading = false, + headerImage = null, + gameTitle = null, + detailCards = emptyList() + ) + } + } + } catch (e: Exception) { + AppLogger.e(TAG, LOG_ERROR_FETCHING_DETAILS, e) + _uiState.update { + it.copy( + isLoading = false, + headerImage = null, + gameTitle = null, + detailCards = emptyList() + ) + } + } + } + } + + private suspend fun findAppId(query: String): Int? { + return try { + val response = RetrofitClient.storeService.searchApps(query) + val game = response.items?.firstOrNull() + game?.id + } catch (e: Exception) { + AppLogger.e(TAG, LOG_ERROR_FINDING_APP_ID, e) + null + } + } + + private fun buildCards(details: SteamAppDetailsData, playerCount: String): List { + val list = mutableListOf() + + list.add(GameCardItem(TITLE_PLAYER_COUNT, playerCount)) + + val price = when { + details.isFree == true -> FREE_TO_PLAY + details.priceOverview != null -> { + val po = details.priceOverview + if ((po.discountPercent ?: 0) > 0) { + "${po.discountPercent}% 할인 (${po.initialFormatted ?: EMPTY_STRING} -> ${po.finalFormatted ?: EMPTY_STRING})" + } else { + po.finalFormatted ?: NO_PRICE_INFO + } + } + else -> NO_PRICE_INFO + } + list.add(GameCardItem(TITLE_PRICE, price)) + + val desc = details.shortDescription?.ifBlank { NO_DESCRIPTION } ?: NO_DESCRIPTION + list.add(GameCardItem(TITLE_DESCRIPTION, desc)) + + val devs = details.developers?.joinToString(DELIMITER_COMMA)?.ifBlank { NO_INFO } ?: NO_INFO + list.add(GameCardItem(TITLE_DEVELOPERS, devs)) + + val pubs = details.publishers?.joinToString(DELIMITER_COMMA)?.ifBlank { NO_INFO } ?: NO_INFO + list.add(GameCardItem(TITLE_PUBLISHERS, pubs)) + + val genres = details.genres?.mapNotNull { it.description }?.joinToString(DELIMITER_COMMA)?.ifBlank { NO_INFO } ?: NO_INFO + list.add(GameCardItem(TITLE_GENRES, genres)) + + return list + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt index 517a89e..282c703 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.example.android_2026_1.R import com.example.android_2026_1.ui.news.NewsRoute import com.example.android_2026_1.ui.profile.ProfileRoute +import com.example.android_2026_1.ui.game.GameRoute @Composable fun MainRoute( @@ -47,6 +48,7 @@ fun MainScreen( HomeScreen( onNavigateToNews = { onEvent(MainEvent.NavigateTo(Screen.News)) }, onNavigateToProfile = { onEvent(MainEvent.NavigateTo(Screen.Profile)) }, + onNavigateToGame = { onEvent(MainEvent.NavigateTo(Screen.Game)) }, modifier = modifier ) } @@ -62,6 +64,12 @@ fun MainScreen( } ProfileRoute(modifier = modifier) } + is Screen.Game -> { + BackHandler { + onEvent(MainEvent.NavigateTo(Screen.Home)) + } + GameRoute(modifier = modifier) + } } } @@ -69,6 +77,7 @@ fun MainScreen( private fun HomeScreen( onNavigateToNews: () -> Unit, onNavigateToProfile: () -> Unit, + onNavigateToGame: () -> Unit, modifier: Modifier = Modifier ) { Scaffold(modifier = modifier.fillMaxSize()) { innerPadding -> @@ -88,6 +97,12 @@ private fun HomeScreen( Button(onClick = onNavigateToProfile) { Text(text = stringResource(R.string.btn_profile)) } + + Spacer(modifier = Modifier.height(12.dp)) + + Button(onClick = onNavigateToGame) { + Text(text = stringResource(R.string.btn_game)) + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/android_2026_1/ui/main/MainViewModel.kt b/app/src/main/java/com/example/android_2026_1/ui/main/MainViewModel.kt index 8292e41..70717ad 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/main/MainViewModel.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/main/MainViewModel.kt @@ -9,6 +9,7 @@ sealed interface Screen { object Home : Screen object News : Screen object Profile : Screen + object Game : Screen } data class MainUiState( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cb9618c..cb963c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,4 +19,11 @@ 스팀 ID 또는 커스텀 URL 입력… 유저를 찾을 수 없습니다. 요청이 너무 많습니다. 잠시 후 다시 시도해 주세요. + + 커뮤니티 검색 + 스팀 커뮤니티 그룹 ID 또는 URL 입력… + + 게임 정보 + + 도전과제 \ No newline at end of file From 6a07cb206a69ed8a3c2a251f407c7d4eca1cd8f2 Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Mon, 27 Jul 2026 13:09:23 +0900 Subject: [PATCH 05/21] =?UTF-8?q?=EC=BB=B4=ED=8C=A8=EB=8B=88=EC=96=B8=20?= =?UTF-8?q?=EC=98=A4=EB=B8=8C=EC=A0=9D=ED=8A=B8=20=EA=B0=80=EB=B3=8D?= =?UTF-8?q?=EA=B2=8C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android_2026_1/ui/game/GameViewModel.kt | 96 +++++++++++++------ app/src/main/res/values/strings.xml | 13 +++ 2 files changed, 78 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/example/android_2026_1/ui/game/GameViewModel.kt b/app/src/main/java/com/example/android_2026_1/ui/game/GameViewModel.kt index 1542152..ab23b79 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/game/GameViewModel.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/game/GameViewModel.kt @@ -1,7 +1,11 @@ package com.example.android_2026_1.ui.game +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.example.android_2026_1.R import com.example.android_2026_1.data.RetrofitClient import com.example.android_2026_1.data.SteamAppDetailsData import com.example.android_2026_1.data.SteamAppItem @@ -14,9 +18,25 @@ import kotlinx.coroutines.launch import java.text.NumberFormat import java.util.Locale +sealed interface UiText { + data class DynamicString(val value: String) : UiText + class StringResource( + @StringRes val resId: Int, + vararg val args: Any + ) : UiText + + @Composable + fun asString(): String { + return when (this) { + is DynamicString -> value + is StringResource -> stringResource(resId, *args) + } + } +} + data class GameCardItem( - val title: String, - val content: String + @StringRes val titleRes: Int, + val content: UiText ) data class GameUiState( @@ -40,17 +60,6 @@ class GameViewModel : ViewModel() { private const val TAG = "GameViewModel" const val EMPTY_STRING = "" - private const val NO_INFO = "정보 없음" - private const val PLAYER_UNIT = "명" - private const val FREE_TO_PLAY = "무료 플레이" - private const val NO_PRICE_INFO = "가격 정보 없음" - private const val NO_DESCRIPTION = "설명 없음" - private const val TITLE_PLAYER_COUNT = "실시간 동시 접속자 수" - private const val TITLE_PRICE = "가격 및 세일 정보" - private const val TITLE_DESCRIPTION = "게임 설명" - private const val TITLE_DEVELOPERS = "개발사" - private const val TITLE_PUBLISHERS = "배급사" - private const val TITLE_GENRES = "장르" private const val DELIMITER_COMMA = ", " private const val LOG_ERROR_SEARCH_APPS = "Error searching apps" @@ -119,12 +128,13 @@ class GameViewModel : ViewModel() { val appDetails = result[appId.toString()] val details = appDetails?.data - var playerCount = NO_INFO + var playerCount: UiText = UiText.StringResource(R.string.no_info) try { val countResponse = SteamExtraClient.statsService.getNumberOfCurrentPlayers(appId) val count = countResponse.response?.playerCount if (count != null) { - playerCount = "${NumberFormat.getNumberInstance(Locale.US).format(count)}$PLAYER_UNIT" + val formattedCount = NumberFormat.getNumberInstance(Locale.US).format(count) + playerCount = UiText.StringResource(R.string.player_unit, formattedCount) } } catch (e: Exception) { AppLogger.e(TAG, LOG_ERROR_LOADING_PLAYER_COUNT, e) @@ -175,36 +185,60 @@ class GameViewModel : ViewModel() { } } - private fun buildCards(details: SteamAppDetailsData, playerCount: String): List { + private fun buildCards(details: SteamAppDetailsData, playerCount: UiText): List { val list = mutableListOf() - list.add(GameCardItem(TITLE_PLAYER_COUNT, playerCount)) + list.add(GameCardItem(R.string.title_player_count, playerCount)) - val price = when { - details.isFree == true -> FREE_TO_PLAY + val price: UiText = when { + details.isFree == true -> UiText.StringResource(R.string.free_to_play) details.priceOverview != null -> { val po = details.priceOverview if ((po.discountPercent ?: 0) > 0) { - "${po.discountPercent}% 할인 (${po.initialFormatted ?: EMPTY_STRING} -> ${po.finalFormatted ?: EMPTY_STRING})" + UiText.StringResource( + R.string.discount_format, + po.discountPercent ?: 0, + po.initialFormatted ?: EMPTY_STRING, + po.finalFormatted ?: EMPTY_STRING + ) } else { - po.finalFormatted ?: NO_PRICE_INFO + po.finalFormatted?.let { UiText.DynamicString(it) } ?: UiText.StringResource(R.string.no_price_info) } } - else -> NO_PRICE_INFO + else -> UiText.StringResource(R.string.no_price_info) } - list.add(GameCardItem(TITLE_PRICE, price)) + list.add(GameCardItem(R.string.title_price, price)) - val desc = details.shortDescription?.ifBlank { NO_DESCRIPTION } ?: NO_DESCRIPTION - list.add(GameCardItem(TITLE_DESCRIPTION, desc)) + val desc: UiText = if (!details.shortDescription.isNullOrBlank()) { + UiText.DynamicString(details.shortDescription) + } else { + UiText.StringResource(R.string.no_description) + } + list.add(GameCardItem(R.string.title_description, desc)) - val devs = details.developers?.joinToString(DELIMITER_COMMA)?.ifBlank { NO_INFO } ?: NO_INFO - list.add(GameCardItem(TITLE_DEVELOPERS, devs)) + val devsString = details.developers?.joinToString(DELIMITER_COMMA) + val devs: UiText = if (!devsString.isNullOrBlank()) { + UiText.DynamicString(devsString) + } else { + UiText.StringResource(R.string.no_info) + } + list.add(GameCardItem(R.string.title_developers, devs)) - val pubs = details.publishers?.joinToString(DELIMITER_COMMA)?.ifBlank { NO_INFO } ?: NO_INFO - list.add(GameCardItem(TITLE_PUBLISHERS, pubs)) + val pubsString = details.publishers?.joinToString(DELIMITER_COMMA) + val pubs: UiText = if (!pubsString.isNullOrBlank()) { + UiText.DynamicString(pubsString) + } else { + UiText.StringResource(R.string.no_info) + } + list.add(GameCardItem(R.string.title_publishers, pubs)) - val genres = details.genres?.mapNotNull { it.description }?.joinToString(DELIMITER_COMMA)?.ifBlank { NO_INFO } ?: NO_INFO - list.add(GameCardItem(TITLE_GENRES, genres)) + val genresString = details.genres?.mapNotNull { it.description }?.joinToString(DELIMITER_COMMA) + val genres: UiText = if (!genresString.isNullOrBlank()) { + UiText.DynamicString(genresString) + } else { + UiText.StringResource(R.string.no_info) + } + list.add(GameCardItem(R.string.title_genres, genres)) return list } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cb963c4..3384f2e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,4 +26,17 @@ 게임 정보 도전과제 + + 정보 없음 + %1$s명 + 무료 플레이 + 가격 정보 없음 + 설명 없음 + 실시간 동시 접속자 수 + 가격 및 세일 정보 + 게임 설명 + 개발사 + 배급사 + 장르 + %1$d%% 할인 (%2$s -> %3$s) \ No newline at end of file From eefcd5041da73ee58cc49169e7385602acbbb501 Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Mon, 27 Jul 2026 13:10:32 +0900 Subject: [PATCH 06/21] =?UTF-8?q?=EC=98=A4=EB=A5=98=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/android_2026_1/ui/game/GameScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/example/android_2026_1/ui/game/GameScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/game/GameScreen.kt index b8648cc..5acd4af 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/game/GameScreen.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/game/GameScreen.kt @@ -211,12 +211,12 @@ private fun DetailInfoCard( verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - text = item.title, + text = stringResource(item.titleRes), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary ) Text( - text = item.content, + text = item.content.asString(), style = MaterialTheme.typography.bodyMedium ) } From f6c3b16b4d20d552c161c5b4c3b2db68a359412a Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Mon, 27 Jul 2026 13:26:07 +0900 Subject: [PATCH 07/21] =?UTF-8?q?MainScreed.kt=20=EB=B0=B1=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=20=EC=9D=BC=EA=B4=84=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/android_2026_1/ui/main/MainScreen.kt | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt index 282c703..ae930b4 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt @@ -43,6 +43,10 @@ fun MainScreen( onEvent: (MainEvent) -> Unit, modifier: Modifier = Modifier ) { + BackHandler(enabled = uiState.currentScreen !is Screen.Home) { + onEvent(MainEvent.NavigateTo(Screen.Home)) + } + when (uiState.currentScreen) { is Screen.Home -> { HomeScreen( @@ -53,21 +57,12 @@ fun MainScreen( ) } is Screen.News -> { - BackHandler { - onEvent(MainEvent.NavigateTo(Screen.Home)) - } NewsRoute(modifier = modifier) } is Screen.Profile -> { - BackHandler { - onEvent(MainEvent.NavigateTo(Screen.Home)) - } ProfileRoute(modifier = modifier) } is Screen.Game -> { - BackHandler { - onEvent(MainEvent.NavigateTo(Screen.Home)) - } GameRoute(modifier = modifier) } } @@ -86,7 +81,7 @@ private fun HomeScreen( .fillMaxSize() .padding(innerPadding), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterVertically) ) { Button(onClick = onNavigateToNews) { Text(text = stringResource(R.string.btn_news)) From f7b48e126974cd2898102e92e7e076bdd2015738 Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Mon, 27 Jul 2026 13:27:39 +0900 Subject: [PATCH 08/21] =?UTF-8?q?AppLogger.kt=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/java/com/example/android_2026_1/util/AppLogger.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/android_2026_1/util/AppLogger.kt b/app/src/main/java/com/example/android_2026_1/util/AppLogger.kt index 4f44a9e..7a2911c 100644 --- a/app/src/main/java/com/example/android_2026_1/util/AppLogger.kt +++ b/app/src/main/java/com/example/android_2026_1/util/AppLogger.kt @@ -4,6 +4,6 @@ import android.util.Log object AppLogger { fun e(tag: String, message: String, throwable: Throwable? = null) { - Log.e(tag, "$message: ${throwable?.message}", throwable) + Log.e(tag, message, throwable) } } \ No newline at end of file From 25ba2a1cae78720774b3166a288dca191bccc6a4 Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Tue, 28 Jul 2026 13:51:11 +0900 Subject: [PATCH 09/21] =?UTF-8?q?MainActivity.kt=20Jetpack=20Navigation=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=EB=B7=B0=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C(=EB=AC=B4=EC=9D=98=EB=AF=B8=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 1 + .../android_2026_1/ui/main/MainScreen.kt | 61 ++++++++----------- .../android_2026_1/ui/main/MainViewModel.kt | 37 ----------- gradle/libs.versions.toml | 2 + 4 files changed, 29 insertions(+), 72 deletions(-) delete mode 100644 app/src/main/java/com/example/android_2026_1/ui/main/MainViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e5018b4..270ea87 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -55,4 +55,5 @@ dependencies { implementation(libs.coil.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation.compose.v280) } \ No newline at end of file diff --git a/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt index ae930b4..49b670a 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt @@ -1,6 +1,5 @@ package com.example.android_2026_1.ui.main -import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -11,59 +10,51 @@ import androidx.compose.material3.Button import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController import com.example.android_2026_1.R +import com.example.android_2026_1.ui.game.GameRoute import com.example.android_2026_1.ui.news.NewsRoute import com.example.android_2026_1.ui.profile.ProfileRoute -import com.example.android_2026_1.ui.game.GameRoute - -@Composable -fun MainRoute( - modifier: Modifier = Modifier, - viewModel: MainViewModel = viewModel() -) { - val uiState by viewModel.uiState.collectAsState() - MainScreen( - uiState = uiState, - onEvent = viewModel::onEvent, - modifier = modifier - ) +sealed class Screen(val route: String) { + object Home : Screen("home") + object News : Screen("news") + object Profile : Screen("profile") + object Game : Screen("game") } @Composable -fun MainScreen( - uiState: MainUiState, - onEvent: (MainEvent) -> Unit, +fun MainRoute( modifier: Modifier = Modifier ) { - BackHandler(enabled = uiState.currentScreen !is Screen.Home) { - onEvent(MainEvent.NavigateTo(Screen.Home)) - } + val navController = rememberNavController() - when (uiState.currentScreen) { - is Screen.Home -> { + NavHost( + navController = navController, + startDestination = Screen.Home.route, + modifier = modifier + ) { + composable(Screen.Home.route) { HomeScreen( - onNavigateToNews = { onEvent(MainEvent.NavigateTo(Screen.News)) }, - onNavigateToProfile = { onEvent(MainEvent.NavigateTo(Screen.Profile)) }, - onNavigateToGame = { onEvent(MainEvent.NavigateTo(Screen.Game)) }, - modifier = modifier + onNavigateToNews = { navController.navigate(Screen.News.route) }, + onNavigateToProfile = { navController.navigate(Screen.Profile.route) }, + onNavigateToGame = { navController.navigate(Screen.Game.route) } ) } - is Screen.News -> { - NewsRoute(modifier = modifier) + composable(Screen.News.route) { + NewsRoute() } - is Screen.Profile -> { - ProfileRoute(modifier = modifier) + composable(Screen.Profile.route) { + ProfileRoute() } - is Screen.Game -> { - GameRoute(modifier = modifier) + composable(Screen.Game.route) { + GameRoute() } } } diff --git a/app/src/main/java/com/example/android_2026_1/ui/main/MainViewModel.kt b/app/src/main/java/com/example/android_2026_1/ui/main/MainViewModel.kt deleted file mode 100644 index 70717ad..0000000 --- a/app/src/main/java/com/example/android_2026_1/ui/main/MainViewModel.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.example.android_2026_1.ui.main - -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update - -sealed interface Screen { - object Home : Screen - object News : Screen - object Profile : Screen - object Game : Screen -} - -data class MainUiState( - val currentScreen: Screen = Screen.Home -) - -sealed interface MainEvent { - data class NavigateTo(val screen: Screen) : MainEvent -} - -class MainViewModel : ViewModel() { - - private val _uiState = MutableStateFlow(MainUiState()) - val uiState = _uiState.asStateFlow() - - fun onEvent(event: MainEvent) { - when (event) { - is MainEvent.NavigateTo -> navigateTo(event.screen) - } - } - - private fun navigateTo(screen: Screen) { - _uiState.update { it.copy(currentScreen = screen) } - } -} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e49be6a..99c32fc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ espressoCore = "3.7.0" lifecycleViewmodelCompose = "2.8.7" material = "1.14.0" navigationCompose = "2.8.5" +navigationComposeVersion = "2.8.0" retrofit = "3.0.0" [libraries] @@ -26,6 +27,7 @@ androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +androidx-navigation-compose-v280 = { module = "androidx.navigation:navigation-compose", version.ref = "navigationComposeVersion" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" } converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } junit = { group = "junit", name = "junit", version.ref = "junit" } From f699762a9fed70eed625643072f6d129d657cba2 Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Tue, 28 Jul 2026 14:27:25 +0900 Subject: [PATCH 10/21] =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=ED=82=A4=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/android_2026_1/ui/news/NewsScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt index edbc714..7af39a3 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt @@ -210,7 +210,10 @@ private fun NewsList( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - items(newsList) { item -> + items( + items = newsList, + key = { item -> item.url } + ) { item -> NewsCard( item = item, showImg = showImg, From aa9cd5f06ceead05c377c45455e84684c83e7eba Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Tue, 28 Jul 2026 14:32:42 +0900 Subject: [PATCH 11/21] =?UTF-8?q?strings=EC=97=90=20=EB=82=B4=EC=9A=A9=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/example/android_2026_1/ui/news/NewsScreen.kt | 2 +- app/src/main/res/values/strings.xml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt index 7af39a3..2336007 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt @@ -299,7 +299,7 @@ private fun SettingsDialog( } Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = empty, onCheckedChange = { empty = it }) - Text("기타 (빈 값)") + Text(stringResource(R.string.news_type_empty)) } } }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3384f2e..6c001a1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ 뉴스 종류 제작자 공지 외부 언론사 뉴스 + 기타 (빈 값) 취소 적용 검색 From 3b1e99374cd10cfef9e926725a69b3d0fe2d3002 Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Wed, 29 Jul 2026 19:25:21 +0900 Subject: [PATCH 12/21] =?UTF-8?q?ui=20=EC=BB=B4=ED=8F=AC=EC=A0=80=EB=B8=94?= =?UTF-8?q?=20=EC=97=B0=EC=82=B0=EC=9A=A9=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=20=EA=B0=80=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android_2026_1/ui/news/NewsScreen.kt | 23 +++++++-------- .../android_2026_1/ui/news/NewsViewModel.kt | 29 ++++++++++++++----- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt index 2336007..b798800 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt @@ -202,7 +202,7 @@ private fun NewsSearchBar( @Composable private fun NewsList( - newsList: List, + newsList: List, showImg: Boolean, onNewsClick: (String) -> Unit ) { @@ -212,12 +212,12 @@ private fun NewsList( ) { items( items = newsList, - key = { item -> item.url } - ) { item -> + key = { uiItem -> uiItem.item.url } + ) { uiItem -> NewsCard( - item = item, + uiItem = uiItem, showImg = showImg, - onClick = { onNewsClick(item.url) } + onClick = { onNewsClick(uiItem.item.url) } ) } } @@ -225,7 +225,7 @@ private fun NewsList( @Composable private fun NewsCard( - item: NewsItem, + uiItem: NewsUiItem, showImg: Boolean, onClick: () -> Unit ) { @@ -238,10 +238,9 @@ private fun NewsCard( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - val imgUrl = ImageUtils.extractImageUrl(item.contents) - if (showImg && imgUrl != null) { + if (showImg && uiItem.imageUrl != null) { AsyncImage( - model = imgUrl, + model = uiItem.imageUrl, contentDescription = null, modifier = Modifier .fillMaxWidth() @@ -251,12 +250,12 @@ private fun NewsCard( ) } Text( - text = item.title, + text = uiItem.item.title, style = MaterialTheme.typography.titleMedium ) - if (item.author.isNotEmpty()) { + if (uiItem.item.author.isNotEmpty()) { Text( - text = item.author, + text = uiItem.item.author, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt b/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt index 2c37020..5482f76 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt @@ -2,19 +2,26 @@ package com.example.android_2026_1.ui.news import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.example.android_2026_1.data.ImageUtils import com.example.android_2026_1.util.AppLogger import com.example.android_2026_1.data.NewsItem import com.example.android_2026_1.data.RetrofitClient import com.example.android_2026_1.data.SteamAppItem +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +data class NewsUiItem( + val item: NewsItem, + val imageUrl: String? +) + data class NewsUiState( val text: String = "", val suggestions: List = emptyList(), - val newsList: List = emptyList(), + val newsList: List = emptyList(), val isLoading: Boolean = false, val showImg: Boolean = true, val dev: Boolean = true, @@ -124,14 +131,22 @@ class NewsViewModel : ViewModel() { private fun applyFilter() { val state = _uiState.value - val filtered = rawNewsList.filter { item -> - val isDev = item.feedname == FEED_DEV_ANNOUNCEMENT - val isEmpty = item.feedname.isNullOrEmpty() - val isExternal = !isEmpty && !isDev + viewModelScope.launch(Dispatchers.Default) { + val filtered = rawNewsList.filter { item -> + val isDev = item.feedname == FEED_DEV_ANNOUNCEMENT + val isEmpty = item.feedname.isNullOrEmpty() + val isExternal = !isEmpty && !isDev + + (state.dev && isDev) || (state.showEmpty && isEmpty) || (state.external && isExternal) + }.map { item -> + NewsUiItem( + item = item, + imageUrl = ImageUtils.extractImageUrl(item.contents) + ) + } - (state.dev && isDev) || (state.showEmpty && isEmpty) || (state.external && isExternal) + _uiState.update { it.copy(newsList = filtered, isLoading = false) } } - _uiState.update { it.copy(newsList = filtered, isLoading = false) } } private fun updateSettings(showImg: Boolean, dev: Boolean, external: Boolean, showEmpty: Boolean) { From 36e009a935e0bfdaaeb140522832b657ec6b4901 Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Wed, 29 Jul 2026 19:28:56 +0900 Subject: [PATCH 13/21] =?UTF-8?q?=EC=83=81=ED=83=9C=EC=99=80=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=EC=97=90=20=EB=B6=88=EB=B3=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EB=A1=9C=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/android_2026_1/ui/news/NewsViewModel.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt b/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt index 5482f76..7ed1ac0 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt @@ -1,5 +1,6 @@ package com.example.android_2026_1.ui.news +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.android_2026_1.data.ImageUtils @@ -13,11 +14,13 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +@Immutable data class NewsUiItem( val item: NewsItem, val imageUrl: String? ) +@Immutable data class NewsUiState( val text: String = "", val suggestions: List = emptyList(), From e4dcf7bd18f76912dd80343d93e7d8ae326f1ec4 Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Wed, 29 Jul 2026 19:43:41 +0900 Subject: [PATCH 14/21] =?UTF-8?q?=EC=9E=90=EB=8F=99=EC=99=84=EC=84=B1=20?= =?UTF-8?q?=EC=BD=94=EB=A3=A8=ED=8B=B4=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EC=9D=91=EB=8B=B5=20=EA=B2=BD=EC=9F=81=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android_2026_1/ui/news/NewsViewModel.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt b/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt index 7ed1ac0..7b9e864 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt @@ -8,7 +8,10 @@ import com.example.android_2026_1.util.AppLogger import com.example.android_2026_1.data.NewsItem import com.example.android_2026_1.data.RetrofitClient import com.example.android_2026_1.data.SteamAppItem +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -35,7 +38,7 @@ data class NewsUiState( sealed interface NewsEvent { data class TextChanged(val text: String) : NewsEvent data class SelectItem(val name: String) : NewsEvent - object Search : NewsEvent + data object Search : NewsEvent data class UpdateSettings( val showImg: Boolean, val dev: Boolean, @@ -49,6 +52,8 @@ class NewsViewModel : ViewModel() { companion object { private const val TAG = "NewsViewModel" private const val FEED_DEV_ANNOUNCEMENT = "steam_community_announcements" + + private const val SEARCH_DEBOUNCE_MILLIS = 300L } private val _uiState = MutableStateFlow(NewsUiState()) @@ -70,20 +75,27 @@ class NewsViewModel : ViewModel() { } } + private var searchJob: Job? = null + private fun onTextChange(newText: String) { _uiState.update { it.copy(text = newText) } + + searchJob?.cancel() + if (newText.isEmpty()) { _uiState.update { it.copy(suggestions = emptyList()) } return } - viewModelScope.launch { + searchJob = viewModelScope.launch { + delay(SEARCH_DEBOUNCE_MILLIS) try { val result = RetrofitClient.storeService.searchApps(newText) _uiState.update { it.copy(suggestions = result.items ?: emptyList()) } } catch (e: Exception) { + if (e is CancellationException) throw e AppLogger.e(TAG, "Error searching apps", e) _uiState.update { it.copy(suggestions = emptyList()) } } From 607c9886dc2e6e6a4414d25cc88f00bc4394dc1d Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Wed, 29 Jul 2026 20:41:51 +0900 Subject: [PATCH 15/21] =?UTF-8?q?=EC=83=9D=EB=AA=85=EC=A3=BC=EA=B8=B0?= =?UTF-8?q?=EC=99=80=EB=8F=84=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/example/android_2026_1/ui/news/NewsScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt index b798800..eb7421f 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.example.android_2026_1.data.ImageUtils @@ -61,7 +62,7 @@ fun NewsRoute( modifier: Modifier = Modifier, viewModel: NewsViewModel = viewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() NewsScreen( uiState = uiState, onEvent = viewModel::onEvent, From e2739eaa0670beeac49fd3d45d11c24a34e3551e Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Wed, 29 Jul 2026 20:48:00 +0900 Subject: [PATCH 16/21] =?UTF-8?q?=EC=A4=91=EB=B3=B5=20api=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android_2026_1/ui/news/NewsViewModel.kt | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt b/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt index 7b9e864..85dca09 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt @@ -103,17 +103,29 @@ class NewsViewModel : ViewModel() { } private fun selectItem(name: String) { + val selectedApp = _uiState.value.suggestions.firstOrNull { it.name == name } _uiState.update { it.copy(text = name, suggestions = emptyList()) } + + if (selectedApp != null) { + searchNewsByAppId(selectedApp.id) + } else { + doSearch() + } } private fun doSearch() { val query = _uiState.value.text if (query.isEmpty()) return + val currentSuggestions = _uiState.value.suggestions _uiState.update { it.copy(isLoading = true, suggestions = emptyList()) } viewModelScope.launch { - val appId = findAppId(query) + val cachedApp = currentSuggestions.firstOrNull { + it.name.equals(query, ignoreCase = true) + } + + val appId = cachedApp?.id ?: findAppId(query) rawNewsList = if (appId != null) { loadNews(appId) } else { @@ -123,13 +135,21 @@ class NewsViewModel : ViewModel() { } } + private fun searchNewsByAppId(appId: Int) { + _uiState.update { it.copy(isLoading = true, suggestions = emptyList()) } + viewModelScope.launch { + rawNewsList = loadNews(appId) + applyFilter() + } + } + private suspend fun findAppId(query: String): Int? { return try { val response = RetrofitClient.storeService.searchApps(query) val matchedGame = response.items?.firstOrNull() matchedGame?.id } catch (e: Exception) { - AppLogger.e(TAG, "Error finding appId", e) + AppLogger.e("NewsViewModel", "Error finding appId", e) null } } From deb9021dccf7d8b2acee420f705a11dc906ee23e Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Mon, 3 Aug 2026 12:51:49 +0900 Subject: [PATCH 17/21] =?UTF-8?q?=EC=88=98=EC=A0=953?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android_2026_1/ui/game/GameScreen.kt | 3 +- .../android_2026_1/ui/game/GameViewModel.kt | 84 +++++++++++++++---- .../ui/profile/ProfileScreen.kt | 3 +- .../ui/profile/ProfileViewModel.kt | 28 ++++++- 4 files changed, 100 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/example/android_2026_1/ui/game/GameScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/game/GameScreen.kt index 5acd4af..3afdc82 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/game/GameScreen.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/game/GameScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.example.android_2026_1.R @@ -45,7 +46,7 @@ fun GameRoute( modifier: Modifier = Modifier, viewModel: GameViewModel = viewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() GameScreen( uiState = uiState, onEvent = viewModel::onEvent, diff --git a/app/src/main/java/com/example/android_2026_1/ui/game/GameViewModel.kt b/app/src/main/java/com/example/android_2026_1/ui/game/GameViewModel.kt index ab23b79..3027736 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/game/GameViewModel.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/game/GameViewModel.kt @@ -2,6 +2,7 @@ package com.example.android_2026_1.ui.game import androidx.annotation.StringRes import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.ui.res.stringResource import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -11,6 +12,9 @@ import com.example.android_2026_1.data.SteamAppDetailsData import com.example.android_2026_1.data.SteamAppItem import com.example.android_2026_1.data.SteamExtraClient import com.example.android_2026_1.util.AppLogger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -21,7 +25,7 @@ import java.util.Locale sealed interface UiText { data class DynamicString(val value: String) : UiText class StringResource( - @StringRes val resId: Int, + @param:StringRes val resId: Int, vararg val args: Any ) : UiText @@ -34,11 +38,13 @@ sealed interface UiText { } } +@Immutable data class GameCardItem( - @StringRes val titleRes: Int, + @param:StringRes val titleRes: Int, val content: UiText ) +@Immutable data class GameUiState( val text: String = GameViewModel.EMPTY_STRING, val suggestions: List = emptyList(), @@ -51,7 +57,7 @@ data class GameUiState( sealed interface GameEvent { data class TextChanged(val text: String) : GameEvent data class SelectItem(val name: String) : GameEvent - object Search : GameEvent + data object Search : GameEvent } class GameViewModel : ViewModel() { @@ -61,16 +67,26 @@ class GameViewModel : ViewModel() { const val EMPTY_STRING = "" private const val DELIMITER_COMMA = ", " + private const val SEARCH_DEBOUNCE_MILLIS = 300L - private const val LOG_ERROR_SEARCH_APPS = "Error searching apps" - private const val LOG_ERROR_LOADING_PLAYER_COUNT = "Error loading player count" - private const val LOG_ERROR_FETCHING_DETAILS = "Error fetching game details" - private const val LOG_ERROR_FINDING_APP_ID = "Error finding appId" + private const val LOG_ERROR_SEARCH_APPS = "앱 검색 중 오류 발생" + private const val LOG_ERROR_LOADING_PLAYER_COUNT = "동시 접속자 수 로딩 중 오류 발생" + private const val LOG_ERROR_FETCHING_DETAILS = "게임 상세 정보 조회 중 오류 발생" + private const val LOG_ERROR_FINDING_APP_ID = "앱 ID 검색 중 오류 발생" } + private data class GameSearchResultData( + val headerImage: String?, + val gameTitle: String?, + val detailCards: List + ) + private val _uiState = MutableStateFlow(GameUiState()) val uiState = _uiState.asStateFlow() + private var searchJob: Job? = null + private val gameCache = mutableMapOf() + fun onEvent(event: GameEvent) { when (event) { is GameEvent.TextChanged -> onTextChange(event.text) @@ -81,17 +97,23 @@ class GameViewModel : ViewModel() { private fun onTextChange(newText: String) { _uiState.update { it.copy(text = newText) } + + searchJob?.cancel() + if (newText.isEmpty()) { _uiState.update { it.copy(suggestions = emptyList()) } return } - viewModelScope.launch { + searchJob = viewModelScope.launch { + delay(SEARCH_DEBOUNCE_MILLIS) try { val result = RetrofitClient.storeService.searchApps(newText) _uiState.update { it.copy(suggestions = result.items ?: emptyList()) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { AppLogger.e(TAG, LOG_ERROR_SEARCH_APPS, e) _uiState.update { it.copy(suggestions = emptyList()) } @@ -101,17 +123,38 @@ class GameViewModel : ViewModel() { private fun selectItem(name: String) { _uiState.update { it.copy(text = name, suggestions = emptyList()) } + doSearch() } private fun doSearch() { - val query = _uiState.value.text + val query = _uiState.value.text.trim() if (query.isEmpty()) return + searchJob?.cancel() + + if (gameCache.containsKey(query)) { + val cached = gameCache[query]!! + _uiState.update { + it.copy( + isLoading = false, + suggestions = emptyList(), + headerImage = cached.headerImage, + gameTitle = cached.gameTitle, + detailCards = cached.detailCards + ) + } + return + } + + val cachedAppId = _uiState.value.suggestions.firstOrNull { + it.name.equals(query, ignoreCase = true) + }?.id + _uiState.update { it.copy(isLoading = true, suggestions = emptyList()) } - viewModelScope.launch { + searchJob = viewModelScope.launch { try { - val appId = findAppId(query) + val appId = cachedAppId ?: findAppId(query) if (appId == null) { _uiState.update { it.copy( @@ -136,18 +179,27 @@ class GameViewModel : ViewModel() { val formattedCount = NumberFormat.getNumberInstance(Locale.US).format(count) playerCount = UiText.StringResource(R.string.player_unit, formattedCount) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { AppLogger.e(TAG, LOG_ERROR_LOADING_PLAYER_COUNT, e) } if (details != null) { val cards = buildCards(details, playerCount) + val searchResultData = GameSearchResultData( + headerImage = details.headerImage, + gameTitle = details.name ?: query, + detailCards = cards + ) + gameCache[query] = searchResultData + _uiState.update { it.copy( isLoading = false, - headerImage = details.headerImage, - gameTitle = details.name ?: query, - detailCards = cards + headerImage = searchResultData.headerImage, + gameTitle = searchResultData.gameTitle, + detailCards = searchResultData.detailCards ) } } else { @@ -160,6 +212,8 @@ class GameViewModel : ViewModel() { ) } } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { AppLogger.e(TAG, LOG_ERROR_FETCHING_DETAILS, e) _uiState.update { @@ -179,6 +233,8 @@ class GameViewModel : ViewModel() { val response = RetrofitClient.storeService.searchApps(query) val game = response.items?.firstOrNull() game?.id + } catch (e: CancellationException) { + throw e } catch (e: Exception) { AppLogger.e(TAG, LOG_ERROR_FINDING_APP_ID, e) null diff --git a/app/src/main/java/com/example/android_2026_1/ui/profile/ProfileScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/profile/ProfileScreen.kt index 6b23939..55f8c62 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/profile/ProfileScreen.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/profile/ProfileScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.example.android_2026_1.R @@ -47,7 +48,7 @@ fun ProfileRoute( modifier: Modifier = Modifier, viewModel: ProfileViewModel = viewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() ProfileScreen( uiState = uiState, onEvent = viewModel::onEvent, diff --git a/app/src/main/java/com/example/android_2026_1/ui/profile/ProfileViewModel.kt b/app/src/main/java/com/example/android_2026_1/ui/profile/ProfileViewModel.kt index ee35432..004fbac 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/profile/ProfileViewModel.kt @@ -1,5 +1,6 @@ package com.example.android_2026_1.ui.profile +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.android_2026_1.util.AppLogger @@ -8,6 +9,7 @@ import com.example.android_2026_1.data.RetrofitClient import com.example.android_2026_1.data.UserProfile import com.example.android_2026_1.data.XmlParserUtils import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -16,6 +18,7 @@ import kotlinx.coroutines.withContext import okhttp3.ResponseBody import retrofit2.HttpException +@Immutable data class ProfileUiState( val text: String = "", val userProfile: UserProfile? = null, @@ -26,7 +29,7 @@ data class ProfileUiState( sealed interface ProfileEvent { data class TextChanged(val text: String) : ProfileEvent - object Search : ProfileEvent + data object Search : ProfileEvent } class ProfileViewModel : ViewModel() { @@ -54,12 +57,19 @@ class ProfileViewModel : ViewModel() { _uiState.update { it.copy(text = newText) } } + + private var searchJob: Job? = null + + private val profileCache = mutableMapOf() + private fun doSearch() { val rawQuery = _uiState.value.text.trim() if (rawQuery.isEmpty()) return val query = extractIdFromQuery(rawQuery) + searchJob?.cancel() + _uiState.update { it.copy( isLoading = true, @@ -69,8 +79,22 @@ class ProfileViewModel : ViewModel() { ) } - viewModelScope.launch { + if (profileCache.containsKey(query)) { + val cachedResult = profileCache[query]!! + _uiState.update { + it.copy( + isLoading = false, + userProfile = cachedResult.profile, + profileUrl = cachedResult.url, + errorMessageResId = cachedResult.errorMessageResId + ) + } + return + } + + searchJob = viewModelScope.launch { val result = fetchUserProfile(query) + profileCache[query] = result _uiState.update { it.copy( isLoading = false, From 207dd738687bb489e31bcd65d4034d53b0005a22 Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Mon, 3 Aug 2026 14:13:58 +0900 Subject: [PATCH 18/21] =?UTF-8?q?=EA=BE=B8=EB=AF=B8=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android_2026_1/ui/main/MainScreen.kt | 58 +++++++++++++++---- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt index 49b670a..7a80d1a 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt @@ -2,18 +2,22 @@ package com.example.android_2026_1.ui.main import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer 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.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -70,24 +74,54 @@ private fun HomeScreen( Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding), + .padding(innerPadding) + .padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterVertically) ) { - Button(onClick = onNavigateToNews) { - Text(text = stringResource(R.string.btn_news)) + Button( + onClick = onNavigateToNews, + modifier = Modifier + .fillMaxWidth() + .height(64.dp), + shape = RectangleShape, + colors = ButtonDefaults.buttonColors(containerColor = Color.Gray) + ) { + Text( + text = stringResource(R.string.btn_news), + fontSize = 18.sp, + color = Color.White + ) } - Spacer(modifier = Modifier.height(12.dp)) - - Button(onClick = onNavigateToProfile) { - Text(text = stringResource(R.string.btn_profile)) + Button( + onClick = onNavigateToProfile, + modifier = Modifier + .fillMaxWidth() + .height(64.dp), + shape = RectangleShape, + colors = ButtonDefaults.buttonColors(containerColor = Color.Gray) + ) { + Text( + text = stringResource(R.string.btn_profile), + fontSize = 18.sp, + color = Color.White + ) } - Spacer(modifier = Modifier.height(12.dp)) - - Button(onClick = onNavigateToGame) { - Text(text = stringResource(R.string.btn_game)) + Button( + onClick = onNavigateToGame, + modifier = Modifier + .fillMaxWidth() + .height(64.dp), + shape = RectangleShape, + colors = ButtonDefaults.buttonColors(containerColor = Color.Gray) + ) { + Text( + text = stringResource(R.string.btn_game), + fontSize = 18.sp, + color = Color.White + ) } } } From 57f7d817f9c7a63b9b176ea8835e96ed726d3d70 Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Mon, 3 Aug 2026 14:22:34 +0900 Subject: [PATCH 19/21] =?UTF-8?q?=EA=BE=B8=EB=AF=B8=EA=B8=B02=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EB=A1=9C=20=EB=AC=B6=EA=B3=A0=20=EA=B3=A1=EB=A5=A0?= =?UTF-8?q?=EA=B3=BC=20=EA=B7=B8=EB=A6=BC=EC=9E=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android_2026_1/ui/main/MainScreen.kt | 121 +++++++++++------- app/src/main/res/values/strings.xml | 3 + 2 files changed, 79 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt index 7a80d1a..14ff9c7 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt @@ -2,20 +2,24 @@ package com.example.android_2026_1.ui.main import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer 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.material3.Button -import androidx.compose.material3.ButtonDefaults +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.compose.NavHost @@ -75,52 +79,79 @@ private fun HomeScreen( modifier = Modifier .fillMaxSize() .padding(innerPadding) - .padding(horizontal = 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterVertically) + .padding(24.dp), + verticalArrangement = Arrangement.Center ) { - Button( - onClick = onNavigateToNews, - modifier = Modifier - .fillMaxWidth() - .height(64.dp), - shape = RectangleShape, - colors = ButtonDefaults.buttonColors(containerColor = Color.Gray) - ) { - Text( - text = stringResource(R.string.btn_news), - fontSize = 18.sp, - color = Color.White - ) - } + Text( + text = stringResource(R.string.dashboard_title), + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) - Button( - onClick = onNavigateToProfile, - modifier = Modifier - .fillMaxWidth() - .height(64.dp), - shape = RectangleShape, - colors = ButtonDefaults.buttonColors(containerColor = Color.Gray) - ) { - Text( - text = stringResource(R.string.btn_profile), - fontSize = 18.sp, - color = Color.White - ) - } + Text( + text = stringResource(R.string.dashboard_subtitle), + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(32.dp)) + + MenuCard( + title = stringResource(R.string.btn_news), + onClick = onNavigateToNews + ) + + Spacer(modifier = Modifier.height(16.dp)) + + MenuCard( + title = stringResource(R.string.btn_profile), + onClick = onNavigateToProfile + ) + + Spacer(modifier = Modifier.height(16.dp)) + + MenuCard( + title = stringResource(R.string.btn_game), + onClick = onNavigateToGame + ) + } + } +} + +@Composable +private fun MenuCard( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .height(72.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + + Spacer(modifier = Modifier.width(16.dp)) - Button( - onClick = onNavigateToGame, - modifier = Modifier - .fillMaxWidth() - .height(64.dp), - shape = RectangleShape, - colors = ButtonDefaults.buttonColors(containerColor = Color.Gray) - ) { Text( - text = stringResource(R.string.btn_game), + text = title, fontSize = 18.sp, - color = Color.White + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c001a1..5b455b5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -40,4 +40,7 @@ 배급사 장르 %1$d%% 할인 (%2$s -> %3$s) + + Dashboard + 이동할 메뉴를 선택해주세요 \ No newline at end of file From 9e536cb2230d933b56fc18006bd51a7dd2054357 Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Mon, 3 Aug 2026 15:55:04 +0900 Subject: [PATCH 20/21] =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android_2026_1/ui/main/MainScreen.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt index 14ff9c7..9467d82 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt @@ -8,16 +8,24 @@ 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.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Newspaper +import androidx.compose.material.icons.filled.SportsEsports import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -99,6 +107,7 @@ private fun HomeScreen( MenuCard( title = stringResource(R.string.btn_news), + icon = Icons.Default.Newspaper, onClick = onNavigateToNews ) @@ -106,6 +115,7 @@ private fun HomeScreen( MenuCard( title = stringResource(R.string.btn_profile), + icon = Icons.Default.AccountCircle, onClick = onNavigateToProfile ) @@ -113,6 +123,7 @@ private fun HomeScreen( MenuCard( title = stringResource(R.string.btn_game), + icon = Icons.Default.SportsEsports, onClick = onNavigateToGame ) } @@ -122,6 +133,7 @@ private fun HomeScreen( @Composable private fun MenuCard( title: String, + icon: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -144,6 +156,12 @@ private fun MenuCard( horizontalArrangement = Arrangement.SpaceBetween ) { Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.primary + ) Spacer(modifier = Modifier.width(16.dp)) @@ -154,6 +172,12 @@ private fun MenuCard( color = MaterialTheme.colorScheme.onSurfaceVariant ) } + + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } } } \ No newline at end of file From 97b98667bc04a43c1da3f51e2b7db7efccc87ff1 Mon Sep 17 00:00:00 2001 From: Rickettsia1234 Date: Wed, 5 Aug 2026 19:17:53 +0900 Subject: [PATCH 21/21] =?UTF-8?q?=EC=B5=9C=EC=A2=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/example/android_2026_1/ui/main/MainScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt index 9467d82..02fa58b 100644 --- a/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt +++ b/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt @@ -54,7 +54,7 @@ fun MainRoute( NavHost( navController = navController, startDestination = Screen.Home.route, - modifier = modifier + modifier = modifier, ) { composable(Screen.Home.route) { HomeScreen(