feat: 퍼즐 화면 UI 가변 높이 고정, 스타터 배치 로직 개선 및 메인 화면 단어 미리보기 기능 추가
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user