Tech/앱동-kotlin 부트캠프

[4주차].새 단어 입력과 팝업창

JSJH._. 2026. 5. 4. 23:41

[4주차] 새 단어 입력과 팝업창

  • 이번 주는 ➕ 버튼을 달아서 내가 직접 새 단어를 추가하고 영구 저장하는 주차입니다.

오늘의 목표

  • 화면 구석에 더하기(➕) 버튼이 생깁니다.
  • 버튼을 누르면 단어를 입력할 수 있는 팝업창(바텀시트)이 밑에서 올라옵니다.
  • 단어와 뜻을 적고 저장을 누르면 진짜로 스크롤 리스트에 단어가 추가됩니다.

1. 4주차 UI 코드 이식

  1. 지난주에 쓰던 ui 폴더 안의 HomeScreen.kt 파일 열기.
  2. 스크롤을 맨 밑으로 내려서 4주차 완성 하단 코드 안의 전체 텍스트 복사.
  3. 기존 HomeScreen.kt 내용을 통째로 복붙하여 덮어쓰기.
  4. 상단 초록색 (Run) 클릭.

2. 기본적인 android 문법 (4주차)

1. var isSheetOpen by remember { mutableStateOf(false) }

UI 컴포넌트의 가시성 상태를 저장하고 컴포즈 트리가 이를 추적하게 만드는 선언문입니다.

  • 상태값이 변경되면, 지정된 해당 상태를 참조하는 UI 노드 부분만 자동으로 재랜더링(Recomposition).

2. Box { }

내부 요소들을 Z축(깊이) 기준으로 겹쳐서 배치할 수 있는 레이아웃 뷰 그룹입니다.

  • FAB를 우측 하단에 상대적으로 띄우거나, 모달(Modal) 뷰를 전체 화면 위로 오버레이 할 때 주로 사용.

3. OutlinedTextField { }

외곽선 테두리가 포함된 텍스트 입력 UI 컴포넌트입니다.

  • onValueChange: 사용자가 키보드로 문자를 입력할 때마다 호출되는 콜백 루틴으로, 변수 상태를 실시간 업데이트.

4. FloatingActionButton

화면 콘텐츠 위의 고정된 위치에 배치되어, 주요 기능 전환을 수행하는 버튼 컴포넌트입니다.

5. Spacer(modifier = Modifier)

UI 컴포넌트 사이에 빈 공간을 생성하는 레이아웃 요소입니다.

  • height나 width 속성을 부여하여 뷰 간의 물리적인 여백과 간격을 제어.

6. if (isSheetOpen) { } (선언적 렌더링)

명령형 언어처럼 뷰 요소의 VISIBLE / GONE 함수를 별도로 호출하지 않습니다.

  • 상태값(isSheetOpen)이 true일 때만 해당 코드 블록 내부의 UI 컴포넌트가 뷰 트리에 렌더링되게 하는 조건문 처리 방식.

3. 에러 대응

발생할 수 있는 문제

  • 단어 저장이 안 됨
    팝업창 닫는 버튼만 눌렀거나, 저장(addWord) 코드가 잘 들어갔는지 체크.
  • 키보드가 입력창을 가림
    imePadding() 코드가 빠졌는지 구조 확인.

4주차 완성 하단 코드

  • HomeScreen.kt 전체 파일 (통째로 덮어쓰기 하세요)
package com.example.bootcamp_wordlist.ui // 지우지 마세요

import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.bootcamp_wordlist.data.Word
import com.example.bootcamp_wordlist.data.WordRepository

enum class SortType { LATEST, ALPHABET }

@Composable
fun HomeScreen() {
    val words = WordRepository.words
    var sortType by remember { mutableStateOf(SortType.LATEST) }

    // 모달창 가시성 상태 변수
    var isSheetOpen by remember { mutableStateOf(false) }

    val sortedWords = when (sortType) {
        SortType.LATEST -> words.sortedByDescending { it.id }
        SortType.ALPHABET -> words.sortedBy { it.spelling }
    }

    // 화면 겹쳐올리기용 Box 컨테이너
    Box(modifier = Modifier.fillMaxSize().background(Color(0xFFF5F5F5))) {
        Column(modifier = Modifier.fillMaxSize()) {
            // 상단 헤더 영역
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .background(Color(0xFFE0E0E0))
                    .padding(start = 24.dp, end = 24.dp, top = 48.dp, bottom = 16.dp)
            ) {
                Text(text = "단어장", fontSize = 22.sp, fontWeight = FontWeight.Bold)
                Spacer(modifier = Modifier.height(16.dp))
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.spacedBy(8.dp)
                ) {
                    SortButton("최신순", sortType == SortType.LATEST, { sortType = SortType.LATEST }, Modifier.weight(1f))
                    SortButton("알파벳순", sortType == SortType.ALPHABET, { sortType = SortType.ALPHABET }, Modifier.weight(1f))
                }
            }
            // 단어 리스트 영역
            LazyColumn(
                modifier = Modifier.fillMaxWidth().weight(1f),
                // 하단 플로팅 버튼 여백(100.dp) 확보
                contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 100.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                items(sortedWords, key = { it.id }) { word ->
                    WordItem(word = word)
                }
            }
        }
        // 플로팅 추가 버튼(FAB) 영역
        Box(
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .navigationBarsPadding() // 시스템 하단바 여백 확보
                .padding(24.dp)
        ) {
            FloatingActionButton(
                onClick = { isSheetOpen = true }, // 클릭 시 모달창 표시 상태로 변경
                shape = CircleShape,
                containerColor = Color.White,
                contentColor = Color.DarkGray
            ) {
                Icon(Icons.Default.Add, contentDescription = "단어 추가")
            }
        }
        // 모달창 조건부 렌더링
        if (isSheetOpen) {
            AddWordSheet(onDismiss = { isSheetOpen = false })
        }
    }
}

// ─── 정렬 버튼 컴포넌트 (3주차와 동일) ───
@Composable
fun SortButton(text: String, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
    Button(
        onClick = onClick,
        modifier = modifier.height(40.dp),
        shape = RoundedCornerShape(12.dp),
        // isSelected 값에 따라 색상 동적 변경 (선택됨: 검정/흰 글씨, 미선택: 흰/회색 글씨)
        colors = ButtonDefaults.buttonColors(
            containerColor = if (isSelected) Color(0xFF1A1A1A) else Color.White,
            contentColor = if (isSelected) Color.White else Color(0xFF666666)
        ),
        elevation = ButtonDefaults.buttonElevation(0.dp) // 그림자 없애기
    ) {
        Text(text = text, fontSize = 14.sp)
    }
}

