icon 추가

This commit is contained in:
2026-09-21 22:34:20 +09:00
parent 274e36d669
commit 6ac67ec59d
842 changed files with 54721 additions and 37620 deletions
+2 -2
View File
@@ -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">
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB