diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml new file mode 100644 index 0000000..60ea70d --- /dev/null +++ b/.idea/caches/deviceStreaming.xml @@ -0,0 +1,2349 @@ + + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..7505d8d --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..3aec57f --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/wordpuzzle/hangul/data/local/AppDatabase.kt b/app/src/main/java/com/wordpuzzle/hangul/data/local/AppDatabase.kt index 6ae3877..b87ad18 100644 --- a/app/src/main/java/com/wordpuzzle/hangul/data/local/AppDatabase.kt +++ b/app/src/main/java/com/wordpuzzle/hangul/data/local/AppDatabase.kt @@ -31,6 +31,9 @@ interface WordDao { @Query("SELECT * FROM words WHERE isLearned = 0 ORDER BY difficulty ASC LIMIT :limit") suspend fun getWordsForPreLearning(limit: Int): List + @Query("SELECT * FROM words ORDER BY RANDOM() LIMIT 1") + suspend fun getRandomWord(): WordEntity? + @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertAll(words: List) diff --git a/app/src/main/java/com/wordpuzzle/hangul/data/repository/WordRepository.kt b/app/src/main/java/com/wordpuzzle/hangul/data/repository/WordRepository.kt index e084846..441736e 100644 --- a/app/src/main/java/com/wordpuzzle/hangul/data/repository/WordRepository.kt +++ b/app/src/main/java/com/wordpuzzle/hangul/data/repository/WordRepository.kt @@ -75,6 +75,23 @@ class WordRepository(private val context: Context) { } } + suspend fun getRandomWord(): KoreanWord? = withContext(Dispatchers.IO) { + val entity = wordDao.getRandomWord() + if (entity != null) { + KoreanWord( + id = entity.id, + word = entity.word, + meaning = entity.meaning, + category = entity.category, + difficulty = entity.difficulty, + example = entity.example, + isLearned = entity.isLearned + ) + } else { + getWordForWidget() + } + } + suspend fun markWordLearned(wordText: String) = withContext(Dispatchers.IO) { wordDao.markWordLearned(wordText, System.currentTimeMillis()) } diff --git a/app/src/main/java/com/wordpuzzle/hangul/domain/engine/TilePoolGenerator.kt b/app/src/main/java/com/wordpuzzle/hangul/domain/engine/TilePoolGenerator.kt index 8fde3be..f1fddb0 100644 --- a/app/src/main/java/com/wordpuzzle/hangul/domain/engine/TilePoolGenerator.kt +++ b/app/src/main/java/com/wordpuzzle/hangul/domain/engine/TilePoolGenerator.kt @@ -2,6 +2,7 @@ package com.wordpuzzle.hangul.domain.engine import com.wordpuzzle.hangul.data.model.CrosswordCell import com.wordpuzzle.hangul.data.model.CrosswordClue +import com.wordpuzzle.hangul.data.model.Direction import com.wordpuzzle.hangul.data.model.TileItem import java.util.UUID @@ -10,7 +11,7 @@ object TilePoolGenerator { /** * 스테이지 그리드와 단어 목록을 바탕으로, * 1) 모든 글자 타일을 무작위로 섞고, - * 2) 섞인 문자 중 단어 수(clues.size)만큼을 임의로 선택하여 그리드의 맞는 빈 칸에 사전 배치합니다. + * 2) 특정 단어에 힌트 문자가 몰리지 않도록 각 단어(clues)에 골고루 1개씩 문자를 사전 배치합니다. * 3) 새로고침 시에도 이 함수를 호출하여 다른 문자가 새롭게 임의 선택되어 배치됩니다. */ fun generateLayoutWithStarterLetters( @@ -30,15 +31,50 @@ object TilePoolGenerator { return Pair(cleanGrid, emptyList()) } - // 1. 단어 수(clues.size)만큼 그리드의 빈 칸 위치(row, col)를 임의로 직접 선택 - val starterCount = clues.size.coerceAtMost(nonBlankCells.size - 1).coerceAtLeast(1) - val selectedStarterPositions = nonBlankCells - .shuffled() - .take(starterCount) - .map { it.row to it.col } - .toSet() + val targetStarterCount = clues.size.coerceAtMost(nonBlankCells.size - 1).coerceAtLeast(1) + val selectedStarterPositions = mutableSetOf>() - // 2. 선택된 위치의 셀에 정확한 정답 글자(correctChar)를 사전 배치 (오배치 원천 차단) + // 각 clue별 셀 좌표 리스트 매핑 + val clueCellsMap = clues.associateWith { clue -> + val cellsInClue = mutableListOf>() + for (i in 0 until clue.length) { + val r = if (clue.direction == Direction.ACROSS) clue.startRow else clue.startRow + i + val c = if (clue.direction == Direction.ACROSS) clue.startCol + i else clue.startCol + if (r in cleanGrid.indices && c in cleanGrid[r].indices && !cleanGrid[r][c].isBlank) { + cellsInClue.add(r to c) + } + } + cellsInClue + } + + // 1. 단어(clues)들을 무작위 순서로 순회하며 각 단어마다 아직 선택되지 않은 셀 중 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 } + + if (unselectedCells.isNotEmpty()) { + val chosenPos = unselectedCells.shuffled().first() + selectedStarterPositions.add(chosenPos) + } + } + + // 2. 단어 교차점 중복 선택 등으로 targetStarterCount에 미달한 경우, 남은 셀 중 무작위 추가 선택 + if (selectedStarterPositions.size < targetStarterCount) { + val remainingCells = nonBlankCells + .map { it.row to it.col } + .filter { it !in selectedStarterPositions } + .shuffled() + + for (pos in remainingCells) { + if (selectedStarterPositions.size >= targetStarterCount) break + selectedStarterPositions.add(pos) + } + } + + // 3. 선택된 위치의 셀에 정확한 정답 글자(correctChar)를 사전 배치 val matrix = cleanGrid.map { row -> row.map { cell -> if (!cell.isBlank && (cell.row to cell.col) in selectedStarterPositions) { @@ -49,7 +85,7 @@ object TilePoolGenerator { } } - // 3. 각 셀과 1:1 대응되는 타일 목록 생성 (사전 배치된 타일은 isUsed = true) + // 4. 각 셀과 1:1 대응되는 타일 목록 생성 (사전 배치된 타일은 isUsed = true) val tiles = nonBlankCells.map { cell -> val isUsed = (cell.row to cell.col) in selectedStarterPositions TileItem( @@ -59,7 +95,7 @@ object TilePoolGenerator { ) } - // 4. 타일 목록을 무작위로 섞어서 반환 + // 5. 타일 목록을 무작위로 섞어서 반환 val shuffledTiles = tiles.shuffled() return Pair(matrix, shuffledTiles) diff --git a/app/src/main/java/com/wordpuzzle/hangul/service/WordNotificationManager.kt b/app/src/main/java/com/wordpuzzle/hangul/service/WordNotificationManager.kt index 7cccf4e..9fa31ce 100644 --- a/app/src/main/java/com/wordpuzzle/hangul/service/WordNotificationManager.kt +++ b/app/src/main/java/com/wordpuzzle/hangul/service/WordNotificationManager.kt @@ -1,5 +1,6 @@ package com.wordpuzzle.hangul.service +import android.R import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -82,13 +83,13 @@ object WordNotificationManager { // 잠금화면용 확장 스타일 알림 구성 val bigTextStyle = NotificationCompat.BigTextStyle() - .setBigContentTitle("💡 예습 단어: ${word.word} [${word.category}]") + .setBigContentTitle("${word.word} [${word.category}]") .bigText("${word.meaning}\n\n예) “${word.example}”") - .setSummaryText("한글 십자말풀이 예습") + .setSummaryText("한글 십자말풀이") val builder = NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(android.R.drawable.ic_dialog_info) - .setContentTitle("💡 예습 단어: ${word.word} [${word.category}]") + .setSmallIcon(R.drawable.ic_dialog_info) + .setContentTitle("${word.word} [${word.category}]") .setContentText(word.meaning) .setStyle(bigTextStyle) .setPriority(NotificationCompat.PRIORITY_LOW) diff --git a/app/src/main/java/com/wordpuzzle/hangul/ui/game/ClueBar.kt b/app/src/main/java/com/wordpuzzle/hangul/ui/game/ClueBar.kt index e01e6e8..b4a345a 100644 --- a/app/src/main/java/com/wordpuzzle/hangul/ui/game/ClueBar.kt +++ b/app/src/main/java/com/wordpuzzle/hangul/ui/game/ClueBar.kt @@ -1,21 +1,23 @@ package com.wordpuzzle.hangul.ui.game -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material3.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.wordpuzzle.hangul.data.model.CrosswordClue import com.wordpuzzle.hangul.data.model.Direction -import com.wordpuzzle.hangul.ui.theme.PrimaryIndigo @Composable fun ClueBar( @@ -29,6 +31,7 @@ fun ClueBar( Surface( modifier = modifier .fillMaxWidth() + .height(50.dp) .padding(horizontal = 10.dp, vertical = 2.dp), shape = RoundedCornerShape(10.dp), color = Color(0xFFF1F5F9), @@ -36,7 +39,7 @@ fun ClueBar( ) { Row( modifier = Modifier - .padding(horizontal = 10.dp, vertical = 6.dp) + .padding(horizontal = 10.dp, vertical = 4.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { @@ -55,9 +58,10 @@ fun ClueBar( ) } - Text( + AutoResizedText( text = activeClue.clue, - fontSize = 13.sp, + targetFontSize = 13.sp, + minFontSize = 8.5.sp, fontWeight = FontWeight.Medium, color = Color(0xFF263238), modifier = Modifier.weight(1f), @@ -86,3 +90,40 @@ fun ClueBar( } } } + +@Composable +fun AutoResizedText( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontWeight: FontWeight? = null, + minFontSize: TextUnit = 8.5.sp, + targetFontSize: TextUnit = 13.sp, + maxLines: Int = 2, + style: TextStyle = LocalTextStyle.current +) { + var resizedFontSize by remember(text) { mutableStateOf(targetFontSize) } + var shouldDraw by remember(text) { mutableStateOf(false) } + + Text( + text = text, + color = color, + fontWeight = fontWeight, + fontSize = resizedFontSize, + maxLines = maxLines, + overflow = TextOverflow.Clip, + onTextLayout = { result -> + if (result.hasVisualOverflow && resizedFontSize > minFontSize) { + resizedFontSize = (resizedFontSize.value - 0.5f).coerceAtLeast(minFontSize.value).sp + } else { + shouldDraw = true + } + }, + modifier = modifier.drawWithContent { + if (shouldDraw) { + drawContent() + } + }, + style = style + ) +} diff --git a/app/src/main/java/com/wordpuzzle/hangul/ui/main/MainLobbyScreen.kt b/app/src/main/java/com/wordpuzzle/hangul/ui/main/MainLobbyScreen.kt index a2dd05a..4b33fef 100644 --- a/app/src/main/java/com/wordpuzzle/hangul/ui/main/MainLobbyScreen.kt +++ b/app/src/main/java/com/wordpuzzle/hangul/ui/main/MainLobbyScreen.kt @@ -1,5 +1,7 @@ package com.wordpuzzle.hangul.ui.main +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.* @@ -19,6 +21,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.wordpuzzle.hangul.data.datastore.UserPreferencesManager @@ -27,6 +31,9 @@ import com.wordpuzzle.hangul.data.repository.WordRepository import com.wordpuzzle.hangul.ui.common.AdBannerView import com.wordpuzzle.hangul.ui.theme.PrimaryIndigo import com.wordpuzzle.hangul.ui.theme.SecondaryOrange +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch @Composable fun MainLobbyScreen( @@ -40,6 +47,7 @@ fun MainLobbyScreen( ) { var previewWord by remember { mutableStateOf(null) } var isLockScreenEnabled by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() LaunchedEffect(Unit) { previewWord = wordRepo.getWordForWidget() @@ -169,12 +177,20 @@ fun MainLobbyScreen( } } - // 사전 학습 (Pre-Learning) 단어 카드 + // 퍼즐 단어 미리보기 카드 (단어 설명 영역 2줄 고정 높이 적용하여 하단 레이아웃 위치 완전 고정) if (previewWord != null) { Card( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 10.dp), + .padding(horizontal = 14.dp, vertical = 10.dp) + .clickable { + coroutineScope.launch { + val nextWord = wordRepo.getRandomWord() + if (nextWord != null) { + previewWord = nextWord + } + } + }, shape = RoundedCornerShape(14.dp), colors = CardDefaults.cardColors(containerColor = Color.White), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) @@ -185,12 +201,21 @@ fun MainLobbyScreen( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "💡 다음 퍼즐 예습 단어", - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - color = PrimaryIndigo - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "💡 퍼즐 단어 미리보기", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = PrimaryIndigo + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "다른 단어 보기", + tint = PrimaryIndigo, + modifier = Modifier.size(14.dp) + ) + } Text( text = "[${previewWord!!.category}]", fontSize = 11.sp, @@ -198,7 +223,7 @@ fun MainLobbyScreen( ) } - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(6.dp)) Text( text = previewWord!!.word, @@ -207,18 +232,20 @@ fun MainLobbyScreen( color = Color(0xFF212121) ) - Spacer(modifier = Modifier.height(2.dp)) + Spacer(modifier = Modifier.height(4.dp)) - Text( + AutoVerticalScrollingText( text = previewWord!!.meaning, fontSize = 12.sp, - color = Color(0xFF616161) + lineHeight = 17.sp, + color = Color(0xFF616161), + height = 38.dp ) } } } - // 스테이지 선택 타이틀 + // 스테이지 선택 타이틀 (항상 고정된 위치 유지) Row( modifier = Modifier .fillMaxWidth() @@ -265,6 +292,53 @@ fun MainLobbyScreen( } } +@Composable +fun AutoVerticalScrollingText( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = 12.sp, + lineHeight: TextUnit = 17.sp, + height: Dp = 38.dp +) { + val scrollState = rememberScrollState() + + LaunchedEffect(text) { + scrollState.scrollTo(0) + delay(1500) + while (isActive) { + if (scrollState.maxValue > 0) { + scrollState.animateScrollTo( + value = scrollState.maxValue, + animationSpec = tween( + durationMillis = (scrollState.maxValue * 35).coerceAtLeast(1500), + easing = LinearEasing + ) + ) + delay(2000) + scrollState.scrollTo(0) + delay(1200) + } else { + break + } + } + } + + Box( + modifier = modifier + .fillMaxWidth() + .height(height) + .verticalScroll(scrollState) + ) { + Text( + text = text, + fontSize = fontSize, + lineHeight = lineHeight, + color = color + ) + } +} + @Composable fun StageCard( stageNumber: Int,