// ─── 단어 카드 컴포넌트 (3주차와 동일) ───
@Composable
fun WordItem(word: Word) {
    val context = LocalContext.current // 창고 접근용 열쇠
    Card(
        modifier = Modifier.fillMaxWidth(),
        shape = RoundedCornerShape(16.dp),
        colors = CardDefaults.cardColors(containerColor = Color.White),
        elevation = CardDefaults.cardElevation(0.dp)
    ) {
        Row(
            modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text(text = word.spelling, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
            // isHidden true → 점 5개, false → 진짜 뜻 출력
            Text(
                text = if (word.isHidden) "●●●●●" else word.meaning,
                fontSize = 14.sp,
                color = if (word.isHidden) Color(0xFFCCCCCC) else Color(0xFF888888),
                modifier = Modifier.weight(1f)
            )
            // 눈 아이콘: 누를 때마다 isHidden true ↔ false 토글
            IconButton(onClick = { WordRepository.toggleHidden(context, word.id) }) {
                Icon(
                    imageVector = if (word.isHidden) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
                    contentDescription = "뜻 숨기기",
                    tint = Color(0xFFAAAAAA)
                )
            }
            // 휴지통: 누르면 이 단어 영구 삭제
            IconButton(onClick = { WordRepository.deleteWord(context, word.id) }) {
                Icon(Icons.Default.Delete, contentDescription = "삭제", tint = Color(0xFFAAAAAA))
            }
        }
    }
}

// ─── 단어 추가 BottomSheet ───
// onDismiss: 시트를 닫을 때 호출할 함수 (isSheetOpen = false 로 만들어줌)
@Composable
fun AddWordSheet(onDismiss: () -> Unit) {
    val context = LocalContext.current

    // 입력창에 사용자가 타이핑할 때마다 실시간으로 값이 바뀌는 State 변수
    var spelling by remember { mutableStateOf("") }
    var meaning by remember { mutableStateOf("") }

    // Box로 전체 화면을 덮는 반투명 딤 배경 생성
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Black.copy(alpha = 0.4f)) // 40% 투명도 검정 오버레이
            .clickable { onDismiss() } // 딤 배경 클릭 시 시트 닫기
    ) {
        // 실제 시트 영역: 화면 아래쪽에 딱 붙여서 배치
        Column(
            modifier = Modifier
                .align(Alignment.BottomCenter) // Box 안에서 아래 가운데 정렬
                .fillMaxWidth()
                // topStart, topEnd만 둥글게 → 위쪽만 깎인 반원형 시트 모양
                .clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp))
                .background(Color.White)
                .navigationBarsPadding() // 하단 네비게이션 바 위에 올라오도록 여백 확보
                .imePadding()            // 키보드가 올라올 때 시트도 같이 위로 밀려남
                .pointerInput(Unit) {
                    detectTapGestures { }
                }
                .padding(24.dp)
        ) {
            // 상단 손잡이 바 (BottomSheet 디자인 관례)
            Box(
                modifier = Modifier
                    .width(48.dp)
                    .height(4.dp)
                    .clip(RoundedCornerShape(2.dp))
                    .background(Color(0xFFDDDDDD))
                    .align(Alignment.CenterHorizontally)
            )
            Spacer(modifier = Modifier.height(20.dp))

            Text("새 단어 추가", fontSize = 20.sp, fontWeight = FontWeight.Bold)
            Spacer(modifier = Modifier.height(20.dp))

            // OutlinedTextField = 테두리가 있는 입력창
            // value: 현재 입력된 텍스트 (spelling State와 연결)
            // onValueChange: 글자 칠 때마다 호출 → spelling 변수를 새 값으로 업데이트
            OutlinedTextField(
                value = spelling,
                onValueChange = { spelling = it }, // it = 방금 입력된 전체 텍스트
                placeholder = { Text("영단어 입력") }, // 아무것도 없을 때 흐리게 보이는 힌트
                modifier = Modifier.fillMaxWidth(),
                shape = RoundedCornerShape(12.dp),
                singleLine = true // 엔터 눌러도 줄바꿈 안 됨 (한 줄 입력만 허용)
            )
            Spacer(modifier = Modifier.height(12.dp))

            OutlinedTextField(
                value = meaning,
                onValueChange = { meaning = it },
                placeholder = { Text("뜻 입력") },
                modifier = Modifier.fillMaxWidth(),
                shape = RoundedCornerShape(12.dp),
                singleLine = true
            )
            Spacer(modifier = Modifier.height(20.dp))

            Button(
                onClick = {
                    // isNotBlank() = 공백만 있는 경우도 막아줌 (스페이스바 연타 방지)
                    if (spelling.isNotBlank() && meaning.isNotBlank()) {
                        // .trim() = 앞뒤 공백 제거 후 창고에 저장
                        WordRepository.addWord(context, spelling.trim(), meaning.trim())
                        onDismiss() // 저장 후 시트 닫기
                    }
                },
                modifier = Modifier.fillMaxWidth().height(52.dp),
                shape = RoundedCornerShape(12.dp),
                // enabled = 두 칸 모두 입력됐을 때만 버튼 활성화 (하나라도 비면 회색으로 비활성)
                enabled = spelling.isNotBlank() && meaning.isNotBlank(),
                colors = ButtonDefaults.buttonColors(
                    containerColor = Color(0xFFE0E0E0),
                    contentColor = Color(0xFF333333)
                )
            ) {
                Text("저장", fontWeight = FontWeight.Bold, fontSize = 16.sp)
            }
            Spacer(modifier = Modifier.height(16.dp))
        }
    }
}