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 android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -30,10 +31,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider 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.SourceEntity import com.example.notebook.data.SourceEntity
import com.example.notebook.ui.components.MarkdownText import com.example.notebook.ui.components.MarkdownText
import com.example.notebook.viewmodel.NotebookViewModel import com.example.notebook.viewmodel.NotebookViewModel
import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@ -58,7 +59,6 @@ fun openFile(context: Context, source: SourceEntity) {
return return
} }
// Tentukan MIME type
val mimeType = when (source.fileType) { val mimeType = when (source.fileType) {
"PDF" -> "application/pdf" "PDF" -> "application/pdf"
"Image" -> "image/*" "Image" -> "image/*"
@ -71,7 +71,6 @@ fun openFile(context: Context, source: SourceEntity) {
else -> "*/*" else -> "*/*"
} }
// Gunakan FileProvider untuk file di internal storage
val uri = FileProvider.getUriForFile( val uri = FileProvider.getUriForFile(
context, context,
"${context.packageName}.fileprovider", "${context.packageName}.fileprovider",
@ -84,11 +83,9 @@ fun openFile(context: Context, source: SourceEntity) {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
// Cek apakah ada aplikasi yang bisa handle
if (intent.resolveActivity(context.packageManager) != null) { if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent) context.startActivity(intent)
} else { } else {
// Fallback: buka dengan chooser
val chooser = Intent.createChooser(intent, "Buka dengan") val chooser = Intent.createChooser(intent, "Buka dengan")
context.startActivity(chooser) context.startActivity(chooser)
} }
@ -160,7 +157,7 @@ fun NotebookDetailScreen(
} }
} }
// Delete confirmation dialog dengan desain modern // Delete confirmation dialog
if (showDeleteDialog && sourceToDelete != null) { if (showDeleteDialog && sourceToDelete != null) {
AlertDialog( AlertDialog(
onDismissRequest = { showDeleteDialog = false }, onDismissRequest = { showDeleteDialog = false },
@ -204,7 +201,6 @@ fun NotebookDetailScreen(
) )
} }
// File picker launcher
val filePickerLauncher = rememberLauncherForActivityResult( val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent() contract = ActivityResultContracts.GetContent()
) { uri: Uri? -> ) { uri: Uri? ->
@ -213,13 +209,11 @@ fun NotebookDetailScreen(
} }
} }
// Load notebook data
LaunchedEffect(notebookId) { LaunchedEffect(notebookId) {
println("🚀 NotebookDetailScreen: LaunchedEffect triggered untuk ID: $notebookId") println("🚀 NotebookDetailScreen: LaunchedEffect triggered untuk ID: $notebookId")
viewModel.selectNotebook(notebookId) viewModel.selectNotebook(notebookId)
} }
// Debug log untuk state changes
LaunchedEffect(notebook) { LaunchedEffect(notebook) {
println("📝 Notebook state updated: ${notebook?.title ?: "NULL"}") println("📝 Notebook state updated: ${notebook?.title ?: "NULL"}")
} }
@ -309,51 +303,6 @@ fun NotebookDetailScreen(
Icon(Icons.Default.Summarize, null, tint = PrimaryPurple) 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 = { bottomBar = {
if (selectedTab == 0) { // Chat tab if (selectedTab == 0) {
Surface( Surface(
color = CardBackground, color = CardBackground,
shadowElevation = 8.dp shadowElevation = 8.dp
@ -457,7 +406,8 @@ fun NotebookDetailScreen(
0 -> ChatTab( 0 -> ChatTab(
messages = chatMessages, messages = chatMessages,
sources = sources, sources = sources,
onUploadClick = { filePickerLauncher.launch("*/*") } onUploadClick = { filePickerLauncher.launch("*/*") },
isLoading = isLoading
) )
1 -> SourcesTab( 1 -> SourcesTab(
sources = sources, sources = sources,
@ -479,9 +429,22 @@ fun NotebookDetailScreen(
fun ChatTab( fun ChatTab(
messages: List<ChatMessageEntity>, messages: List<ChatMessageEntity>,
sources: List<SourceEntity>, 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 // Empty state dengan desain modern
Column( Column(
modifier = Modifier modifier = Modifier
@ -538,6 +501,7 @@ fun ChatTab(
} }
} else { } else {
LazyColumn( LazyColumn(
state = listState,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(BackgroundWhite), .background(BackgroundWhite),
@ -547,6 +511,77 @@ fun ChatTab(
items(messages) { message -> items(messages) { message ->
ChatBubble(message = 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 shadowElevation = if (!message.isUserMessage) 1.dp else 0.dp
) { ) {
// === GANTI TEXT() MENJADI MarkdownText() DI SINI === if (message.isUserMessage) {
MarkdownText( Text(
markdown = message.message, text = message.message,
textColor = if (message.isUserMessage) Color.White else TextPrimary, modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
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( Text(
@ -629,7 +671,6 @@ fun ChatBubble(message: ChatMessageEntity) {
} }
} }
@Composable @Composable
fun SourcesTab( fun SourcesTab(
sources: List<SourceEntity>, sources: List<SourceEntity>,
@ -727,7 +768,6 @@ fun SourceCard(
.padding(16.dp), .padding(16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// File icon dengan desain modern
Box( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)

View File

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

View File

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