icon 추가
@@ -11,9 +11,9 @@
|
||||
<application
|
||||
android:name=".WordPuzzleApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@android:drawable/ic_dialog_info"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@android:drawable/ic_dialog_info"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.WordPuzzle">
|
||||
|
||||
|
||||
@@ -7,22 +7,22 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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.remote.RemoteConfigManager
|
||||
import com.wordpuzzle.hangul.data.repository.StageRepository
|
||||
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.StageSelectionScreen
|
||||
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
|
||||
|
||||
@@ -58,6 +58,7 @@ fun WordPuzzleAppNavHost(
|
||||
val navController = rememberNavController()
|
||||
val currentUnlockedStage by prefs.currentStageFlow.collectAsState(initial = 1)
|
||||
val hintCount by prefs.hintCountFlow.collectAsState(initial = 3)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
@@ -67,9 +68,18 @@ fun WordPuzzleAppNavHost(
|
||||
MainLobbyScreen(
|
||||
currentUnlockedStage = currentUnlockedStage,
|
||||
hintCount = hintCount,
|
||||
onSelectStage = { stageNum ->
|
||||
onContinueGame = { stageNum ->
|
||||
navController.navigate("game/$stageNum")
|
||||
},
|
||||
onRestartGame = {
|
||||
coroutineScope.launch {
|
||||
prefs.setStageCleared(0) // 진행 단계 1단계로 리셋
|
||||
navController.navigate("game/1")
|
||||
}
|
||||
},
|
||||
onNavigateToStageSelection = {
|
||||
navController.navigate("stage_selection")
|
||||
},
|
||||
onNavigateToVocabulary = {
|
||||
navController.navigate("vocabulary")
|
||||
},
|
||||
@@ -81,6 +91,18 @@ fun WordPuzzleAppNavHost(
|
||||
)
|
||||
}
|
||||
|
||||
composable("stage_selection") {
|
||||
StageSelectionScreen(
|
||||
currentUnlockedStage = currentUnlockedStage,
|
||||
onSelectStage = { stageNum ->
|
||||
navController.navigate("game/$stageNum")
|
||||
},
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "game/{stageNumber}",
|
||||
arguments = listOf(navArgument("stageNumber") { type = NavType.IntType })
|
||||
|
||||
@@ -64,11 +64,17 @@ object AdMobManager {
|
||||
fun showRewardedAd(
|
||||
activity: Activity,
|
||||
onUserEarnedReward: () -> Unit,
|
||||
onAdOpened: () -> Unit = {},
|
||||
onAdClosed: () -> Unit = {}
|
||||
) {
|
||||
val currentAd = rewardedAd
|
||||
if (currentAd != null) {
|
||||
currentAd.fullScreenContentCallback = object : FullScreenContentCallback() {
|
||||
override fun onAdShowedFullScreenContent() {
|
||||
Log.d(TAG, "Rewarded ad showed full screen.")
|
||||
onAdOpened()
|
||||
}
|
||||
|
||||
override fun onAdDismissedFullScreenContent() {
|
||||
rewardedAd = null
|
||||
loadRewardedAd(activity)
|
||||
@@ -89,7 +95,6 @@ object AdMobManager {
|
||||
} else {
|
||||
Log.d(TAG, "Rewarded ad was not ready, reloading...")
|
||||
loadRewardedAd(activity)
|
||||
// 테스트 편의상 광고가 아직 로드되지 않은 경우 바로 보상 처리하거나 안내
|
||||
onUserEarnedReward()
|
||||
onAdClosed()
|
||||
}
|
||||
@@ -126,10 +131,19 @@ object AdMobManager {
|
||||
/**
|
||||
* 전면 광고 노출
|
||||
*/
|
||||
fun showInterstitialAd(activity: Activity, onClosed: () -> Unit = {}) {
|
||||
fun showInterstitialAd(
|
||||
activity: Activity,
|
||||
onAdOpened: () -> Unit = {},
|
||||
onClosed: () -> Unit = {}
|
||||
) {
|
||||
val currentAd = interstitialAd
|
||||
if (currentAd != null) {
|
||||
currentAd.fullScreenContentCallback = object : FullScreenContentCallback() {
|
||||
override fun onAdShowedFullScreenContent() {
|
||||
Log.d(TAG, "Interstitial ad showed full screen.")
|
||||
onAdOpened()
|
||||
}
|
||||
|
||||
override fun onAdDismissedFullScreenContent() {
|
||||
interstitialAd = null
|
||||
loadInterstitialAd(activity)
|
||||
|
||||
@@ -18,7 +18,8 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
|
||||
class UserPreferencesManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
const val MAX_DAILY_AD_HINTS = 5
|
||||
const val MAX_DAILY_AD_HINTS = 3 // 일일 무료 광고 시청 횟수 3회로 변경
|
||||
const val MAX_HINT_LIMIT = 5 // 힌트 최대 보유 제한 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")
|
||||
@@ -50,7 +51,7 @@ class UserPreferencesManager(private val context: Context) {
|
||||
val count = if (lastDate == today) {
|
||||
preferences[KEY_DAILY_AD_WATCH_COUNT] ?: 0
|
||||
} else {
|
||||
0 // 새로운 날짜면 리셋
|
||||
0
|
||||
}
|
||||
Pair(count, MAX_DAILY_AD_HINTS)
|
||||
}
|
||||
@@ -113,12 +114,13 @@ class UserPreferencesManager(private val context: Context) {
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val currentHints = preferences[KEY_HINT_COUNT] ?: 3
|
||||
|
||||
if (currentCount < MAX_DAILY_AD_HINTS) {
|
||||
// 보유 힌트가 5개 미만이고, 일일 시청 횟수(3회) 미만일 때만 힌트 획득
|
||||
if (currentCount < MAX_DAILY_AD_HINTS && currentHints < MAX_HINT_LIMIT) {
|
||||
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
|
||||
preferences[KEY_HINT_COUNT] = (currentHints + 1).coerceAtMost(MAX_HINT_LIMIT)
|
||||
rewarded = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ data class WordEntity(
|
||||
val meaning: String,
|
||||
val category: String,
|
||||
val difficulty: Int,
|
||||
val example: String,
|
||||
val isLearned: Boolean = false,
|
||||
val isPreLearned: Boolean = false,
|
||||
val learnedTimestamp: Long = 0L
|
||||
@@ -47,7 +46,7 @@ interface WordDao {
|
||||
suspend fun markWordPreLearned(id: String)
|
||||
}
|
||||
|
||||
@Database(entities = [WordEntity::class], version = 1, exportSchema = false)
|
||||
@Database(entities = [WordEntity::class], version = 2, exportSchema = false)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun wordDao(): WordDao
|
||||
|
||||
@@ -61,7 +60,9 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"wordpuzzle_db"
|
||||
).build()
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ data class KoreanWord(
|
||||
val meaning: String,
|
||||
val category: String,
|
||||
val difficulty: Int,
|
||||
val example: String,
|
||||
val isLearned: Boolean = false
|
||||
)
|
||||
|
||||
|
||||
@@ -61,6 +61,44 @@ class StageRepository(private val context: Context) {
|
||||
return loadStagesJson().size
|
||||
}
|
||||
|
||||
suspend fun getAllStagesData(): List<StageJsonData> = withContext(Dispatchers.IO) {
|
||||
loadStagesJson()
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 진행 중인 스테이지(currentStage) 및 다음 1~2단계 스테이지의 단어들로 구성된
|
||||
* 단어 미리보기 후보 목록을 생성합니다.
|
||||
*/
|
||||
suspend fun getPreviewWordsForStage(currentStage: Int): List<KoreanWord> = withContext(Dispatchers.IO) {
|
||||
val stages = loadStagesJson()
|
||||
val previewList = mutableListOf<KoreanWord>()
|
||||
val seenWords = mutableSetOf<String>()
|
||||
|
||||
val targetStageNums = listOf(currentStage, currentStage + 1, currentStage + 2)
|
||||
|
||||
for (stNum in targetStageNums) {
|
||||
val stageData = stages.find { it.stageNumber == stNum }
|
||||
if (stageData != null) {
|
||||
val cluesShuffled = stageData.clues.shuffled()
|
||||
for (clue in cluesShuffled) {
|
||||
if (clue.word !in seenWords) {
|
||||
seenWords.add(clue.word)
|
||||
previewList.add(
|
||||
KoreanWord(
|
||||
id = clue.id,
|
||||
word = clue.word,
|
||||
meaning = clue.clue,
|
||||
category = stageData.title,
|
||||
difficulty = stageData.difficulty
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
previewList
|
||||
}
|
||||
|
||||
/**
|
||||
* 스테이지의 순수 그리드 템플릿(글자가 채워지지 않은 상태)을 생성합니다.
|
||||
*/
|
||||
@@ -125,7 +163,6 @@ class StageRepository(private val context: Context) {
|
||||
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(
|
||||
|
||||
@@ -29,7 +29,6 @@ class WordRepository(private val context: Context) {
|
||||
meaning = it.meaning,
|
||||
category = it.category,
|
||||
difficulty = it.difficulty,
|
||||
example = it.example,
|
||||
isLearned = false,
|
||||
isPreLearned = false
|
||||
)
|
||||
@@ -49,7 +48,6 @@ class WordRepository(private val context: Context) {
|
||||
meaning = it.meaning,
|
||||
category = it.category,
|
||||
difficulty = it.difficulty,
|
||||
example = it.example,
|
||||
isLearned = it.isLearned
|
||||
)
|
||||
}
|
||||
@@ -66,12 +64,11 @@ class WordRepository(private val context: Context) {
|
||||
meaning = entity.meaning,
|
||||
category = entity.category,
|
||||
difficulty = entity.difficulty,
|
||||
example = entity.example,
|
||||
isLearned = entity.isLearned
|
||||
)
|
||||
} else {
|
||||
// fallback
|
||||
KoreanWord("def", "일석이조", "한 가지 일로 두 가지 이익을 얻음.", "사자성어", 1, "건강도 챙기고 돈도 아끼니 일석이조다.")
|
||||
KoreanWord("def", "일석이조", "한 가지 일로 두 가지 이익을 얻음.", "사자성어", 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +81,6 @@ class WordRepository(private val context: Context) {
|
||||
meaning = entity.meaning,
|
||||
category = entity.category,
|
||||
difficulty = entity.difficulty,
|
||||
example = entity.example,
|
||||
isLearned = entity.isLearned
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -10,8 +10,8 @@ object TilePoolGenerator {
|
||||
|
||||
/**
|
||||
* 스테이지 그리드와 단어 목록을 바탕으로,
|
||||
* 1) 모든 글자 타일을 무작위로 섞고,
|
||||
* 2) 특정 단어에 힌트 문자가 몰리지 않도록 각 단어(clues)에 골고루 1개씩 문자를 사전 배치합니다.
|
||||
* 1) 사전 힌트 문자는 퍼즐판에 배치하고 하단 타일판에서는 완전히 제거합니다.
|
||||
* 2) 어떤 단어이든 문자 수의 50% 이하만 사전 채워지도록 제한하여 시작 힌트 문자를 골고루 배치합니다.
|
||||
* 3) 새로고침 시에도 이 함수를 호출하여 다른 문자가 새롭게 임의 선택되어 배치됩니다.
|
||||
*/
|
||||
fun generateLayoutWithStarterLetters(
|
||||
@@ -47,21 +47,23 @@ object TilePoolGenerator {
|
||||
cellsInClue
|
||||
}
|
||||
|
||||
// 1. 단어(clues)들을 무작위 순서로 순회하며 각 단어마다 아직 선택되지 않은 셀 중 1개를 선택하여 배치 (특정 단어 몰림 방지)
|
||||
// 1. 단어(clues)들을 무작위 순서로 순회하며, 각 단어의 50% 이하 제한을 준수하면서 1개씩 사전 배치
|
||||
val shuffledClues = clues.shuffled()
|
||||
for (clue in shuffledClues) {
|
||||
if (selectedStarterPositions.size >= targetStarterCount) break
|
||||
|
||||
val cells = clueCellsMap[clue] ?: continue
|
||||
val unselectedCells = cells.filter { it !in selectedStarterPositions }
|
||||
val validCandidates = cells.filter { pos ->
|
||||
pos !in selectedStarterPositions && canAddStarterPosition(pos, selectedStarterPositions, clues, clueCellsMap)
|
||||
}
|
||||
|
||||
if (unselectedCells.isNotEmpty()) {
|
||||
val chosenPos = unselectedCells.shuffled().first()
|
||||
if (validCandidates.isNotEmpty()) {
|
||||
val chosenPos = validCandidates.shuffled().first()
|
||||
selectedStarterPositions.add(chosenPos)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 단어 교차점 중복 선택 등으로 targetStarterCount에 미달한 경우, 남은 셀 중 무작위 추가 선택
|
||||
// 2. 만약 targetStarterCount에 미달한 경우, 50% 이하 제한을 만족하는 남은 셀 중 무작위 추가 선택
|
||||
if (selectedStarterPositions.size < targetStarterCount) {
|
||||
val remainingCells = nonBlankCells
|
||||
.map { it.row to it.col }
|
||||
@@ -70,7 +72,9 @@ object TilePoolGenerator {
|
||||
|
||||
for (pos in remainingCells) {
|
||||
if (selectedStarterPositions.size >= targetStarterCount) break
|
||||
selectedStarterPositions.add(pos)
|
||||
if (canAddStarterPosition(pos, selectedStarterPositions, clues, clueCellsMap)) {
|
||||
selectedStarterPositions.add(pos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,19 +89,44 @@ object TilePoolGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 각 셀과 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 tiles = nonBlankCells
|
||||
.filter { cell -> (cell.row to cell.col) !in selectedStarterPositions }
|
||||
.map { cell ->
|
||||
TileItem(
|
||||
id = UUID.randomUUID().toString(),
|
||||
character = cell.correctChar,
|
||||
isUsed = false
|
||||
)
|
||||
}
|
||||
|
||||
// 5. 타일 목록을 무작위로 섞어서 반환
|
||||
val shuffledTiles = tiles.shuffled()
|
||||
|
||||
return Pair(matrix, shuffledTiles)
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 셀 좌표(pos)를 시작 힌트로 추가했을 때, 해당 셀이 포함된 어떤 단어이든
|
||||
* 전체 단어 길이의 50% 이하(floor(length * 0.5)) 제한을 넘지 않는지 검증합니다.
|
||||
*/
|
||||
private fun canAddStarterPosition(
|
||||
pos: Pair<Int, Int>,
|
||||
currentSelected: Set<Pair<Int, Int>>,
|
||||
clues: List<CrosswordClue>,
|
||||
clueCellsMap: Map<CrosswordClue, List<Pair<Int, Int>>>
|
||||
): Boolean {
|
||||
val tempSelected = currentSelected + pos
|
||||
for (clue in clues) {
|
||||
val cells = clueCellsMap[clue] ?: continue
|
||||
if (pos in cells) {
|
||||
val starterCount = cells.count { it in tempSelected }
|
||||
val maxAllowed = (clue.length * 0.5f).toInt().coerceAtLeast(1)
|
||||
if (starterCount > maxAllowed) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,15 @@ data class TileSolveResult(
|
||||
val nextFocusedRow: Int,
|
||||
val nextFocusedCol: Int,
|
||||
val nextActiveClue: CrosswordClue?,
|
||||
val isStageClear: Boolean
|
||||
val isStageClear: Boolean,
|
||||
val isStageFailed: Boolean = false
|
||||
)
|
||||
|
||||
class SolveTileUseCase {
|
||||
|
||||
/**
|
||||
* 쉬운 모드: 문자 타일을 터치했을 때, 정답인 빈 칸을 찾아 자동으로 삽입하고
|
||||
* 다음 빈 칸(단어 내 다음 빈 칸 또는 가장 가까운 단어의 시작지점 기준 첫 빈 칸)으로 포커스를 자동 이동합니다.
|
||||
* 다음 빈 칸으로 포커스를 자동 이동합니다.
|
||||
*/
|
||||
fun processEasyTileTap(
|
||||
stage: CrosswordStage,
|
||||
@@ -78,9 +79,9 @@ class SolveTileUseCase {
|
||||
}
|
||||
|
||||
val intermediateStage = stage.copy(grid = newGrid, tilePool = newTilePool)
|
||||
val isClear = checkStageClear(intermediateStage)
|
||||
val (isClear, isFailed) = checkStageStatus(intermediateStage)
|
||||
|
||||
// 3. 다음 포커스 위치 및 활성 단어 결정 (단어 완성 시 가장 가까운 단어의 시작지점 기준 첫 빈 칸으로 이동)
|
||||
// 3. 다음 포커스 위치 및 활성 단어 결정
|
||||
val (nextR, nextC, nextClue) = findNextFocusCell(
|
||||
grid = newGrid,
|
||||
clues = stage.clues,
|
||||
@@ -89,7 +90,6 @@ class SolveTileUseCase {
|
||||
currentClue = matchedClue
|
||||
)
|
||||
|
||||
// 최종 그리드에 포커스 상태 반영
|
||||
val finalGrid = newGrid.mapIndexed { r, row ->
|
||||
row.mapIndexed { c, cell ->
|
||||
cell.copy(isFocused = (r == nextR && c == nextC))
|
||||
@@ -101,13 +101,14 @@ class SolveTileUseCase {
|
||||
nextFocusedRow = nextR,
|
||||
nextFocusedCol = nextC,
|
||||
nextActiveClue = nextClue,
|
||||
isStageClear = isClear
|
||||
isStageClear = isClear,
|
||||
isStageFailed = isFailed
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 모드: 선택된 빈 칸에 타일 문자를 삽입하고,
|
||||
* 해당 단어의 다음 빈 칸으로 이동하거나 단어가 완성되면 가장 가까운 단어의 시작지점 기준 첫 빈 칸으로 자동 이동합니다.
|
||||
* 일반 모드: 선택된 빈 칸에 타일 문자를 삽입하고
|
||||
* 다음 빈 칸으로 자동 포커스 이동합니다.
|
||||
*/
|
||||
fun processNormalTileTap(
|
||||
stage: CrosswordStage,
|
||||
@@ -122,7 +123,6 @@ class SolveTileUseCase {
|
||||
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
|
||||
@@ -136,12 +136,10 @@ class SolveTileUseCase {
|
||||
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) {
|
||||
@@ -153,9 +151,8 @@ class SolveTileUseCase {
|
||||
}
|
||||
|
||||
val intermediateStage = stage.copy(grid = newGrid, tilePool = newTilePool)
|
||||
val isClear = checkStageClear(intermediateStage)
|
||||
val (isClear, isFailed) = checkStageStatus(intermediateStage)
|
||||
|
||||
// 다음 포커스 칸 및 단어 찾기
|
||||
val (nextR, nextC, nextClue) = findNextFocusCell(
|
||||
grid = newGrid,
|
||||
clues = stage.clues,
|
||||
@@ -175,16 +172,11 @@ class SolveTileUseCase {
|
||||
nextFocusedRow = nextR,
|
||||
nextFocusedCol = nextC,
|
||||
nextActiveClue = nextClue,
|
||||
isStageClear = isClear
|
||||
isStageClear = isClear,
|
||||
isStageFailed = isFailed
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 포커스 칸 찾기:
|
||||
* 1) 현재 단어(activeClue) 내에 아직 채워지지 않은 다음 빈 칸이 있다면 그 칸으로 순차 이동
|
||||
* 2) 현재 단어가 모두 완성되었다면, 아직 완성되지 않은 단어들 중 '단어 시작 지점(startRow, startCol)'이
|
||||
* 현재 위치와 가장 가까운 단어를 선택하고, 그 단어의 '처음 비어있는 칸'으로 포커스를 이동합니다.
|
||||
*/
|
||||
private fun findNextFocusCell(
|
||||
grid: List<List<CrosswordCell>>,
|
||||
clues: List<CrosswordClue>,
|
||||
@@ -192,7 +184,6 @@ class SolveTileUseCase {
|
||||
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) {
|
||||
@@ -207,17 +198,15 @@ class SolveTileUseCase {
|
||||
}
|
||||
|
||||
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() // 없다면 단어 내 첫 번째 빈 칸
|
||||
} ?: 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) {
|
||||
@@ -235,12 +224,10 @@ class SolveTileUseCase {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -253,13 +240,9 @@ class SolveTileUseCase {
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 칸이 채워졌거나 fallback
|
||||
return Triple(currentR, currentC, currentClue)
|
||||
}
|
||||
|
||||
/**
|
||||
* 채워진 셀 터치 시 글자 취소/삭제
|
||||
*/
|
||||
fun removeCellChar(
|
||||
stage: CrosswordStage,
|
||||
row: Int,
|
||||
@@ -296,24 +279,46 @@ class SolveTileUseCase {
|
||||
}
|
||||
|
||||
/**
|
||||
* 힌트 사용: 미완성된 셀 1개의 정답을 즉시 공개하고 다음 빈 칸으로 포커스 이동
|
||||
* 힌트 사용:
|
||||
* 오직 '완전히 비어있는 칸(userChar == null)'에만 힌트를 적용합니다.
|
||||
* 이미 채워졌거나 힌트로 공개된 칸에는 절대로 힌트가 적용되지 않아 타일 수량이 소실되는 현상을 방지합니다.
|
||||
*/
|
||||
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))
|
||||
|
||||
// 1. 현재 선택된 활성 단어(activeClue) 내의 완전히 비어있는 칸(userChar == null) 우선 검색
|
||||
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.isRevealedByHint) {
|
||||
emptyCells.add(Pair(r, c))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emptyCells.isEmpty()) return null
|
||||
// 2. 현재 선택된 단어에 빈 칸이 없다면 전체 그리드에서 완전히 비어있는 칸(userChar == null) 검색
|
||||
if (emptyCells.isEmpty()) {
|
||||
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.isRevealedByHint) {
|
||||
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
|
||||
|
||||
// 타일 풀에서 미사용 정답 글자 1개를 소진(isUsed = true) 처리
|
||||
var usedTile = false
|
||||
val newTilePool = stage.tilePool.map {
|
||||
if (!usedTile && it.character == answerChar && !it.isUsed) {
|
||||
@@ -333,7 +338,7 @@ class SolveTileUseCase {
|
||||
}
|
||||
|
||||
val intermediateStage = stage.copy(grid = newGrid, tilePool = newTilePool)
|
||||
val isClear = checkStageClear(intermediateStage)
|
||||
val (isClear, isFailed) = checkStageStatus(intermediateStage)
|
||||
|
||||
val (nextR, nextC, nextClue) = findNextFocusCell(
|
||||
grid = newGrid,
|
||||
@@ -354,21 +359,37 @@ class SolveTileUseCase {
|
||||
nextFocusedRow = nextR,
|
||||
nextFocusedCol = nextC,
|
||||
nextActiveClue = nextClue,
|
||||
isStageClear = isClear
|
||||
isStageClear = isClear,
|
||||
isStageFailed = isFailed
|
||||
)
|
||||
}
|
||||
|
||||
private fun checkStageClear(stage: CrosswordStage): Boolean {
|
||||
/**
|
||||
* 스테이지 상태 판별:
|
||||
* - isClear: 모든 칸이 채워지고 모든 글자가 맞음
|
||||
* - isFailed: 모든 칸이 채워졌으나 틀린 글자가 포함되어 있음
|
||||
*/
|
||||
private fun checkStageStatus(stage: CrosswordStage): Pair<Boolean, Boolean> {
|
||||
var isAllFilled = true
|
||||
var isAllCorrect = true
|
||||
|
||||
for (row in stage.grid) {
|
||||
for (cell in row) {
|
||||
if (!cell.isBlank) {
|
||||
if (cell.userChar == null || cell.userChar != cell.correctChar) {
|
||||
return false
|
||||
if (cell.userChar == null) {
|
||||
isAllFilled = false
|
||||
isAllCorrect = false
|
||||
} else if (cell.userChar != cell.correctChar) {
|
||||
isAllCorrect = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
val isClear = isAllFilled && isAllCorrect
|
||||
val isFailed = isAllFilled && !isAllCorrect
|
||||
|
||||
return Pair(isClear, isFailed)
|
||||
}
|
||||
|
||||
private fun isCellInClue(r: Int, c: Int, clue: CrosswordClue): Boolean {
|
||||
|
||||
@@ -51,11 +51,10 @@ object WordNotificationManager {
|
||||
word = entity.word,
|
||||
meaning = entity.meaning,
|
||||
category = entity.category,
|
||||
difficulty = entity.difficulty,
|
||||
example = entity.example
|
||||
difficulty = entity.difficulty
|
||||
)
|
||||
} else {
|
||||
KoreanWord("def", "일석이조", "한 가지 일로 두 가지 이익을 얻음.", "사자성어", 1, "건강도 챙기고 돈도 아끼니 일석이조다.")
|
||||
KoreanWord("def", "일석이조", "한 가지 일로 두 가지 이익을 얻음.", "사자성어", 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +83,7 @@ object WordNotificationManager {
|
||||
// 잠금화면용 확장 스타일 알림 구성
|
||||
val bigTextStyle = NotificationCompat.BigTextStyle()
|
||||
.setBigContentTitle("${word.word} [${word.category}]")
|
||||
.bigText("${word.meaning}\n\n예) “${word.example}”")
|
||||
.bigText(word.meaning)
|
||||
.setSummaryText("한글 십자말풀이")
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
@@ -96,8 +95,8 @@ object WordNotificationManager {
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // 잠금화면에 전체 내용 표시
|
||||
.setOngoing(true) // 상시 고정 알림
|
||||
.setContentIntent(mainPendingIntent)
|
||||
.addAction(android.R.drawable.ic_media_next, "다음 단어 ➡️", nextWordPendingIntent)
|
||||
.addAction(android.R.drawable.ic_media_play, "퍼즐 풀기 🎮", mainPendingIntent)
|
||||
.addAction(R.drawable.ic_media_next, "다음 단어 ➡️", nextWordPendingIntent)
|
||||
.addAction(R.drawable.ic_media_play, "퍼즐 풀기 🎮", mainPendingIntent)
|
||||
|
||||
try {
|
||||
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, builder.build())
|
||||
|
||||
@@ -1,30 +1,70 @@
|
||||
package com.wordpuzzle.hangul.ui.common
|
||||
|
||||
import android.content.Context
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.google.android.gms.ads.AdListener
|
||||
import com.google.android.gms.ads.AdRequest
|
||||
import com.google.android.gms.ads.AdSize
|
||||
import com.google.android.gms.ads.AdView
|
||||
import com.google.android.gms.ads.LoadAdError
|
||||
import com.wordpuzzle.hangul.ads.AdMobManager
|
||||
|
||||
@Composable
|
||||
fun AdBannerView(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AndroidView(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
factory = { context ->
|
||||
AdView(context).apply {
|
||||
setAdSize(AdSize.BANNER)
|
||||
adUnitId = AdMobManager.TEST_BANNER_ID
|
||||
loadAd(AdRequest.Builder().build())
|
||||
.heightIn(min = 50.dp)
|
||||
.wrapContentHeight(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
factory = { ctx ->
|
||||
AdView(ctx).apply {
|
||||
val adaptiveSize = getAdaptiveAdSize(ctx)
|
||||
setAdSize(adaptiveSize)
|
||||
adUnitId = AdMobManager.TEST_BANNER_ID
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
adListener = object : AdListener() {
|
||||
override fun onAdFailedToLoad(error: LoadAdError) {
|
||||
try {
|
||||
setAdSize(AdSize.BANNER)
|
||||
loadAd(AdRequest.Builder().build())
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
loadAd(AdRequest.Builder().build())
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAdaptiveAdSize(context: Context): AdSize {
|
||||
val displayMetrics: DisplayMetrics = context.resources.displayMetrics
|
||||
val adWidthPixels = displayMetrics.widthPixels
|
||||
val density = displayMetrics.density
|
||||
val adWidth = (adWidthPixels / density).toInt()
|
||||
return AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(context, adWidth)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.wordpuzzle.hangul.ui.common
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Gravity
|
||||
import android.widget.Toast
|
||||
|
||||
object ToastUtils {
|
||||
fun showTopToast(context: Context, message: String, duration: Int = Toast.LENGTH_SHORT) {
|
||||
val toast = Toast.makeText(context, message, duration)
|
||||
toast.setGravity(Gravity.TOP or Gravity.CENTER_HORIZONTAL, 0, 180)
|
||||
toast.show()
|
||||
}
|
||||
}
|
||||
@@ -24,22 +24,25 @@ fun ClueBar(
|
||||
activeClue: CrosswordClue?,
|
||||
clues: List<CrosswordClue>,
|
||||
onSwitchDirection: () -> Unit,
|
||||
isExpandedMode: Boolean = false,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val activePalette = getCluePalette(clues, activeClue)
|
||||
val barHeight = if (isExpandedMode) 68.dp else 50.dp
|
||||
val clueFontSize = if (isExpandedMode) 14.sp else 13.sp
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp)
|
||||
.height(barHeight)
|
||||
.padding(horizontal = 10.dp, vertical = 2.dp),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color(0xFFF1F5F9),
|
||||
shadowElevation = 2.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
.padding(horizontal = 10.dp, vertical = 2.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -60,7 +63,7 @@ fun ClueBar(
|
||||
|
||||
AutoResizedText(
|
||||
text = activeClue.clue,
|
||||
targetFontSize = 13.sp,
|
||||
targetFontSize = clueFontSize,
|
||||
minFontSize = 8.5.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color(0xFF263238),
|
||||
@@ -105,11 +108,15 @@ fun AutoResizedText(
|
||||
var resizedFontSize by remember(text) { mutableStateOf(targetFontSize) }
|
||||
var shouldDraw by remember(text) { mutableStateOf(false) }
|
||||
|
||||
// 폰트 크기에 비례하여 줄 간격을 촘촘하게 축소하여 2번째 줄 잘림 완벽 방지
|
||||
val computedLineHeight = (resizedFontSize.value * 1.18f).sp
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
color = color,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = resizedFontSize,
|
||||
lineHeight = computedLineHeight,
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Clip,
|
||||
onTextLayout = { result ->
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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
|
||||
@@ -10,16 +8,13 @@ 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
|
||||
@@ -63,24 +58,24 @@ fun CrosswordGrid(
|
||||
val gridSize = grid.size
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
val availableWidth = maxWidth - 12.dp
|
||||
val availableHeight = maxHeight - 8.dp
|
||||
val boardSize = min(availableWidth, availableHeight).coerceAtLeast(80.dp)
|
||||
// 절대 원칙: 스크린 가로 폭 전체에 100% 무조건 맞춰서 정사각형 퍼즐 보드 생성
|
||||
val fullWidth = maxWidth - 6.dp
|
||||
val boardSize = fullWidth.coerceAtLeast(120.dp)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.size(boardSize),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = Color(0xFFECEFF1),
|
||||
shadowElevation = 3.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(4.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
.padding(3.dp),
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
for (r in 0 until gridSize) {
|
||||
@@ -88,7 +83,8 @@ fun CrosswordGrid(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
for (c in 0 until gridSize) {
|
||||
val cell = grid[r][c]
|
||||
@@ -107,7 +103,7 @@ fun CrosswordGrid(
|
||||
onClick = { onCellClick(r, c) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.aspectRatio(1f)
|
||||
.padding(1.dp)
|
||||
)
|
||||
}
|
||||
@@ -131,7 +127,6 @@ fun GridCellView(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (cell.isBlank) {
|
||||
// 빈 공간 (검은 셀)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
@@ -140,7 +135,6 @@ fun GridCellView(
|
||||
return
|
||||
}
|
||||
|
||||
// 단어별 고유 파스텔 배경색 계산
|
||||
val primaryPalette = if (matchingClues.isNotEmpty()) {
|
||||
getCluePalette(clues, matchingClues.first())
|
||||
} else {
|
||||
@@ -153,11 +147,10 @@ fun GridCellView(
|
||||
|
||||
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)) // 단어 고유 파스텔
|
||||
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) {
|
||||
@@ -173,26 +166,22 @@ fun GridCellView(
|
||||
}
|
||||
|
||||
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
|
||||
gridSize <= 5 -> 22.sp
|
||||
gridSize <= 6 -> 19.sp
|
||||
else -> 16.sp
|
||||
}
|
||||
val badgeFontSize = if (gridSize <= 5) 9.sp else 7.5.sp
|
||||
val badgeFontSize = if (gridSize <= 5) 9.5.sp else 8.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(
|
||||
@@ -206,7 +195,6 @@ fun GridCellView(
|
||||
)
|
||||
}
|
||||
|
||||
// 입력된 한글 문자
|
||||
if (cell.userChar != null) {
|
||||
Text(
|
||||
text = cell.userChar.toString(),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -16,10 +16,15 @@ 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.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.wordpuzzle.hangul.ads.AdMobManager
|
||||
import com.wordpuzzle.hangul.ui.common.AdBannerView
|
||||
import com.wordpuzzle.hangul.ui.common.ToastUtils
|
||||
import com.wordpuzzle.hangul.ui.theme.BackgroundLight
|
||||
import com.wordpuzzle.hangul.ui.theme.PrimaryIndigo
|
||||
import com.wordpuzzle.hangul.ui.theme.SecondaryOrange
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -33,35 +38,56 @@ fun GameScreen(
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val activity = context as? Activity
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
LaunchedEffect(stageNumber) {
|
||||
viewModel.loadStage(stageNumber)
|
||||
}
|
||||
|
||||
// 클리어 이벤트 수신: 팝업 없이 30초 룰에 따라 즉시 또는 전면 광고 후 다음 스테이지 전환
|
||||
// 앱이 백그라운드로 내려가거나 온파즈/온스톱 시 타이머를 일시정지하고, 온리줌 시 재개
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE,
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
viewModel.pauseTimer()
|
||||
}
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
viewModel.resumeTimer()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
// 클리어 이벤트 수신: 상단 메시지 토스트 출력 후 다음 스테이지 전환
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.stageClearEvent.collect { event ->
|
||||
if (event.isLastStage) {
|
||||
Toast.makeText(context, "🎉 모든 스테이지를 클리어하셨습니다!", Toast.LENGTH_SHORT).show()
|
||||
ToastUtils.showTopToast(context, "🎉 모든 스테이지를 클리어하셨습니다!")
|
||||
onNavigateBack()
|
||||
} else if (event.isFastClear) {
|
||||
Toast.makeText(
|
||||
ToastUtils.showTopToast(
|
||||
context,
|
||||
"⚡ ${event.elapsedSeconds}초 만에 빠른 클리어! (광고 없이 통과)",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
"⚡ ${event.elapsedSeconds}초 만에 빠른 클리어! (광고 없이 통과)"
|
||||
)
|
||||
onNavigateToNextStage(event.nextStageNumber)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
ToastUtils.showTopToast(
|
||||
context,
|
||||
"클리어! (소요 시간: ${event.elapsedSeconds}초)",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
"클리어! (소요 시간: ${event.elapsedSeconds}초)"
|
||||
)
|
||||
|
||||
if (activity != null) {
|
||||
AdMobManager.showInterstitialAd(activity) {
|
||||
onNavigateToNextStage(event.nextStageNumber)
|
||||
}
|
||||
AdMobManager.showInterstitialAd(
|
||||
activity = activity,
|
||||
onAdOpened = { viewModel.pauseTimer() },
|
||||
onClosed = { onNavigateToNextStage(event.nextStageNumber) }
|
||||
)
|
||||
} else {
|
||||
onNavigateToNextStage(event.nextStageNumber)
|
||||
}
|
||||
@@ -98,27 +124,45 @@ fun GameScreen(
|
||||
}
|
||||
},
|
||||
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)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.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
|
||||
)
|
||||
// 1. 새로고침 (셔플) 버튼 - 힌트 버튼 왼편 배치
|
||||
IconButton(
|
||||
onClick = { viewModel.onShuffleTiles() },
|
||||
modifier = Modifier.size(34.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "문자 새로고침 및 재배치",
|
||||
tint = PrimaryIndigo,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// 2. 힌트 버튼
|
||||
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(
|
||||
@@ -127,29 +171,45 @@ fun GameScreen(
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
// 하단 AdMob 배너 광고
|
||||
// 하단 AdMob 배너 광고 (항상 안심 노출)
|
||||
AdBannerView()
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.background(BackgroundLight)
|
||||
) {
|
||||
val screenWidth = this.maxWidth
|
||||
val screenHeight = this.maxHeight
|
||||
val screenAspectRatio = if (screenWidth > 0.dp) screenHeight / screenWidth else 1.85f
|
||||
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else if (uiState.stage != null) {
|
||||
val stage = uiState.stage!!
|
||||
|
||||
// 세로 화면 종횡비 구분 (16:9 이하 클래식 / 20:9 이상 울트라 롱)
|
||||
val isClassicRatio = screenAspectRatio <= 1.85f
|
||||
val isUltraLongRatio = screenAspectRatio >= 2.1f
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
.padding(
|
||||
horizontal = if (isClassicRatio) 6.dp else 4.dp,
|
||||
vertical = if (isUltraLongRatio) 6.dp else 2.dp
|
||||
),
|
||||
verticalArrangement = if (isUltraLongRatio) Arrangement.SpaceBetween else Arrangement.Top,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// 1. 중앙 십자말풀이 그리드
|
||||
val gridModifier = if (isClassicRatio) {
|
||||
Modifier.width(screenWidth * 0.92f)
|
||||
} else {
|
||||
Modifier.fillMaxWidth()
|
||||
}
|
||||
|
||||
CrosswordGrid(
|
||||
grid = stage.grid,
|
||||
clues = stage.clues,
|
||||
@@ -157,26 +217,29 @@ fun GameScreen(
|
||||
focusedRow = uiState.focusedRow,
|
||||
focusedCol = uiState.focusedCol,
|
||||
onCellClick = { r, c -> viewModel.onCellClick(r, c) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
modifier = gridModifier
|
||||
)
|
||||
|
||||
// 2. 단어 힌트 텍스트 바
|
||||
Spacer(modifier = Modifier.height(if (isUltraLongRatio) 8.dp else 4.dp))
|
||||
|
||||
ClueBar(
|
||||
activeClue = uiState.activeClue,
|
||||
clues = stage.clues,
|
||||
onSwitchDirection = { viewModel.onSwitchDirection() }
|
||||
onSwitchDirection = { viewModel.onSwitchDirection() },
|
||||
isExpandedMode = isUltraLongRatio
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Spacer(modifier = Modifier.height(if (isUltraLongRatio) 8.dp else 4.dp))
|
||||
|
||||
// 3. 하단 문자 타일 풀
|
||||
TilePoolView(
|
||||
tiles = stage.tilePool,
|
||||
starterLettersCount = stage.starterLettersCount,
|
||||
onTileClick = { tile -> viewModel.onTileClick(tile) },
|
||||
onShuffle = { viewModel.onShuffleTiles() }
|
||||
isExpandedMode = isUltraLongRatio,
|
||||
modifier = if (isUltraLongRatio) {
|
||||
Modifier.weight(1f).fillMaxWidth()
|
||||
} else {
|
||||
Modifier.wrapContentHeight().fillMaxWidth()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -196,6 +259,14 @@ fun GameScreen(
|
||||
onDismiss = { viewModel.setShowHintDialog(false) }
|
||||
)
|
||||
}
|
||||
|
||||
// 오답 완료 다이얼로그 (모든 칸을 채웠으나 틀린 경우)
|
||||
if (uiState.isStageFailed) {
|
||||
StageFailedDialog(
|
||||
onRestart = { viewModel.onRestartStageOnFailure() },
|
||||
onNavigateHome = onNavigateBack
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ data class GameUiState(
|
||||
val dailyAdWatchedCount: Int = 0,
|
||||
val dailyMaxAdLimit: Int = 5,
|
||||
val isStageClear: Boolean = false,
|
||||
val isStageFailed: Boolean = false,
|
||||
val showHintDialog: Boolean = false,
|
||||
val isLoading: Boolean = true
|
||||
)
|
||||
@@ -49,6 +50,10 @@ class GameViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val stageClearEvent: SharedFlow<StageClearEvent> = _stageClearEvent.asSharedFlow()
|
||||
|
||||
private var stageStartTimeMillis: Long = System.currentTimeMillis()
|
||||
private var totalPausedDurationMillis: Long = 0L
|
||||
private var pauseStartTimeMillis: Long = 0L
|
||||
private var isTimerPaused: Boolean = false
|
||||
|
||||
private var cleanTemplateGrid: List<List<CrosswordCell>>? = null
|
||||
|
||||
init {
|
||||
@@ -72,7 +77,7 @@ class GameViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
fun loadStage(stageNumber: Int) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, isStageClear = false) }
|
||||
_uiState.update { it.copy(isLoading = true, isStageClear = false, isStageFailed = false) }
|
||||
|
||||
val template = stageRepo.getCleanStageTemplate(stageNumber)
|
||||
cleanTemplateGrid = template?.first
|
||||
@@ -80,9 +85,11 @@ class GameViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val stage = stageRepo.getStage(stageNumber)
|
||||
|
||||
if (stage != null) {
|
||||
stageStartTimeMillis = System.currentTimeMillis() // 스테이지 시작 시간 기록
|
||||
stageStartTimeMillis = System.currentTimeMillis()
|
||||
totalPausedDurationMillis = 0L
|
||||
pauseStartTimeMillis = 0L
|
||||
isTimerPaused = false
|
||||
|
||||
// 시작 시 아직 비어있는 첫 번째 셀 탐색
|
||||
val firstEmpty = findFirstEmptyCell(stage.grid) ?: findFirstNonBlankCell(stage.grid)
|
||||
val (initialR, initialC) = firstEmpty
|
||||
|
||||
@@ -109,12 +116,40 @@ class GameViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
fun pauseTimer() {
|
||||
if (!isTimerPaused) {
|
||||
isTimerPaused = true
|
||||
pauseStartTimeMillis = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
fun resumeTimer() {
|
||||
if (isTimerPaused) {
|
||||
if (pauseStartTimeMillis > 0L) {
|
||||
totalPausedDurationMillis += (System.currentTimeMillis() - pauseStartTimeMillis)
|
||||
}
|
||||
pauseStartTimeMillis = 0L
|
||||
isTimerPaused = false
|
||||
}
|
||||
}
|
||||
|
||||
fun getEffectiveElapsedSeconds(): Long {
|
||||
val now = System.currentTimeMillis()
|
||||
val activePausedDuration = if (isTimerPaused && pauseStartTimeMillis > 0L) {
|
||||
now - pauseStartTimeMillis
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
val totalPaused = totalPausedDurationMillis + activePausedDuration
|
||||
val effectiveMillis = (now - stageStartTimeMillis - totalPaused).coerceAtLeast(0L)
|
||||
return effectiveMillis / 1000L
|
||||
}
|
||||
|
||||
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) }
|
||||
@@ -145,7 +180,6 @@ class GameViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val currentState = _uiState.value
|
||||
val stage = currentState.stage ?: return
|
||||
|
||||
// 포커스된 칸에 타일을 입력하고 다음 빈 칸으로 자동 포커스 이동
|
||||
val result = solveTileUseCase.processNormalTileTap(
|
||||
stage = stage,
|
||||
tile = tile,
|
||||
@@ -164,7 +198,8 @@ class GameViewModel(application: Application) : AndroidViewModel(application) {
|
||||
focusedRow = result.nextFocusedRow,
|
||||
focusedCol = result.nextFocusedCol,
|
||||
activeClue = result.nextActiveClue,
|
||||
isStageClear = result.isStageClear
|
||||
isStageClear = result.isStageClear,
|
||||
isStageFailed = result.isStageFailed
|
||||
)
|
||||
}
|
||||
|
||||
@@ -188,10 +223,6 @@ class GameViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리프레시(새로고침) 버튼 클릭 시:
|
||||
* 전체 문자를 다시 셔플하고, 다른 문자가 새롭게 임의 선택되어 다른 위치에 사전 배치됩니다.
|
||||
*/
|
||||
fun onShuffleTiles() {
|
||||
val currentStage = _uiState.value.stage ?: return
|
||||
val baseGrid = cleanTemplateGrid ?: return
|
||||
@@ -219,12 +250,27 @@ class GameViewModel(application: Application) : AndroidViewModel(application) {
|
||||
),
|
||||
activeClue = initialClue,
|
||||
focusedRow = initialR,
|
||||
focusedCol = initialC
|
||||
focusedCol = initialC,
|
||||
isStageFailed = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 오답 팝업에서 [재시작] 버튼 터치 시:
|
||||
* 타이머(전체광고용 경과 시간)는 유지한 채, 힌트 문자를 새롭게 재배치하고 게임판을 초기화합니다.
|
||||
*/
|
||||
fun onRestartStageOnFailure() {
|
||||
_uiState.update { it.copy(isStageFailed = false) }
|
||||
onShuffleTiles()
|
||||
}
|
||||
|
||||
fun setShowHintDialog(show: Boolean) {
|
||||
if (show) {
|
||||
pauseTimer()
|
||||
} else {
|
||||
resumeTimer()
|
||||
}
|
||||
_uiState.update { it.copy(showHintDialog = show) }
|
||||
}
|
||||
|
||||
@@ -243,7 +289,8 @@ class GameViewModel(application: Application) : AndroidViewModel(application) {
|
||||
focusedRow = result.nextFocusedRow,
|
||||
focusedCol = result.nextFocusedCol,
|
||||
activeClue = result.nextActiveClue,
|
||||
isStageClear = result.isStageClear
|
||||
isStageClear = result.isStageClear,
|
||||
isStageFailed = result.isStageFailed
|
||||
)
|
||||
}
|
||||
if (result.isStageClear) {
|
||||
@@ -255,19 +302,26 @@ class GameViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
fun onWatchAdForHint(activity: Activity) {
|
||||
pauseTimer()
|
||||
AdMobManager.showRewardedAd(
|
||||
activity = activity,
|
||||
onUserEarnedReward = {
|
||||
viewModelScope.launch {
|
||||
prefs.addHintFromAd()
|
||||
}
|
||||
},
|
||||
onAdOpened = {
|
||||
pauseTimer()
|
||||
},
|
||||
onAdClosed = {
|
||||
resumeTimer()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleStageClear(stage: CrosswordStage) {
|
||||
viewModelScope.launch {
|
||||
val elapsedSeconds = ((System.currentTimeMillis() - stageStartTimeMillis) / 1000).coerceAtLeast(0)
|
||||
val elapsedSeconds = getEffectiveElapsedSeconds()
|
||||
val isFastClear = elapsedSeconds <= 30L
|
||||
|
||||
prefs.setStageCleared(stage.stageNumber)
|
||||
|
||||
@@ -23,12 +23,15 @@ fun HintRewardDialog(
|
||||
currentHints: Int,
|
||||
dailyAdWatchedCount: Int,
|
||||
dailyMaxAdLimit: Int,
|
||||
onUseHint: () -> Unit,
|
||||
onUseHint: () -> Unit = {},
|
||||
onWatchAd: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
onDismiss: () -> Unit,
|
||||
showUseHintButton: Boolean = true
|
||||
) {
|
||||
val maxHintLimit = 5
|
||||
val isHintMaxed = currentHints >= maxHintLimit
|
||||
val remainingAdCount = (dailyMaxAdLimit - dailyAdWatchedCount).coerceAtLeast(0)
|
||||
val isAdAvailable = remainingAdCount > 0
|
||||
val isAdAvailable = remainingAdCount > 0 && !isHintMaxed
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
@@ -50,7 +53,7 @@ fun HintRewardDialog(
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "힌트 도움받기",
|
||||
text = if (showUseHintButton) "힌트 도움받기" else "힌트 충전 및 보유 현황",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFF212121)
|
||||
@@ -59,15 +62,16 @@ fun HintRewardDialog(
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "보유 힌트: ${currentHints}개",
|
||||
text = "보유 힌트: ${currentHints}개 / 최대 ${maxHintLimit}개",
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = PrimaryIndigo
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (currentHints > 0) {
|
||||
// 게임 화면일 경우에만 [힌트 1개 사용하기] 버튼 노출
|
||||
if (showUseHintButton && currentHints > 0) {
|
||||
Button(
|
||||
onClick = {
|
||||
onUseHint()
|
||||
@@ -110,12 +114,23 @@ fun HintRewardDialog(
|
||||
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)
|
||||
)
|
||||
|
||||
if (isHintMaxed) {
|
||||
Text(
|
||||
text = "⚠️ 힌트 최대 보유 개수(5개)에 도달했습니다.",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = SecondaryOrange,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "오늘 남은 시청 횟수: $remainingAdCount / $dailyMaxAdLimit",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (remainingAdCount > 0) PrimaryIndigo else Color(0xFFE53935)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,12 +146,19 @@ fun HintRewardDialog(
|
||||
enabled = isAdAvailable,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PrimaryIndigo)
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PrimaryIndigo,
|
||||
disabledContainerColor = Color(0xFFE0E0E0)
|
||||
)
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.PlayCircleOutline, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = if (isAdAvailable) "광고 보고 힌트 받기" else "오늘 힌트 한도 소진",
|
||||
text = when {
|
||||
isHintMaxed -> "힌트 최대 보유(5개) 중"
|
||||
remainingAdCount <= 0 -> "오늘 힌트 시청 한도 소진"
|
||||
else -> "광고 보고 힌트 받기"
|
||||
},
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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.Cancel
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun StageFailedDialog(
|
||||
onRestart: () -> Unit,
|
||||
onNavigateHome: () -> Unit
|
||||
) {
|
||||
Dialog(onDismissRequest = {}) {
|
||||
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.Cancel,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFE53935),
|
||||
modifier = Modifier.size(56.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "틀린 답이 포함되어 있습니다!",
|
||||
fontSize = 19.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFF212121),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
Text(
|
||||
text = "모든 칸을 채웠지만 정답과 다른 곳이 있습니다.\n다시 도전해보세요!",
|
||||
fontSize = 13.sp,
|
||||
color = Color(0xFF757575),
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// 재시작 버튼
|
||||
Button(
|
||||
onClick = onRestart,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PrimaryIndigo)
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Refresh, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "재시작 (힌트 문자 재배치)",
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
// 메인으로 돌아가기 버튼
|
||||
OutlinedButton(
|
||||
onClick = onNavigateHome,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFF616161))
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Home, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "메인으로 돌아가기",
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
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
|
||||
@@ -17,6 +14,8 @@ 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.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.wordpuzzle.hangul.data.model.TileItem
|
||||
@@ -27,75 +26,74 @@ import com.wordpuzzle.hangul.ui.theme.PrimaryIndigoLight
|
||||
@Composable
|
||||
fun TilePoolView(
|
||||
tiles: List<TileItem>,
|
||||
starterLettersCount: Int,
|
||||
onTileClick: (TileItem) -> Unit,
|
||||
onShuffle: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
isExpandedMode: Boolean = false
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
.padding(
|
||||
horizontal = if (isExpandedMode) 8.dp else 6.dp,
|
||||
vertical = if (isExpandedMode) 4.dp else 2.dp
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = Color.White,
|
||||
shadowElevation = 4.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
.padding(
|
||||
horizontal = if (isExpandedMode) 12.dp else 10.dp,
|
||||
vertical = if (isExpandedMode) 10.dp else 6.dp
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
// 상단 컨트롤 바 (시작 힌트 안내 뱃지 및 새로고침 버튼)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
// 하단 문자 패널 - 사용 가능한 타일만 노출
|
||||
val availableTiles = tiles.filter { !it.isUsed }
|
||||
|
||||
IconButton(
|
||||
onClick = onShuffle,
|
||||
modifier = Modifier.size(26.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "문자 새로고침 및 재배치",
|
||||
tint = Color(0xFF607D8B),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
val tileSize = if (isExpandedMode) {
|
||||
when {
|
||||
availableTiles.size > 16 -> 50.dp
|
||||
availableTiles.size > 10 -> 58.dp
|
||||
else -> 66.dp
|
||||
}
|
||||
} else {
|
||||
when {
|
||||
availableTiles.size > 16 -> 36.dp
|
||||
availableTiles.size > 10 -> 40.dp
|
||||
else -> 44.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
|
||||
val fontSize = if (isExpandedMode) {
|
||||
when {
|
||||
availableTiles.size > 16 -> 24.sp
|
||||
availableTiles.size > 10 -> 28.sp
|
||||
else -> 32.sp
|
||||
}
|
||||
} else {
|
||||
when {
|
||||
availableTiles.size > 16 -> 16.sp
|
||||
availableTiles.size > 10 -> 18.sp
|
||||
else -> 20.sp
|
||||
}
|
||||
}
|
||||
|
||||
val spacingDp = if (isExpandedMode) 8.dp else 5.dp
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp)
|
||||
.wrapContentHeight()
|
||||
.padding(vertical = if (isExpandedMode) 4.dp else 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacingDp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(spacingDp)
|
||||
) {
|
||||
tiles.forEach { tile ->
|
||||
availableTiles.forEach { tile ->
|
||||
LetterTileButton(
|
||||
tile = tile,
|
||||
size = tileSize,
|
||||
@@ -111,34 +109,28 @@ fun TilePoolView(
|
||||
@Composable
|
||||
fun LetterTileButton(
|
||||
tile: TileItem,
|
||||
size: androidx.compose.ui.unit.Dp = 38.dp,
|
||||
fontSize: androidx.compose.ui.unit.TextUnit = 17.sp,
|
||||
size: Dp = 40.dp,
|
||||
fontSize: TextUnit = 18.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)
|
||||
)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(PrimaryIndigoLight)
|
||||
.border(
|
||||
width = if (isEnabled) 1.5.dp else 1.dp,
|
||||
color = if (isEnabled) PrimaryIndigo else Color(0xFFBDBDBD),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
width = 1.8.dp,
|
||||
color = PrimaryIndigo,
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
.clickable(enabled = isEnabled, onClick = onClick),
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = tile.character.toString(),
|
||||
fontSize = fontSize,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = if (isEnabled) PrimaryIndigo else Color(0xFF9E9E9E)
|
||||
color = PrimaryIndigo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ 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
|
||||
@@ -41,7 +40,7 @@ class LockScreenActivity : ComponentActivity() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(true)
|
||||
setTurnScreenOn(true)
|
||||
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
val keyguardManager = getSystemService(KEYGUARD_SERVICE) as KeyguardManager
|
||||
keyguardManager.requestDismissKeyguard(this, null)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -67,11 +66,10 @@ class LockScreenActivity : ComponentActivity() {
|
||||
word = candidate.word,
|
||||
meaning = candidate.meaning,
|
||||
category = candidate.category,
|
||||
difficulty = candidate.difficulty,
|
||||
example = candidate.example
|
||||
difficulty = candidate.difficulty
|
||||
)
|
||||
} else {
|
||||
KoreanWord("def", "일석이조", "돌 하나로 새 두 마리를 잡는다는 사자성어.", "사자성어", 1, "건강도 챙기고 돈도 아끼니 일석이조다.")
|
||||
KoreanWord("def", "일석이조", "돌 하나로 새 두 마리를 잡는다는 사자성어.", "사자성어", 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,22 +180,6 @@ fun LockScreenContent(
|
||||
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 {
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
package com.wordpuzzle.hangul.ui.main
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
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.automirrored.filled.ArrowForwardIos
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -20,15 +19,19 @@ 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.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.wordpuzzle.hangul.ads.AdMobManager
|
||||
import com.wordpuzzle.hangul.data.datastore.UserPreferencesManager
|
||||
import com.wordpuzzle.hangul.data.model.KoreanWord
|
||||
import com.wordpuzzle.hangul.data.repository.StageRepository
|
||||
import com.wordpuzzle.hangul.data.repository.WordRepository
|
||||
import com.wordpuzzle.hangul.ui.common.AdBannerView
|
||||
import com.wordpuzzle.hangul.ui.game.HintRewardDialog
|
||||
import com.wordpuzzle.hangul.ui.theme.PrimaryIndigo
|
||||
import com.wordpuzzle.hangul.ui.theme.SecondaryOrange
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -39,26 +42,55 @@ import kotlinx.coroutines.launch
|
||||
fun MainLobbyScreen(
|
||||
currentUnlockedStage: Int,
|
||||
hintCount: Int,
|
||||
onSelectStage: (Int) -> Unit,
|
||||
onContinueGame: (Int) -> Unit,
|
||||
onRestartGame: () -> Unit,
|
||||
onNavigateToStageSelection: () -> Unit,
|
||||
onNavigateToVocabulary: () -> Unit,
|
||||
onNavigateToSettings: () -> Unit,
|
||||
wordRepo: WordRepository,
|
||||
prefs: UserPreferencesManager
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val stageRepo = remember { StageRepository(context) }
|
||||
|
||||
var previewWordList by remember { mutableStateOf<List<KoreanWord>>(emptyList()) }
|
||||
var previewWordIndex by remember { mutableIntStateOf(0) }
|
||||
var previewWord by remember { mutableStateOf<KoreanWord?>(null) }
|
||||
var isLockScreenEnabled by remember { mutableStateOf(false) }
|
||||
var totalStages by remember { mutableIntStateOf(100) }
|
||||
var showRestartDialog by remember { mutableStateOf(false) }
|
||||
var showHintRewardDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val adInfo by prefs.dailyAdWatchInfoFlow.collectAsState(initial = Pair(0, 5))
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
// 현재 진행 중인 스테이지(currentUnlockedStage) 및 +1, +2 단계 후 스테이지의 단어들로 미리보기 후보군 구성
|
||||
LaunchedEffect(currentUnlockedStage) {
|
||||
val words = stageRepo.getPreviewWordsForStage(currentUnlockedStage)
|
||||
previewWordList = words
|
||||
previewWordIndex = 0
|
||||
if (words.isNotEmpty()) {
|
||||
previewWord = words.first()
|
||||
} else {
|
||||
previewWord = wordRepo.getRandomWord()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
previewWord = wordRepo.getWordForWidget()
|
||||
prefs.lockscreenEnabledFlow.collect {
|
||||
isLockScreenEnabled = it
|
||||
val count = stageRepo.getTotalStageCount()
|
||||
if (count > 0) {
|
||||
totalStages = count
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
AdBannerView()
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color.White,
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
AdBannerView()
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
@@ -68,7 +100,7 @@ fun MainLobbyScreen(
|
||||
.background(Color(0xFFF7F9FC))
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// 상단 헤더 영역
|
||||
// [1. 상단 헤더 영역]
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -111,7 +143,7 @@ fun MainLobbyScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 힌트 및 학습 현황 카드
|
||||
// 힌트 및 나의 단어장 현황 카드 (보유 힌트 클릭 시 충전 팝업 오픈)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
@@ -119,7 +151,9 @@ fun MainLobbyScreen(
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White.copy(alpha = 0.15f),
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable { showHintRewardDialog = true }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(10.dp),
|
||||
@@ -133,7 +167,7 @@ fun MainLobbyScreen(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Column {
|
||||
Text(text = "보유 힌트", fontSize = 10.sp, color = Color(0xFFE0E0E0))
|
||||
Text(text = "보유 힌트 (충전 >)", fontSize = 10.sp, color = Color(0xFFE0E0E0))
|
||||
Text(
|
||||
text = "${hintCount}개",
|
||||
fontSize = 15.sp,
|
||||
@@ -177,17 +211,33 @@ fun MainLobbyScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 퍼즐 단어 미리보기 카드 (단어 설명 영역 2줄 고정 높이 적용하여 하단 레이아웃 위치 완전 고정)
|
||||
// [2. 퍼즐 단어 미리보기 카드]
|
||||
if (previewWord != null) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.clickable {
|
||||
coroutineScope.launch {
|
||||
val nextWord = wordRepo.getRandomWord()
|
||||
if (nextWord != null) {
|
||||
previewWord = nextWord
|
||||
if (previewWordList.isNotEmpty()) {
|
||||
val nextIdx = previewWordIndex + 1
|
||||
if (nextIdx < previewWordList.size) {
|
||||
previewWordIndex = nextIdx
|
||||
previewWord = previewWordList[nextIdx]
|
||||
} else {
|
||||
val randomWord = wordRepo.getRandomWord()
|
||||
if (randomWord != null) {
|
||||
previewWord = randomWord
|
||||
} else {
|
||||
previewWordIndex = 0
|
||||
previewWord = previewWordList.firstOrNull()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val randomWord = wordRepo.getRandomWord()
|
||||
if (randomWord != null) {
|
||||
previewWord = randomWord
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -195,7 +245,7 @@ fun MainLobbyScreen(
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -216,14 +266,9 @@ fun MainLobbyScreen(
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "[${previewWord!!.category}]",
|
||||
fontSize = 11.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = previewWord!!.word,
|
||||
@@ -232,62 +277,155 @@ fun MainLobbyScreen(
|
||||
color = Color(0xFF212121)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
AutoVerticalScrollingText(
|
||||
text = previewWord!!.meaning,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 17.sp,
|
||||
color = Color(0xFF616161),
|
||||
height = 38.dp
|
||||
height = 36.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 스테이지 선택 타이틀 (항상 고정된 위치 유지)
|
||||
Row(
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
// [3. 메인 대형 가로 버튼 3개 (이어하기 / 새로하기 / 스테이지 선택)]
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "스테이지 선택",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFF212121)
|
||||
// 1) 이어하기 버튼
|
||||
MainActionButton(
|
||||
title = "▶️ 이어하기",
|
||||
subtitle = "현재 스테이지 $currentUnlockedStage 도전하기",
|
||||
containerColor = PrimaryIndigo,
|
||||
contentColor = Color.White,
|
||||
onClick = { onContinueGame(currentUnlockedStage) }
|
||||
)
|
||||
|
||||
// 2) 새로하기 버튼
|
||||
MainActionButton(
|
||||
title = "🔄 새로하기",
|
||||
subtitle = "1단계부터 처음으로 도전하기",
|
||||
containerColor = SecondaryOrange,
|
||||
contentColor = Color.White,
|
||||
onClick = { showRestartDialog = true }
|
||||
)
|
||||
|
||||
// 3) 스테이지 선택 버튼
|
||||
MainActionButton(
|
||||
title = "🎮 스테이지 선택",
|
||||
subtitle = "${totalStages}개 스테이지 전체 목록 보기",
|
||||
containerColor = Color(0xFF00897B),
|
||||
contentColor = Color.White,
|
||||
onClick = onNavigateToStageSelection
|
||||
)
|
||||
}
|
||||
|
||||
// 스테이지 그리드
|
||||
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
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
StageCard(
|
||||
stageNumber = stageNum,
|
||||
isUnlocked = isUnlocked,
|
||||
isCurrent = isCurrent,
|
||||
// 힌트 충전 및 보유 현황 팝업 (사용하기 버튼은 숨김 모드)
|
||||
if (showHintRewardDialog) {
|
||||
val activity = context as? Activity
|
||||
HintRewardDialog(
|
||||
currentHints = hintCount,
|
||||
dailyAdWatchedCount = adInfo.first,
|
||||
dailyMaxAdLimit = adInfo.second,
|
||||
showUseHintButton = false,
|
||||
onWatchAd = {
|
||||
activity?.let { act ->
|
||||
AdMobManager.showRewardedAd(
|
||||
activity = act,
|
||||
onUserEarnedReward = {
|
||||
coroutineScope.launch {
|
||||
prefs.addHintFromAd()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
onDismiss = { showHintRewardDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
// 새로하기 확인 대화상자
|
||||
if (showRestartDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRestartDialog = false },
|
||||
title = { Text("새로하기 확인", fontWeight = FontWeight.Bold) },
|
||||
text = { Text("1단계부터 처음으로 새로 시작하시겠습니까?\n(현재 진행 상황이 1단계로 리셋됩니다.)") },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (isUnlocked) onSelectStage(stageNum)
|
||||
}
|
||||
)
|
||||
showRestartDialog = false
|
||||
onRestartGame()
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = SecondaryOrange)
|
||||
) {
|
||||
Text("새로 시작")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRestartDialog = false }) {
|
||||
Text("취소")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainActionButton(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
containerColor: Color,
|
||||
contentColor: Color,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(68.dp)
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = containerColor),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 3.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = contentColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontSize = 11.5.sp,
|
||||
color = contentColor.copy(alpha = 0.85f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos,
|
||||
contentDescription = null,
|
||||
tint = contentColor.copy(alpha = 0.85f),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -299,7 +437,7 @@ fun AutoVerticalScrollingText(
|
||||
color: Color = Color.Unspecified,
|
||||
fontSize: TextUnit = 12.sp,
|
||||
lineHeight: TextUnit = 17.sp,
|
||||
height: Dp = 38.dp
|
||||
height: Dp = 36.dp
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
@@ -338,57 +476,3 @@ fun AutoVerticalScrollingText(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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,245 @@
|
||||
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.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.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
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.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 com.wordpuzzle.hangul.data.model.StageJsonData
|
||||
import com.wordpuzzle.hangul.data.repository.StageRepository
|
||||
import com.wordpuzzle.hangul.ui.common.AdBannerView
|
||||
import com.wordpuzzle.hangul.ui.theme.BackgroundLight
|
||||
import com.wordpuzzle.hangul.ui.theme.PrimaryIndigo
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StageSelectionScreen(
|
||||
currentUnlockedStage: Int,
|
||||
onSelectStage: (Int) -> Unit,
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val stageRepo = remember { StageRepository(context) }
|
||||
var stagesList by remember { mutableStateOf<List<StageJsonData>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
stagesList = stageRepo.getAllStagesData()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "스테이지 선택 (${stagesList.size}개)",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "뒤로가기"
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = BackgroundLight)
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
AdBannerView()
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.background(BackgroundLight)
|
||||
) {
|
||||
if (stagesList.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
items(stagesList, key = { it.stageNumber }) { stage ->
|
||||
val isUnlocked = stage.stageNumber <= currentUnlockedStage
|
||||
val isCurrent = stage.stageNumber == currentUnlockedStage
|
||||
|
||||
StageSelectionCard(
|
||||
stageNumber = stage.stageNumber,
|
||||
title = stage.title,
|
||||
difficulty = stage.difficulty,
|
||||
isUnlocked = isUnlocked,
|
||||
isCurrent = isCurrent,
|
||||
onClick = {
|
||||
if (isUnlocked) {
|
||||
onSelectStage(stage.stageNumber)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StageSelectionCard(
|
||||
stageNumber: Int,
|
||||
title: String,
|
||||
difficulty: Int,
|
||||
isUnlocked: Boolean,
|
||||
isCurrent: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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) 2.dp else 0.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = when {
|
||||
isCurrent -> Color.White.copy(alpha = 0.2f)
|
||||
isUnlocked -> Color(0xFFE8EAF6)
|
||||
else -> Color(0xFFE0E0E0)
|
||||
},
|
||||
modifier = Modifier.size(44.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
if (isUnlocked) {
|
||||
Text(
|
||||
text = "$stageNumber",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isCurrent) Color.White else PrimaryIndigo
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = "잠김",
|
||||
tint = Color.Gray,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = when {
|
||||
isCurrent -> Color.White
|
||||
isUnlocked -> Color(0xFF212121)
|
||||
else -> Color.Gray
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "난이도: ${"★".repeat(difficulty.coerceAtLeast(1))}",
|
||||
fontSize = 12.sp,
|
||||
color = when {
|
||||
isCurrent -> Color(0xFFC5CAE9)
|
||||
isUnlocked -> Color(0xFF757575)
|
||||
else -> Color.LightGray
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = when {
|
||||
isCurrent -> Color(0xFFFF9800)
|
||||
isUnlocked -> Color(0xFFE8F5E9)
|
||||
else -> Color(0xFFE0E0E0)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (isCurrent) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text = "도전 중",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
} else if (isUnlocked) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF4CAF50),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text = "클리어",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFF2E7D32)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "잠김",
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,16 +120,6 @@ fun VocabularyWordCard(word: KoreanWord) {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ fun SettingsScreen(
|
||||
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
|
||||
|
||||
@@ -151,55 +150,7 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 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. 게임 환경
|
||||
// 2. 게임 환경
|
||||
Text(
|
||||
text = "게임 환경",
|
||||
fontSize = 14.sp,
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
package com.wordpuzzle.hangul.ui.widget
|
||||
|
||||
import android.R
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.*
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.actionStartActivity
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.*
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
@@ -26,21 +30,24 @@ 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 randomWord = db.wordDao().getRandomWord()
|
||||
if (randomWord != null) {
|
||||
randomWord
|
||||
} else {
|
||||
val list = db.wordDao().getWordsForPreLearning(1)
|
||||
list.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
val wordTitle = wordData?.word ?: "가을하늘"
|
||||
val wordCategory = wordData?.category ?: "자연"
|
||||
val wordMeaning = wordData?.meaning ?: "가을철의 맑고 푸른 하늘."
|
||||
val wordExample = wordData?.example ?: "가을하늘이 높고 푸르다."
|
||||
val wordTitle = wordData?.word ?: "일석이조"
|
||||
val wordCategory = wordData?.category ?: "사자성어"
|
||||
val wordMeaning = wordData?.meaning ?: "한 가지 일로 두 가지 이익을 얻음."
|
||||
|
||||
provideContent {
|
||||
WidgetContent(
|
||||
wordTitle = wordTitle,
|
||||
wordCategory = wordCategory,
|
||||
wordMeaning = wordMeaning,
|
||||
wordExample = wordExample
|
||||
wordMeaning = wordMeaning
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -49,18 +56,18 @@ class WordGlanceWidget : GlanceAppWidget() {
|
||||
private fun WidgetContent(
|
||||
wordTitle: String,
|
||||
wordCategory: String,
|
||||
wordMeaning: String,
|
||||
wordExample: String
|
||||
wordMeaning: String
|
||||
) {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(ImageProvider(android.R.drawable.dialog_holo_light_frame))
|
||||
.padding(12.dp)
|
||||
.clickable(actionStartActivity<MainActivity>()),
|
||||
.background(ImageProvider(R.drawable.dialog_holo_light_frame))
|
||||
.padding(10.dp)
|
||||
.clickable(actionRunCallback<NextWordAction>()),
|
||||
verticalAlignment = Alignment.Vertical.CenterVertically,
|
||||
horizontalAlignment = Alignment.Horizontal.Start
|
||||
) {
|
||||
// 상단 컨트롤 헤더 (타이틀 + 🎮 퍼즐풀기 게임 실행 버튼)
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Vertical.CenterVertically
|
||||
@@ -68,57 +75,82 @@ class WordGlanceWidget : GlanceAppWidget() {
|
||||
Text(
|
||||
text = "💡 오늘의 예습 단어",
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = ColorProvider(androidx.compose.ui.graphics.Color(0xFF3F51B5))
|
||||
color = ColorProvider(Color(0xFF3F51B5))
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = GlanceModifier.defaultWeight())
|
||||
|
||||
// 상단 우측 퍼즐풀기 게임 실행 버튼
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.background(ImageProvider(R.drawable.btn_default_small))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.clickable(actionStartActivity<MainActivity>()),
|
||||
verticalAlignment = Alignment.Vertical.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "🎮 퍼즐풀기",
|
||||
style = TextStyle(
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = ColorProvider(Color(0xFF1E88E5))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = GlanceModifier.height(6.dp))
|
||||
|
||||
// 단어명 및 카테고리
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Vertical.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = wordTitle,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = ColorProvider(Color(0xFF212121))
|
||||
)
|
||||
)
|
||||
Spacer(modifier = GlanceModifier.width(6.dp))
|
||||
Text(
|
||||
text = "[$wordCategory]",
|
||||
style = TextStyle(
|
||||
fontSize = 11.sp,
|
||||
color = ColorProvider(androidx.compose.ui.graphics.Color(0xFF757575))
|
||||
color = ColorProvider(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,
|
||||
maxLines = 3,
|
||||
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))
|
||||
color = ColorProvider(Color(0xFF424242))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NextWordAction : ActionCallback {
|
||||
override suspend fun onAction(
|
||||
context: Context,
|
||||
glanceId: GlanceId,
|
||||
parameters: ActionParameters
|
||||
) {
|
||||
WordGlanceWidget().update(context, glanceId)
|
||||
}
|
||||
}
|
||||
|
||||
class WordWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = WordGlanceWidget()
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 52 KiB |