Typing Indicator dan Auto Scroll ke Chat Terbaru

This commit is contained in:
202310715297 RAIHAN ARIQ MUZAKKI 2025-11-18 14:54:51 +07:00
parent ad5aaefdc4
commit 53472b2768
3 changed files with 110 additions and 70 deletions

View File

@ -5,12 +5,13 @@ import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@ -30,10 +31,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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 kotlinx.coroutines.launch
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
@ -58,7 +59,6 @@ fun openFile(context: Context, source: SourceEntity) {
return
}
// Tentukan MIME type
val mimeType = when (source.fileType) {
"PDF" -> "application/pdf"
"Image" -> "image/*"
@ -71,7 +71,6 @@ fun openFile(context: Context, source: SourceEntity) {
else -> "*/*"
}
// Gunakan FileProvider untuk file di internal storage
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
@ -84,11 +83,9 @@ fun openFile(context: Context, source: SourceEntity) {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
// Cek apakah ada aplikasi yang bisa handle
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
} else {
// Fallback: buka dengan chooser
val chooser = Intent.createChooser(intent, "Buka dengan")
context.startActivity(chooser)
}
@ -160,7 +157,7 @@ fun NotebookDetailScreen(
}
}
// Delete confirmation dialog dengan desain modern
// Delete confirmation dialog
if (showDeleteDialog && sourceToDelete != null) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
@ -204,7 +201,6 @@ fun NotebookDetailScreen(
)
}
// File picker launcher
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
@ -213,13 +209,11 @@ fun NotebookDetailScreen(
}
}
// Load notebook data
LaunchedEffect(notebookId) {
println("🚀 NotebookDetailScreen: LaunchedEffect triggered untuk ID: $notebookId")
viewModel.selectNotebook(notebookId)
}
// Debug log untuk state changes
LaunchedEffect(notebook) {
println("📝 Notebook state updated: ${notebook?.title ?: "NULL"}")
}
@ -309,51 +303,6 @@ fun NotebookDetailScreen(
Icon(Icons.Default.Summarize, null, tint = PrimaryPurple)
}
)
// Debug: Test PDF extraction
Divider(modifier = Modifier.padding(vertical = 4.dp))
DropdownMenuItem(
text = {
Text(
"🔍 Test PDF Extract",
fontWeight = FontWeight.Medium,
color = TextPrimary
)
},
onClick = {
showUploadMenu = false
val pdfSource = sources.firstOrNull { it.fileType == "PDF" }
if (pdfSource != null) {
println("🔍 Testing PDF: ${pdfSource.fileName}")
val result = com.example.notebook.utils.PdfHelper.extractTextFromPdf(pdfSource.filePath)
if (result != null) {
println("📊 Full text length: ${result.length} karakter")
println("📝 First 500 chars: ${result.take(500)}")
println("📝 Last 500 chars: ${result.takeLast(500)}")
android.widget.Toast.makeText(
context,
"✅ Extracted ${result.length} chars\nPreview: ${result.take(100)}...",
android.widget.Toast.LENGTH_LONG
).show()
} else {
android.widget.Toast.makeText(
context,
"❌ PDF extraction returned null!",
android.widget.Toast.LENGTH_LONG
).show()
}
} else {
android.widget.Toast.makeText(
context,
"⚠️ Tidak ada PDF yang diupload",
android.widget.Toast.LENGTH_SHORT
).show()
}
},
leadingIcon = {
Icon(Icons.Default.BugReport, null, tint = PrimaryPurple)
}
)
}
}
}
@ -364,7 +313,7 @@ fun NotebookDetailScreen(
)
},
bottomBar = {
if (selectedTab == 0) { // Chat tab
if (selectedTab == 0) {
Surface(
color = CardBackground,
shadowElevation = 8.dp
@ -457,7 +406,8 @@ fun NotebookDetailScreen(
0 -> ChatTab(
messages = chatMessages,
sources = sources,
onUploadClick = { filePickerLauncher.launch("*/*") }
onUploadClick = { filePickerLauncher.launch("*/*") },
isLoading = isLoading
)
1 -> SourcesTab(
sources = sources,
@ -479,9 +429,22 @@ fun NotebookDetailScreen(
fun ChatTab(
messages: List<ChatMessageEntity>,
sources: List<SourceEntity>,
onUploadClick: () -> Unit
onUploadClick: () -> Unit,
isLoading: Boolean = false
) {
if (messages.isEmpty()) {
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
// Auto scroll ke chat terbaru
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
coroutineScope.launch {
listState.animateScrollToItem(messages.size)
}
}
}
if (messages.isEmpty() && !isLoading) {
// Empty state dengan desain modern
Column(
modifier = Modifier
@ -538,6 +501,7 @@ fun ChatTab(
}
} else {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.background(BackgroundWhite),
@ -547,6 +511,77 @@ fun ChatTab(
items(messages) { message ->
ChatBubble(message = message)
}
// Typing indicator saat AI sedang memproses
if (isLoading) {
item {
TypingIndicator()
}
}
}
}
}
@Composable
fun TypingIndicator() {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
// AI Avatar
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(LightPurple),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.AutoAwesome,
contentDescription = null,
tint = PrimaryPurple,
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
// Typing animation card
Surface(
color = CardBackground,
shape = RoundedCornerShape(
topStart = 20.dp,
topEnd = 20.dp,
bottomStart = 4.dp,
bottomEnd = 20.dp
),
shadowElevation = 1.dp
) {
Row(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Animated dots
repeat(3) { index ->
val infiniteTransition = rememberInfiniteTransition(label = "dot$index")
val scale by infiniteTransition.animateFloat(
initialValue = 0.5f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(600, delayMillis = index * 150),
repeatMode = RepeatMode.Reverse
),
label = "scale$index"
)
Box(
modifier = Modifier
.size(8.dp * scale)
.clip(CircleShape)
.background(TextSecondary.copy(alpha = 0.6f))
)
}
}
}
}
}
@ -592,12 +627,19 @@ fun ChatBubble(message: ChatMessageEntity) {
),
shadowElevation = if (!message.isUserMessage) 1.dp else 0.dp
) {
// === 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)
)
if (message.isUserMessage) {
Text(
text = message.message,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
color = Color.White,
fontSize = 15.sp
)
} else {
MarkdownText(
markdown = message.message,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
}
}
Text(
@ -629,7 +671,6 @@ fun ChatBubble(message: ChatMessageEntity) {
}
}
@Composable
fun SourcesTab(
sources: List<SourceEntity>,
@ -727,7 +768,6 @@ fun SourceCard(
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// File icon dengan desain modern
Box(
modifier = Modifier
.size(48.dp)

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>