Merge remote-tracking branch 'origin/cobaDatabase' into cobaDatabase

# Conflicts:
#	app/src/main/java/com/example/notebook/MainActivity.kt
This commit is contained in:
Awang 2025-11-14 21:56:08 +07:00
commit 2ac0393847
10 changed files with 598 additions and 137 deletions

View File

@ -68,7 +68,7 @@ dependencies {
debugImplementation(libs.ui.tooling) debugImplementation(libs.ui.tooling)
debugImplementation(libs.ui.test.manifest) debugImplementation(libs.ui.test.manifest)
// Room Database - TAMBAHKAN INI SEMUA // Room Database
val roomVersion = "2.6.1" val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion") implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion") implementation("androidx.room:room-ktx:$roomVersion")
@ -80,4 +80,18 @@ dependencies {
// ViewModel & Lifecycle // ViewModel & Lifecycle
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
// Retrofit untuk HTTP requests
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// OkHttp untuk logging
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Gson
implementation("com.google.code.gson:gson:2.10.1")
// PDF Support
implementation("com.tom-roush:pdfbox-android:2.0.27.0")
} }

View File

@ -41,18 +41,12 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
var isDarkMode by remember { mutableStateOf(false) } NotebookTheme {
NotebookTheme(darkTheme = isDarkMode) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
) { ) {
NotebookApp( NotebookApp(viewModel = viewModel)
viewModel = viewModel,
isDarkMode = isDarkMode,
onThemeChange = { isDarkMode = it }
)
} }
} }
} }
@ -61,13 +55,10 @@ class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun NotebookApp( fun NotebookApp(viewModel: NotebookViewModel) {
viewModel: NotebookViewModel,
isDarkMode: Boolean,
onThemeChange: (Boolean) -> Unit
) {
var selectedTabIndex by remember { mutableIntStateOf(0) } var selectedTabIndex by remember { mutableIntStateOf(0) }
val tabs = listOf("Studio", "Chat", "Sources") val tabs = listOf("Studio", "Chat", "Sources")
var showGoogleAppsMenu by remember { mutableStateOf(false) }
var showSettingsMenu by remember { mutableStateOf(false) } var showSettingsMenu by remember { mutableStateOf(false) }
var showAccountScreen by remember { mutableStateOf(false) } var showAccountScreen by remember { mutableStateOf(false) }
var chatInput by remember { mutableStateOf("") } var chatInput by remember { mutableStateOf("") }
@ -96,14 +87,14 @@ fun NotebookApp(
IconButton(onClick = { showSettingsMenu = true }) { IconButton(onClick = { showSettingsMenu = true }) {
Icon(Icons.Filled.Settings, contentDescription = "Settings") Icon(Icons.Filled.Settings, contentDescription = "Settings")
} }
SettingsMenu( SettingsMenu(expanded = showSettingsMenu, onDismiss = { showSettingsMenu = false })
expanded = showSettingsMenu, }
onDismiss = { showSettingsMenu = false }, Box {
isDarkMode = isDarkMode, IconButton(onClick = { showGoogleAppsMenu = true }) {
onThemeChange = onThemeChange Icon(Icons.Filled.Apps, contentDescription = "Google Apps")
) }
GoogleAppsMenu(expanded = showGoogleAppsMenu, onDismiss = { showGoogleAppsMenu = false })
} }
Box( Box(
modifier = Modifier modifier = Modifier
.size(32.dp) .size(32.dp)
@ -185,14 +176,14 @@ fun StudioScreen(viewModel: NotebookViewModel) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(Color.White)
.padding(16.dp) .padding(16.dp)
) { ) {
Text( Text(
"Notebook terbaru", "Notebook terbaru",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground color = Color.Black
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@ -224,9 +215,7 @@ fun NewNotebookCard(onClick: () -> Unit) {
.height(120.dp) .height(120.dp)
.clickable(onClick = onClick), .clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(containerColor = Color(0xFFF0F4F7))
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) { ) {
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -237,20 +226,13 @@ fun NewNotebookCard(onClick: () -> Unit) {
modifier = Modifier modifier = Modifier
.size(40.dp) .size(40.dp)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer), .background(Color(0xFFE1E3E6)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(Icons.Default.Add, contentDescription = "Buat notebook baru", tint = Color.Black)
Icons.Default.Add,
contentDescription = "Buat notebook baru",
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text("Buat notebook baru", color = Color.Black)
"Buat notebook baru",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
} }
} }
@ -268,9 +250,7 @@ fun NotebookCard(
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick), .clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F9FA))
containerColor = MaterialTheme.colorScheme.surface
)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -283,13 +263,13 @@ fun NotebookCard(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.primaryContainer), .background(Color(0xFFE8EAF6)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
Icons.Default.Description, Icons.Default.Description,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer tint = Color(0xFF5C6BC0)
) )
} }
@ -302,15 +282,14 @@ fun NotebookCard(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis
color = MaterialTheme.colorScheme.onSurface
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = if (notebook.description.isNotBlank()) notebook.description text = if (notebook.description.isNotBlank()) notebook.description
else "Belum ada deskripsi", else "Belum ada deskripsi",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = Color.Gray,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@ -318,7 +297,7 @@ fun NotebookCard(
Text( Text(
text = dateFormat.format(Date(notebook.updatedAt)), text = dateFormat.format(Date(notebook.updatedAt)),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = Color.Gray
) )
} }
@ -327,7 +306,7 @@ fun NotebookCard(
Icon( Icon(
Icons.Default.Delete, Icons.Default.Delete,
contentDescription = "Hapus", contentDescription = "Hapus",
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = Color.Gray
) )
} }
} }
@ -405,7 +384,7 @@ fun SourcesScreen(viewModel: NotebookViewModel) {
} }
} }
// === MENU COMPONENTS === // === MENU COMPONENTS (Tetap sama) ===
@Composable @Composable
fun AccountScreen(onDismiss: () -> Unit) { fun AccountScreen(onDismiss: () -> Unit) {
Dialog( Dialog(
@ -456,40 +435,8 @@ fun AccountScreen(onDismiss: () -> Unit) {
} }
@Composable @Composable
fun SettingsMenu( fun SettingsMenu(expanded: Boolean, onDismiss: () -> Unit) {
expanded: Boolean,
onDismiss: () -> Unit,
isDarkMode: Boolean,
onThemeChange: (Boolean) -> Unit
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
// Dark Mode Toggle
DropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (isDarkMode) Icons.Default.DarkMode else Icons.Default.LightMode,
contentDescription = null
)
Spacer(modifier = Modifier.width(12.dp))
Text(if (isDarkMode) "Mode Gelap" else "Mode Terang")
}
Switch(
checked = isDarkMode,
onCheckedChange = onThemeChange
)
}
},
onClick = { onThemeChange(!isDarkMode) }
)
Divider()
DropdownMenuItem( DropdownMenuItem(
text = { Text("NotebookLM Help") }, text = { Text("NotebookLM Help") },
onClick = { }, onClick = { },
@ -498,3 +445,20 @@ fun SettingsMenu(
} }
} }
@Composable
fun GoogleAppsMenu(expanded: Boolean, onDismiss: () -> Unit) {
val apps = listOf(
"Account" to Icons.Default.AccountCircle,
"Gmail" to Icons.Default.Mail,
"Drive" to Icons.Default.Cloud
)
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
apps.forEach { (name, icon) ->
DropdownMenuItem(
text = { Text(name) },
onClick = { },
leadingIcon = { Icon(icon, contentDescription = name) }
)
}
}
}

