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") @Query("SELECT * FROM words WHERE isLearned = 0 ORDER BY difficulty ASC LIMIT :limit")
suspend fun getWordsForPreLearning(limit: Int): List<WordEntity> suspend fun getWordsForPreLearning(limit: Int): List<WordEntity>
@Query("SELECT * FROM words ORDER BY RANDOM() LIMIT 1")
suspend fun getRandomWord(): WordEntity?
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAll(words: List<WordEntity>) 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) { suspend fun markWordLearned(wordText: String) = withContext(Dispatchers.IO) {
wordDao.markWordLearned(wordText, System.currentTimeMillis()) 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.CrosswordCell
import com.wordpuzzle.hangul.data.model.CrosswordClue import com.wordpuzzle.hangul.data.model.CrosswordClue
import com.wordpuzzle.hangul.data.model.Direction
import com.wordpuzzle.hangul.data.model.TileItem import com.wordpuzzle.hangul.data.model.TileItem
import java.util.UUID import java.util.UUID
@@ -10,7 +11,7 @@ object TilePoolGenerator {
/** /**
* 스테이지 그리드와 단어 목록을 바탕으로, * 스테이지 그리드와 단어 목록을 바탕으로,
* 1) 모든 글자 타일을 무작위로 섞고, * 1) 모든 글자 타일을 무작위로 섞고,
* 2) 섞인 문자 중 단어 수(clues.size)만큼을 임의로 선택하여 그리드의 맞는 빈 칸에 사전 배치합니다. * 2) 특정 단어에 힌트 문자가 몰리지 않도록 각 단어(clues)에 골고루 1개씩 문자를 사전 배치합니다.
* 3) 새로고침 시에도 이 함수를 호출하여 다른 문자가 새롭게 임의 선택되어 배치됩니다. * 3) 새로고침 시에도 이 함수를 호출하여 다른 문자가 새롭게 임의 선택되어 배치됩니다.
*/ */
fun generateLayoutWithStarterLetters( fun generateLayoutWithStarterLetters(
@@ -30,15 +31,50 @@ object TilePoolGenerator {
return Pair(cleanGrid, emptyList()) return Pair(cleanGrid, emptyList())
} }
// 1. 단어 수(clues.size)만큼 그리드의 빈 칸 위치(row, col)를 임의로 직접 선택 val targetStarterCount = clues.size.coerceAtMost(nonBlankCells.size - 1).coerceAtLeast(1)
val starterCount = clues.size.coerceAtMost(nonBlankCells.size - 1).coerceAtLeast(1) val selectedStarterPositions = mutableSetOf<Pair<Int, Int>>()
val selectedStarterPositions = nonBlankCells
.shuffled()
.take(starterCount)
.map { it.row to it.col }
.toSet()
// 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 -> val matrix = cleanGrid.map { row ->
row.map { cell -> row.map { cell ->
if (!cell.isBlank && (cell.row to cell.col) in selectedStarterPositions) { 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 tiles = nonBlankCells.map { cell ->
val isUsed = (cell.row to cell.col) in selectedStarterPositions val isUsed = (cell.row to cell.col) in selectedStarterPositions
TileItem( TileItem(
@@ -59,7 +95,7 @@ object TilePoolGenerator {
) )
} }
// 4. 타일 목록을 무작위로 섞어서 반환 // 5. 타일 목록을 무작위로 섞어서 반환
val shuffledTiles = tiles.shuffled() val shuffledTiles = tiles.shuffled()
return Pair(matrix, shuffledTiles) return Pair(matrix, shuffledTiles)
@@ -1,5 +1,6 @@
package com.wordpuzzle.hangul.service package com.wordpuzzle.hangul.service
import android.R
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
@@ -82,13 +83,13 @@ object WordNotificationManager {
// 잠금화면용 확장 스타일 알림 구성 // 잠금화면용 확장 스타일 알림 구성
val bigTextStyle = NotificationCompat.BigTextStyle() val bigTextStyle = NotificationCompat.BigTextStyle()
.setBigContentTitle("💡 예습 단어: ${word.word} [${word.category}]") .setBigContentTitle("${word.word} [${word.category}]")
.bigText("${word.meaning}\n\n예) “${word.example}”") .bigText("${word.meaning}\n\n예) “${word.example}”")
.setSummaryText("한글 십자말풀이 예습") .setSummaryText("한글 십자말풀이")
val builder = NotificationCompat.Builder(context, CHANNEL_ID) val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(R.drawable.ic_dialog_info)
.setContentTitle("💡 예습 단어: ${word.word} [${word.category}]") .setContentTitle("${word.word} [${word.category}]")
.setContentText(word.meaning) .setContentText(word.meaning)
.setStyle(bigTextStyle) .setStyle(bigTextStyle)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
@@ -1,21 +1,23 @@
package com.wordpuzzle.hangul.ui.game package com.wordpuzzle.hangul.ui.game
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.wordpuzzle.hangul.data.model.CrosswordClue import com.wordpuzzle.hangul.data.model.CrosswordClue
import com.wordpuzzle.hangul.data.model.Direction import com.wordpuzzle.hangul.data.model.Direction
import com.wordpuzzle.hangul.ui.theme.PrimaryIndigo
@Composable @Composable
fun ClueBar( fun ClueBar(
@@ -29,6 +31,7 @@ fun ClueBar(
Surface( Surface(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(50.dp)
.padding(horizontal = 10.dp, vertical = 2.dp), .padding(horizontal = 10.dp, vertical = 2.dp),
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
color = Color(0xFFF1F5F9), color = Color(0xFFF1F5F9),
@@ -36,7 +39,7 @@ fun ClueBar(
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.padding(horizontal = 10.dp, vertical = 6.dp) .padding(horizontal = 10.dp, vertical = 4.dp)
.fillMaxWidth(), .fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -55,9 +58,10 @@ fun ClueBar(
) )
} }
Text( AutoResizedText(
text = activeClue.clue, text = activeClue.clue,
fontSize = 13.sp, targetFontSize = 13.sp,
minFontSize = 8.5.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = Color(0xFF263238), color = Color(0xFF263238),
modifier = Modifier.weight(1f), 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 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* 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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.wordpuzzle.hangul.data.datastore.UserPreferencesManager 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.common.AdBannerView
import com.wordpuzzle.hangul.ui.theme.PrimaryIndigo import com.wordpuzzle.hangul.ui.theme.PrimaryIndigo
import com.wordpuzzle.hangul.ui.theme.SecondaryOrange import com.wordpuzzle.hangul.ui.theme.SecondaryOrange
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@Composable @Composable
fun MainLobbyScreen( fun MainLobbyScreen(
@@ -40,6 +47,7 @@ fun MainLobbyScreen(
) { ) {
var previewWord by remember { mutableStateOf<KoreanWord?>(null) } var previewWord by remember { mutableStateOf<KoreanWord?>(null) }
var isLockScreenEnabled by remember { mutableStateOf(false) } var isLockScreenEnabled by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
previewWord = wordRepo.getWordForWidget() previewWord = wordRepo.getWordForWidget()
@@ -169,12 +177,20 @@ fun MainLobbyScreen(
} }
} }
// 사전 학습 (Pre-Learning) 단어 카드 // 퍼즐 단어 미리보기 카드 (단어 설명 영역 2줄 고정 높이 적용하여 하단 레이아웃 위치 완전 고정)
if (previewWord != null) { if (previewWord != null) {
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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), shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = Color.White), colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
@@ -185,12 +201,21 @@ fun MainLobbyScreen(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Row(verticalAlignment = Alignment.CenterVertically) {
text = "💡 다음 퍼즐 예습 단어", Text(
fontSize = 12.sp, text = "💡 퍼즐 단어 미리보기",
fontWeight = FontWeight.Bold, fontSize = 12.sp,
color = PrimaryIndigo 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(
text = "[${previewWord!!.category}]", text = "[${previewWord!!.category}]",
fontSize = 11.sp, fontSize = 11.sp,
@@ -198,7 +223,7 @@ fun MainLobbyScreen(
) )
} }
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(6.dp))
Text( Text(
text = previewWord!!.word, text = previewWord!!.word,
@@ -207,18 +232,20 @@ fun MainLobbyScreen(
color = Color(0xFF212121) color = Color(0xFF212121)
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( AutoVerticalScrollingText(
text = previewWord!!.meaning, text = previewWord!!.meaning,
fontSize = 12.sp, fontSize = 12.sp,
color = Color(0xFF616161) lineHeight = 17.sp,
color = Color(0xFF616161),
height = 38.dp
) )
} }
} }
} }
// 스테이지 선택 타이틀 // 스테이지 선택 타이틀 (항상 고정된 위치 유지)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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 @Composable
fun StageCard( fun StageCard(
stageNumber: Int, stageNumber: Int,