Ariq Desain

This commit is contained in:
202310715082 FAZRI ABDURRAHMAN 2025-11-17 18:13:15 +07:00
parent 385608225e
commit ad5aaefdc4
6 changed files with 607 additions and 141 deletions

View File

@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-11-14T10:53:18.219362400Z">
<DropdownSelection timestamp="2025-11-17T09:59:25.576794100Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=10DEC90GZE0004R" />
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\Fazri Abdurrahman\.android\avd\Medium_Phone.avd" />
</handle>
</Target>
</DropdownSelection>

View File

@ -15,7 +15,7 @@ object ApiConstants {
const val BASE_URL = "https://generativelanguage.googleapis.com/"
// Model yang digunakan (Flash = gratis & cepat)
const val MODEL_NAME = "gemini-2.5-flash"
const val MODEL_NAME = "gemini-2.0-flash"
// System instruction untuk AI
const val SYSTEM_INSTRUCTION = """

View File

@ -15,7 +15,7 @@ import java.util.concurrent.TimeUnit
*/
interface GeminiApiService {
@POST("v1beta/models/gemini-2.5-flash:generateContent")
@POST("v1beta/models/gemini-2.0-flash:generateContent")
suspend fun generateContent(
@Query("key") apiKey: String,
@Body request: GeminiRequest

View File

@ -10,12 +10,11 @@ import androidx.room.PrimaryKey
data class NotebookEntity(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val title: String, // Judul notebook
val description: String, // Deskripsi singkat
val createdAt: Long, // Timestamp pembuatan
val updatedAt: Long, // Timestamp update terakhir
val sourceCount: Int = 0 // Jumlah sumber yang diupload
val sourceCount: Int = 0, // Jumlah sumber yang diupload
)
/**

View File

@ -0,0 +1,291 @@
package com.example.notebook.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.util.Locale
/**
* MarkdownText - lightweight markdown renderer tanpa dependency.
* Mendukung: heading (#..), bold (**text**), italic (*text*), inline code (`code`),
* code block (```...```), bullet list (- / * / +), numbered list (1. item), paragraph.
*/
@Composable
fun MarkdownText(
markdown: String,
modifier: Modifier = Modifier,
textColor: Color = Color(0xFF111827)
) {
val elements = parseMarkdown(markdown)
Column(modifier = modifier) {
elements.forEachIndexed { idx, element ->
when (element) {
is MarkdownElement.Heading -> {
Text(
text = element.text,
fontSize = when (element.level) {
1 -> 22.sp
2 -> 18.sp
3 -> 16.sp
else -> 14.sp
},
fontWeight = FontWeight.Bold,
color = textColor,
modifier = Modifier.padding(vertical = 6.dp)
)
}
is MarkdownElement.Paragraph -> {
Text(
text = buildInlineAnnotatedString(element.text, textColor),
fontSize = 14.sp,
color = textColor,
modifier = Modifier.padding(bottom = 8.dp),
)
}
is MarkdownElement.ListItem -> {
Row(modifier = Modifier.padding(bottom = 4.dp, start = 8.dp)) {
Text(
text = if (element.isNumbered) "${element.number}. " else "",
fontWeight = FontWeight.SemiBold,
color = textColor,
modifier = Modifier.padding(end = 6.dp)
)
Text(
text = buildInlineAnnotatedString(element.text, textColor),
fontSize = 14.sp,
color = textColor,
modifier = Modifier.weight(1f)
)
}
}
is MarkdownElement.CodeBlock -> {
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFF5F7FA), shape = RoundedCornerShape(8.dp))
.padding(12.dp)
.padding(bottom = 8.dp)
) {
Text(
text = element.code,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
color = Color(0xFF1F2937)
)
}
}
}
// small spacer between blocks (already handled by padding) - optional
if (idx == elements.lastIndex) {
Spacer(modifier = Modifier.height(0.dp))
}
}
}
}
/** ---------- Parser & Inline renderer ---------- **/
/**
* Parses markdown string into block elements.
*/
private fun parseMarkdown(markdown: String): List<MarkdownElement> {
val lines = markdown.replace("\r\n", "\n").lines()
val elements = mutableListOf<MarkdownElement>()
var i = 0
while (i < lines.size) {
val raw = lines[i]
val line = raw.trimEnd()
// Skip pure empty lines (but keep grouping paragraphs)
if (line.isBlank()) {
i++
continue
}
// Code fence start
if (line.startsWith("```")) {
val fenceLang = line.removePrefix("```").trim() // unused but could be saved
val codeLines = mutableListOf<String>()
i++
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
codeLines.add(lines[i])
i++
}
// skip the closing ```
if (i < lines.size && lines[i].trimStart().startsWith("```")) {
i++
}
elements.add(MarkdownElement.CodeBlock(codeLines.joinToString("\n")))
continue
}
// Heading: #, ##, ###
if (line.startsWith("#")) {
val hashes = line.takeWhile { it == '#' }
val level = hashes.length.coerceAtMost(6)
val text = line.dropWhile { it == '#' }.trim()
elements.add(MarkdownElement.Heading(level, text))
i++
continue
}
// Numbered list: "1. item"
val numberedRegex = """^\s*(\d+)\.\s+(.+)$""".toRegex()
val numberedMatch = numberedRegex.find(line)
if (numberedMatch != null) {
val number = numberedMatch.groupValues[1].toIntOrNull() ?: 1
val text = numberedMatch.groupValues[2]
elements.add(MarkdownElement.ListItem(text, isNumbered = true, number = number))
i++
continue
}
// Bullet list: "- item" or "* item" or "+ item"
val bulletRegex = """^\s*[-\*\+]\s+(.+)$""".toRegex()
val bulletMatch = bulletRegex.find(line)
if (bulletMatch != null) {
val text = bulletMatch.groupValues[1]
elements.add(MarkdownElement.ListItem(text, isNumbered = false, number = 0))
i++
continue
}
// Paragraph: gather consecutive non-empty, non-block lines into single paragraph
val paraLines = mutableListOf<String>()
paraLines.add(line)
i++
while (i < lines.size) {
val nextRaw = lines[i]
val next = nextRaw.trimEnd()
if (next.isBlank()) break
// stop paragraph if next is a block start
if (next.startsWith("```") || next.startsWith("#") ||
numberedRegex.matches(next) || bulletRegex.matches(next)
) {
break
}
paraLines.add(next)
i++
}
elements.add(MarkdownElement.Paragraph(paraLines.joinToString(" ").trim()))
}
return elements
}
/**
* Build AnnotatedString with inline styles:
* - inline code: `code`
* - bold: **bold**
* - italic: *italic*
*
* This is a simple scanner that prioritizes inline code, then bold, then italic.
*/
private fun buildInlineAnnotatedString(text: String, defaultColor: Color) = buildAnnotatedString {
var idx = 0
val len = text.length
fun safeIndexOf(substr: String, from: Int): Int {
if (from >= len) return -1
val found = text.indexOf(substr, from)
return found
}
while (idx < len) {
// Inline code has highest priority: `code`
if (text[idx] == '`') {
val end = safeIndexOf("`", idx + 1)
if (end != -1) {
val content = text.substring(idx + 1, end)
withStyle(
style = SpanStyle(
fontFamily = FontFamily.Monospace,
background = Color(0xFFF3F4F6),
color = Color(0xFF7C3AED)
)
) {
append(content)
}
idx = end + 1
continue
} else {
// no closing backtick, append literal
append(text[idx])
idx++
continue
}
}
// Bold: **text**
if (idx + 1 < len && text[idx] == '*' && text[idx + 1] == '*') {
val end = text.indexOf("**", idx + 2)
if (end != -1) {
val content = text.substring(idx + 2, end)
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold, color = defaultColor)) {
append(content)
}
idx = end + 2
continue
} else {
// no closing, treat as literal
append(text[idx])
idx++
continue
}
}
// Italic: *text* (ensure not part of bold)
if (text[idx] == '*') {
// skip if next is also '*' because that would be bold and handled above
if (idx + 1 < len && text[idx + 1] == '*') {
// handled already
} else {
val end = text.indexOf("*", idx + 1)
if (end != -1) {
val content = text.substring(idx + 1, end)
withStyle(style = SpanStyle(fontStyle = FontStyle.Italic, color = defaultColor)) {
append(content)
}
idx = end + 1
continue
} else {
append(text[idx])
idx++
continue
}
}
}
// Default: append single char
append(text[idx])
idx++
}
}
/** ---------- Markdown element sealed class ---------- */
private sealed class MarkdownElement {
data class Paragraph(val text: String) : MarkdownElement()
data class Heading(val level: Int, val text: String) : MarkdownElement()
data class ListItem(val text: String, val isNumbered: Boolean = false, val number: Int = 0) :
MarkdownElement()
data class CodeBlock(val code: String) : MarkdownElement()
}

View File

@ -32,11 +32,20 @@ import androidx.core.content.FileProvider
import com.example.notebook.data.ChatMessageEntity
import com.example.notebook.data.NotebookEntity
import com.example.notebook.data.SourceEntity
import com.example.notebook.ui.components.MarkdownText
import com.example.notebook.viewmodel.NotebookViewModel
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
// Color theme untuk konsistensi dengan menu utama
private val PrimaryPurple = Color(0xFF7C3AED)
private val LightPurple = Color(0xFFF3F0FF)
private val BackgroundWhite = Color(0xFFFAFAFA)
private val CardBackground = Color.White
private val TextPrimary = Color(0xFF1F2937)
private val TextSecondary = Color(0xFF6B7280)
/**
* Fungsi untuk buka file dengan aplikasi default
*/
@ -113,46 +122,61 @@ fun NotebookDetailScreen(
var showDeleteDialog by remember { mutableStateOf(false) }
var sourceToDelete by remember { mutableStateOf<SourceEntity?>(null) }
// Loading overlay
// Loading overlay dengan desain modern
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
.background(Color.Black.copy(alpha = 0.6f))
.clickable(enabled = false) { },
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier.padding(32.dp),
shape = RoundedCornerShape(16.dp)
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = CardBackground),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
CircularProgressIndicator(color = PrimaryPurple)
Spacer(modifier = Modifier.height(20.dp))
Text(
"Processing...",
style = MaterialTheme.typography.bodyLarge
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = TextPrimary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"AI sedang membaca dokumen",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary
)
}
}
}
}
// Delete confirmation dialog
// Delete confirmation dialog dengan desain modern
if (showDeleteDialog && sourceToDelete != null) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Hapus File?") },
text = { Text("File \"${sourceToDelete?.fileName}\" akan dihapus permanen. Lanjutkan?") },
title = {
Text(
"Hapus File?",
fontWeight = FontWeight.Bold,
color = TextPrimary
)
},
text = {
Text(
"File \"${sourceToDelete?.fileName}\" akan dihapus permanen. Lanjutkan?",
color = TextSecondary
)
},
confirmButton = {
Button(
onClick = {
@ -160,19 +184,23 @@ fun NotebookDetailScreen(
showDeleteDialog = false
sourceToDelete = null
},
colors = ButtonDefaults.buttonColors(containerColor = Color.Red)
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFEF4444)),
shape = RoundedCornerShape(12.dp)
) {
Text("Hapus")
Text("Hapus", fontWeight = FontWeight.SemiBold)
}
},
dismissButton = {
TextButton(onClick = {
showDeleteDialog = false
sourceToDelete = null
}) {
Text("Batal")
TextButton(
onClick = {
showDeleteDialog = false
sourceToDelete = null
}
) {
Text("Batal", color = TextSecondary, fontWeight = FontWeight.Medium)
}
}
},
shape = RoundedCornerShape(20.dp)
)
}
@ -201,61 +229,96 @@ fun NotebookDetailScreen(
}
Scaffold(
containerColor = BackgroundWhite,
topBar = {
TopAppBar(
title = {
Column {
Text(
text = notebook?.title ?: "Loading...",
fontSize = 18.sp,
fontWeight = FontWeight.Bold
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = TextPrimary
)
if (notebook != null) {
Text(
text = "${sources.size} sources",
fontSize = 12.sp,
color = Color.Gray
text = "${sources.size} sources tersimpan",
fontSize = 13.sp,
color = TextSecondary,
fontWeight = FontWeight.Medium
)
}
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = TextPrimary
)
}
},
actions = {
Box {
IconButton(onClick = { showUploadMenu = true }) {
Icon(Icons.Default.Add, contentDescription = "Upload")
Icon(
Icons.Default.Add,
contentDescription = "Upload",
tint = PrimaryPurple,
modifier = Modifier.size(28.dp)
)
}
DropdownMenu(
expanded = showUploadMenu,
onDismissRequest = { showUploadMenu = false }
onDismissRequest = { showUploadMenu = false },
modifier = Modifier.background(CardBackground, RoundedCornerShape(16.dp))
) {
DropdownMenuItem(
text = { Text("Upload File") },
text = {
Text(
"Upload File",
fontWeight = FontWeight.Medium,
color = TextPrimary
)
},
onClick = {
filePickerLauncher.launch("*/*")
showUploadMenu = false
},
leadingIcon = { Icon(Icons.Default.CloudUpload, null) }
leadingIcon = {
Icon(Icons.Default.CloudUpload, null, tint = PrimaryPurple)
}
)
if (sources.isNotEmpty()) {
Divider()
Divider(modifier = Modifier.padding(vertical = 4.dp))
DropdownMenuItem(
text = { Text("Generate Summary") },
text = {
Text(
"Generate Summary",
fontWeight = FontWeight.Medium,
color = TextPrimary
)
},
onClick = {
viewModel.generateSummary(notebookId)
selectedTab = 0 // Switch ke chat tab
selectedTab = 0
showUploadMenu = false
},
leadingIcon = { Icon(Icons.Default.Summarize, null) }
leadingIcon = {
Icon(Icons.Default.Summarize, null, tint = PrimaryPurple)
}
)
// Debug: Test PDF extraction
Divider()
Divider(modifier = Modifier.padding(vertical = 4.dp))
DropdownMenuItem(
text = { Text("🔍 Test PDF Extract") },
text = {
Text(
"🔍 Test PDF Extract",
fontWeight = FontWeight.Medium,
color = TextPrimary
)
},
onClick = {
showUploadMenu = false
val pdfSource = sources.firstOrNull { it.fileType == "PDF" }
@ -287,38 +350,67 @@ fun NotebookDetailScreen(
).show()
}
},
leadingIcon = { Icon(Icons.Default.BugReport, null) }
leadingIcon = {
Icon(Icons.Default.BugReport, null, tint = PrimaryPurple)
}
)
}
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = CardBackground
)
)
},
bottomBar = {
if (selectedTab == 0) { // Chat tab
BottomAppBar {
Surface(
color = CardBackground,
shadowElevation = 8.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = chatInput,
onValueChange = { chatInput = it },
placeholder = { Text("Ask anything about your sources...") },
modifier = Modifier.weight(1f)
placeholder = {
Text(
"Tanyakan apapun tentang dokumen Anda...",
color = TextSecondary
)
},
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PrimaryPurple,
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = {
if (chatInput.isNotBlank()) {
viewModel.sendUserMessage(notebookId, chatInput)
chatInput = ""
}
}
},
modifier = Modifier
.size(48.dp)
.background(
if (chatInput.isNotBlank()) PrimaryPurple else Color(0xFFE5E7EB),
CircleShape
)
) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = if (chatInput.isNotBlank()) Color.White else TextSecondary
)
}
}
}
@ -326,13 +418,38 @@ fun NotebookDetailScreen(
}
) { padding ->
Column(modifier = Modifier.padding(padding)) {
TabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) }
)
// Custom Tab dengan desain modern
Surface(
color = CardBackground,
shadowElevation = 2.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
tabs.forEachIndexed { index, title ->
Surface(
modifier = Modifier
.weight(1f)
.clickable { selectedTab = index },
shape = RoundedCornerShape(12.dp),
color = if (selectedTab == index) PrimaryPurple else Color.Transparent
) {
Box(
modifier = Modifier.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = title,
fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Medium,
color = if (selectedTab == index) Color.White else TextSecondary,
fontSize = 15.sp
)
}
}
}
}
}
@ -365,41 +482,67 @@ fun ChatTab(
onUploadClick: () -> Unit
) {
if (messages.isEmpty()) {
// Empty state
// Empty state dengan desain modern
Column(
modifier = Modifier
.fillMaxSize()
.background(BackgroundWhite)
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.Chat,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = Color.Gray
)
Spacer(modifier = Modifier.height(16.dp))
Box(
modifier = Modifier
.size(80.dp)
.background(LightPurple, CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Chat,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = PrimaryPurple
)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = if (sources.isEmpty()) "Upload sources to start chatting"
else "Ask me anything about your sources!",
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center
text = if (sources.isEmpty()) "Upload dokumen untuk mulai chat"
else "Tanyakan apapun tentang dokumen Anda!",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = TextPrimary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (sources.isEmpty())
"AI akan membantu Anda memahami dokumen dengan lebih baik"
else "AI siap menjawab pertanyaan Anda",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = TextSecondary
)
if (sources.isEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onUploadClick) {
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onUploadClick,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryPurple),
shape = RoundedCornerShape(16.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp)
) {
Icon(Icons.Default.CloudUpload, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Upload Source")
Text("Upload Dokumen", fontWeight = FontWeight.SemiBold)
}
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.background(BackgroundWhite),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(messages) { message ->
ChatBubble(message = message)
@ -417,18 +560,18 @@ fun ChatBubble(message: ChatMessageEntity) {
horizontalArrangement = if (message.isUserMessage) Arrangement.End else Arrangement.Start
) {
if (!message.isUserMessage) {
// AI Avatar
// AI Avatar dengan desain modern
Box(
modifier = Modifier
.size(32.dp)
.size(36.dp)
.clip(CircleShape)
.background(Color(0xFF6200EE)),
.background(LightPurple),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.AutoAwesome,
contentDescription = null,
tint = Color.White,
tint = PrimaryPurple,
modifier = Modifier.size(20.dp)
)
}
@ -439,50 +582,54 @@ fun ChatBubble(message: ChatMessageEntity) {
modifier = Modifier.widthIn(max = 280.dp),
horizontalAlignment = if (message.isUserMessage) Alignment.End else Alignment.Start
) {
Card(
colors = CardDefaults.cardColors(
containerColor = if (message.isUserMessage)
Color(0xFF6200EE)
else
Color(0xFFF0F0F0)
),
Surface(
color = if (message.isUserMessage) PrimaryPurple else CardBackground,
shape = RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = if (message.isUserMessage) 16.dp else 4.dp,
bottomEnd = if (message.isUserMessage) 4.dp else 16.dp
)
topStart = 20.dp,
topEnd = 20.dp,
bottomStart = if (message.isUserMessage) 20.dp else 4.dp,
bottomEnd = if (message.isUserMessage) 4.dp else 20.dp
),
shadowElevation = if (!message.isUserMessage) 1.dp else 0.dp
) {
Text(
text = message.message,
modifier = Modifier.padding(12.dp),
color = if (message.isUserMessage) Color.White else Color.Black
// === GANTI TEXT() MENJADI MarkdownText() DI SINI ===
MarkdownText(
markdown = message.message,
textColor = if (message.isUserMessage) Color.White else TextPrimary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
}
Text(
text = dateFormat.format(Date(message.timestamp)),
fontSize = 10.sp,
color = Color.Gray,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
fontSize = 11.sp,
color = TextSecondary,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
if (message.isUserMessage) {
Spacer(modifier = Modifier.width(8.dp))
// User Avatar
// User Avatar dengan desain modern
Box(
modifier = Modifier
.size(32.dp)
.size(36.dp)
.clip(CircleShape)
.background(Color.Magenta),
.background(PrimaryPurple),
contentAlignment = Alignment.Center
) {
Text("M", color = Color.White, fontWeight = FontWeight.Bold)
Icon(
Icons.Default.Person,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
}
}
}
@Composable
fun SourcesTab(
sources: List<SourceEntity>,
@ -494,41 +641,58 @@ fun SourcesTab(
Column(
modifier = Modifier
.fillMaxSize()
.background(BackgroundWhite)
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.Description,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = Color.Gray
)
Spacer(modifier = Modifier.height(16.dp))
Box(
modifier = Modifier
.size(80.dp)
.background(LightPurple, CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Description,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = PrimaryPurple
)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "No sources yet",
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center
text = "Belum ada dokumen",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = TextPrimary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Upload documents to get started",
text = "Upload dokumen untuk memulai",
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray,
color = TextSecondary,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onUploadClick) {
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onUploadClick,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryPurple),
shape = RoundedCornerShape(16.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp)
) {
Icon(Icons.Default.CloudUpload, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Upload Source")
Text("Upload Dokumen", fontWeight = FontWeight.SemiBold)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.background(BackgroundWhite),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(sources) { source ->
SourceCard(
@ -553,29 +717,31 @@ fun SourceCard(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpen() },
colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F9FA))
colors = CardDefaults.cardColors(containerColor = CardBackground),
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// File icon
// File icon dengan desain modern
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp))
.size(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(
when (source.fileType) {
"PDF" -> Color(0xFFE53935)
"Image" -> Color(0xFF43A047)
"Text", "Markdown" -> Color(0xFF1E88E5)
"Audio" -> Color(0xFFFF6F00)
"Word" -> Color(0xFF2196F3)
"PowerPoint" -> Color(0xFFFF6D00)
else -> Color.Gray
}.copy(alpha = 0.1f)
"PDF" -> Color(0xFFE53935).copy(alpha = 0.15f)
"Image" -> Color(0xFF43A047).copy(alpha = 0.15f)
"Text", "Markdown" -> Color(0xFF1E88E5).copy(alpha = 0.15f)
"Audio" -> Color(0xFFFF6F00).copy(alpha = 0.15f)
"Word" -> Color(0xFF2196F3).copy(alpha = 0.15f)
"PowerPoint" -> Color(0xFFFF6D00).copy(alpha = 0.15f)
else -> Color.Gray.copy(alpha = 0.15f)
}
),
contentAlignment = Alignment.Center
) {
@ -590,6 +756,7 @@ fun SourceCard(
else -> Icons.Default.InsertDriveFile
},
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = when (source.fileType) {
"PDF" -> Color(0xFFE53935)
"Image" -> Color(0xFF43A047)
@ -602,32 +769,41 @@ fun SourceCard(
)
}
Spacer(modifier = Modifier.width(12.dp))
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = source.fileName,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
color = TextPrimary
)
Spacer(modifier = Modifier.height(4.dp))
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = source.fileType,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
fontWeight = FontWeight.Medium,
color = TextSecondary
)
Text(
text = "${dateFormat.format(Date(source.uploadedAt))}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
color = TextSecondary
)
}
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "Delete", tint = Color.Gray)
IconButton(
onClick = onDelete,
modifier = Modifier.size(40.dp)
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = Color(0xFF9CA3AF)
)
}
}
}