first commit
This commit is contained in:
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user