From 5867c80588fb63bdfbd282cfdd63311f46ecc0ce Mon Sep 17 00:00:00 2001 From: Raihan Ariq <202310715297@mhs.ubharajaya.ac.id> Date: Thu, 13 Nov 2025 11:07:11 +0700 Subject: [PATCH] Tes Fungsi Database --- app/src/main/AndroidManifest.xml | 7 + .../java/com/example/notebook/MainActivity.kt | 11 + .../ui/screen/NotebookDetailScreen.kt | 430 ++++++++++++++++++ .../com/example/notebook/utils/FileHelper.kt | 114 +++++ .../notebook/viewmodel/NotebookViewModel.kt | 44 ++ 5 files changed, 606 insertions(+) create mode 100644 app/src/main/java/com/example/notebook/ui/screen/NotebookDetailScreen.kt create mode 100644 app/src/main/java/com/example/notebook/utils/FileHelper.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a2adb8f..9ea171b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,13 @@ + + + + + + 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, + sources: List, + 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, + 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/notebook/utils/FileHelper.kt b/app/src/main/java/com/example/notebook/utils/FileHelper.kt new file mode 100644 index 0000000..1c97023 --- /dev/null +++ b/app/src/main/java/com/example/notebook/utils/FileHelper.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/notebook/viewmodel/NotebookViewModel.kt b/app/src/main/java/com/example/notebook/viewmodel/NotebookViewModel.kt index 9db16cd..e4d7075 100644 --- a/app/src/main/java/com/example/notebook/viewmodel/NotebookViewModel.kt +++ b/app/src/main/java/com/example/notebook/viewmodel/NotebookViewModel.kt @@ -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 === /**