Tes Fungsi Database
This commit is contained in:
parent
793600dd5a
commit
5867c80588
@ -2,6 +2,13 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
|
||||
@ -60,6 +60,17 @@ fun NotebookApp(viewModel: NotebookViewModel) {
|
||||
var showSettingsMenu by remember { mutableStateOf(false) }
|
||||
var showAccountScreen by remember { mutableStateOf(false) }
|
||||
var chatInput by remember { mutableStateOf("") }
|
||||
var selectedNotebookId by remember { mutableIntStateOf(-1) }
|
||||
|
||||
// Kalau ada notebook yang dipilih, tampilkan detail screen
|
||||
if (selectedNotebookId != -1) {
|
||||
com.example.notebook.ui.screens.NotebookDetailScreen(
|
||||
viewModel = viewModel,
|
||||
notebookId = selectedNotebookId,
|
||||
onBack = { selectedNotebookId = -1 }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (showAccountScreen) {
|
||||
AccountScreen(onDismiss = { showAccountScreen = false })
|
||||
|
||||
@ -0,0 +1,430 @@
|
||||
package com.example.notebook.ui.screens
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.notebook.data.ChatMessageEntity
|
||||
import com.example.notebook.data.NotebookEntity
|
||||
import com.example.notebook.data.SourceEntity
|
||||
import com.example.notebook.viewmodel.NotebookViewModel
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NotebookDetailScreen(
|
||||
viewModel: NotebookViewModel,
|
||||
notebookId: Int,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val notebook by viewModel.currentNotebook.collectAsState()
|
||||
val sources by viewModel.sources.collectAsState()
|
||||
val chatMessages by viewModel.chatMessages.collectAsState()
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
val tabs = listOf("Chat", "Sources")
|
||||
var chatInput by remember { mutableStateOf("") }
|
||||
var showUploadMenu by remember { mutableStateOf(false) }
|
||||
|
||||
// File picker launcher
|
||||
val filePickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
uri?.let {
|
||||
viewModel.uploadFile(context, it, notebookId)
|
||||
}
|
||||
}
|
||||
|
||||
// Load notebook data
|
||||
LaunchedEffect(notebookId) {
|
||||
viewModel.selectNotebook(notebookId)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = notebook?.title ?: "Loading...",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
if (notebook != null) {
|
||||
Text(
|
||||
text = "${sources.size} sources",
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
Box {
|
||||
IconButton(onClick = { showUploadMenu = true }) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Upload")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showUploadMenu,
|
||||
onDismissRequest = { showUploadMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Upload File") },
|
||||
onClick = {
|
||||
filePickerLauncher.launch("*/*")
|
||||
showUploadMenu = false
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.CloudUpload, null) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
if (selectedTab == 0) { // Chat tab
|
||||
BottomAppBar {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = chatInput,
|
||||
onValueChange = { chatInput = it },
|
||||
placeholder = { Text("Ask anything about your sources...") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (chatInput.isNotBlank()) {
|
||||
viewModel.sendUserMessage(notebookId, chatInput)
|
||||
chatInput = ""
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.padding(padding)) {
|
||||
TabRow(selectedTabIndex = selectedTab) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(title) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (selectedTab) {
|
||||
0 -> ChatTab(
|
||||
messages = chatMessages,
|
||||
sources = sources,
|
||||
onUploadClick = { filePickerLauncher.launch("*/*") }
|
||||
)
|
||||
1 -> SourcesTab(
|
||||
sources = sources,
|
||||
onUploadClick = { filePickerLauncher.launch("*/*") },
|
||||
onDeleteSource = { source ->
|
||||
// TODO: Implement delete
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatTab(
|
||||
messages: List<ChatMessageEntity>,
|
||||
sources: List<SourceEntity>,
|
||||
onUploadClick: () -> Unit
|
||||
) {
|
||||
if (messages.isEmpty()) {
|
||||
// Empty state
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Chat,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = Color.Gray
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.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
|
||||
)
|
||||
if (sources.isEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(onClick = onUploadClick) {
|
||||
Icon(Icons.Default.CloudUpload, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Upload Source")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(messages) { message ->
|
||||
ChatBubble(message = message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatBubble(message: ChatMessageEntity) {
|
||||
val dateFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (message.isUserMessage) Arrangement.End else Arrangement.Start
|
||||
) {
|
||||
if (!message.isUserMessage) {
|
||||
// AI Avatar
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF6200EE)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AutoAwesome,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
Column(
|
||||
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)
|
||||
),
|
||||
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
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = message.message,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
color = if (message.isUserMessage) Color.White else Color.Black
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = dateFormat.format(Date(message.timestamp)),
|
||||
fontSize = 10.sp,
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (message.isUserMessage) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
// User Avatar
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Magenta),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("M", color = Color.White, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SourcesTab(
|
||||
sources: List<SourceEntity>,
|
||||
onUploadClick: () -> Unit,
|
||||
onDeleteSource: (SourceEntity) -> Unit
|
||||
) {
|
||||
if (sources.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Description,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = Color.Gray
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "No sources yet",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Upload documents to get started",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.Gray,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(onClick = onUploadClick) {
|
||||
Icon(Icons.Default.CloudUpload, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Upload Source")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(sources) { source ->
|
||||
SourceCard(
|
||||
source = source,
|
||||
onDelete = { onDeleteSource(source) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SourceCard(source: SourceEntity, onDelete: () -> Unit) {
|
||||
val dateFormat = SimpleDateFormat("dd MMM yyyy", Locale.getDefault())
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F9FA))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// File icon
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
when (source.fileType) {
|
||||
"PDF" -> Color(0xFFE53935)
|
||||
"Image" -> Color(0xFF43A047)
|
||||
"Text", "Markdown" -> Color(0xFF1E88E5)
|
||||
"Audio" -> Color(0xFFFF6F00)
|
||||
else -> Color.Gray
|
||||
}.copy(alpha = 0.1f)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
when (source.fileType) {
|
||||
"PDF" -> Icons.Default.PictureAsPdf
|
||||
"Image" -> Icons.Default.Image
|
||||
"Text", "Markdown" -> Icons.Default.Description
|
||||
"Audio" -> Icons.Default.AudioFile
|
||||
else -> Icons.Default.InsertDriveFile
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = when (source.fileType) {
|
||||
"PDF" -> Color(0xFFE53935)
|
||||
"Image" -> Color(0xFF43A047)
|
||||
"Text", "Markdown" -> Color(0xFF1E88E5)
|
||||
"Audio" -> Color(0xFFFF6F00)
|
||||
else -> Color.Gray
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = source.fileName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row {
|
||||
Text(
|
||||
text = source.fileType,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
Text(
|
||||
text = " • ${dateFormat.format(Date(source.uploadedAt))}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete", tint = Color.Gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
app/src/main/java/com/example/notebook/utils/FileHelper.kt
Normal file
114
app/src/main/java/com/example/notebook/utils/FileHelper.kt
Normal file
@ -0,0 +1,114 @@
|
||||
package com.example.notebook.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
/**
|
||||
* Helper untuk handle file operations
|
||||
*/
|
||||
object FileHelper {
|
||||
|
||||
/**
|
||||
* Copy file dari URI ke internal storage
|
||||
* Return: Path file yang disimpan
|
||||
*/
|
||||
fun copyFileToInternalStorage(context: Context, uri: Uri, notebookId: Int): String? {
|
||||
try {
|
||||
val fileName = getFileName(context, uri) ?: "file_${System.currentTimeMillis()}"
|
||||
|
||||
// Buat folder untuk notebook ini
|
||||
val notebookDir = File(context.filesDir, "notebooks/$notebookId")
|
||||
if (!notebookDir.exists()) {
|
||||
notebookDir.mkdirs()
|
||||
}
|
||||
|
||||
// Buat file baru
|
||||
val destinationFile = File(notebookDir, fileName)
|
||||
|
||||
// Copy file
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
FileOutputStream(destinationFile).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
return destinationFile.absolutePath
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambil nama file dari URI
|
||||
*/
|
||||
fun getFileName(context: Context, uri: Uri): String? {
|
||||
var fileName: String? = null
|
||||
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (nameIndex >= 0) {
|
||||
fileName = cursor.getString(nameIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fileName
|
||||
}
|
||||
|
||||
/**
|
||||
* Deteksi tipe file dari extension
|
||||
*/
|
||||
fun getFileType(fileName: String): String {
|
||||
return when (fileName.substringAfterLast('.').lowercase()) {
|
||||
"pdf" -> "PDF"
|
||||
"txt" -> "Text"
|
||||
"md", "markdown" -> "Markdown"
|
||||
"jpg", "jpeg", "png", "gif" -> "Image"
|
||||
"mp3", "wav", "m4a" -> "Audio"
|
||||
"mp4", "avi", "mkv" -> "Video"
|
||||
else -> "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Baca text dari file
|
||||
*/
|
||||
fun readTextFromFile(filePath: String): String? {
|
||||
return try {
|
||||
File(filePath).readText()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ukuran file
|
||||
*/
|
||||
fun formatFileSize(size: Long): String {
|
||||
if (size < 1024) return "$size B"
|
||||
val kb = size / 1024.0
|
||||
if (kb < 1024) return "%.2f KB".format(kb)
|
||||
val mb = kb / 1024.0
|
||||
if (mb < 1024) return "%.2f MB".format(mb)
|
||||
val gb = mb / 1024.0
|
||||
return "%.2f GB".format(gb)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hapus file
|
||||
*/
|
||||
fun deleteFile(filePath: String): Boolean {
|
||||
return try {
|
||||
File(filePath).delete()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -146,6 +146,50 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file upload dari URI
|
||||
*/
|
||||
fun uploadFile(context: android.content.Context, uri: android.net.Uri, notebookId: Int) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
val fileName = com.example.notebook.utils.FileHelper.getFileName(context, uri) ?: "unknown"
|
||||
val filePath = com.example.notebook.utils.FileHelper.copyFileToInternalStorage(context, uri, notebookId)
|
||||
|
||||
if (filePath != null) {
|
||||
val fileType = com.example.notebook.utils.FileHelper.getFileType(fileName)
|
||||
repository.addSource(notebookId, fileName, fileType, filePath)
|
||||
println("✅ File berhasil diupload: $fileName")
|
||||
} else {
|
||||
println("❌ Error: Gagal menyimpan file")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("❌ Error upload file: ${e.message}")
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hapus source
|
||||
*/
|
||||
fun deleteSource(source: com.example.notebook.data.SourceEntity) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
repository.deleteSource(source)
|
||||
// Hapus file fisik juga
|
||||
com.example.notebook.utils.FileHelper.deleteFile(source.filePath)
|
||||
println("✅ Source berhasil dihapus: ${source.fileName}")
|
||||
} catch (e: Exception) {
|
||||
println("❌ Error menghapus source: ${e.message}")
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === CHAT FUNCTIONS ===
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user