Ariq Desain
This commit is contained in:
parent
385608225e
commit
ad5aaefdc4
4
.idea/deploymentTargetSelector.xml
generated
4
.idea/deploymentTargetSelector.xml
generated
@ -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>
|
||||
|
||||
@ -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 = """
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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 = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteDialog = false
|
||||
sourceToDelete = null
|
||||
}) {
|
||||
Text("Batal")
|
||||
}
|
||||
) {
|
||||
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,15 +418,40 @@ fun NotebookDetailScreen(
|
||||
}
|
||||
) { 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 ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (selectedTab) {
|
||||
0 -> ChatTab(
|
||||
@ -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
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.background(LightPurple, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Chat,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = Color.Gray
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = PrimaryPurple
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
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
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.background(LightPurple, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Description,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = Color.Gray
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = PrimaryPurple
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user