View File

@ -0,0 +1,30 @@
package com.example.notebook.api
/**
* Constants untuk Gemini API
*
* PENTING: Jangan commit API key ke Git!
* Untuk production, gunakan BuildConfig atau environment variable
*/
object ApiConstants {
// GANTI INI DENGAN API KEY KAMU
const val GEMINI_API_KEY = "AIzaSyCVYFUMcKqCDKN5Z_vNwT2Z4VHgjJ5V7dI"
// Endpoint Gemini API
const val BASE_URL = "https://generativelanguage.googleapis.com/"
// Model yang digunakan (Flash = gratis & cepat)
const val MODEL_NAME = "gemini-2.0-flash"
// System instruction untuk AI
const val SYSTEM_INSTRUCTION = """
Kamu adalah asisten AI yang membantu pengguna memahami dokumen mereka.
Tugasmu:
1. Membuat ringkasan yang jelas dan informatif dari dokumen
2. Menjawab pertanyaan pengguna berdasarkan isi dokumen
3. Selalu rujuk informasi yang ada di dokumen
4. Jika informasi tidak ada di dokumen, katakan dengan jelas
5. Gunakan bahasa Indonesia yang mudah dipahami
"""
}

View File

@ -0,0 +1,48 @@
package com.example.notebook.api
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.Query
import java.util.concurrent.TimeUnit
/**
* Retrofit interface untuk Gemini API
*/
interface GeminiApiService {
@POST("v1beta/models/gemini-2.0-flash:generateContent")
suspend fun generateContent(
@Query("key") apiKey: String,
@Body request: GeminiRequest
): Response<GeminiResponse>
companion object {
fun create(): GeminiApiService {
// Logger untuk debug
val logger = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
// HTTP Client
val client = OkHttpClient.Builder()
.addInterceptor(logger)
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
// Retrofit instance
return Retrofit.Builder()
.baseUrl(ApiConstants.BASE_URL)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(GeminiApiService::class.java)
}
}
}

