Typing Indicator dan Auto Scroll ke Chat Terbaru
This commit is contained in:
parent
ad5aaefdc4
commit
53472b2768
@ -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)
|
||||||
@ -807,4 +847,4 @@ fun SourceCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
Loading…
x
Reference in New Issue
Block a user