feat: 퍼즐 화면 UI 가변 높이 고정, 스타터 배치 로직 개선 및 메인 화면 단어 미리보기 기능 추가

This commit is contained in:
2026-09-16 20:48:58 +09:00
parent 9d0654bab8
commit 3fbaedc39d
14 changed files with 2614 additions and 34 deletions
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+2349
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
</GradleProjectSettings>
</option>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
@@ -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<WordEntity>
@Query("SELECT * FROM words ORDER BY RANDOM() LIMIT 1")
suspend fun getRandomWord(): WordEntity?
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAll(words: List<WordEntity>)
@@ -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())
}
@@ -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<Pair<Int, Int>>()
// 2. 선택된 위치의 셀에 정확한 정답 글자(correctChar)를 사전 배치 (오배치 원천 차단)
// 각 clue별 셀 좌표 리스트 매핑
val clueCellsMap = clues.associateWith { clue ->
val cellsInClue = mutableListOf<Pair<Int, Int>>()
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)
@@ -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)
@@ -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
)
}
@@ -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<KoreanWord?>(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,