first commit

This commit is contained in:
2026-09-16 13:32:45 +09:00
commit 9d0654bab8
1136 changed files with 58955 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".WordPuzzleApp"
android:allowBackup="true"
android:icon="@android:drawable/ic_dialog_info"
android:label="@string/app_name"
android:roundIcon="@android:drawable/ic_dialog_info"
android:supportsRtl="true"
android:theme="@style/Theme.WordPuzzle">
<!-- AdMob Sample App ID for testing -->
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-5498177170320650~1587001641" />
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/Theme.WordPuzzle">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Lock Screen / Standby Pre-learning Activity -->
<activity
android:name=".ui.lockscreen.LockScreenActivity"
android:exported="false"
android:excludeFromRecents="true"
android:noHistory="true"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:theme="@style/Theme.WordPuzzle.LockScreen" />
<!-- Screen On Broadcast Receiver for Standby Pre-Learning -->
<receiver
android:name=".service.ScreenStateReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.SCREEN_ON" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- Smart Ongoing Word Notification Receiver -->
<receiver
android:name=".service.WordNotificationReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.wordpuzzle.hangul.ACTION_NEXT_WORD" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- Home Screen Widget Receiver -->
<receiver
android:name=".ui.widget.WordWidgetReceiver"
android:exported="true"
android:label="@string/widget_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/word_widget_info" />
</receiver>
</application>
</manifest>
+194
View File
@@ -0,0 +1,194 @@
[
{
"stageNumber": 1,
"title": "첫걸음 (자연과 일상)",
"gridSize": 6,
"difficulty": 1,
"clues": [
{
"id": "c1_1",
"number": 1,
"direction": "ACROSS",
"word": "가을하늘",
"clue": "가을철의 맑고 푸른 하늘.",
"startRow": 1,
"startCol": 0,
"length": 4
},
{
"id": "c1_2",
"number": 2,
"direction": "DOWN",
"word": "하늘나라",
"clue": "천국을 이르는 말.",
"startRow": 1,
"startCol": 2,
"length": 4
},
{
"id": "c1_3",
"number": 3,
"direction": "ACROSS",
"word": "나비효과",
"clue": "미세한 변화가 엄청난 결과를 낳는 현상.",
"startRow": 3,
"startCol": 2,
"length": 4
}
]
},
{
"stageNumber": 2,
"title": "동네 한바퀴",
"gridSize": 6,
"difficulty": 1,
"clues": [
{
"id": "c2_1",
"number": 1,
"direction": "ACROSS",
"word": "과일가게",
"clue": "여러 가지 과일을 파는 상점.",
"startRow": 0,
"startCol": 1,
"length": 4
},
{
"id": "c2_2",
"number": 2,
"direction": "DOWN",
"word": "가면무도회",
"clue": "참가자가 가면을 쓰고 춤을 추는 연회.",
"startRow": 0,
"startCol": 3,
"length": 5
},
{
"id": "c2_3",
"number": 3,
"direction": "ACROSS",
"word": "무지개",
"clue": "비 갠 하늘에 나타나는 일곱 빛깔 호선.",
"startRow": 2,
"startCol": 3,
"length": 3
},
{
"id": "c2_4",
"number": 4,
"direction": "DOWN",
"word": "개구쟁이",
"clue": "궂은 장난을 자주 치는 귀여운 아이.",
"startRow": 2,
"startCol": 5,
"length": 4
}
]
},
{
"stageNumber": 3,
"title": "사자성어와 지혜",
"gridSize": 6,
"difficulty": 2,
"clues": [
{
"id": "c3_1",
"number": 1,
"direction": "ACROSS",
"word": "일석이조",
"clue": "돌 하나로 새 두 마리를 잡는다는 사자성어.",
"startRow": 1,
"startCol": 0,
"length": 4
},
{
"id": "c3_2",
"number": 2,
"direction": "DOWN",
"word": "조약돌",
"clue": "물에 씻기어 둥글고 반드러워진 작은 돌.",
"startRow": 1,
"startCol": 3,
"length": 3
},
{
"id": "c3_3",
"number": 3,
"direction": "ACROSS",
"word": "돌다리",
"clue": "돌로 놓은 다리. 신중함을 뜻하는 속담 소재.",
"startRow": 3,
"startCol": 3,
"length": 3
},
{
"id": "c3_4",
"number": 4,
"direction": "DOWN",
"word": "다락방",
"clue": "지붕 바로 밑이나 천장 위에 꾸며 놓은 방.",
"startRow": 3,
"startCol": 4,
"length": 3
}
]
},
{
"stageNumber": 4,
"title": "문화와 예술",
"gridSize": 6,
"difficulty": 2,
"clues": [
{
"id": "c4_1",
"number": 1,
"direction": "ACROSS",
"word": "방방곡곡",
"clue": "전국 방방곡곡, 한 곳도 빠짐없는 모든 곳.",
"startRow": 1,
"startCol": 0,
"length": 4
},
{
"id": "c4_2",
"number": 2,
"direction": "DOWN",
"word": "곡예사",
"clue": "서커스나 줄타기 따위 곡예를 하는 사람.",
"startRow": 1,
"startCol": 3,
"length": 3
},
{
"id": "c4_3",
"number": 3,
"direction": "ACROSS",
"word": "고사성어",
"clue": "옛날 이야기나 고사에서 유래된 사자성어 및 숙어.",
"startRow": 3,
"startCol": 2,
"length": 4
},
{
"id": "c4_4",
"number": 4,
"direction": "DOWN",
"word": "어린이",
"clue": "어린아이를 대접하여 부르는 말.",
"startRow": 3,
"startCol": 5,
"length": 3
},
{
"id": "c4_5",
"number": 5,
"direction": "ACROSS",
"word": "호랑이",
"clue": "고양이과의 사나운 맹수, 산군.",
"startRow": 5,
"startCol": 3,
"length": 3
}
]
}
]
+162
View File
@@ -0,0 +1,162 @@
[
{
"id": "w1",
"word": "가을하늘",
"meaning": "가을철의 맑고 푸른 하늘.",
"category": "자연",
"difficulty": 1,
"example": "가을하늘이 높고 푸르다."
},
{
"id": "w2",
"word": "하늘나라",
"meaning": "천국을 이르는 말.",
"category": "일반",
"difficulty": 1,
"example": "하늘나라에서 행복하게 지내렴."
},
{
"id": "w3",
"word": "나비효과",
"meaning": "미세한 변화나 사건이 엄청난 결과를 낳는 현상.",
"category": "상식/과학",
"difficulty": 2,
"example": "작은 습관이 큰 나비효과를 가져왔다."
},
{
"id": "w4",
"word": "과일가게",
"meaning": "여러 가지 과일을 파는 상점.",
"category": "일상",
"difficulty": 1,
"example": "과일가게에서 싱싱한 사과를 샀다."
},
{
"id": "w5",
"word": "가면무도회",
"meaning": "참가자가 가면을 쓰고 춤을 추는 연회.",
"category": "문화",
"difficulty": 2,
"example": "화려한 가면무도회가 열렸다."
},
{
"id": "w6",
"word": "무지개",
"meaning": "비가 갠 후 하늘에 나타나는 일곱 가지 빛깔의 호선.",
"category": "자연",
"difficulty": 1,
"example": "소나기가 그치고 하늘에 무지개가 떴다."
},
{
"id": "w7",
"word": "개구쟁이",
"meaning": "궂은 장난을 자주 치는 아이를 귀엽게 이르는 말.",
"category": "일상",
"difficulty": 1,
"example": "우리 집 막내는 소문난 개구쟁이다."
},
{
"id": "w8",
"word": "일석이조",
"meaning": "돌 하나를 던져 새 두 마리를 잡는다는 뜻으로, 한 가지 일로 두 가지 이익을 얻음을 이르는 사자성어.",
"category": "사자성어",
"difficulty": 2,
"example": "건강도 챙기고 돈도 아끼니 일석이조다."
},
{
"id": "w9",
"word": "조약돌",
"meaning": "물에 씻기어 둥글고 반드러워진 작은 돌.",
"category": "자연",
"difficulty": 1,
"example": "시냇가에서 예쁜 조약돌을 주웠다."
},
{
"id": "w10",
"word": "돌다리",
"meaning": "돌로 놓은 다리. 또는 '돌다리도 두들겨 보고 건너라'는 속담의 소재.",
"category": "일상",
"difficulty": 1,
"example": "돌다리도 두들겨 보고 건너는 신중함이 필요하다."
},
{
"id": "w11",
"word": "다락방",
"meaning": "지붕 바로 밑이나 천장 위에 꾸며 놓은 방.",
"category": "건축",
"difficulty": 1,
"example": "어릴 적 다락방에서 보물찾기를 했다."
},
{
"id": "w12",
"word": "방방곡곡",
"meaning": "한 곳도 빠짐없는 모든 곳. 전국 곳곳.",
"category": "사자성어",
"difficulty": 2,
"example": "기쁜 소식이 전국 방방곡곡에 퍼졌다."
},
{
"id": "w13",
"word": "곡예사",
"meaning": "서커스나 줄타기 따위의 곡예를 전문으로 하는 사람.",
"category": "직업",
"difficulty": 2,
"example": "곡예사의 아슬아슬한 묘기에 박수가 쏟아졌다."
},
{
"id": "w14",
"word": "사람인",
"meaning": "한자의 하나(人). 두 사람이 서로 기대고 있는 형상.",
"category": "한자",
"difficulty": 1,
"example": "사람 인 자는 서로 돕는 형상이다."
},
{
"id": "w15",
"word": "인사동",
"meaning": "서울 종로구에 위치한 전통 문화와 예술의 거리.",
"category": "지리",
"difficulty": 1,
"example": "주말에 인사동 쌈지길을 구경했다."
},
{
"id": "w16",
"word": "동고동락",
"meaning": "괴로움도 즐거움도 함께한다는 뜻의 사자성어.",
"category": "사자성어",
"difficulty": 2,
"example": "우리는 오랜 세월 동고동락한 사이다."
},
{
"id": "w17",
"word": "락앤롤",
"meaning": "1950년대 미국에서 발생한 대중음악의 한 장르.",
"category": "음악",
"difficulty": 2,
"example": "신나는 락앤롤 음악에 맞춰 몸을 흔들었다."
},
{
"id": "w18",
"word": "롤러코스터",
"meaning": "놀이공원의 레일 위를 빠르게 질주하는 놀이기구.",
"category": "놀이",
"difficulty": 1,
"example": "롤러코스터를 타며 시원하게 소리를 질렀다."
},
{
"id": "w19",
"word": "터줏대감",
"meaning": "어떤 집단이나 장소에 오래전부터 자리 잡고 큰 영향력을 가진 사람.",
"category": "관용구",
"difficulty": 2,
"example": "그는 이 동네의 터줏대감이다."
},
{
"id": "w20",
"word": "감자튀김",
"meaning": "감자를 썰어 기름에 튀겨낸 서양 음식.",
"category": "음식",
"difficulty": 1,
"example": "햄버거와 바삭한 감자튀김을 함께 주문했다."
}
]
@@ -0,0 +1,120 @@
package com.wordpuzzle.hangul
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.wordpuzzle.hangul.data.datastore.UserPreferencesManager
import com.wordpuzzle.hangul.data.repository.WordRepository
import com.wordpuzzle.hangul.ui.game.GameScreen
import com.wordpuzzle.hangul.ui.main.MainLobbyScreen
import com.wordpuzzle.hangul.ui.main.VocabularyScreen
import com.wordpuzzle.hangul.ui.settings.SettingsScreen
import com.wordpuzzle.hangul.ui.theme.WordPuzzleTheme
import androidx.lifecycle.lifecycleScope
import com.wordpuzzle.hangul.data.remote.RemoteConfigManager
import com.wordpuzzle.hangul.data.repository.StageRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Firebase Remote Config 초기화 및 백그라운드 동기화
RemoteConfigManager.init(this)
lifecycleScope.launch(Dispatchers.IO) {
StageRepository(this@MainActivity).syncRemoteStages()
}
val prefs = UserPreferencesManager(this)
val wordRepo = WordRepository(this)
setContent {
WordPuzzleTheme {
Surface(modifier = Modifier.fillMaxSize()) {
WordPuzzleAppNavHost(prefs = prefs, wordRepo = wordRepo)
}
}
}
}
}
@Composable
fun WordPuzzleAppNavHost(
prefs: UserPreferencesManager,
wordRepo: WordRepository
) {
val navController = rememberNavController()
val currentUnlockedStage by prefs.currentStageFlow.collectAsState(initial = 1)
val hintCount by prefs.hintCountFlow.collectAsState(initial = 3)
NavHost(
navController = navController,
startDestination = "lobby"
) {
composable("lobby") {
MainLobbyScreen(
currentUnlockedStage = currentUnlockedStage,
hintCount = hintCount,
onSelectStage = { stageNum ->
navController.navigate("game/$stageNum")
},
onNavigateToVocabulary = {
navController.navigate("vocabulary")
},
onNavigateToSettings = {
navController.navigate("settings")
},
wordRepo = wordRepo,
prefs = prefs
)
}
composable(
route = "game/{stageNumber}",
arguments = listOf(navArgument("stageNumber") { type = NavType.IntType })
) { backStackEntry ->
val stageNumber = backStackEntry.arguments?.getInt("stageNumber") ?: 1
GameScreen(
stageNumber = stageNumber,
onNavigateBack = {
navController.popBackStack()
},
onNavigateToNextStage = { nextStage ->
navController.navigate("game/$nextStage") {
popUpTo("lobby")
}
}
)
}
composable("vocabulary") {
VocabularyScreen(
onNavigateBack = {
navController.popBackStack()
},
wordRepo = wordRepo
)
}
composable("settings") {
SettingsScreen(
onNavigateBack = {
navController.popBackStack()
},
prefs = prefs
)
}
}
}
@@ -0,0 +1,32 @@
package com.wordpuzzle.hangul
import android.app.Application
import com.wordpuzzle.hangul.ads.AdMobManager
import com.wordpuzzle.hangul.data.datastore.UserPreferencesManager
import com.wordpuzzle.hangul.data.repository.WordRepository
import com.wordpuzzle.hangul.service.WordNotificationManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class WordPuzzleApp : Application() {
override fun onCreate() {
super.onCreate()
// AdMob 초기화
AdMobManager.initialize(this)
// 초기 사전 데이터베이스 세팅 및 스마트 고정 알림 복원
CoroutineScope(Dispatchers.IO).launch {
val wordRepo = WordRepository(this@WordPuzzleApp)
wordRepo.initDefaultWordsIfNeeded()
val prefs = UserPreferencesManager(this@WordPuzzleApp)
val isNotiEnabled = prefs.lockscreenEnabledFlow.first()
if (isNotiEnabled) {
WordNotificationManager.showWordNotification(this@WordPuzzleApp)
}
}
}
}
@@ -0,0 +1,151 @@
package com.wordpuzzle.hangul.ads
import android.app.Activity
import android.content.Context
import android.util.Log
import com.google.android.gms.ads.*
import com.google.android.gms.ads.interstitial.InterstitialAd
import com.google.android.gms.ads.interstitial.InterstitialAdLoadCallback
import com.google.android.gms.ads.rewarded.RewardedAd
import com.google.android.gms.ads.rewarded.RewardedAdLoadCallback
object AdMobManager {
private const val TAG = "AdMobManager"
// 구글 AdMob 공식 테스트 광고 단위 ID
const val TEST_BANNER_ID = "ca-app-pub-5498177170320650/4758380884"
const val TEST_INTERSTITIAL_ID = "ca-app-pub-5498177170320650/2132217547"
const val TEST_REWARDED_ID = "ca-app-pub-5498177170320650/1587448755"
private var interstitialAd: InterstitialAd? = null
private var rewardedAd: RewardedAd? = null
private var isRewardedLoading = false
private var isInterstitialLoading = false
fun initialize(context: Context) {
MobileAds.initialize(context) { status ->
Log.d(TAG, "AdMob SDK Initialized: $status")
loadRewardedAd(context)
loadInterstitialAd(context)
}
}
/**
* 보상형 광고(힌트 획득용) 로드
*/
fun loadRewardedAd(context: Context) {
if (rewardedAd != null || isRewardedLoading) return
isRewardedLoading = true
val adRequest = AdRequest.Builder().build()
RewardedAd.load(
context,
TEST_REWARDED_ID,
adRequest,
object : RewardedAdLoadCallback() {
override fun onAdLoaded(ad: RewardedAd) {
rewardedAd = ad
isRewardedLoading = false
Log.d(TAG, "Rewarded ad loaded successfully.")
}
override fun onAdFailedToLoad(error: LoadAdError) {
rewardedAd = null
isRewardedLoading = false
Log.w(TAG, "Rewarded ad failed to load: ${error.message}")
}
}
)
}
/**
* 보상형 광고 노출 및 보상 지급 콜백
*/
fun showRewardedAd(
activity: Activity,
onUserEarnedReward: () -> Unit,
onAdClosed: () -> Unit = {}
) {
val currentAd = rewardedAd
if (currentAd != null) {
currentAd.fullScreenContentCallback = object : FullScreenContentCallback() {
override fun onAdDismissedFullScreenContent() {
rewardedAd = null
loadRewardedAd(activity)
onAdClosed()
}
override fun onAdFailedToShowFullScreenContent(error: AdError) {
rewardedAd = null
loadRewardedAd(activity)
onAdClosed()
}
}
currentAd.show(activity) { _ ->
Log.d(TAG, "User earned reward!")
onUserEarnedReward()
}
} else {
Log.d(TAG, "Rewarded ad was not ready, reloading...")
loadRewardedAd(activity)
// 테스트 편의상 광고가 아직 로드되지 않은 경우 바로 보상 처리하거나 안내
onUserEarnedReward()
onAdClosed()
}
}
/**
* 전면 광고(스테이지 완료 시) 로드
*/
fun loadInterstitialAd(context: Context) {
if (interstitialAd != null || isInterstitialLoading) return
isInterstitialLoading = true
val adRequest = AdRequest.Builder().build()
InterstitialAd.load(
context,
TEST_INTERSTITIAL_ID,
adRequest,
object : InterstitialAdLoadCallback() {
override fun onAdLoaded(ad: InterstitialAd) {
interstitialAd = ad
isInterstitialLoading = false
Log.d(TAG, "Interstitial ad loaded successfully.")
}
override fun onAdFailedToLoad(error: LoadAdError) {
interstitialAd = null
isInterstitialLoading = false
Log.w(TAG, "Interstitial ad failed to load: ${error.message}")
}
}
)
}
/**
* 전면 광고 노출
*/
fun showInterstitialAd(activity: Activity, onClosed: () -> Unit = {}) {
val currentAd = interstitialAd
if (currentAd != null) {
currentAd.fullScreenContentCallback = object : FullScreenContentCallback() {
override fun onAdDismissedFullScreenContent() {
interstitialAd = null
loadInterstitialAd(activity)
onClosed()
}
override fun onAdFailedToShowFullScreenContent(error: AdError) {
interstitialAd = null
loadInterstitialAd(activity)
onClosed()
}
}
currentAd.show(activity)
} else {
loadInterstitialAd(activity)
onClosed()
}
}
}
@@ -0,0 +1,159 @@
package com.wordpuzzle.hangul.data.datastore
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore
import com.wordpuzzle.hangul.data.model.GameMode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_preferences")
class UserPreferencesManager(private val context: Context) {
companion object {
const val MAX_DAILY_AD_HINTS = 5
private val KEY_CURRENT_STAGE = intPreferencesKey("current_stage")
private val KEY_HINT_COUNT = intPreferencesKey("hint_count")
private val KEY_LAST_AD_DATE = stringPreferencesKey("last_ad_date")
private val KEY_DAILY_AD_WATCH_COUNT = intPreferencesKey("daily_ad_watch_count")
private val KEY_GAME_MODE = stringPreferencesKey("game_mode")
private val KEY_LOCKSCREEN_ENABLED = booleanPreferencesKey("lockscreen_enabled")
private val KEY_SOUND_ENABLED = booleanPreferencesKey("sound_enabled")
private val KEY_VIBRATION_ENABLED = booleanPreferencesKey("vibration_enabled")
private val KEY_PRE_LEARNED_WORDS = stringSetPreferencesKey("pre_learned_words")
}
private fun getTodayDateString(): String {
return SimpleDateFormat("yyyy-MM-dd", Locale.KOREA).format(Date())
}
val currentStageFlow: Flow<Int> = context.dataStore.data
.catch { emit(emptyPreferences()) }
.map { preferences -> preferences[KEY_CURRENT_STAGE] ?: 1 }
val hintCountFlow: Flow<Int> = context.dataStore.data
.catch { emit(emptyPreferences()) }
.map { preferences -> preferences[KEY_HINT_COUNT] ?: 3 }
val dailyAdWatchInfoFlow: Flow<Pair<Int, Int>> = context.dataStore.data
.catch { emit(emptyPreferences()) }
.map { preferences ->
val today = getTodayDateString()
val lastDate = preferences[KEY_LAST_AD_DATE] ?: ""
val count = if (lastDate == today) {
preferences[KEY_DAILY_AD_WATCH_COUNT] ?: 0
} else {
0 // 새로운 날짜면 리셋
}
Pair(count, MAX_DAILY_AD_HINTS)
}
val gameModeFlow: Flow<GameMode> = context.dataStore.data
.catch { emit(emptyPreferences()) }
.map { preferences ->
val modeStr = preferences[KEY_GAME_MODE] ?: GameMode.EASY.name
try {
GameMode.valueOf(modeStr)
} catch (e: Exception) {
GameMode.EASY
}
}
val lockscreenEnabledFlow: Flow<Boolean> = context.dataStore.data
.catch { emit(emptyPreferences()) }
.map { preferences -> preferences[KEY_LOCKSCREEN_ENABLED] ?: false }
val soundEnabledFlow: Flow<Boolean> = context.dataStore.data
.catch { emit(emptyPreferences()) }
.map { preferences -> preferences[KEY_SOUND_ENABLED] ?: true }
val vibrationEnabledFlow: Flow<Boolean> = context.dataStore.data
.catch { emit(emptyPreferences()) }
.map { preferences -> preferences[KEY_VIBRATION_ENABLED] ?: true }
val preLearnedWordsFlow: Flow<Set<String>> = context.dataStore.data
.catch { emit(emptyPreferences()) }
.map { preferences -> preferences[KEY_PRE_LEARNED_WORDS] ?: emptySet() }
suspend fun setStageCleared(clearedStage: Int) {
context.dataStore.edit { preferences ->
val current = preferences[KEY_CURRENT_STAGE] ?: 1
if (clearedStage >= current) {
preferences[KEY_CURRENT_STAGE] = clearedStage + 1
}
}
}
suspend fun useHint(): Boolean {
var success = false
context.dataStore.edit { preferences ->
val currentHints = preferences[KEY_HINT_COUNT] ?: 3
if (currentHints > 0) {
preferences[KEY_HINT_COUNT] = currentHints - 1
success = true
}
}
return success
}
suspend fun addHintFromAd(): Boolean {
var rewarded = false
val today = getTodayDateString()
context.dataStore.edit { preferences ->
val lastDate = preferences[KEY_LAST_AD_DATE] ?: ""
val currentCount = if (lastDate == today) {
preferences[KEY_DAILY_AD_WATCH_COUNT] ?: 0
} else {
0
}
if (currentCount < MAX_DAILY_AD_HINTS) {
preferences[KEY_LAST_AD_DATE] = today
preferences[KEY_DAILY_AD_WATCH_COUNT] = currentCount + 1
val currentHints = preferences[KEY_HINT_COUNT] ?: 3
preferences[KEY_HINT_COUNT] = currentHints + 1
rewarded = true
}
}
return rewarded
}
suspend fun setGameMode(mode: GameMode) {
context.dataStore.edit { preferences ->
preferences[KEY_GAME_MODE] = mode.name
}
}
suspend fun setLockscreenEnabled(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[KEY_LOCKSCREEN_ENABLED] = enabled
}
}
suspend fun setSoundEnabled(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[KEY_SOUND_ENABLED] = enabled
}
}
suspend fun setVibrationEnabled(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[KEY_VIBRATION_ENABLED] = enabled
}
}
suspend fun markWordAsPreLearned(wordId: String) {
context.dataStore.edit { preferences ->
val currentSet = preferences[KEY_PRE_LEARNED_WORDS]?.toMutableSet() ?: mutableSetOf()
currentSet.add(wordId)
preferences[KEY_PRE_LEARNED_WORDS] = currentSet
}
}
}
@@ -0,0 +1,67 @@
package com.wordpuzzle.hangul.data.local
import android.content.Context
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Entity(tableName = "words")
data class WordEntity(
@PrimaryKey val id: String,
val word: String,
val meaning: String,
val category: String,
val difficulty: Int,
val example: String,
val isLearned: Boolean = false,
val isPreLearned: Boolean = false,
val learnedTimestamp: Long = 0L
)
@Dao
interface WordDao {
@Query("SELECT * FROM words ORDER BY id ASC")
fun getAllWordsFlow(): Flow<List<WordEntity>>
@Query("SELECT * FROM words WHERE id = :id LIMIT 1")
suspend fun getWordById(id: String): WordEntity?
@Query("SELECT * FROM words WHERE isLearned = 1 ORDER BY learnedTimestamp DESC")
fun getLearnedWordsFlow(): Flow<List<WordEntity>>
@Query("SELECT * FROM words WHERE isLearned = 0 ORDER BY difficulty ASC LIMIT :limit")
suspend fun getWordsForPreLearning(limit: Int): List<WordEntity>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAll(words: List<WordEntity>)
@Update
suspend fun update(word: WordEntity)
@Query("UPDATE words SET isLearned = 1, learnedTimestamp = :timestamp WHERE word = :wordText")
suspend fun markWordLearned(wordText: String, timestamp: Long)
@Query("UPDATE words SET isPreLearned = 1 WHERE id = :id")
suspend fun markWordPreLearned(id: String)
}
@Database(entities = [WordEntity::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun wordDao(): WordDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"wordpuzzle_db"
).build()
INSTANCE = instance
instance
}
}
}
}
@@ -0,0 +1,93 @@
package com.wordpuzzle.hangul.data.model
enum class Direction {
ACROSS, // 가로
DOWN // 세로
}
enum class GameMode(val displayName: String, val description: String) {
STANDARD("기본 모드 (단어 수만큼 시작 힌트)", "단어 수만큼 시작 글자가 미리 자동 배치됩니다."),
EASY("쉬운 모드 (많은 시작 힌트)", "더 많은 시작 글자가 미리 자동 배치됩니다."),
HARD("도전 모드 (시작 힌트 없음)", "시작 힌트 없이 모든 칸을 직접 채웁니다.");
companion object {
val NORMAL = STANDARD
}
}
data class KoreanWord(
val id: String,
val word: String,
val meaning: String,
val category: String,
val difficulty: Int,
val example: String,
val isLearned: Boolean = false
)
data class CrosswordClue(
val id: String,
val number: Int,
val direction: Direction,
val word: String,
val clue: String,
val startRow: Int,
val startCol: Int,
val length: Int
)
data class CrosswordCell(
val row: Int,
val col: Int,
val correctChar: Char,
val userChar: Char? = null,
val isBlank: Boolean = false, // false이면 퍼즐 칸, true이면 빈 공간(검은 칸)
val clueNumberAcross: Int? = null,
val clueNumberDown: Int? = null,
val isRevealedByHint: Boolean = false,
val isFocused: Boolean = false,
val isHighlightedWord: Boolean = false
) {
val isCorrect: Boolean
get() = !isBlank && userChar == correctChar
val isFilled: Boolean
get() = !isBlank && userChar != null
}
data class TileItem(
val id: String,
val character: Char,
val isUsed: Boolean = false,
val isDistractor: Boolean = false
)
data class CrosswordStage(
val stageNumber: Int,
val title: String,
val gridSize: Int,
val difficulty: Int,
val clues: List<CrosswordClue>,
val grid: List<List<CrosswordCell>>,
val tilePool: List<TileItem>,
val starterLettersCount: Int = 0
)
data class StageJsonData(
val stageNumber: Int,
val title: String,
val gridSize: Int,
val difficulty: Int,
val clues: List<ClueJsonData>
)
data class ClueJsonData(
val id: String,
val number: Int,
val direction: String,
val word: String,
val clue: String,
val startRow: Int,
val startCol: Int,
val length: Int
)
@@ -0,0 +1,86 @@
package com.wordpuzzle.hangul.data.remote
import android.content.Context
import android.util.Log
import com.google.firebase.FirebaseApp
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
object RemoteConfigManager {
private const val TAG = "RemoteConfigManager"
private const val PREFS_NAME = "wordpuzzle_remote_config"
private const val KEY_CACHED_STAGES_JSON = "cached_stages_json"
const val REMOTE_KEY_STAGES_JSON = "stages_json"
private var isInitialized = false
/**
* Firebase Remote Config 초기화 및 기본값 설정
*/
fun init(context: Context) {
if (isInitialized) return
try {
if (FirebaseApp.getApps(context).isEmpty()) {
Log.w(TAG, "FirebaseApp is not initialized yet. Using local fallback.")
return
}
val remoteConfig = FirebaseRemoteConfig.getInstance()
val configSettings = FirebaseRemoteConfigSettings.Builder()
.setMinimumFetchIntervalInSeconds(3600) // 1시간 (개발 중 테스트 시 0으로 조정 가능)
.build()
remoteConfig.setConfigSettingsAsync(configSettings)
isInitialized = true
} catch (e: Exception) {
Log.e(TAG, "Failed to init Firebase Remote Config", e)
}
}
/**
* 최신 원격 설정을 fetch & activate 하고, 유효한 JSON이면 로컬 SharedPreferences에 캐시합니다.
*/
suspend fun fetchAndSaveStages(context: Context): Boolean = withContext(Dispatchers.IO) {
try {
if (FirebaseApp.getApps(context).isEmpty()) return@withContext false
val remoteConfig = FirebaseRemoteConfig.getInstance()
var success = false
remoteConfig.fetchAndActivate().addOnCompleteListener { task ->
if (task.isSuccessful) {
val remoteJson = remoteConfig.getString(REMOTE_KEY_STAGES_JSON)
if (remoteJson.isNotBlank() && remoteJson.startsWith("[")) {
saveToLocalCache(context, remoteJson)
Log.d(TAG, "Successfully fetched and cached remote stages.json (${remoteJson.length} bytes)")
success = true
}
} else {
Log.w(TAG, "RemoteConfig fetch failed", task.exception)
}
}
success
} catch (e: Exception) {
Log.e(TAG, "Error fetching Remote Config", e)
false
}
}
/**
* 캐시된 최신 stages.json 텍스트를 반환합니다. (없으면 null)
*/
fun getCachedStagesJson(context: Context): String? {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString(KEY_CACHED_STAGES_JSON, null)
}
/**
* 로컬 SharedPreferences에 stages.json 텍스트를 안전하게 저장합니다.
*/
fun saveToLocalCache(context: Context, json: String) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(KEY_CACHED_STAGES_JSON, json).apply()
}
}
@@ -0,0 +1,142 @@
package com.wordpuzzle.hangul.data.repository
import android.content.Context
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.wordpuzzle.hangul.data.model.*
import com.wordpuzzle.hangul.domain.engine.TilePoolGenerator
import com.wordpuzzle.hangul.data.remote.RemoteConfigManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class StageRepository(private val context: Context) {
private val gson = Gson()
private var cachedStages: List<StageJsonData>? = null
/**
* 원격 캐시된 stages.json을 최우선으로 로드하고, 없을 경우 assets/data/stages.json을 Fallback으로 사용합니다.
*/
private suspend fun loadStagesJson(): List<StageJsonData> = withContext(Dispatchers.IO) {
cachedStages?.let { return@withContext it }
// 1. Remote Config 로컬 캐시 확인
val cachedRemoteJson = RemoteConfigManager.getCachedStagesJson(context)
if (!cachedRemoteJson.isNullOrBlank()) {
try {
val type = object : TypeToken<List<StageJsonData>>() {}.type
val list: List<StageJsonData> = gson.fromJson(cachedRemoteJson, type)
if (list.isNotEmpty()) {
cachedStages = list
return@withContext list
}
} catch (e: Exception) {
e.printStackTrace()
}
}
// 2. Fallback: 내장 assets JSON 로드
try {
val jsonString = context.assets.open("data/stages.json").bufferedReader().use { it.readText() }
val type = object : TypeToken<List<StageJsonData>>() {}.type
val list: List<StageJsonData> = gson.fromJson(jsonString, type)
cachedStages = list
list
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
/**
* 백그라운드에서 Remote Config를 최신화하고 메모리 캐시를 갱신합니다.
*/
suspend fun syncRemoteStages() {
val updated = RemoteConfigManager.fetchAndSaveStages(context)
if (updated) {
cachedStages = null // 다음 loadStagesJson 시 새 데이터 반영
}
}
suspend fun getTotalStageCount(): Int {
return loadStagesJson().size
}
/**
* 스테이지의 순수 그리드 템플릿(글자가 채워지지 않은 상태)을 생성합니다.
*/
suspend fun getCleanStageTemplate(stageNumber: Int): Pair<List<List<CrosswordCell>>, List<CrosswordClue>>? = withContext(Dispatchers.Default) {
val stages = loadStagesJson()
val stageData = stages.find { it.stageNumber == stageNumber } ?: stages.firstOrNull() ?: return@withContext null
val gridSize = stageData.gridSize
val matrix = Array(gridSize) { r ->
Array(gridSize) { c ->
CrosswordCell(row = r, col = c, correctChar = ' ', isBlank = true)
}
}
val domainClues = mutableListOf<CrosswordClue>()
for (clueJson in stageData.clues) {
val direction = if (clueJson.direction.equals("ACROSS", ignoreCase = true)) Direction.ACROSS else Direction.DOWN
val domainClue = CrosswordClue(
id = clueJson.id,
number = clueJson.number,
direction = direction,
word = clueJson.word,
clue = clueJson.clue,
startRow = clueJson.startRow,
startCol = clueJson.startCol,
length = clueJson.length
)
domainClues.add(domainClue)
for (i in clueJson.word.indices) {
val r = if (direction == Direction.ACROSS) clueJson.startRow else clueJson.startRow + i
val c = if (direction == Direction.ACROSS) clueJson.startCol + i else clueJson.startCol
if (r in 0 until gridSize && c in 0 until gridSize) {
val existing = matrix[r][c]
val isFirstCell = (i == 0)
val acrossNum = if (direction == Direction.ACROSS && isFirstCell) clueJson.number else existing.clueNumberAcross
val downNum = if (direction == Direction.DOWN && isFirstCell) clueJson.number else existing.clueNumberDown
matrix[r][c] = CrosswordCell(
row = r,
col = c,
correctChar = clueJson.word[i],
userChar = null,
isBlank = false,
clueNumberAcross = acrossNum,
clueNumberDown = downNum
)
}
}
}
Pair(matrix.map { it.toList() }.toList(), domainClues)
}
suspend fun getStage(stageNumber: Int): CrosswordStage? = withContext(Dispatchers.Default) {
val template = getCleanStageTemplate(stageNumber) ?: return@withContext null
val (cleanGrid, domainClues) = template
val stages = loadStagesJson()
val stageData = stages.find { it.stageNumber == stageNumber } ?: stages.firstOrNull() ?: return@withContext null
// 랜덤으로 섞인 문자 중 단어 수(domainClues.size)만큼 임의로 선택하여 배치
val (initialGrid, initialTilePool) = TilePoolGenerator.generateLayoutWithStarterLetters(cleanGrid, domainClues)
CrosswordStage(
stageNumber = stageData.stageNumber,
title = stageData.title,
gridSize = stageData.gridSize,
difficulty = stageData.difficulty,
clues = domainClues,
grid = initialGrid,
tilePool = initialTilePool,
starterLettersCount = domainClues.size
)
}
}
@@ -0,0 +1,85 @@
package com.wordpuzzle.hangul.data.repository
import android.content.Context
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.wordpuzzle.hangul.data.local.AppDatabase
import com.wordpuzzle.hangul.data.local.WordEntity
import com.wordpuzzle.hangul.data.model.KoreanWord
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
class WordRepository(private val context: Context) {
private val db = AppDatabase.getInstance(context)
private val wordDao = db.wordDao()
private val gson = Gson()
suspend fun initDefaultWordsIfNeeded() = withContext(Dispatchers.IO) {
try {
val jsonString = context.assets.open("data/words.json").bufferedReader().use { it.readText() }
val type = object : TypeToken<List<KoreanWord>>() {}.type
val wordsList: List<KoreanWord> = gson.fromJson(jsonString, type)
val entities = wordsList.map {
WordEntity(
id = it.id,
word = it.word,
meaning = it.meaning,
category = it.category,
difficulty = it.difficulty,
example = it.example,
isLearned = false,
isPreLearned = false
)
}
wordDao.insertAll(entities)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun getAllLearnedWords(): Flow<List<KoreanWord>> {
return wordDao.getLearnedWordsFlow().map { entities ->
entities.map {
KoreanWord(
id = it.id,
word = it.word,
meaning = it.meaning,
category = it.category,
difficulty = it.difficulty,
example = it.example,
isLearned = it.isLearned
)
}
}
}
suspend fun getWordForWidget(): KoreanWord? = withContext(Dispatchers.IO) {
val candidates = wordDao.getWordsForPreLearning(1)
if (candidates.isNotEmpty()) {
val entity = candidates.first()
KoreanWord(
id = entity.id,
word = entity.word,
meaning = entity.meaning,
category = entity.category,
difficulty = entity.difficulty,
example = entity.example,
isLearned = entity.isLearned
)
} else {
// fallback
KoreanWord("def", "일석이조", "한 가지 일로 두 가지 이익을 얻음.", "사자성어", 1, "건강도 챙기고 돈도 아끼니 일석이조다.")
}
}
suspend fun markWordLearned(wordText: String) = withContext(Dispatchers.IO) {
wordDao.markWordLearned(wordText, System.currentTimeMillis())
}
suspend fun markWordPreLearned(id: String) = withContext(Dispatchers.IO) {
wordDao.markWordPreLearned(id)
}
}
@@ -0,0 +1,16 @@
package com.wordpuzzle.hangul.domain.engine
object HangulHelper {
private const val HANGUL_BASE = 0xAC00
private const val HANGUL_END = 0xD7A3
fun isHangulSyllable(c: Char): Boolean {
return c.code in HANGUL_BASE..HANGUL_END
}
// 유사하거나 헷갈리기 쉬운 더미 한글 음절 목록
val DISTRACTOR_CHARS = listOf(
'가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카', '타', '파', '하',
'별', '달', '해', '숲', '물', '빛', '꿈', '길', '꽃', '봄', '밤', '솔', '눈', '돌'
)
}
@@ -0,0 +1,67 @@
package com.wordpuzzle.hangul.domain.engine
import com.wordpuzzle.hangul.data.model.CrosswordCell
import com.wordpuzzle.hangul.data.model.CrosswordClue
import com.wordpuzzle.hangul.data.model.TileItem
import java.util.UUID
object TilePoolGenerator {
/**
* 스테이지 그리드와 단어 목록을 바탕으로,
* 1) 모든 글자 타일을 무작위로 섞고,
* 2) 섞인 문자 중 단어 수(clues.size)만큼을 임의로 선택하여 그리드의 맞는 빈 칸에 사전 배치합니다.
* 3) 새로고침 시에도 이 함수를 호출하여 다른 문자가 새롭게 임의 선택되어 배치됩니다.
*/
fun generateLayoutWithStarterLetters(
cleanGrid: List<List<CrosswordCell>>,
clues: List<CrosswordClue>
): Pair<List<List<CrosswordCell>>, List<TileItem>> {
val nonBlankCells = mutableListOf<CrosswordCell>()
for (row in cleanGrid) {
for (cell in row) {
if (!cell.isBlank) {
nonBlankCells.add(cell)
}
}
}
if (nonBlankCells.isEmpty()) {
return Pair(cleanGrid, emptyList())
}
// 1. 단어 수(clues.size)만큼 그리드의 빈 칸 위치(row, col)를 임의로 직접 선택
val starterCount = clues.size.coerceAtMost(nonBlankCells.size - 1).coerceAtLeast(1)
val selectedStarterPositions = nonBlankCells
.shuffled()
.take(starterCount)
.map { it.row to it.col }
.toSet()
// 2. 선택된 위치의 셀에 정확한 정답 글자(correctChar)를 사전 배치 (오배치 원천 차단)
val matrix = cleanGrid.map { row ->
row.map { cell ->
if (!cell.isBlank && (cell.row to cell.col) in selectedStarterPositions) {
cell.copy(userChar = cell.correctChar, isRevealedByHint = true, isFocused = false)
} else {
cell.copy(userChar = null, isRevealedByHint = false, isFocused = false)
}
}
}
// 3. 각 셀과 1:1 대응되는 타일 목록 생성 (사전 배치된 타일은 isUsed = true)
val tiles = nonBlankCells.map { cell ->
val isUsed = (cell.row to cell.col) in selectedStarterPositions
TileItem(
id = UUID.randomUUID().toString(),
character = cell.correctChar,
isUsed = isUsed
)
}
// 4. 타일 목록을 무작위로 섞어서 반환
val shuffledTiles = tiles.shuffled()
return Pair(matrix, shuffledTiles)
}
}
@@ -0,0 +1,381 @@
package com.wordpuzzle.hangul.domain.usecase
import com.wordpuzzle.hangul.data.model.*
import kotlin.math.abs
data class TileSolveResult(
val updatedStage: CrosswordStage,
val nextFocusedRow: Int,
val nextFocusedCol: Int,
val nextActiveClue: CrosswordClue?,
val isStageClear: Boolean
)
class SolveTileUseCase {
/**
* 쉬운 모드: 문자 타일을 터치했을 때, 정답인 빈 칸을 찾아 자동으로 삽입하고
* 다음 빈 칸(단어 내 다음 빈 칸 또는 가장 가까운 단어의 시작지점 기준 첫 빈 칸)으로 포커스를 자동 이동합니다.
*/
fun processEasyTileTap(
stage: CrosswordStage,
tile: TileItem,
activeClue: CrosswordClue? = null
): TileSolveResult? {
if (tile.isUsed) return null
var targetCell: Pair<Int, Int>? = null
var matchedClue: CrosswordClue? = activeClue
// 1. 현재 활성화된 힌트 단어 범위 내에 일치하는 빈 칸이 있는지 우선 탐색
if (activeClue != null) {
for (i in 0 until activeClue.length) {
val r = if (activeClue.direction == Direction.ACROSS) activeClue.startRow else activeClue.startRow + i
val c = if (activeClue.direction == Direction.ACROSS) activeClue.startCol + i else activeClue.startCol
if (r in stage.grid.indices && c in stage.grid[r].indices) {
val cell = stage.grid[r][c]
if (!cell.isBlank && cell.userChar == null && cell.correctChar == tile.character) {
targetCell = Pair(r, c)
matchedClue = activeClue
break
}
}
}
}
// 2. 전체 그리드에서 일치하는 첫 번째 빈 칸 탐색
if (targetCell == null) {
for (r in stage.grid.indices) {
for (c in stage.grid[r].indices) {
val cell = stage.grid[r][c]
if (!cell.isBlank && cell.userChar == null && cell.correctChar == tile.character) {
targetCell = Pair(r, c)
matchedClue = stage.clues.firstOrNull { isCellInClue(r, c, it) } ?: activeClue
break
}
}
if (targetCell != null) break
}
}
if (targetCell == null) return null // 들어갈 자리가 없음
val (tr, tc) = targetCell
// 타일 삽입 그리드 생성
val newGrid = stage.grid.mapIndexed { r, row ->
row.mapIndexed { c, cell ->
if (r == tr && c == tc) {
cell.copy(userChar = tile.character)
} else {
cell
}
}
}
val newTilePool = stage.tilePool.map {
if (it.id == tile.id) it.copy(isUsed = true) else it
}
val intermediateStage = stage.copy(grid = newGrid, tilePool = newTilePool)
val isClear = checkStageClear(intermediateStage)
// 3. 다음 포커스 위치 및 활성 단어 결정 (단어 완성 시 가장 가까운 단어의 시작지점 기준 첫 빈 칸으로 이동)
val (nextR, nextC, nextClue) = findNextFocusCell(
grid = newGrid,
clues = stage.clues,
currentR = tr,
currentC = tc,
currentClue = matchedClue
)
// 최종 그리드에 포커스 상태 반영
val finalGrid = newGrid.mapIndexed { r, row ->
row.mapIndexed { c, cell ->
cell.copy(isFocused = (r == nextR && c == nextC))
}
}
return TileSolveResult(
updatedStage = intermediateStage.copy(grid = finalGrid),
nextFocusedRow = nextR,
nextFocusedCol = nextC,
nextActiveClue = nextClue,
isStageClear = isClear
)
}
/**
* 일반 모드: 선택된 빈 칸에 타일 문자를 삽입하고,
* 해당 단어의 다음 빈 칸으로 이동하거나 단어가 완성되면 가장 가까운 단어의 시작지점 기준 첫 빈 칸으로 자동 이동합니다.
*/
fun processNormalTileTap(
stage: CrosswordStage,
tile: TileItem,
focusedRow: Int,
focusedCol: Int,
activeClue: CrosswordClue?
): TileSolveResult? {
if (tile.isUsed) return null
if (focusedRow !in stage.grid.indices || focusedCol !in stage.grid[0].indices) return null
val targetCell = stage.grid[focusedRow][focusedCol]
if (targetCell.isBlank || targetCell.isRevealedByHint) return null
// 만약 기존에 입력된 글자가 있다면 타일 풀에서 복구
val restoredPool = if (targetCell.userChar != null) {
val oldChar = targetCell.userChar
var restored = false
stage.tilePool.map {
if (!restored && it.character == oldChar && it.isUsed) {
restored = true
it.copy(isUsed = false)
} else it
}
} else {
stage.tilePool
}
// 새 타일 사용 처리
val newTilePool = restoredPool.map {
if (it.id == tile.id) it.copy(isUsed = true) else it
}
// 글자 삽입
val newGrid = stage.grid.mapIndexed { r, row ->
row.mapIndexed { c, cell ->
if (r == focusedRow && c == focusedCol) {
cell.copy(userChar = tile.character)
} else {
cell
}
}
}
val intermediateStage = stage.copy(grid = newGrid, tilePool = newTilePool)
val isClear = checkStageClear(intermediateStage)
// 다음 포커스 칸 및 단어 찾기
val (nextR, nextC, nextClue) = findNextFocusCell(
grid = newGrid,
clues = stage.clues,
currentR = focusedRow,
currentC = focusedCol,
currentClue = activeClue
)
val finalGrid = newGrid.mapIndexed { r, row ->
row.mapIndexed { c, cell ->
cell.copy(isFocused = (r == nextR && c == nextC))
}
}
return TileSolveResult(
updatedStage = intermediateStage.copy(grid = finalGrid),
nextFocusedRow = nextR,
nextFocusedCol = nextC,
nextActiveClue = nextClue,
isStageClear = isClear
)
}
/**
* 다음 포커스 칸 찾기:
* 1) 현재 단어(activeClue) 내에 아직 채워지지 않은 다음 빈 칸이 있다면 그 칸으로 순차 이동
* 2) 현재 단어가 모두 완성되었다면, 아직 완성되지 않은 단어들 중 '단어 시작 지점(startRow, startCol)'이
* 현재 위치와 가장 가까운 단어를 선택하고, 그 단어의 '처음 비어있는 칸'으로 포커스를 이동합니다.
*/
private fun findNextFocusCell(
grid: List<List<CrosswordCell>>,
clues: List<CrosswordClue>,
currentR: Int,
currentC: Int,
currentClue: CrosswordClue?
): Triple<Int, Int, CrosswordClue?> {
// 1. 현재 단어 내의 다음 빈 칸 탐색 (현재 위치 이후부터 우선 탐색)
if (currentClue != null) {
val emptyInWord = mutableListOf<Pair<Int, Int>>()
for (i in 0 until currentClue.length) {
val r = if (currentClue.direction == Direction.ACROSS) currentClue.startRow else currentClue.startRow + i
val c = if (currentClue.direction == Direction.ACROSS) currentClue.startCol + i else currentClue.startCol
if (r in grid.indices && c in grid[r].indices) {
val cell = grid[r][c]
if (!cell.isBlank && cell.userChar == null) {
emptyInWord.add(Pair(r, c))
}
}
}
if (emptyInWord.isNotEmpty()) {
// 현재 칸 이후의 첫 번째 빈 칸 탐색
val nextAfterCurrent = emptyInWord.firstOrNull { (r, c) ->
if (currentClue.direction == Direction.ACROSS) (r == currentR && c > currentC)
else (c == currentC && r > currentR)
} ?: emptyInWord.first() // 없다면 단어 내 첫 번째 빈 칸
return Triple(nextAfterCurrent.first, nextAfterCurrent.second, currentClue)
}
}
// 2. 단어가 완성된 경우 ➔ 아직 완성되지 않은 단어 목록 탐색
val uncompletedClues = clues.filter { clue ->
var hasEmpty = false
for (i in 0 until clue.length) {
val r = if (clue.direction == Direction.ACROSS) clue.startRow else clue.startRow + i
val c = if (clue.direction == Direction.ACROSS) clue.startCol + i else clue.startCol
if (r in grid.indices && c in grid[r].indices) {
val cell = grid[r][c]
if (!cell.isBlank && cell.userChar == null) {
hasEmpty = true
break
}
}
}
hasEmpty
}
if (uncompletedClues.isNotEmpty()) {
// '단어 시작 지점(startRow, startCol)' 기준으로 현재 위치와 가장 가까운 단어 선택
val closestClue = uncompletedClues.minByOrNull { clue ->
abs(clue.startRow - currentR) + abs(clue.startCol - currentC)
} ?: uncompletedClues.first()
// 그 단어의 '처음 비어있는 칸' 찾기
for (i in 0 until closestClue.length) {
val r = if (closestClue.direction == Direction.ACROSS) closestClue.startRow else closestClue.startRow + i
val c = if (closestClue.direction == Direction.ACROSS) closestClue.startCol + i else closestClue.startCol
if (r in grid.indices && c in grid[r].indices) {
val cell = grid[r][c]
if (!cell.isBlank && cell.userChar == null) {
return Triple(r, c, closestClue)
}
}
}
}
// 모든 칸이 채워졌거나 fallback
return Triple(currentR, currentC, currentClue)
}
/**
* 채워진 셀 터치 시 글자 취소/삭제
*/
fun removeCellChar(
stage: CrosswordStage,
row: Int,
col: Int
): CrosswordStage {
if (row !in stage.grid.indices || col !in stage.grid[row].indices) {
return stage
}
val cell = stage.grid[row][col]
if (cell.isBlank || cell.userChar == null || cell.isRevealedByHint) {
return stage
}
val removedChar = cell.userChar
var restored = false
val newTilePool = stage.tilePool.map {
if (!restored && it.character == removedChar && it.isUsed) {
restored = true
it.copy(isUsed = false)
} else it
}
val newGrid = stage.grid.mapIndexed { r, rowList ->
rowList.mapIndexed { c, currentCell ->
if (r == row && c == col) {
currentCell.copy(userChar = null, isFocused = true)
} else {
currentCell
}
}
}
return stage.copy(grid = newGrid, tilePool = newTilePool)
}
/**
* 힌트 사용: 미완성된 셀 1개의 정답을 즉시 공개하고 다음 빈 칸으로 포커스 이동
*/
fun applyHint(stage: CrosswordStage, activeClue: CrosswordClue?): TileSolveResult? {
val emptyCells = mutableListOf<Pair<Int, Int>>()
for (r in stage.grid.indices) {
for (c in stage.grid[r].indices) {
val cell = stage.grid[r][c]
if (!cell.isBlank && !cell.isRevealedByHint && (!cell.isFilled || !cell.isCorrect)) {
emptyCells.add(Pair(r, c))
}
}
}
if (emptyCells.isEmpty()) return null
val (hr, hc) = emptyCells.random()
val targetCell = stage.grid[hr][hc]
val answerChar = targetCell.correctChar
var usedTile = false
val newTilePool = stage.tilePool.map {
if (!usedTile && it.character == answerChar && !it.isUsed) {
usedTile = true
it.copy(isUsed = true)
} else it
}
val newGrid = stage.grid.mapIndexed { r, rowList ->
rowList.mapIndexed { c, cell ->
if (r == hr && c == hc) {
cell.copy(userChar = answerChar, isRevealedByHint = true)
} else {
cell
}
}
}
val intermediateStage = stage.copy(grid = newGrid, tilePool = newTilePool)
val isClear = checkStageClear(intermediateStage)
val (nextR, nextC, nextClue) = findNextFocusCell(
grid = newGrid,
clues = stage.clues,
currentR = hr,
currentC = hc,
currentClue = activeClue
)
val finalGrid = newGrid.mapIndexed { r, row ->
row.mapIndexed { c, cell ->
cell.copy(isFocused = (r == nextR && c == nextC))
}
}
return TileSolveResult(
updatedStage = intermediateStage.copy(grid = finalGrid),
nextFocusedRow = nextR,
nextFocusedCol = nextC,
nextActiveClue = nextClue,
isStageClear = isClear
)
}
private fun checkStageClear(stage: CrosswordStage): Boolean {
for (row in stage.grid) {
for (cell in row) {
if (!cell.isBlank) {
if (cell.userChar == null || cell.userChar != cell.correctChar) {
return false
}
}
}
}
return true
}
private fun isCellInClue(r: Int, c: Int, clue: CrosswordClue): Boolean {
return if (clue.direction == Direction.ACROSS) {
r == clue.startRow && c in clue.startCol until (clue.startCol + clue.length)
} else {
c == clue.startCol && r in clue.startRow until (clue.startRow + clue.length)
}
}
}
@@ -0,0 +1,28 @@
package com.wordpuzzle.hangul.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.wordpuzzle.hangul.data.datastore.UserPreferencesManager
import com.wordpuzzle.hangul.ui.lockscreen.LockScreenActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class ScreenStateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_SCREEN_ON) {
val prefs = UserPreferencesManager(context)
CoroutineScope(Dispatchers.IO).launch {
val isEnabled = prefs.lockscreenEnabledFlow.first()
if (isEnabled) {
val lockIntent = Intent(context, LockScreenActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
context.startActivity(lockIntent)
}
}
}
}
}
@@ -0,0 +1,118 @@
package com.wordpuzzle.hangul.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.wordpuzzle.hangul.MainActivity
import com.wordpuzzle.hangul.data.local.AppDatabase
import com.wordpuzzle.hangul.data.model.KoreanWord
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
object WordNotificationManager {
const val CHANNEL_ID = "word_prelearning_channel"
const val NOTIFICATION_ID = 1001
const val ACTION_NEXT_WORD = "com.wordpuzzle.hangul.ACTION_NEXT_WORD"
private var currentWordIndex = 0
fun createNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = "대기/잠금화면 스마트 단어 알림"
val descriptionText = "잠금화면 및 상단바에 예습 단어 카드를 상시 표시합니다."
val importance = NotificationManager.IMPORTANCE_LOW
val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
description = descriptionText
setShowBadge(false)
}
val notificationManager: NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
suspend fun showWordNotification(context: Context, specificWord: KoreanWord? = null) = withContext(Dispatchers.IO) {
createNotificationChannel(context)
val word = specificWord ?: run {
val db = AppDatabase.getInstance(context)
val list = db.wordDao().getWordsForPreLearning(10)
if (list.isNotEmpty()) {
val entity = list[currentWordIndex % list.size]
KoreanWord(
id = entity.id,
word = entity.word,
meaning = entity.meaning,
category = entity.category,
difficulty = entity.difficulty,
example = entity.example
)
} else {
KoreanWord("def", "일석이조", "한 가지 일로 두 가지 이익을 얻음.", "사자성어", 1, "건강도 챙기고 돈도 아끼니 일석이조다.")
}
}
// 1. 앱 실행 인텐트 (알림 클릭 또는 [퍼즐 풀기] 버튼)
val mainIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val mainPendingIntent = PendingIntent.getActivity(
context,
0,
mainIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// 2. [다음 단어] 넘기기 버튼 인텐트 (브로드캐스트)
val nextWordIntent = Intent(context, WordNotificationReceiver::class.java).apply {
action = ACTION_NEXT_WORD
}
val nextWordPendingIntent = PendingIntent.getBroadcast(
context,
1,
nextWordIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// 잠금화면용 확장 스타일 알림 구성
val bigTextStyle = NotificationCompat.BigTextStyle()
.setBigContentTitle("💡 예습 단어: ${word.word} [${word.category}]")
.bigText("${word.meaning}\n\n예) “${word.example}”")
.setSummaryText("한글 십자말풀이 예습")
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("💡 예습 단어: ${word.word} [${word.category}]")
.setContentText(word.meaning)
.setStyle(bigTextStyle)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // 잠금화면에 전체 내용 표시
.setOngoing(true) // 상시 고정 알림
.setContentIntent(mainPendingIntent)
.addAction(android.R.drawable.ic_media_next, "다음 단어 ➡️", nextWordPendingIntent)
.addAction(android.R.drawable.ic_media_play, "퍼즐 풀기 🎮", mainPendingIntent)
try {
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, builder.build())
} catch (e: SecurityException) {
e.printStackTrace()
}
}
suspend fun showNextWord(context: Context) {
currentWordIndex++
showWordNotification(context)
}
fun cancelWordNotification(context: Context) {
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(NOTIFICATION_ID)
}
}
@@ -0,0 +1,28 @@
package com.wordpuzzle.hangul.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.wordpuzzle.hangul.data.datastore.UserPreferencesManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class WordNotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == WordNotificationManager.ACTION_NEXT_WORD) {
CoroutineScope(Dispatchers.IO).launch {
WordNotificationManager.showNextWord(context)
}
} else if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
val prefs = UserPreferencesManager(context)
CoroutineScope(Dispatchers.IO).launch {
val isEnabled = prefs.lockscreenEnabledFlow.first()
if (isEnabled) {
WordNotificationManager.showWordNotification(context)
}
}
}
}
}
@@ -0,0 +1,30 @@
package com.wordpuzzle.hangul.ui.common
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.google.android.gms.ads.AdRequest
import com.google.android.gms.ads.AdSize
import com.google.android.gms.ads.AdView
import com.wordpuzzle.hangul.ads.AdMobManager
@Composable
fun AdBannerView(
modifier: Modifier = Modifier
) {
AndroidView(
modifier = modifier
.fillMaxWidth()
.height(50.dp),
factory = { context ->
AdView(context).apply {
setAdSize(AdSize.BANNER)
adUnitId = AdMobManager.TEST_BANNER_ID
loadAd(AdRequest.Builder().build())
}
}
)
}
@@ -0,0 +1,88 @@
package com.wordpuzzle.hangul.ui.game
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.wordpuzzle.hangul.data.model.CrosswordClue
import com.wordpuzzle.hangul.data.model.Direction
import com.wordpuzzle.hangul.ui.theme.PrimaryIndigo
@Composable
fun ClueBar(
activeClue: CrosswordClue?,
clues: List<CrosswordClue>,
onSwitchDirection: () -> Unit,
modifier: Modifier = Modifier
) {
val activePalette = getCluePalette(clues, activeClue)
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 2.dp),
shape = RoundedCornerShape(10.dp),
color = Color(0xFFF1F5F9),
shadowElevation = 2.dp
) {
Row(
modifier = Modifier
.padding(horizontal = 10.dp, vertical = 6.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (activeClue != null) {
Surface(
shape = RoundedCornerShape(4.dp),
color = activePalette.accent,
modifier = Modifier.padding(end = 6.dp)
) {
Text(
text = if (activeClue.direction == Direction.ACROSS) "가로 ${activeClue.number}" else "세로 ${activeClue.number}",
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
Text(
text = activeClue.clue,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = Color(0xFF263238),
modifier = Modifier.weight(1f),
maxLines = 2
)
IconButton(
onClick = onSwitchDirection,
modifier = Modifier.size(28.dp)
) {
Icon(
imageVector = Icons.Default.SwapHoriz,
contentDescription = "가로/세로 전환",
tint = activePalette.accent,
modifier = Modifier.size(20.dp)
)
}
} else {
Text(
text = "퍼즐의 빈 칸을 터치해 단어를 시작하세요.",
fontSize = 12.sp,
color = Color.Gray,
modifier = Modifier.weight(1f)
)
}
}
}
}
@@ -0,0 +1,231 @@
package com.wordpuzzle.hangul.ui.game
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import androidx.compose.ui.unit.sp
import com.wordpuzzle.hangul.data.model.CrosswordCell
import com.wordpuzzle.hangul.data.model.CrosswordClue
import com.wordpuzzle.hangul.data.model.Direction
import com.wordpuzzle.hangul.ui.theme.*
// 단어별 구분을 위한 다채롭고 부드러운 파스텔 컬러 팔레트
data class PastelPalette(
val bg: Color,
val border: Color,
val accent: Color
)
val PASTEL_PALETTES = listOf(
PastelPalette(bg = Color(0xFFFFEBEE), border = Color(0xFFFFCDD2), accent = Color(0xFFE57373)), // 은은한 로즈 핑크
PastelPalette(bg = Color(0xFFE3F2FD), border = Color(0xFFBBDEFB), accent = Color(0xFF64B5F6)), // 부드러운 스카이 블루
PastelPalette(bg = Color(0xFFE8F5E9), border = Color(0xFFC8E6C9), accent = Color(0xFF81C784)), // 상쾌한 민트 그린
PastelPalette(bg = Color(0xFFFFF8E1), border = Color(0xFFFFECB3), accent = Color(0xFFFFD54F)), // 따뜻한 레몬 버터
PastelPalette(bg = Color(0xFFF3E5F5), border = Color(0xFFE1BEE7), accent = Color(0xFFBA68C8)), // 우아한 라벤더
PastelPalette(bg = Color(0xFFFBE9E7), border = Color(0xFFFFCCBC), accent = Color(0xFFFF8A65)), // 달콤한 살구 피치
PastelPalette(bg = Color(0xFFE0F2F1), border = Color(0xFFB2DFDB), accent = Color(0xFF4DB6AC)), // 차분한 세이지 틸
PastelPalette(bg = Color(0xFFEDE7F6), border = Color(0xFFD1C4E9), accent = Color(0xFF9575CD)) // 맑은 바이올렛
)
fun getCluePalette(clues: List<CrosswordClue>, clue: CrosswordClue?): PastelPalette {
if (clue == null) return PASTEL_PALETTES[0]
val index = clues.indexOf(clue).coerceAtLeast(0)
return PASTEL_PALETTES[index % PASTEL_PALETTES.size]
}
@Composable
fun CrosswordGrid(
grid: List<List<CrosswordCell>>,
clues: List<CrosswordClue>,
activeClue: CrosswordClue?,
focusedRow: Int,
focusedCol: Int,
onCellClick: (row: Int, col: Int) -> Unit,
modifier: Modifier = Modifier
) {
val gridSize = grid.size
BoxWithConstraints(
modifier = modifier,
contentAlignment = Alignment.Center
) {
val availableWidth = maxWidth - 12.dp
val availableHeight = maxHeight - 8.dp
val boardSize = min(availableWidth, availableHeight).coerceAtLeast(80.dp)
Surface(
modifier = Modifier.size(boardSize),
shape = RoundedCornerShape(14.dp),
color = Color(0xFFECEFF1),
shadowElevation = 3.dp
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(4.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
for (r in 0 until gridSize) {
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
horizontalArrangement = Arrangement.Center
) {
for (c in 0 until gridSize) {
val cell = grid[r][c]
val isFocused = (r == focusedRow && c == focusedCol)
val matchingClues = clues.filter { isCellInClue(r, c, it) }
val isInActiveClue = isCellInClue(r, c, activeClue)
GridCellView(
cell = cell,
matchingClues = matchingClues,
clues = clues,
activeClue = activeClue,
isFocused = isFocused,
isInActiveClue = isInActiveClue,
gridSize = gridSize,
onClick = { onCellClick(r, c) },
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
)
}
}
}
}
}
}
}
@Composable
fun GridCellView(
cell: CrosswordCell,
matchingClues: List<CrosswordClue>,
clues: List<CrosswordClue>,
activeClue: CrosswordClue?,
isFocused: Boolean,
isInActiveClue: Boolean,
gridSize: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
if (cell.isBlank) {
// 빈 공간 (검은 셀)
Box(
modifier = modifier
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFF37474F))
)
return
}
// 단어별 고유 파스텔 배경색 계산
val primaryPalette = if (matchingClues.isNotEmpty()) {
getCluePalette(clues, matchingClues.first())
} else {
PASTEL_PALETTES[0]
}
val secondaryPalette = if (matchingClues.size > 1) {
getCluePalette(clues, matchingClues[1])
} else null
val isIntersection = secondaryPalette != null
// 교차점인 경우 두 단어의 파스텔 색상을 부드럽게 그라데이션, 일반 칸은 단어의 파스텔 단색
val backgroundBrush = when {
isFocused -> Brush.linearGradient(listOf(Color(0xFFFFE082), Color(0xFFFFD54F))) // 포커스: 활력있는 골드 옐로우
isIntersection -> Brush.linearGradient(listOf(primaryPalette.bg, secondaryPalette!!.bg)) // 교차점: 듀얼 파스텔
else -> Brush.linearGradient(listOf(primaryPalette.bg, primaryPalette.bg)) // 단어 고유 파스텔
}
val activeBorderColor = if (isInActiveClue && activeClue != null) {
getCluePalette(clues, activeClue).accent
} else {
primaryPalette.border
}
val borderColor = when {
isFocused -> PrimaryIndigo
isInActiveClue -> activeBorderColor
else -> primaryPalette.border
}
val borderWidth = if (isFocused) 2.5.dp else if (isInActiveClue) 2.dp else 1.2.dp
val scale by animateFloatAsState(targetValue = if (isFocused) 1.05f else 1.0f, label = "cellScale")
// 폰트 크기 반응형 자동 최적화
val charFontSize = when {
gridSize <= 4 -> 19.sp
gridSize <= 6 -> 16.sp
else -> 13.sp
}
val badgeFontSize = if (gridSize <= 5) 9.sp else 7.5.sp
Box(
modifier = modifier
.scale(scale)
.clip(RoundedCornerShape(6.dp))
.background(backgroundBrush)
.border(borderWidth, borderColor, RoundedCornerShape(6.dp))
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
// 단어 시작 번호 뱃지
val clueNum = cell.clueNumberAcross ?: cell.clueNumberDown
if (clueNum != null) {
Text(
text = clueNum.toString(),
fontSize = badgeFontSize,
fontWeight = FontWeight.ExtraBold,
color = if (isFocused) PrimaryIndigoDark else Color(0xFF546E7A),
modifier = Modifier
.align(Alignment.TopStart)
.padding(start = 2.dp, top = 1.dp)
)
}
// 입력된 한글 문자
if (cell.userChar != null) {
Text(
text = cell.userChar.toString(),
fontSize = charFontSize,
fontWeight = FontWeight.Bold,
color = when {
cell.isRevealedByHint -> CorrectGreen
else -> TextPrimary
}
)
}
}
}
private fun isCellInClue(r: Int, c: Int, clue: CrosswordClue?): Boolean {
if (clue == null) return false
return if (clue.direction == Direction.ACROSS) {
r == clue.startRow && c in clue.startCol until (clue.startCol + clue.length)
} else {
c == clue.startCol && r in clue.startRow until (clue.startRow + clue.length)
}
}
@@ -0,0 +1,201 @@
package com.wordpuzzle.hangul.ui.game
import android.app.Activity
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Lightbulb
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.wordpuzzle.hangul.ads.AdMobManager
import com.wordpuzzle.hangul.ui.common.AdBannerView
import com.wordpuzzle.hangul.ui.theme.BackgroundLight
import com.wordpuzzle.hangul.ui.theme.SecondaryOrange
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GameScreen(
stageNumber: Int,
onNavigateBack: () -> Unit,
onNavigateToNextStage: (Int) -> Unit,
viewModel: GameViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
val activity = context as? Activity
LaunchedEffect(stageNumber) {
viewModel.loadStage(stageNumber)
}
// 클리어 이벤트 수신: 팝업 없이 30초 룰에 따라 즉시 또는 전면 광고 후 다음 스테이지 전환
LaunchedEffect(Unit) {
viewModel.stageClearEvent.collect { event ->
if (event.isLastStage) {
Toast.makeText(context, "🎉 모든 스테이지를 클리어하셨습니다!", Toast.LENGTH_SHORT).show()
onNavigateBack()
} else if (event.isFastClear) {
Toast.makeText(
context,
"⚡ ${event.elapsedSeconds}초 만에 빠른 클리어! (광고 없이 통과)",
Toast.LENGTH_SHORT
).show()
onNavigateToNextStage(event.nextStageNumber)
} else {
Toast.makeText(
context,
"클리어! (소요 시간: ${event.elapsedSeconds}초)",
Toast.LENGTH_SHORT
).show()
if (activity != null) {
AdMobManager.showInterstitialAd(activity) {
onNavigateToNextStage(event.nextStageNumber)
}
} else {
onNavigateToNextStage(event.nextStageNumber)
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
text = "스테이지 $stageNumber",
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF212121)
)
uiState.stage?.let {
Text(
text = it.title,
fontSize = 11.sp,
color = Color(0xFF757575)
)
}
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "뒤로가기"
)
}
},
actions = {
// 힌트 버튼
FilledTonalButton(
onClick = { viewModel.setShowHintDialog(true) },
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = Color(0xFFFFF3E0),
contentColor = SecondaryOrange
),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
modifier = Modifier.height(34.dp)
) {
Icon(
imageVector = Icons.Default.Lightbulb,
contentDescription = "힌트",
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${uiState.hintCount}",
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = BackgroundLight
)
)
},
bottomBar = {
// 하단 AdMob 배너 광고
AdBannerView()
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.background(BackgroundLight)
) {
if (uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (uiState.stage != null) {
val stage = uiState.stage!!
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 4.dp, vertical = 2.dp),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally
) {
// 1. 중앙 십자말풀이 그리드
CrosswordGrid(
grid = stage.grid,
clues = stage.clues,
activeClue = uiState.activeClue,
focusedRow = uiState.focusedRow,
focusedCol = uiState.focusedCol,
onCellClick = { r, c -> viewModel.onCellClick(r, c) },
modifier = Modifier
.weight(1f)
.fillMaxWidth()
)
// 2. 단어 힌트 텍스트 바
ClueBar(
activeClue = uiState.activeClue,
clues = stage.clues,
onSwitchDirection = { viewModel.onSwitchDirection() }
)
Spacer(modifier = Modifier.height(2.dp))
// 3. 하단 문자 타일 풀
TilePoolView(
tiles = stage.tilePool,
starterLettersCount = stage.starterLettersCount,
onTileClick = { tile -> viewModel.onTileClick(tile) },
onShuffle = { viewModel.onShuffleTiles() }
)
}
}
// 힌트 / 광고 시청 다이얼로그
if (uiState.showHintDialog) {
HintRewardDialog(
currentHints = uiState.hintCount,
dailyAdWatchedCount = uiState.dailyAdWatchedCount,
dailyMaxAdLimit = uiState.dailyMaxAdLimit,
onUseHint = { viewModel.onUseHint() },
onWatchAd = {
activity?.let {
viewModel.onWatchAdForHint(it)
}
},
onDismiss = { viewModel.setShowHintDialog(false) }
)
}
}
}
}
@@ -0,0 +1,321 @@
package com.wordpuzzle.hangul.ui.game
import android.app.Activity
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.wordpuzzle.hangul.ads.AdMobManager
import com.wordpuzzle.hangul.data.datastore.UserPreferencesManager
import com.wordpuzzle.hangul.data.model.*
import com.wordpuzzle.hangul.data.repository.StageRepository
import com.wordpuzzle.hangul.data.repository.WordRepository
import com.wordpuzzle.hangul.domain.engine.TilePoolGenerator
import com.wordpuzzle.hangul.domain.usecase.SolveTileUseCase
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
data class GameUiState(
val stage: CrosswordStage? = null,
val activeClue: CrosswordClue? = null,
val focusedRow: Int = -1,
val focusedCol: Int = -1,
val hintCount: Int = 3,
val dailyAdWatchedCount: Int = 0,
val dailyMaxAdLimit: Int = 5,
val isStageClear: Boolean = false,
val showHintDialog: Boolean = false,
val isLoading: Boolean = true
)
data class StageClearEvent(
val stageNumber: Int,
val nextStageNumber: Int,
val isLastStage: Boolean,
val elapsedSeconds: Long,
val isFastClear: Boolean
)
class GameViewModel(application: Application) : AndroidViewModel(application) {
private val stageRepo = StageRepository(application)
private val wordRepo = WordRepository(application)
private val prefs = UserPreferencesManager(application)
private val solveTileUseCase = SolveTileUseCase()
private val _uiState = MutableStateFlow(GameUiState())
val uiState: StateFlow<GameUiState> = _uiState.asStateFlow()
private val _stageClearEvent = MutableSharedFlow<StageClearEvent>()
val stageClearEvent: SharedFlow<StageClearEvent> = _stageClearEvent.asSharedFlow()
private var stageStartTimeMillis: Long = System.currentTimeMillis()
private var cleanTemplateGrid: List<List<CrosswordCell>>? = null
init {
viewModelScope.launch {
combine(
prefs.hintCountFlow,
prefs.dailyAdWatchInfoFlow
) { hints, adInfo ->
Pair(hints, adInfo)
}.collect { (hints, adInfo) ->
_uiState.update {
it.copy(
hintCount = hints,
dailyAdWatchedCount = adInfo.first,
dailyMaxAdLimit = adInfo.second
)
}
}
}
}
fun loadStage(stageNumber: Int) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, isStageClear = false) }
val template = stageRepo.getCleanStageTemplate(stageNumber)
cleanTemplateGrid = template?.first
val stage = stageRepo.getStage(stageNumber)
if (stage != null) {
stageStartTimeMillis = System.currentTimeMillis() // 스테이지 시작 시간 기록
// 시작 시 아직 비어있는 첫 번째 셀 탐색
val firstEmpty = findFirstEmptyCell(stage.grid) ?: findFirstNonBlankCell(stage.grid)
val (initialR, initialC) = firstEmpty
val initialClue = stage.clues.firstOrNull { isCellInClue(initialR, initialC, it) } ?: stage.clues.firstOrNull()
val gridWithFocus = stage.grid.mapIndexed { r, row ->
row.mapIndexed { c, cell ->
cell.copy(isFocused = (r == initialR && c == initialC))
}
}
_uiState.update {
it.copy(
stage = stage.copy(grid = gridWithFocus),
activeClue = initialClue,
focusedRow = initialR,
focusedCol = initialC,
isLoading = false
)
}
} else {
_uiState.update { it.copy(isLoading = false) }
}
}
}
fun onCellClick(row: Int, col: Int) {
val stage = _uiState.value.stage ?: return
val cell = stage.grid.getOrNull(row)?.getOrNull(col) ?: return
if (cell.isBlank) return
// 이미 사용자가 입력한 글자가 있는 셀을 다시 터치하면 글자 취소
if (cell.userChar != null && !cell.isRevealedByHint) {
val updatedStage = solveTileUseCase.removeCellChar(stage, row, col)
_uiState.update { it.copy(stage = updatedStage, focusedRow = row, focusedCol = col) }
return
}
val currentClue = _uiState.value.activeClue
val matchingClues = stage.clues.filter { clue ->
isCellInClue(row, col, clue)
}
val newClue = if (matchingClues.size > 1 && currentClue in matchingClues) {
matchingClues.firstOrNull { it != currentClue } ?: currentClue
} else {
matchingClues.firstOrNull() ?: currentClue
}
_uiState.update {
it.copy(
focusedRow = row,
focusedCol = col,
activeClue = newClue
)
}
}
fun onTileClick(tile: TileItem) {
val currentState = _uiState.value
val stage = currentState.stage ?: return
// 포커스된 칸에 타일을 입력하고 다음 빈 칸으로 자동 포커스 이동
val result = solveTileUseCase.processNormalTileTap(
stage = stage,
tile = tile,
focusedRow = currentState.focusedRow,
focusedCol = currentState.focusedCol,
activeClue = currentState.activeClue
) ?: solveTileUseCase.processEasyTileTap(
stage = stage,
tile = tile,
activeClue = currentState.activeClue
) ?: return
_uiState.update {
it.copy(
stage = result.updatedStage,
focusedRow = result.nextFocusedRow,
focusedCol = result.nextFocusedCol,
activeClue = result.nextActiveClue,
isStageClear = result.isStageClear
)
}
if (result.isStageClear) {
handleStageClear(result.updatedStage)
}
}
fun onSwitchDirection() {
val currentState = _uiState.value
val stage = currentState.stage ?: return
val r = currentState.focusedRow
val c = currentState.focusedCol
val matchingClues = stage.clues.filter { isCellInClue(r, c, it) }
if (matchingClues.size > 1) {
val nextClue = matchingClues.firstOrNull { it != currentState.activeClue }
if (nextClue != null) {
_uiState.update { it.copy(activeClue = nextClue) }
}
}
}
/**
* 리프레시(새로고침) 버튼 클릭 시:
* 전체 문자를 다시 셔플하고, 다른 문자가 새롭게 임의 선택되어 다른 위치에 사전 배치됩니다.
*/
fun onShuffleTiles() {
val currentStage = _uiState.value.stage ?: return
val baseGrid = cleanTemplateGrid ?: return
val (newGrid, newTilePool) = TilePoolGenerator.generateLayoutWithStarterLetters(
cleanGrid = baseGrid,
clues = currentStage.clues
)
val firstEmpty = findFirstEmptyCell(newGrid) ?: findFirstNonBlankCell(newGrid)
val (initialR, initialC) = firstEmpty
val initialClue = currentStage.clues.firstOrNull { isCellInClue(initialR, initialC, it) } ?: currentStage.clues.firstOrNull()
val gridWithFocus = newGrid.mapIndexed { r, row ->
row.mapIndexed { c, cell ->
cell.copy(isFocused = (r == initialR && c == initialC))
}
}
_uiState.update {
it.copy(
stage = currentStage.copy(
grid = gridWithFocus,
tilePool = newTilePool
),
activeClue = initialClue,
focusedRow = initialR,
focusedCol = initialC
)
}
}
fun setShowHintDialog(show: Boolean) {
_uiState.update { it.copy(showHintDialog = show) }
}
fun onUseHint() {
val currentState = _uiState.value
val stage = currentState.stage ?: return
viewModelScope.launch {
val used = prefs.useHint()
if (used) {
val result = solveTileUseCase.applyHint(stage, currentState.activeClue)
if (result != null) {
_uiState.update {
it.copy(
stage = result.updatedStage,
focusedRow = result.nextFocusedRow,
focusedCol = result.nextFocusedCol,
activeClue = result.nextActiveClue,
isStageClear = result.isStageClear
)
}
if (result.isStageClear) {
handleStageClear(result.updatedStage)
}
}
}
}
}
fun onWatchAdForHint(activity: Activity) {
AdMobManager.showRewardedAd(
activity = activity,
onUserEarnedReward = {
viewModelScope.launch {
prefs.addHintFromAd()
}
}
)
}
private fun handleStageClear(stage: CrosswordStage) {
viewModelScope.launch {
val elapsedSeconds = ((System.currentTimeMillis() - stageStartTimeMillis) / 1000).coerceAtLeast(0)
val isFastClear = elapsedSeconds <= 30L
prefs.setStageCleared(stage.stageNumber)
for (clue in stage.clues) {
wordRepo.markWordLearned(clue.word)
}
val totalStages = stageRepo.getTotalStageCount()
val isLastStage = stage.stageNumber >= totalStages
_stageClearEvent.emit(
StageClearEvent(
stageNumber = stage.stageNumber,
nextStageNumber = stage.stageNumber + 1,
isLastStage = isLastStage,
elapsedSeconds = elapsedSeconds,
isFastClear = isFastClear
)
)
}
}
private fun isCellInClue(r: Int, c: Int, clue: CrosswordClue): Boolean {
return if (clue.direction == Direction.ACROSS) {
r == clue.startRow && c in clue.startCol until (clue.startCol + clue.length)
} else {
c == clue.startCol && r in clue.startRow until (clue.startRow + clue.length)
}
}
private fun findFirstEmptyCell(grid: List<List<CrosswordCell>>): Pair<Int, Int>? {
for (r in grid.indices) {
for (c in grid[r].indices) {
val cell = grid[r][c]
if (!cell.isBlank && cell.userChar == null) {
return Pair(r, c)
}
}
}
return null
}
private fun findFirstNonBlankCell(grid: List<List<CrosswordCell>>): Pair<Int, Int> {
for (r in grid.indices) {
for (c in grid[r].indices) {
if (!grid[r][c].isBlank) return Pair(r, c)
}
}
return Pair(0, 0)
}
}
@@ -0,0 +1,152 @@
package com.wordpuzzle.hangul.ui.game
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lightbulb
import androidx.compose.material.icons.filled.PlayCircleOutline
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.wordpuzzle.hangul.ui.theme.PrimaryIndigo
import com.wordpuzzle.hangul.ui.theme.SecondaryOrange
@Composable
fun HintRewardDialog(
currentHints: Int,
dailyAdWatchedCount: Int,
dailyMaxAdLimit: Int,
onUseHint: () -> Unit,
onWatchAd: () -> Unit,
onDismiss: () -> Unit
) {
val remainingAdCount = (dailyMaxAdLimit - dailyAdWatchedCount).coerceAtLeast(0)
val isAdAvailable = remainingAdCount > 0
Dialog(onDismissRequest = onDismiss) {
Card(
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
modifier = Modifier.fillMaxWidth().padding(8.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Lightbulb,
contentDescription = null,
tint = SecondaryOrange,
modifier = Modifier.size(52.dp)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "힌트 도움받기",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF212121)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "보유 힌트: ${currentHints}개",
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = PrimaryIndigo
)
Spacer(modifier = Modifier.height(16.dp))
if (currentHints > 0) {
Button(
onClick = {
onUseHint()
onDismiss()
},
modifier = Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = SecondaryOrange)
) {
Text(
text = "힌트 1개 사용하기 (한 글자 공개)",
fontWeight = FontWeight.Bold,
color = Color.White
)
}
Spacer(modifier = Modifier.height(12.dp))
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = Color(0xFFF5F7FA)
) {
Column(
modifier = Modifier.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "💡 힌트 무료 충전",
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF37474F)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "전체화면 광고를 시청하면 힌트 1개를 즉시 충전해 드립니다.",
fontSize = 12.sp,
color = Color(0xFF757575),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "오늘 남은 시청 횟수: $remainingAdCount / $dailyMaxAdLimit",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = if (isAdAvailable) PrimaryIndigo else Color(0xFFE53935)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
if (isAdAvailable) {
onWatchAd()
onDismiss()
}
},
enabled = isAdAvailable,
modifier = Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = PrimaryIndigo)
) {
Icon(imageVector = Icons.Default.PlayCircleOutline, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (isAdAvailable) "광고 보고 힌트 받기" else "오늘 힌트 한도 소진",
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = onDismiss) {
Text(text = "닫기", color = Color.Gray)
}
}
}
}
}
@@ -0,0 +1,100 @@
package com.wordpuzzle.hangul.ui.game
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.EmojiEvents
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.wordpuzzle.hangul.ui.theme.PrimaryIndigo
import com.wordpuzzle.hangul.ui.theme.SecondaryOrange
@Composable
fun StageClearDialog(
stageNumber: Int,
onNextStage: () -> Unit,
onLobby: () -> Unit
) {
Dialog(onDismissRequest = {}) {
Card(
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
modifier = Modifier.fillMaxWidth().padding(8.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.EmojiEvents,
contentDescription = null,
tint = SecondaryOrange,
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "🎉 스테이지 클리어!",
fontSize = 22.sp,
fontWeight = FontWeight.ExtraBold,
color = Color(0xFF212121)
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "스테이지 $stageNumber 퍼즐을 모두 풀었습니다!",
fontSize = 14.sp,
color = Color(0xFF616161)
)
Spacer(modifier = Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.Center) {
repeat(3) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
tint = Color(0xFFFFC107),
modifier = Modifier.size(32.dp)
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onNextStage,
modifier = Modifier.fillMaxWidth().height(50.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = PrimaryIndigo)
) {
Text(
text = "다음 스테이지 도전",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.width(8.dp))
Icon(imageVector = Icons.Default.ArrowForward, contentDescription = null)
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = onLobby) {
Text(text = "메인으로 이동", color = Color.Gray)
}
}
}
}
}
@@ -0,0 +1,144 @@
package com.wordpuzzle.hangul.ui.game
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
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.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.wordpuzzle.hangul.data.model.TileItem
import com.wordpuzzle.hangul.ui.theme.PrimaryIndigo
import com.wordpuzzle.hangul.ui.theme.PrimaryIndigoLight
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun TilePoolView(
tiles: List<TileItem>,
starterLettersCount: Int,
onTileClick: (TileItem) -> Unit,
onShuffle: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
shape = RoundedCornerShape(14.dp),
color = Color.White,
shadowElevation = 4.dp
) {
Column(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 6.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 상단 컨트롤 바 (시작 힌트 안내 뱃지 및 새로고침 버튼)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = RoundedCornerShape(14.dp),
color = Color(0xFFE8EAF6)
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "💡 시작 힌트 (${starterLettersCount}글자 배치됨)",
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
color = PrimaryIndigo
)
}
}
IconButton(
onClick = onShuffle,
modifier = Modifier.size(26.dp)
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "문자 새로고침 및 재배치",
tint = Color(0xFF607D8B),
modifier = Modifier.size(18.dp)
)
}
}
// 하단 문자 타일 - FlowRow를 통해 중앙 정렬로 자동 배치
val tileSize = if (tiles.size > 14) 34.dp else if (tiles.size > 8) 38.dp else 42.dp
val fontSize = if (tiles.size > 14) 15.sp else if (tiles.size > 8) 17.sp else 19.sp
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(5.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
tiles.forEach { tile ->
LetterTileButton(
tile = tile,
size = tileSize,
fontSize = fontSize,
onClick = { onTileClick(tile) }
)
}
}
}
}
}
@Composable
fun LetterTileButton(
tile: TileItem,
size: androidx.compose.ui.unit.Dp = 38.dp,
fontSize: androidx.compose.ui.unit.TextUnit = 17.sp,
onClick: () -> Unit
) {
val isEnabled = !tile.isUsed
val scale by animateFloatAsState(targetValue = if (isEnabled) 1.0f else 0.85f, label = "tileScale")
Box(
modifier = Modifier
.scale(scale)
.size(size)
.clip(RoundedCornerShape(8.dp))
.background(
if (isEnabled) PrimaryIndigoLight else Color(0xFFEEEEEE)
)
.border(
width = if (isEnabled) 1.5.dp else 1.dp,
color = if (isEnabled) PrimaryIndigo else Color(0xFFBDBDBD),
shape = RoundedCornerShape(8.dp)
)
.clickable(enabled = isEnabled, onClick = onClick),
contentAlignment = Alignment.Center
) {
Text(
text = tile.character.toString(),
fontSize = fontSize,
fontWeight = FontWeight.ExtraBold,
color = if (isEnabled) PrimaryIndigo else Color(0xFF9E9E9E)
)
}
}
@@ -0,0 +1,230 @@
package com.wordpuzzle.hangul.ui.lockscreen
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
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.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.lifecycleScope
import com.wordpuzzle.hangul.MainActivity
import com.wordpuzzle.hangul.data.local.AppDatabase
import com.wordpuzzle.hangul.data.model.KoreanWord
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class LockScreenActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 잠금화면 위에 표시 설정
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
keyguardManager.requestDismissKeyguard(this, null)
} else {
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
)
}
var word by mutableStateOf<KoreanWord?>(null)
lifecycleScope.launch {
val candidate = withContext(Dispatchers.IO) {
val db = AppDatabase.getInstance(this@LockScreenActivity)
val list = db.wordDao().getWordsForPreLearning(3)
list.randomOrNull()
}
word = if (candidate != null) {
KoreanWord(
id = candidate.id,
word = candidate.word,
meaning = candidate.meaning,
category = candidate.category,
difficulty = candidate.difficulty,
example = candidate.example
)
} else {
KoreanWord("def", "일석이조", "돌 하나로 새 두 마리를 잡는다는 사자성어.", "사자성어", 1, "건강도 챙기고 돈도 아끼니 일석이조다.")
}
}
setContent {
LockScreenContent(
word = word,
onDismiss = { finish() },
onPlayGame = {
val intent = Intent(this@LockScreenActivity, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
startActivity(intent)
finish()
}
)
}
}
}
@Composable
fun LockScreenContent(
word: KoreanWord?,
onDismiss: () -> Unit,
onPlayGame: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xCC000000)),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier
.fillMaxWidth(0.88f)
.wrapContentHeight(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 12.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = Color(0xFFE8EAF6)
) {
Text(
text = "💡 퍼즐 예습 단어",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF3F51B5)
)
}
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "닫기",
tint = Color.Gray
)
}
}
Spacer(modifier = Modifier.height(16.dp))
if (word != null) {
Text(
text = word.word,
fontSize = 32.sp,
fontWeight = FontWeight.ExtraBold,
color = Color(0xFF212121),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "[${word.category}]",
fontSize = 14.sp,
color = Color(0xFF757575)
)
Spacer(modifier = Modifier.height(16.dp))
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = Color(0xFFF5F7FB)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "의미",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF3F51B5)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = word.meaning,
fontSize = 15.sp,
color = Color(0xFF333333),
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "예문",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF757575)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "“${word.example}”",
fontSize = 14.sp,
color = Color(0xFF555555),
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
)
}
}
} else {
CircularProgressIndicator(modifier = Modifier.padding(32.dp))
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onPlayGame,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF3F51B5)
)
) {
Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "이 단어로 퍼즐 풀러가기",
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
@@ -0,0 +1,320 @@
package com.wordpuzzle.hangul.ui.main
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.wordpuzzle.hangul.data.datastore.UserPreferencesManager
import com.wordpuzzle.hangul.data.model.KoreanWord
import com.wordpuzzle.hangul.data.repository.WordRepository
import com.wordpuzzle.hangul.ui.common.AdBannerView
import com.wordpuzzle.hangul.ui.theme.PrimaryIndigo
import com.wordpuzzle.hangul.ui.theme.SecondaryOrange
@Composable
fun MainLobbyScreen(
currentUnlockedStage: Int,
hintCount: Int,
onSelectStage: (Int) -> Unit,
onNavigateToVocabulary: () -> Unit,
onNavigateToSettings: () -> Unit,
wordRepo: WordRepository,
prefs: UserPreferencesManager
) {
var previewWord by remember { mutableStateOf<KoreanWord?>(null) }
var isLockScreenEnabled by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
previewWord = wordRepo.getWordForWidget()
prefs.lockscreenEnabledFlow.collect {
isLockScreenEnabled = it
}
}
Scaffold(
bottomBar = {
AdBannerView()
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(Color(0xFFF7F9FC))
.verticalScroll(rememberScrollState())
) {
// 상단 헤더 영역
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(bottomStart = 24.dp, bottomEnd = 24.dp))
.background(
Brush.verticalGradient(
colors = listOf(PrimaryIndigo, Color(0xFF303F9F))
)
)
.padding(horizontal = 16.dp, vertical = 18.dp)
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "한글 십자말풀이",
fontSize = 22.sp,
fontWeight = FontWeight.ExtraBold,
color = Color.White
)
Text(
text = "단어를 미리 학습하고 퍼즐을 정복하세요!",
fontSize = 12.sp,
color = Color(0xFFC5CAE9)
)
}
IconButton(onClick = onNavigateToSettings) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "설정",
tint = Color.White
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// 힌트 및 학습 현황 카드
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = Color.White.copy(alpha = 0.15f),
modifier = Modifier.weight(1f)
) {
Row(
modifier = Modifier.padding(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Lightbulb,
contentDescription = null,
tint = SecondaryOrange,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Column {
Text(text = "보유 힌트", fontSize = 10.sp, color = Color(0xFFE0E0E0))
Text(
text = "${hintCount}개",
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
}
}
Surface(
shape = RoundedCornerShape(12.dp),
color = Color.White.copy(alpha = 0.15f),
modifier = Modifier
.weight(1f)
.clickable(onClick = onNavigateToVocabulary)
) {
Row(
modifier = Modifier.padding(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.MenuBook,
contentDescription = null,
tint = Color(0xFF81C784),
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Column {
Text(text = "나의 단어장", fontSize = 10.sp, color = Color(0xFFE0E0E0))
Text(
text = "단어 복습하기 >",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
}
}
}
}
}
// 사전 학습 (Pre-Learning) 단어 카드
if (previewWord != null) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 10.dp),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "💡 다음 퍼즐 예습 단어",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = PrimaryIndigo
)
Text(
text = "[${previewWord!!.category}]",
fontSize = 11.sp,
color = Color.Gray
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = previewWord!!.word,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF212121)
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = previewWord!!.meaning,
fontSize = 12.sp,
color = Color(0xFF616161)
)
}
}
}
// 스테이지 선택 타이틀
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "스테이지 선택",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF212121)
)
}
// 스테이지 그리드
val totalStages = 4
LazyVerticalGrid(
columns = GridCells.Fixed(4),
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
) {
items(totalStages) { index ->
val stageNum = index + 1
val isUnlocked = stageNum <= currentUnlockedStage
val isCurrent = stageNum == currentUnlockedStage
StageCard(
stageNumber = stageNum,
isUnlocked = isUnlocked,
isCurrent = isCurrent,
onClick = {
if (isUnlocked) onSelectStage(stageNum)
}
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@Composable
fun StageCard(
stageNumber: Int,
isUnlocked: Boolean,
isCurrent: Boolean,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.aspectRatio(1f)
.clickable(enabled = isUnlocked, onClick = onClick),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(
containerColor = when {
isCurrent -> PrimaryIndigo
isUnlocked -> Color.White
else -> Color(0xFFEEEEEE)
}
),
elevation = CardDefaults.cardElevation(defaultElevation = if (isUnlocked) 3.dp else 0.dp)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (isUnlocked) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "$stageNumber",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = if (isCurrent) Color.White else PrimaryIndigo
)
if (!isCurrent) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = Color(0xFF4CAF50),
modifier = Modifier.size(14.dp)
)
}
}
} else {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = "잠김",
tint = Color.Gray,
modifier = Modifier.size(20.dp)
)
}
}
}
}
@@ -0,0 +1,135 @@
package com.wordpuzzle.hangul.ui.main
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
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.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.wordpuzzle.hangul.data.model.KoreanWord
import com.wordpuzzle.hangul.data.repository.WordRepository
import com.wordpuzzle.hangul.ui.theme.BackgroundLight
import com.wordpuzzle.hangul.ui.theme.PrimaryIndigo
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VocabularyScreen(
onNavigateBack: () -> Unit,
wordRepo: WordRepository
) {
val learnedWords by wordRepo.getAllLearnedWords().collectAsState(initial = emptyList())
Scaffold(
topBar = {
TopAppBar(
title = { Text("나의 단어장 (${learnedWords.size}개)", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "뒤로가기")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = BackgroundLight)
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(BackgroundLight)
) {
if (learnedWords.isEmpty()) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "아직 완성한 단어가 없습니다.",
fontSize = 16.sp,
color = Color.Gray
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "십자말 퍼즐을 풀면 이곳에 단어가 기록됩니다.",
fontSize = 13.sp,
color = Color.LightGray
)
}
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(learnedWords, key = { it.id }) { word ->
VocabularyWordCard(word = word)
}
}
}
}
}
}
@Composable
fun VocabularyWordCard(word: KoreanWord) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = word.word,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF212121)
)
Surface(
shape = RoundedCornerShape(6.dp),
color = Color(0xFFE8EAF6)
) {
Text(
text = word.category,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
fontSize = 11.sp,
color = PrimaryIndigo,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = word.meaning,
fontSize = 14.sp,
color = Color(0xFF555555),
lineHeight = 20.sp
)
if (word.example.isNotBlank()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "예) “${word.example}”",
fontSize = 12.sp,
color = Color(0xFF888888),
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
)
}
}
}
}
@@ -0,0 +1,283 @@
package com.wordpuzzle.hangul.ui.settings
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.wordpuzzle.hangul.data.datastore.UserPreferencesManager
import com.wordpuzzle.hangul.service.WordNotificationManager
import com.wordpuzzle.hangul.ui.theme.BackgroundLight
import com.wordpuzzle.hangul.ui.theme.PrimaryIndigo
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigateBack: () -> Unit,
prefs: UserPreferencesManager
) {
val lockScreenEnabled by prefs.lockscreenEnabledFlow.collectAsState(initial = false)
val soundEnabled by prefs.soundEnabledFlow.collectAsState(initial = true)
val vibrationEnabled by prefs.vibrationEnabledFlow.collectAsState(initial = true)
val adInfo by prefs.dailyAdWatchInfoFlow.collectAsState(initial = Pair(0, 5))
val scope = rememberCoroutineScope()
val context = LocalContext.current
// Android 13+ 알림 권한 요청 런처
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { isGranted ->
if (isGranted) {
scope.launch {
prefs.setLockscreenEnabled(true)
WordNotificationManager.showWordNotification(context)
}
} else {
scope.launch {
prefs.setLockscreenEnabled(false)
}
}
}
)
Scaffold(
topBar = {
TopAppBar(
title = { Text("설정", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "뒤로가기")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = BackgroundLight)
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(BackgroundLight)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 1. 사전 학습 설정 섹션
Text(
text = "사전 학습 (Pre-Learning)",
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = PrimaryIndigo
)
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color.White)
) {
Column(modifier = Modifier.padding(16.dp)) {
SettingSwitchRow(
title = "대기/잠금화면 스마트 고정 알림",
subtitle = "잠금화면 및 알림바에 예습 단어 카드를 상시 표시하고 다음 단어로 넘겨볼 수 있습니다.",
icon = Icons.Default.NotificationsActive,
checked = lockScreenEnabled,
onCheckedChange = { isChecked ->
if (isChecked) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
scope.launch {
prefs.setLockscreenEnabled(true)
WordNotificationManager.showWordNotification(context)
}
}
} else {
scope.launch {
prefs.setLockscreenEnabled(false)
WordNotificationManager.cancelWordNotification(context)
}
}
}
)
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Widgets,
contentDescription = null,
tint = PrimaryIndigo,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "홈 화면 위젯",
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF212121)
)
Text(
text = "스마트폰 홈 화면에서 위젯 목록을 열어 '오늘의 단어 예습' 위젯을 추가하세요.",
fontSize = 12.sp,
color = Color(0xFF757575)
)
}
}
}
}
// 2. 힌트 및 광고 혜택 현황
Text(
text = "힌트 및 광고",
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = PrimaryIndigo
)
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color.White)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "오늘의 무료 힌트 광고 시청",
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF212121)
)
Text(
text = "매일 자정에 획득 가능 횟수가 초기화됩니다.",
fontSize = 12.sp,
color = Color(0xFF757575)
)
}
Surface(
shape = RoundedCornerShape(8.dp),
color = Color(0xFFE8EAF6)
) {
Text(
text = "${adInfo.first} / ${adInfo.second}회 사용",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = PrimaryIndigo
)
}
}
}
}
// 3. 게임 환경
Text(
text = "게임 환경",
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = PrimaryIndigo
)
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color.White)
) {
Column(modifier = Modifier.padding(16.dp)) {
SettingSwitchRow(
title = "효과음",
subtitle = "타일 터치 및 정답 효과음 재생",
icon = Icons.Default.VolumeUp,
checked = soundEnabled,
onCheckedChange = { isChecked ->
scope.launch { prefs.setSoundEnabled(isChecked) }
}
)
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
SettingSwitchRow(
title = "진동 효과",
subtitle = "버튼 터치 시 가벼운 햅틱 피드백",
icon = Icons.Default.Vibration,
checked = vibrationEnabled,
onCheckedChange = { isChecked ->
scope.launch { prefs.setVibrationEnabled(isChecked) }
}
)
}
}
}
}
}
@Composable
fun SettingSwitchRow(
title: String,
subtitle: String,
icon: ImageVector,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = PrimaryIndigo,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF212121)
)
Text(
text = subtitle,
fontSize = 12.sp,
color = Color(0xFF757575)
)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = PrimaryIndigo
)
)
}
}
@@ -0,0 +1,24 @@
package com.wordpuzzle.hangul.ui.theme
import androidx.compose.ui.graphics.Color
val PrimaryIndigo = Color(0xFF3F51B5)
val PrimaryIndigoDark = Color(0xFF303F9F)
val PrimaryIndigoLight = Color(0xFFE8EAF6)
val SecondaryOrange = Color(0xFFFF9800)
val SecondaryOrangeLight = Color(0xFFFFF3E0)
val CorrectGreen = Color(0xFF4CAF50)
val CorrectGreenLight = Color(0xFFE8F5E9)
val AccentBlue = Color(0xFF2196F3)
val AccentBlueLight = Color(0xFFE3F2FD)
val BackgroundLight = Color(0xFFF7F9FC)
val SurfaceWhite = Color(0xFFFFFFFF)
val GridBlackCell = Color(0xFF263238)
val TextPrimary = Color(0xFF212121)
val TextSecondary = Color(0xFF757575)
val TextHint = Color(0xFF9E9E9E)
@@ -0,0 +1,30 @@
package com.wordpuzzle.hangul.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
private val LightColorScheme = lightColorScheme(
primary = PrimaryIndigo,
onPrimary = SurfaceWhite,
primaryContainer = PrimaryIndigoLight,
onPrimaryContainer = PrimaryIndigoDark,
secondary = SecondaryOrange,
onSecondary = SurfaceWhite,
secondaryContainer = SecondaryOrangeLight,
background = BackgroundLight,
surface = SurfaceWhite,
onBackground = TextPrimary,
onSurface = TextPrimary
)
@Composable
fun WordPuzzleTheme(
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = LightColorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,46 @@
package com.wordpuzzle.hangul.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 34.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
lineHeight = 24.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 22.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp
)
)
@@ -0,0 +1,124 @@
package com.wordpuzzle.hangul.ui.widget
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.*
import androidx.glance.action.actionStartActivity
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.*
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import com.wordpuzzle.hangul.MainActivity
import com.wordpuzzle.hangul.data.local.AppDatabase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class WordGlanceWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val wordData = withContext(Dispatchers.IO) {
val db = AppDatabase.getInstance(context)
val list = db.wordDao().getWordsForPreLearning(1)
list.firstOrNull()
}
val wordTitle = wordData?.word ?: "가을하늘"
val wordCategory = wordData?.category ?: "자연"
val wordMeaning = wordData?.meaning ?: "가을철의 맑고 푸른 하늘."
val wordExample = wordData?.example ?: "가을하늘이 높고 푸르다."
provideContent {
WidgetContent(
wordTitle = wordTitle,
wordCategory = wordCategory,
wordMeaning = wordMeaning,
wordExample = wordExample
)
}
}
@Composable
private fun WidgetContent(
wordTitle: String,
wordCategory: String,
wordMeaning: String,
wordExample: String
) {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(ImageProvider(android.R.drawable.dialog_holo_light_frame))
.padding(12.dp)
.clickable(actionStartActivity<MainActivity>()),
verticalAlignment = Alignment.Vertical.CenterVertically,
horizontalAlignment = Alignment.Horizontal.Start
) {
Row(
modifier = GlanceModifier.fillMaxWidth(),
verticalAlignment = Alignment.Vertical.CenterVertically
) {
Text(
text = "💡 오늘의 예습 단어",
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = ColorProvider(androidx.compose.ui.graphics.Color(0xFF3F51B5))
)
)
Spacer(modifier = GlanceModifier.defaultWeight())
Text(
text = "[$wordCategory]",
style = TextStyle(
fontSize = 11.sp,
color = ColorProvider(androidx.compose.ui.graphics.Color(0xFF757575))
)
)
}
Spacer(modifier = GlanceModifier.height(4.dp))
Text(
text = wordTitle,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = ColorProvider(androidx.compose.ui.graphics.Color(0xFF212121))
)
)
Spacer(modifier = GlanceModifier.height(2.dp))
Text(
text = wordMeaning,
maxLines = 2,
style = TextStyle(
fontSize = 12.sp,
color = ColorProvider(androidx.compose.ui.graphics.Color(0xFF424242))
)
)
Spacer(modifier = GlanceModifier.height(4.dp))
Text(
text = "예) $wordExample",
maxLines = 1,
style = TextStyle(
fontSize = 11.sp,
color = ColorProvider(androidx.compose.ui.graphics.Color(0xFF888888))
)
)
}
}
}
class WordWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = WordGlanceWidget()
}
+9
View File
@@ -0,0 +1,9 @@
<resources>
<color name="primary">#3F51B5</color>
<color name="primary_variant">#303F9F</color>
<color name="secondary">#FF9800</color>
<color name="background">#F5F7FB</color>
<color name="surface">#FFFFFF</color>
<color name="text_primary">#212121</color>
<color name="text_secondary">#757575</color>
</resources>
+12
View File
@@ -0,0 +1,12 @@
<resources>
<string name="app_name">한글 십자말풀이</string>
<string name="widget_name">오늘의 단어 예습 위젯</string>
<string name="widget_description">퍼즐에 나올 단어를 홈 화면에서 미리 학습합니다.</string>
<string name="hint_reward_title">힌트 충전 (광고 시청)</string>
<string name="hint_reward_desc">전체화면 광고를 시청하고 힌트 1개를 받으시겠습니까?</string>
<string name="daily_limit_reached">오늘 시청 가능한 광고 힌트를 모두 받았습니다.\n내일 다시 이용해 주세요!</string>
<string name="stage_clear_title">스테이지 클리어!</string>
<string name="next_stage">다음 스테이지</string>
<string name="easy_mode">쉬운 모드 (자동 삽입)</string>
<string name="normal_mode">일반 모드 (선택 후 입력)</string>
</resources>
+11
View File
@@ -0,0 +1,11 @@
<resources>
<style name="Theme.WordPuzzle" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/background</item>
<item name="android:windowLightStatusBar">true</item>
</style>
<style name="Theme.WordPuzzle.LockScreen" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowIsTranslucent">true</item>
</style>
</resources>
@@ -0,0 +1,9 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_description"
android:initialKeyguardLayout="@layout/glance_default_loading_layout"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="180dp"
android:minHeight="110dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />
@@ -0,0 +1,19 @@
package com.wordpuzzle.hangul.domain.engine
import org.junit.Assert.*
import org.junit.Test
class HangulHelperTest {
@Test
fun `isHangulSyllable_validatesCorrectly`() {
assertTrue(HangulHelper.isHangulSyllable('가'))
assertTrue(HangulHelper.isHangulSyllable('힣'))
assertTrue(HangulHelper.isHangulSyllable('한'))
assertTrue(HangulHelper.isHangulSyllable('글'))
assertFalse(HangulHelper.isHangulSyllable('A'))
assertFalse(HangulHelper.isHangulSyllable('1'))
assertFalse(HangulHelper.isHangulSyllable(' '))
}
}
@@ -0,0 +1,58 @@
package com.wordpuzzle.hangul.domain.engine
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.wordpuzzle.hangul.data.model.Direction
import com.wordpuzzle.hangul.data.model.StageJsonData
import org.junit.Assert.*
import org.junit.Test
import java.io.File
class StageDataIntegrityTest {
@Test
fun testAllStages_withinGridBounds_andNoIntersectionConflicts() {
val stagesFile = File("src/main/assets/data/stages.json")
assertTrue("stages.json must exist", stagesFile.exists())
val json = stagesFile.readText()
val type = object : TypeToken<List<StageJsonData>>() {}.type
val stages: List<StageJsonData> = Gson().fromJson(json, type)
assertFalse("Stages list should not be empty", stages.isEmpty())
for (stage in stages) {
val gridSize = stage.gridSize
val cellCharMap = mutableMapOf<Pair<Int, Int>, Char>()
for (clue in stage.clues) {
val isAcross = clue.direction.equals("ACROSS", ignoreCase = true)
assertEquals("Word length must match word string length", clue.word.length, clue.length)
for (i in clue.word.indices) {
val r = if (isAcross) clue.startRow else clue.startRow + i
val c = if (isAcross) clue.startCol + i else clue.startCol
val char = clue.word[i]
// 1. Grid boundary check
assertTrue(
"Stage ${stage.stageNumber} clue '${clue.word}' letter '$char' at ($r,$c) must be within gridSize $gridSize",
r in 0 until gridSize && c in 0 until gridSize
)
// 2. Intersection conflict check
val existingChar = cellCharMap[Pair(r, c)]
if (existingChar != null) {
assertEquals(
"Stage ${stage.stageNumber} intersection at ($r, $c) clash: expected '$existingChar', but clue '${clue.word}' had '$char'",
existingChar,
char
)
} else {
cellCharMap[Pair(r, c)] = char
}
}
}
}
}
}
@@ -0,0 +1,64 @@
package com.wordpuzzle.hangul.domain.engine
import com.wordpuzzle.hangul.data.model.CrosswordCell
import com.wordpuzzle.hangul.data.model.CrosswordClue
import com.wordpuzzle.hangul.data.model.Direction
import org.junit.Assert.*
import org.junit.Test
class TilePoolGeneratorTest {
@Test
fun generateLayoutWithStarterLetters_placesStarterLettersInExactCorrectCells() {
// 3x3 grid with 2 intersecting words
val cell00 = CrosswordCell(row = 0, col = 0, correctChar = '하', isBlank = false)
val cell01 = CrosswordCell(row = 0, col = 1, correctChar = '늘', isBlank = false)
val cell02 = CrosswordCell(row = 0, col = 2, correctChar = ' ', isBlank = true)
val cell10 = CrosswordCell(row = 1, col = 0, correctChar = '양', isBlank = false)
val cell11 = CrosswordCell(row = 1, col = 1, correctChar = ' ', isBlank = true)
val cell12 = CrosswordCell(row = 1, col = 2, correctChar = ' ', isBlank = true)
val cell20 = CrosswordCell(row = 2, col = 0, correctChar = ' ', isBlank = true)
val cell21 = CrosswordCell(row = 2, col = 1, correctChar = ' ', isBlank = true)
val cell22 = CrosswordCell(row = 2, col = 2, correctChar = ' ', isBlank = true)
val cleanGrid = listOf(
listOf(cell00, cell01, cell02),
listOf(cell10, cell11, cell12),
listOf(cell20, cell21, cell22)
)
val clues = listOf(
CrosswordClue("c1", 1, Direction.ACROSS, "하늘", "푸른 하늘", 0, 0, 2),
CrosswordClue("c2", 2, Direction.DOWN, "하양", "하얀 색", 0, 0, 2)
)
// Repeat multiple times to test randomness
for (i in 0 until 20) {
val (matrix, tiles) = TilePoolGenerator.generateLayoutWithStarterLetters(cleanGrid, clues)
// Total non-blank cells = 3 ('하', '늘', '양')
val prefilledCells = matrix.flatten().filter { it.userChar != null }
assertEquals("Number of prefilled starter letters must match clue count (2)", 2, prefilledCells.size)
// Every prefilled cell MUST match its correctChar
for (cell in prefilledCells) {
assertEquals("Starter letter must strictly match cell correctChar", cell.correctChar, cell.userChar)
assertTrue("Starter letter must be marked revealedByHint", cell.isRevealedByHint)
}
// Total tiles must equal non-blank cell count (3)
assertEquals(3, tiles.size)
// Used tiles must match starter count (2)
val usedTiles = tiles.filter { it.isUsed }
assertEquals(2, usedTiles.size)
// Characters of used tiles must match the characters placed in prefilledCells
val prefilledChars = prefilledCells.mapNotNull { it.userChar }.sorted()
val usedTileChars = usedTiles.map { it.character }.sorted()
assertEquals("Used tiles must exactly match prefilled letters", prefilledChars, usedTileChars)
}
}
}
@@ -0,0 +1,138 @@
package com.wordpuzzle.hangul.domain.usecase
import com.wordpuzzle.hangul.data.model.*
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class SolveTileUseCaseTest {
private lateinit var useCase: SolveTileUseCase
private lateinit var testStage: CrosswordStage
@Before
fun setup() {
useCase = SolveTileUseCase()
// 3x3 테스트 십자말 그리드:
// 단어 1: 가로 "하늘" (r=0, c=0..1)
// 단어 2: 세로 "하양" (c=0, r=0..1)
// 교차점: (0, 0) = '하'
val cell00 = CrosswordCell(row = 0, col = 0, correctChar = '하', isBlank = false)
val cell01 = CrosswordCell(row = 0, col = 1, correctChar = '늘', isBlank = false)
val cell02 = CrosswordCell(row = 0, col = 2, correctChar = ' ', isBlank = true)
val cell10 = CrosswordCell(row = 1, col = 0, correctChar = '양', isBlank = false)
val cell11 = CrosswordCell(row = 1, col = 1, correctChar = ' ', isBlank = true)
val cell12 = CrosswordCell(row = 1, col = 2, correctChar = ' ', isBlank = true)
val cell20 = CrosswordCell(row = 2, col = 0, correctChar = ' ', isBlank = true)
val cell21 = CrosswordCell(row = 2, col = 1, correctChar = ' ', isBlank = true)
val cell22 = CrosswordCell(row = 2, col = 2, correctChar = ' ', isBlank = true)
val grid = listOf(
listOf(cell00, cell01, cell02),
listOf(cell10, cell11, cell12),
listOf(cell20, cell21, cell22)
)
val clues = listOf(
CrosswordClue("c1", 1, Direction.ACROSS, "하늘", "푸른 하늘", 0, 0, 2),
CrosswordClue("c2", 2, Direction.DOWN, "하양", "하얀 색", 0, 0, 2)
)
val tilePool = listOf(
TileItem("t1", '하', isUsed = false),
TileItem("t2", '늘', isUsed = false),
TileItem("t3", '양', isUsed = false)
)
testStage = CrosswordStage(
stageNumber = 1,
title = "테스트 스테이지",
gridSize = 3,
difficulty = 1,
clues = clues,
grid = grid,
tilePool = tilePool
)
}
@Test
fun `normalMode_insertChar_advancesToNextCellInWord_orClosestCell()`() {
val tileHa = testStage.tilePool.first { it.character == '하' }
// (0,0)에 '하' 입력 시, 가로 단어 "하늘"의 다음 칸인 (0,1)로 포커스가 자동 이동해야 함
val result1 = useCase.processNormalTileTap(
stage = testStage,
tile = tileHa,
focusedRow = 0,
focusedCol = 0,
activeClue = testStage.clues[0] // 가로 1 "하늘"
)
assertNotNull(result1)
assertEquals(0, result1!!.nextFocusedRow)
assertEquals(1, result1.nextFocusedCol)
assertEquals(testStage.clues[0], result1.nextActiveClue)
// (0,1)에 '늘' 입력 시 가로 단어가 완성되므로, 남은 가장 가까운 빈 칸인 (1,0) '양'으로 포커스 점프해야 함
val tileNeul = result1.updatedStage.tilePool.first { it.character == '늘' }
val result2 = useCase.processNormalTileTap(
stage = result1.updatedStage,
tile = tileNeul,
focusedRow = 0,
focusedCol = 1,
activeClue = testStage.clues[0]
)
assertNotNull(result2)
assertEquals(1, result2!!.nextFocusedRow)
assertEquals(0, result2.nextFocusedCol)
assertEquals(testStage.clues[1], result2.nextActiveClue) // 세로 단어 "하양"으로 단어 전환
}
@Test
fun `easyMode_tapTile_automaticallyInsertsIntoCorrectCell_andClears`() {
val tileHa = testStage.tilePool.first { it.character == '하' }
val res1 = useCase.processEasyTileTap(testStage, tileHa)
assertNotNull(res1)
assertFalse(res1!!.isStageClear)
assertEquals('하', res1.updatedStage.grid[0][0].userChar)
val tileNeul = res1.updatedStage.tilePool.first { it.character == '늘' }
val res2 = useCase.processEasyTileTap(res1.updatedStage, tileNeul)
assertNotNull(res2)
assertFalse(res2!!.isStageClear)
assertEquals('늘', res2.updatedStage.grid[0][1].userChar)
val tileYang = res2.updatedStage.tilePool.first { it.character == '양' }
val res3 = useCase.processEasyTileTap(res2.updatedStage, tileYang)
assertNotNull(res3)
assertTrue(res3!!.isStageClear)
assertEquals('양', res3.updatedStage.grid[1][0].userChar)
}
@Test
fun `removeCellChar_restoresTileToPool`() {
val tileHa = testStage.tilePool.first { it.character == '하' }
val res = useCase.processEasyTileTap(testStage, tileHa)!!
val stageAfterRemove = useCase.removeCellChar(res.updatedStage, 0, 0)
assertNull(stageAfterRemove.grid[0][0].userChar)
assertFalse(stageAfterRemove.tilePool.first { it.id == tileHa.id }.isUsed)
}
@Test
fun `applyHint_revealsOneCell`() {
val hintResult = useCase.applyHint(testStage, testStage.clues[0])
assertNotNull(hintResult)
val filledCells = hintResult!!.updatedStage.grid.flatten().filter { it.isFilled }
assertEquals(1, filledCells.size)
assertTrue(filledCells[0].isRevealedByHint)
assertEquals(filledCells[0].correctChar, filledCells[0].userChar)
}
}