View File

@ -0,0 +1,94 @@
package com.example.notebook.api
import com.google.gson.annotations.SerializedName
/**
* Request model untuk Gemini API
*/
data class GeminiRequest(
@SerializedName("contents")
val contents: List<Content>,
@SerializedName("generationConfig")
val generationConfig: GenerationConfig? = null,
@SerializedName("systemInstruction")
val systemInstruction: SystemInstruction? = null
)
data class Content(
@SerializedName("parts")
val parts: List<Part>,
@SerializedName("role")
val role: String = "user"
)
data class Part(
@SerializedName("text")
val text: String
)
data class GenerationConfig(
@SerializedName("temperature")
val temperature: Double = 0.7,
@SerializedName("maxOutputTokens")
val maxOutputTokens: Int = 2048,
@SerializedName("topP")
val topP: Double = 0.95
)
data class SystemInstruction(
@SerializedName("parts")
val parts: List<Part>
)
/**
* Response model dari Gemini API
*/
data class GeminiResponse(
@SerializedName("candidates")
val candidates: List<Candidate>?,
@SerializedName("error")
val error: ApiError? = null
)
data class Candidate(
@SerializedName("content")
val content: Content,
@SerializedName("finishReason")
val finishReason: String?,
@SerializedName("safetyRatings")
val safetyRatings: List<SafetyRating>?
)
data class SafetyRating(
@SerializedName("category")
val category: String,
@SerializedName("probability")
val probability: String
)
data class ApiError(
@SerializedName("code")
val code: Int,
@SerializedName("message")
val message: String,
@SerializedName("status")
val status: String
)
/**
* Helper untuk extract text dari response
*/
fun GeminiResponse.getTextResponse(): String? {
return candidates?.firstOrNull()?.content?.parts?.firstOrNull()?.text
}

View File

