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/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/.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..270ea87 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,15 +1,16 @@
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"
minSdk = 26
- targetSdk = 36
+ targetSdk = 37
versionCode = 1
versionName = "1.0"
@@ -29,15 +30,30 @@ 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)
+ 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)
+ implementation(libs.androidx.navigation.compose.v280)
}
\ 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 @@
-
+
+
- val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
- v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
- insets
+ setContent {
+ MainRoute()
}
}
}
\ No newline at end of file
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
new file mode 100644
index 0000000..0e50406
--- /dev/null
+++ b/app/src/main/java/com/example/android_2026_1/data/SteamApi.kt
@@ -0,0 +1,328 @@
+package com.example.android_2026_1.data
+
+import com.google.gson.annotations.SerializedName
+import okhttp3.ResponseBody
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import retrofit2.http.GET
+import retrofit2.http.Header
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+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_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"
+ 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 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"
+ }
+}
+
+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(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(SteamApiService.HEADER_COOKIE) cookie: String? = null,
+ @Header(SteamApiService.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(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(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
+}
+
+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(SteamApiService.XML_ERROR_TAG) || xml.isBlank()) return null
+
+ return try {
+ 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(SteamApiService.XML_REGEX_AVATAR, RegexOption.DOT_MATCHES_ALL)
+ .find(xml)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } }
+ val state = Regex(SteamApiService.XML_REGEX_STATE, RegexOption.DOT_MATCHES_ALL)
+ .find(xml)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } }
+ 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
+ UserProfile(name, avatar ?: "", state ?: "", summary ?: "", steamId64)
+ } catch (e: Exception) {
+ 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(
+ val name: String,
+ val avatarUrl: String,
+ val stateMessage: String,
+ val summary: String = "",
+ val steamId64: String? = null
+)
+
+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..3afdc82
--- /dev/null
+++ b/app/src/main/java/com/example/android_2026_1/ui/game/GameScreen.kt
@@ -0,0 +1,225 @@
+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.compose.collectAsStateWithLifecycle
+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.collectAsStateWithLifecycle()
+ 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 = stringResource(item.titleRes),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = item.content.asString(),
+ 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..3027736
--- /dev/null
+++ b/app/src/main/java/com/example/android_2026_1/ui/game/GameViewModel.kt
@@ -0,0 +1,301 @@
+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
+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
+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
+import kotlinx.coroutines.launch
+import java.text.NumberFormat
+import java.util.Locale
+
+sealed interface UiText {
+ data class DynamicString(val value: String) : UiText
+ class StringResource(
+ @param:StringRes val resId: Int,
+ vararg val args: Any
+ ) : UiText
+
+ @Composable
+ fun asString(): String {
+ return when (this) {
+ is DynamicString -> value
+ is StringResource -> stringResource(resId, *args)
+ }
+ }
+}
+
+@Immutable
+data class GameCardItem(
+ @param:StringRes val titleRes: Int,
+ val content: UiText
+)
+
+@Immutable
+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
+ data object Search : GameEvent
+}
+
+class GameViewModel : ViewModel() {
+
+ companion object {
+ private const val TAG = "GameViewModel"
+
+ const val EMPTY_STRING = ""
+ private const val DELIMITER_COMMA = ", "
+ private const val SEARCH_DEBOUNCE_MILLIS = 300L
+
+ 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)
+ is GameEvent.SelectItem -> selectItem(event.name)
+ is GameEvent.Search -> doSearch()
+ }
+ }
+
+ private fun onTextChange(newText: String) {
+ _uiState.update { it.copy(text = newText) }
+
+ searchJob?.cancel()
+
+ if (newText.isEmpty()) {
+ _uiState.update { it.copy(suggestions = emptyList()) }
+ return
+ }
+
+ 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()) }
+ }
+ }
+ }
+
+ private fun selectItem(name: String) {
+ _uiState.update { it.copy(text = name, suggestions = emptyList()) }
+ doSearch()
+ }
+
+ private fun doSearch() {
+ 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()) }
+
+ searchJob = viewModelScope.launch {
+ try {
+ val appId = cachedAppId ?: 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: UiText = UiText.StringResource(R.string.no_info)
+ try {
+ val countResponse = SteamExtraClient.statsService.getNumberOfCurrentPlayers(appId)
+ val count = countResponse.response?.playerCount
+ if (count != null) {
+ 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 = searchResultData.headerImage,
+ gameTitle = searchResultData.gameTitle,
+ detailCards = searchResultData.detailCards
+ )
+ }
+ } else {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ headerImage = null,
+ gameTitle = null,
+ detailCards = emptyList()
+ )
+ }
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } 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: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ AppLogger.e(TAG, LOG_ERROR_FINDING_APP_ID, e)
+ null
+ }
+ }
+
+ private fun buildCards(details: SteamAppDetailsData, playerCount: UiText): List {
+ val list = mutableListOf()
+
+ list.add(GameCardItem(R.string.title_player_count, playerCount))
+
+ 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) {
+ UiText.StringResource(
+ R.string.discount_format,
+ po.discountPercent ?: 0,
+ po.initialFormatted ?: EMPTY_STRING,
+ po.finalFormatted ?: EMPTY_STRING
+ )
+ } else {
+ po.finalFormatted?.let { UiText.DynamicString(it) } ?: UiText.StringResource(R.string.no_price_info)
+ }
+ }
+ else -> UiText.StringResource(R.string.no_price_info)
+ }
+ list.add(GameCardItem(R.string.title_price, price))
+
+ 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 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 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 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
+ }
+}
\ 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
new file mode 100644
index 0000000..02fa58b
--- /dev/null
+++ b/app/src/main/java/com/example/android_2026_1/ui/main/MainScreen.kt
@@ -0,0 +1,183 @@
+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.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
+import androidx.compose.ui.unit.sp
+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
+
+sealed class Screen(val route: String) {
+ object Home : Screen("home")
+ object News : Screen("news")
+ object Profile : Screen("profile")
+ object Game : Screen("game")
+}
+
+@Composable
+fun MainRoute(
+ modifier: Modifier = Modifier
+) {
+ val navController = rememberNavController()
+
+ NavHost(
+ navController = navController,
+ startDestination = Screen.Home.route,
+ modifier = modifier,
+ ) {
+ composable(Screen.Home.route) {
+ HomeScreen(
+ onNavigateToNews = { navController.navigate(Screen.News.route) },
+ onNavigateToProfile = { navController.navigate(Screen.Profile.route) },
+ onNavigateToGame = { navController.navigate(Screen.Game.route) }
+ )
+ }
+ composable(Screen.News.route) {
+ NewsRoute()
+ }
+ composable(Screen.Profile.route) {
+ ProfileRoute()
+ }
+ composable(Screen.Game.route) {
+ GameRoute()
+ }
+ }
+}
+
+@Composable
+private fun HomeScreen(
+ onNavigateToNews: () -> Unit,
+ onNavigateToProfile: () -> Unit,
+ onNavigateToGame: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Scaffold(modifier = modifier.fillMaxSize()) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(24.dp),
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = stringResource(R.string.dashboard_title),
+ fontSize = 28.sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+
+ 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),
+ icon = Icons.Default.Newspaper,
+ onClick = onNavigateToNews
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ MenuCard(
+ title = stringResource(R.string.btn_profile),
+ icon = Icons.Default.AccountCircle,
+ onClick = onNavigateToProfile
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ MenuCard(
+ title = stringResource(R.string.btn_game),
+ icon = Icons.Default.SportsEsports,
+ onClick = onNavigateToGame
+ )
+ }
+ }
+}
+
+@Composable
+private fun MenuCard(
+ title: String,
+ icon: ImageVector,
+ 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) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(28.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Text(
+ text = title,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 0000000..eb7421f
--- /dev/null
+++ b/app/src/main/java/com/example/android_2026_1/ui/news/NewsScreen.kt
@@ -0,0 +1,363 @@
+package com.example.android_2026_1.ui.news
+
+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.compose.collectAsStateWithLifecycle
+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(
+ modifier: Modifier = Modifier,
+ viewModel: NewsViewModel = viewModel()
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ 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(
+ items = newsList,
+ key = { uiItem -> uiItem.item.url }
+ ) { uiItem ->
+ NewsCard(
+ uiItem = uiItem,
+ showImg = showImg,
+ onClick = { onNewsClick(uiItem.item.url) }
+ )
+ }
+ }
+}
+
+@Composable
+private fun NewsCard(
+ uiItem: NewsUiItem,
+ showImg: Boolean,
+ onClick: () -> Unit
+) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onClick() }
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ if (showImg && uiItem.imageUrl != null) {
+ AsyncImage(
+ model = uiItem.imageUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(180.dp)
+ .clip(RoundedCornerShape(8.dp)),
+ contentScale = ContentScale.Crop
+ )
+ }
+ Text(
+ text = uiItem.item.title,
+ style = MaterialTheme.typography.titleMedium
+ )
+ if (uiItem.item.author.isNotEmpty()) {
+ Text(
+ text = uiItem.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(stringResource(R.string.news_type_empty))
+ }
+ }
+ },
+ 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/ui/news/NewsViewModel.kt b/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt
new file mode 100644
index 0000000..85dca09
--- /dev/null
+++ b/app/src/main/java/com/example/android_2026_1/ui/news/NewsViewModel.kt
@@ -0,0 +1,198 @@
+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
+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
+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(),
+ 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
+ data 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 const val SEARCH_DEBOUNCE_MILLIS = 300L
+ }
+
+ 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 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
+ }
+
+ 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()) }
+ }
+ }
+ }
+
+ 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 cachedApp = currentSuggestions.firstOrNull {
+ it.name.equals(query, ignoreCase = true)
+ }
+
+ val appId = cachedApp?.id ?: findAppId(query)
+ rawNewsList = if (appId != null) {
+ loadNews(appId)
+ } else {
+ emptyList()
+ }
+ applyFilter()
+ }
+ }
+
+ 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("NewsViewModel", "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
+ 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)
+ )
+ }
+
+ _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/ui/profile/ProfileScreen.kt b/app/src/main/java/com/example/android_2026_1/ui/profile/ProfileScreen.kt
new file mode 100644
index 0000000..55f8c62
--- /dev/null
+++ b/app/src/main/java/com/example/android_2026_1/ui/profile/ProfileScreen.kt
@@ -0,0 +1,248 @@
+package com.example.android_2026_1.ui.profile
+
+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.compose.collectAsStateWithLifecycle
+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(
+ modifier: Modifier = Modifier,
+ viewModel: ProfileViewModel = viewModel()
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ 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/ui/profile/ProfileViewModel.kt b/app/src/main/java/com/example/android_2026_1/ui/profile/ProfileViewModel.kt
new file mode 100644
index 0000000..004fbac
--- /dev/null
+++ b/app/src/main/java/com/example/android_2026_1/ui/profile/ProfileViewModel.kt
@@ -0,0 +1,205 @@
+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
+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.Job
+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
+
+@Immutable
+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
+ data 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 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())
+ 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 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,
+ userProfile = null,
+ profileUrl = null,
+ errorMessageResId = null
+ )
+ }
+
+ 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,
+ userProfile = result.profile,
+ profileUrl = result.url,
+ errorMessageResId = result.errorMessageResId
+ )
+ }
+ }
+ }
+
+ private fun extractIdFromQuery(rawQuery: String): String {
+ var cleaned = rawQuery.trim().removeSuffix("/")
+ 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("/")
+ }
+
+ 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() == HTTP_TOO_MANY_REQUESTS) {
+ 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/util/AppLogger.kt b/app/src/main/java/com/example/android_2026_1/util/AppLogger.kt
new file mode 100644
index 0000000..7a2911c
--- /dev/null
+++ b/app/src/main/java/com/example/android_2026_1/util/AppLogger.kt
@@ -0,0 +1,9 @@
+package com.example.android_2026_1.util
+
+import android.util.Log
+
+object AppLogger {
+ fun e(tag: String, message: String, throwable: Throwable? = null) {
+ Log.e(tag, message, throwable)
+ }
+}
\ 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..5b455b5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,46 @@
- android_2026_1
+ 스팀_api_테스트
+
+ 게임 뉴스
+ 게임 검색…
+ 게임에 뉴스가 없음
+ 설정
+ 이미지 보이기
+ 뉴스 종류
+ 제작자 공지
+ 외부 언론사 뉴스
+ 기타 (빈 값)
+ 취소
+ 적용
+ 검색
+ 닫기
+ 설정창
+
+ 유저 검색
+ 스팀 ID 또는 커스텀 URL 입력…
+ 유저를 찾을 수 없습니다.
+ 요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.
+
+ 커뮤니티 검색
+ 스팀 커뮤니티 그룹 ID 또는 URL 입력…
+
+ 게임 정보
+
+ 도전과제
+
+ 정보 없음
+ %1$s명
+ 무료 플레이
+ 가격 정보 없음
+ 설명 없음
+ 실시간 동시 접속자 수
+ 가격 및 세일 정보
+ 게임 설명
+ 개발사
+ 배급사
+ 장르
+ %1$d%% 할인 (%2$s -> %3$s)
+
+ Dashboard
+ 이동할 메뉴를 선택해주세요
\ 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..99c32fc 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,25 +1,43 @@
[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"
+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"
-appcompat = "1.7.1"
+lifecycleViewmodelCompose = "2.8.7"
material = "1.14.0"
+navigationCompose = "2.8.5"
+navigationComposeVersion = "2.8.0"
+retrofit = "3.0.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-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" }
+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" }
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" }
+retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
+
[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