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,