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> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <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"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=10DEC90GZE0004R" /> <DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\Fazri Abdurrahman\.android\avd\Medium_Phone.avd" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

View File

@ -15,7 +15,7 @@ object ApiConstants {
const val BASE_URL = "https://generativelanguage.googleapis.com/" const val BASE_URL = "https://generativelanguage.googleapis.com/"
// Model yang digunakan (Flash = gratis & cepat) // 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 // System instruction untuk AI
const val SYSTEM_INSTRUCTION = """ const val SYSTEM_INSTRUCTION = """

View File

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

View File

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