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