Jetpack Compose 프로젝트에 MainActivity.kt로 붙여넣으세요. build.gradle에 androidx.compose.foundation:foundation:1.6.0 이상, AndroidManifest.xml에 인터넷 권한이 필요합니다.
package com.wisemakers.readcards
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
// AndroidManifest.xml 에 추가: <uses-permission android:name="android.permission.INTERNET" />
// build.gradle(app): androidx.compose.foundation:foundation:1.6.0 이상 필요 (VerticalPager 포함)
private const val API_BASE = "https://wm.starsoft.or.kr/api"
private val tones = listOf(
Color(0xFF0F8A66),
Color(0xFFD4891A),
Color(0xFF1E6FB3)
)
data class BookSummary(val id: String, val title: String, val count: Int)
data class BookDetail(val title: String, val items: List<String>)
private sealed class Screen {
object ListScreen : Screen()
data class Reader(val id: String) : Screen()
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface(color = Color(0xFF0B0E0C)) {
ReadCardsApp()
}
}
}
}
}
@Composable
private fun ReadCardsApp() {
var screen by remember { mutableStateOf<Screen>(Screen.ListScreen) }
when (val s = screen) {
is Screen.ListScreen -> BookListScreen(onOpen = { id -> screen = Screen.Reader(id) })
is Screen.Reader -> ReaderScreen(bookId = s.id, onBack = { screen = Screen.ListScreen })
}
}
@Composable
private fun BookListScreen(onOpen: (String) -> Unit) {
var books by remember { mutableStateOf<List<BookSummary>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
books = fetchBooks()
loading = false
}
Column(Modifier.fillMaxSize().padding(20.dp)) {
Text("책 목록", color = Color(0xFFF3F6F3), fontSize = 22.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(16.dp))
when {
loading -> Text("불러오는 중…", color = Color(0xFF9AA39C))
books.isEmpty() -> Text("책이 없습니다.", color = Color(0xFF9AA39C))
else -> LazyColumn {
items(books) { book ->
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
.clip(RoundedCornerShape(16.dp))
.background(Color(0xFF12161A))
.clickable { onOpen(book.id) }
.padding(16.dp)
) {
Column {
Text(book.title, color = Color(0xFFF3F6F3), fontSize = 16.sp, fontWeight = FontWeight.Bold)
Text("${book.count}장", color = Color(0xFF8B948E), fontSize = 12.sp)
}
}
}
}
}
}
}
@Composable
private fun ReaderScreen(bookId: String, onBack: () -> Unit) {
var book by remember { mutableStateOf<BookDetail?>(null) }
var loading by remember { mutableStateOf(true) }
LaunchedEffect(bookId) {
book = fetchBook(bookId)
loading = false
}
Box(Modifier.fillMaxSize()) {
when {
loading -> CenteredMessage("불러오는 중…")
book == null || book!!.items.isEmpty() -> CenteredMessage("표시할 텍스트가 없습니다.")
else -> ReaderPager(book!!.items)
}
BackButton(onBack, Modifier.align(Alignment.TopStart))
}
}
@Composable
private fun BackButton(onBack: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.padding(14.dp)
.clip(RoundedCornerShape(999.dp))
.background(Color(0x8C0B0E0C))
.clickable { onBack() }
.padding(horizontal = 14.dp, vertical = 8.dp)
) {
Text("← 목록", color = Color(0xFFF3F6F3), fontSize = 13.sp, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun CenteredMessage(text: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text, color = Color(0xFF9AA39C))
}
}
@Composable
private fun ReaderPager(items: List<String>) {
val pagerState = rememberPagerState(pageCount = { items.size })
Box(Modifier.fillMaxSize()) {
VerticalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
val tone = tones[page % tones.size]
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.radialGradient(
colors = listOf(tone.copy(alpha = 0.38f), Color(0xFF0B0E0C))
)
)
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = items[page],
color = Color(0xFFF3F6F3),
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
lineHeight = 36.sp
)
}
}
Row(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(top = 16.dp, start = 90.dp, end = 16.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
items.indices.forEach { i ->
Box(
modifier = Modifier
.weight(1f)
.height(3.dp)
.background(
if (i <= pagerState.currentPage) Color.White.copy(alpha = 0.85f)
else Color.White.copy(alpha = 0.18f)
)
)
}
}
Text(
text = "${pagerState.currentPage + 1} / ${items.size}",
color = Color.White.copy(alpha = 0.45f),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 24.dp)
)
}
}
private suspend fun fetchBooks(): List<BookSummary> = withContext(Dispatchers.IO) {
try {
val json = JSONObject(httpGet("$API_BASE/books"))
val arr = json.optJSONArray("books") ?: JSONArray()
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
BookSummary(o.optString("id"), o.optString("title"), o.optInt("count"))
}
} catch (e: Exception) {
emptyList()
}
}
private suspend fun fetchBook(id: String): BookDetail? = withContext(Dispatchers.IO) {
try {
val json = JSONObject(httpGet("$API_BASE/books/$id"))
val book = json.optJSONObject("book") ?: return@withContext null
val arr = book.optJSONArray("items") ?: JSONArray()
val items = (0 until arr.length()).map { arr.getString(it) }
BookDetail(book.optString("title"), items)
} catch (e: Exception) {
null
}
}
private fun httpGet(url: String): String {
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 8000
conn.readTimeout = 8000
val text = conn.inputStream.bufferedReader().use { it.readText() }
conn.disconnect()
return text
}