@ -0,0 +1,112 @@
package com.example.notebook.api
/**
* Repository untuk handle Gemini API calls
*/
class GeminiRepository {
private val apiService = GeminiApiService.create()
/**
* Generate summary dari text
*/
suspend fun generateSummary(text: String): Result<String> {
return try {
val prompt = """
Buatlah ringkasan yang komprehensif dari dokumen berikut dalam bahasa Indonesia.
Ringkasan harus:
- Mencakup poin-poin utama
- Mudah dipahami
- Panjang sekitar 3-5 paragraf
Dokumen:
$text
""".trimIndent()
val request = createRequest(prompt)
val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request)
if (response.isSuccessful) {
val body = response.body()
val textResponse = body?.getTextResponse()
if (textResponse != null) {
Result.success(textResponse)
} else {
Result.failure(Exception("Empty response from API"))
}
} else {
Result.failure(Exception("API Error: ${response.code()} - ${response.message()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Chat dengan context dokumen
*/
suspend fun chatWithDocument(
userMessage: String,
documentContext: String,
chatHistory: List<Pair<String, String>> = emptyList()
): Result<String> {
return try {
// Build context dengan chat history
val contextBuilder = StringBuilder()
contextBuilder.append("Konteks Dokumen:\n$documentContext\n\n")
if (chatHistory.isNotEmpty()) {
contextBuilder.append("Riwayat Chat:\n")
chatHistory.forEach { (user, ai) ->
contextBuilder.append("User: $user\n")
contextBuilder.append("AI: $ai\n\n")
}
}
contextBuilder.append("Pertanyaan User: $userMessage\n\n")
contextBuilder.append("Jawab berdasarkan dokumen di atas. Jika informasi tidak ada di dokumen, katakan dengan jelas.")
val request = createRequest(contextBuilder.toString())
val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request)
if (response.isSuccessful) {
val body = response.body()
val textResponse = body?.getTextResponse()
if (textResponse != null) {
Result.success(textResponse)
} else {
Result.failure(Exception("Empty response from API"))
}
} else {
val errorBody = response.errorBody()?.string()
Result.failure(Exception("API Error: ${response.code()} - $errorBody"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Create request object dengan system instruction
*/
private fun createRequest(prompt: String): GeminiRequest {
return GeminiRequest(
contents = listOf(
Content(
parts = listOf(Part(prompt)),
role = "user"
)
),
generationConfig = GenerationConfig(
temperature = 0.7,
maxOutputTokens = 2048,
topP = 0.95
),
systemInstruction = SystemInstruction(
parts = listOf(Part(ApiConstants.SYSTEM_INSTRUCTION.trimIndent()))
)
)
}
}

View File

@ -60,9 +60,19 @@ fun NotebookDetailScreen(
// Load notebook data // Load notebook data
LaunchedEffect(notebookId) { LaunchedEffect(notebookId) {
println("🚀 NotebookDetailScreen: LaunchedEffect triggered untuk ID: $notebookId")
viewModel.selectNotebook(notebookId) viewModel.selectNotebook(notebookId)
} }
// Debug log untuk state changes
LaunchedEffect(notebook) {
println("📝 Notebook state updated: ${notebook?.title ?: "NULL"}")
}
LaunchedEffect(sources) {
println("📚 Sources updated: ${sources.size} items")
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@ -104,6 +114,18 @@ fun NotebookDetailScreen(
}, },
leadingIcon = { Icon(Icons.Default.CloudUpload, null) } leadingIcon = { Icon(Icons.Default.CloudUpload, null) }
) )
if (sources.isNotEmpty()) {
Divider()
DropdownMenuItem(
text = { Text("Generate Summary") },
onClick = {
viewModel.generateSummary(notebookId)
selectedTab = 0 // Switch ke chat tab
showUploadMenu = false
},
leadingIcon = { Icon(Icons.Default.Summarize, null) }
)
}
} }
} }
} }

View File

@ -76,11 +76,27 @@ object FileHelper {
} }
/** /**
* Baca text dari file * Baca text dari file (support Text, Markdown, dan PDF)
*/ */
fun readTextFromFile(filePath: String): String? { fun readTextFromFile(filePath: String): String? {
return try { return try {
File(filePath).readText() val file = File(filePath)
val extension = file.extension.lowercase()
when (extension) {
"pdf" -> {
// Extract text dari PDF
PdfHelper.extractTextFromPdf(filePath)
}
"txt", "md", "markdown" -> {
// Baca text biasa
file.readText()
}
else -> {
println("⚠️ Format file tidak didukung untuk ekstraksi teks: $extension")
null
}
}
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
null null

View File

@ -0,0 +1,93 @@
package com.example.notebook.utils
import android.content.Context
import android.net.Uri
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
import com.tom_roush.pdfbox.pdmodel.PDDocument
import com.tom_roush.pdfbox.text.PDFTextStripper
import java.io.File
/**
* Helper untuk extract text dari PDF
*/
object PdfHelper {
/**
* Initialize PDFBox (panggil sekali saat app start)
*/
fun initialize(context: Context) {
PDFBoxResourceLoader.init(context)
}
/**
* Extract text dari PDF file
* Return: Text content atau null jika error
*/
fun extractTextFromPdf(filePath: String): String? {
return try {
val file = File(filePath)
val document = PDDocument.load(file)
val stripper = PDFTextStripper()
val text = stripper.getText(document)
document.close()
// Cleanup whitespace
text.trim()
} catch (e: Exception) {
println("❌ Error extract PDF: ${e.message}")
e.printStackTrace()
null
}
}
/**
* Extract text dari PDF URI (sebelum disimpan)
*/
fun extractTextFromPdfUri(context: Context, uri: Uri): String? {
return try {
val inputStream = context.contentResolver.openInputStream(uri)
val document = PDDocument.load(inputStream)
val stripper = PDFTextStripper()
val text = stripper.getText(document)
document.close()
inputStream?.close()
text.trim()
} catch (e: Exception) {
println("❌ Error extract PDF from URI: ${e.message}")
e.printStackTrace()
null
}
}
/**
* Get info PDF (jumlah halaman, dll)
*/
fun getPdfInfo(filePath: String): PdfInfo? {
return try {
val file = File(filePath)
val document = PDDocument.load(file)
val info = PdfInfo(
pageCount = document.numberOfPages,
title = document.documentInformation.title ?: "Unknown",
author = document.documentInformation.author ?: "Unknown"
)
document.close()
info
} catch (e: Exception) {
null
}
}
}
data class PdfInfo(
val pageCount: Int,
val title: String,
val author: String
)

View File

@ -3,6 +3,7 @@ package com.example.notebook.viewmodel
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.notebook.api.GeminiRepository
import com.example.notebook.data.AppDatabase import com.example.notebook.data.AppDatabase
import com.example.notebook.data.NotebookEntity import com.example.notebook.data.NotebookEntity
import com.example.notebook.data.NotebookRepository import com.example.notebook.data.NotebookRepository
@ -20,6 +21,7 @@ import kotlinx.coroutines.launch
class NotebookViewModel(application: Application) : AndroidViewModel(application) { class NotebookViewModel(application: Application) : AndroidViewModel(application) {
private val repository: NotebookRepository private val repository: NotebookRepository
private val geminiRepository = GeminiRepository()
// State untuk list notebooks // State untuk list notebooks
private val _notebooks = MutableStateFlow<List<NotebookEntity>>(emptyList()) private val _notebooks = MutableStateFlow<List<NotebookEntity>>(emptyList())
@ -41,6 +43,10 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
private val _isLoading = MutableStateFlow(false) private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow() val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
// State error
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
init { init {
val database = AppDatabase.getDatabase(application) val database = AppDatabase.getDatabase(application)
repository = NotebookRepository(database.notebookDao()) repository = NotebookRepository(database.notebookDao())
@ -49,9 +55,6 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
// === NOTEBOOK FUNCTIONS === // === NOTEBOOK FUNCTIONS ===
/**
* Load semua notebooks dari database
*/
private fun loadNotebooks() { private fun loadNotebooks() {
viewModelScope.launch { viewModelScope.launch {
repository.getAllNotebooks().collect { notebooks -> repository.getAllNotebooks().collect { notebooks ->
@ -60,15 +63,11 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
} }
} }
/**
* Buat notebook baru
*/
fun createNotebook(title: String, description: String = "") { fun createNotebook(title: String, description: String = "") {
viewModelScope.launch { viewModelScope.launch {
_isLoading.value = true _isLoading.value = true
try { try {
val notebookId = repository.createNotebook(title, description) val notebookId = repository.createNotebook(title, description)
// Otomatis ter-update karena Flow
println("✅ Notebook berhasil dibuat dengan ID: $notebookId") println("✅ Notebook berhasil dibuat dengan ID: $notebookId")
} catch (e: Exception) { } catch (e: Exception) {
println("❌ Error membuat notebook: ${e.message}") println("❌ Error membuat notebook: ${e.message}")
@ -78,33 +77,28 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
} }
} }
/**
* Pilih notebook untuk dibuka
*/
fun selectNotebook(notebookId: Int) { fun selectNotebook(notebookId: Int) {
println("📂 selectNotebook dipanggil dengan ID: $notebookId")
viewModelScope.launch { viewModelScope.launch {
repository.getNotebookById(notebookId).collect { notebook -> repository.getNotebookById(notebookId).collect { notebook ->
println("📖 Notebook data: ${notebook?.title ?: "NULL"}")
_currentNotebook.value = notebook _currentNotebook.value = notebook
notebook?.let { notebook?.let {
println("📦 Loading sources untuk notebook ID: $notebookId")
loadSources(notebookId) loadSources(notebookId)
println("💬 Loading chat history untuk notebook ID: $notebookId")
loadChatHistory(notebookId) loadChatHistory(notebookId)
} }
} }
} }
} }
/**
* Update notebook
*/
fun updateNotebook(notebook: NotebookEntity) { fun updateNotebook(notebook: NotebookEntity) {
viewModelScope.launch { viewModelScope.launch {
repository.updateNotebook(notebook) repository.updateNotebook(notebook)
} }
} }
/**
* Hapus notebook
*/
fun deleteNotebook(notebook: NotebookEntity) { fun deleteNotebook(notebook: NotebookEntity) {
viewModelScope.launch { viewModelScope.launch {
repository.deleteNotebook(notebook) repository.deleteNotebook(notebook)
@ -113,9 +107,6 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
// === SOURCE FUNCTIONS === // === SOURCE FUNCTIONS ===
/**
* Load sources untuk notebook tertentu
*/
private fun loadSources(notebookId: Int) { private fun loadSources(notebookId: Int) {
viewModelScope.launch { viewModelScope.launch {
repository.getSourcesByNotebook(notebookId).collect { sources -> repository.getSourcesByNotebook(notebookId).collect { sources ->
@ -124,9 +115,6 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
} }
} }
/**
* Tambah source baru
*/
fun addSource( fun addSource(
notebookId: Int, notebookId: Int,
fileName: String, fileName: String,
@ -146,9 +134,6 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
} }
} }
/**
* Handle file upload dari URI
*/
fun uploadFile(context: android.content.Context, uri: android.net.Uri, notebookId: Int) { fun uploadFile(context: android.content.Context, uri: android.net.Uri, notebookId: Int) {
viewModelScope.launch { viewModelScope.launch {
_isLoading.value = true _isLoading.value = true
@ -171,15 +156,11 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
} }
} }
/** fun deleteSource(source: SourceEntity) {
* Hapus source
*/
fun deleteSource(source: com.example.notebook.data.SourceEntity) {
viewModelScope.launch { viewModelScope.launch {
_isLoading.value = true _isLoading.value = true
try { try {
repository.deleteSource(source) repository.deleteSource(source)
// Hapus file fisik juga
com.example.notebook.utils.FileHelper.deleteFile(source.filePath) com.example.notebook.utils.FileHelper.deleteFile(source.filePath)
println("✅ Source berhasil dihapus: ${source.fileName}") println("✅ Source berhasil dihapus: ${source.fileName}")
} catch (e: Exception) { } catch (e: Exception) {
@ -192,9 +173,6 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
// === CHAT FUNCTIONS === // === CHAT FUNCTIONS ===
/**
* Load chat history
*/
private fun loadChatHistory(notebookId: Int) { private fun loadChatHistory(notebookId: Int) {
viewModelScope.launch { viewModelScope.launch {
repository.getChatHistory(notebookId).collect { messages -> repository.getChatHistory(notebookId).collect { messages ->
@ -203,36 +181,126 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
} }
} }
/**
* Kirim pesan user
*/
fun sendUserMessage(notebookId: Int, message: String) { fun sendUserMessage(notebookId: Int, message: String) {
viewModelScope.launch { viewModelScope.launch {
_isLoading.value = true
try {
repository.sendMessage(notebookId, message, isUserMessage = true) repository.sendMessage(notebookId, message, isUserMessage = true)
// TODO: Panggil Gemini API di sini
// Sementara kirim dummy AI response val documentContext = buildDocumentContext()
simulateAIResponse(notebookId, message)
if (documentContext.isEmpty()) {
val reply = "Maaf, belum ada dokumen yang diupload. Silakan upload dokumen terlebih dahulu untuk bisa bertanya."
repository.sendMessage(notebookId, reply, isUserMessage = false)
} else {
val chatHistory = _chatMessages.value
.takeLast(10)
.filter { it.isUserMessage }
.mapNotNull { userMsg ->
val aiMsg = _chatMessages.value.find {
!it.isUserMessage && it.timestamp > userMsg.timestamp
} }
if (aiMsg != null) {
Pair(userMsg.message, aiMsg.message)
} else null
} }
/** val result = geminiRepository.chatWithDocument(
* Simulasi AI response (sementara sebelum Gemini API) userMessage = message,
*/ documentContext = documentContext,
private fun simulateAIResponse(notebookId: Int, userMessage: String) { chatHistory = chatHistory
viewModelScope.launch { )
// Delay simulasi "AI thinking"
kotlinx.coroutines.delay(1000) result.fold(
val aiResponse = "Ini adalah response sementara untuk: \"$userMessage\"" onSuccess = { aiResponse ->
repository.sendMessage(notebookId, aiResponse, isUserMessage = false) repository.sendMessage(notebookId, aiResponse, isUserMessage = false)
println("✅ AI response berhasil: ${aiResponse.take(50)}...")
},
onFailure = { error ->
val errorMsg = "Maaf, terjadi error: ${error.message}"
repository.sendMessage(notebookId, errorMsg, isUserMessage = false)
println("❌ Error dari Gemini: ${error.message}")
_errorMessage.value = error.message
}
)
}
} catch (e: Exception) {
println("❌ Error mengirim pesan: ${e.message}")
_errorMessage.value = e.message
} finally {
_isLoading.value = false
}
} }
} }
/**
* Clear chat history
*/
fun clearChatHistory(notebookId: Int) { fun clearChatHistory(notebookId: Int) {
viewModelScope.launch { viewModelScope.launch {
repository.clearChatHistory(notebookId) repository.clearChatHistory(notebookId)
} }
} }
// === GEMINI FUNCTIONS ===
private fun buildDocumentContext(): String {
val context = StringBuilder()
_sources.value.forEach { source ->
when (source.fileType) {
"Text", "Markdown" -> {
val content = com.example.notebook.utils.FileHelper.readTextFromFile(source.filePath)
if (content != null) {
context.append("=== ${source.fileName} ===\n")
context.append(content)
context.append("\n\n")
}
}
else -> {
context.append("=== ${source.fileName} ===\n")
context.append("[File type ${source.fileType} - content extraction not yet supported]\n\n")
}
}
}
return context.toString()
}
fun generateSummary(notebookId: Int) {
viewModelScope.launch {
_isLoading.value = true
try {
val documentContext = buildDocumentContext()
if (documentContext.isEmpty()) {
_errorMessage.value = "Tidak ada dokumen untuk diringkas"
return@launch
}
val result = geminiRepository.generateSummary(documentContext)
result.fold(
onSuccess = { summary ->
repository.sendMessage(
notebookId,
"📝 Ringkasan Dokumen:\n\n$summary",
isUserMessage = false
)
println("✅ Summary berhasil: ${summary.take(100)}...")
},
onFailure = { error ->
_errorMessage.value = "Error generate summary: ${error.message}"
println("❌ Error generate summary: ${error.message}")
}
)
} catch (e: Exception) {
_errorMessage.value = e.message
println("❌ Error: ${e.message}")
} finally {
_isLoading.value = false
}
}
}
fun clearError() {
_errorMessage.value = null
}
} }