Merge remote-tracking branch 'origin/cobaDatabase' into cobaDatabase
# Conflicts: # app/src/main/java/com/example/notebook/MainActivity.kt
This commit is contained in:
commit
2ac0393847
@ -68,7 +68,7 @@ dependencies {
|
||||
debugImplementation(libs.ui.tooling)
|
||||
debugImplementation(libs.ui.test.manifest)
|
||||
|
||||
// Room Database - TAMBAHKAN INI SEMUA
|
||||
// Room Database
|
||||
val roomVersion = "2.6.1"
|
||||
implementation("androidx.room:room-runtime:$roomVersion")
|
||||
implementation("androidx.room:room-ktx:$roomVersion")
|
||||
@ -80,4 +80,18 @@ dependencies {
|
||||
// ViewModel & Lifecycle
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-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")
|
||||
}
|
||||
@ -41,18 +41,12 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
var isDarkMode by remember { mutableStateOf(false) }
|
||||
|
||||
NotebookTheme(darkTheme = isDarkMode) {
|
||||
NotebookTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
NotebookApp(
|
||||
viewModel = viewModel,
|
||||
isDarkMode = isDarkMode,
|
||||
onThemeChange = { isDarkMode = it }
|
||||
)
|
||||
NotebookApp(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -61,13 +55,10 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NotebookApp(
|
||||
viewModel: NotebookViewModel,
|
||||
isDarkMode: Boolean,
|
||||
onThemeChange: (Boolean) -> Unit
|
||||
) {
|
||||
fun NotebookApp(viewModel: NotebookViewModel) {
|
||||
var selectedTabIndex by remember { mutableIntStateOf(0) }
|
||||
val tabs = listOf("Studio", "Chat", "Sources")
|
||||
var showGoogleAppsMenu by remember { mutableStateOf(false) }
|
||||
var showSettingsMenu by remember { mutableStateOf(false) }
|
||||
var showAccountScreen by remember { mutableStateOf(false) }
|
||||
var chatInput by remember { mutableStateOf("") }
|
||||
@ -96,14 +87,14 @@ fun NotebookApp(
|
||||
IconButton(onClick = { showSettingsMenu = true }) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = "Settings")
|
||||
}
|
||||
SettingsMenu(
|
||||
expanded = showSettingsMenu,
|
||||
onDismiss = { showSettingsMenu = false },
|
||||
isDarkMode = isDarkMode,
|
||||
onThemeChange = onThemeChange
|
||||
)
|
||||
SettingsMenu(expanded = showSettingsMenu, onDismiss = { showSettingsMenu = false })
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { showGoogleAppsMenu = true }) {
|
||||
Icon(Icons.Filled.Apps, contentDescription = "Google Apps")
|
||||
}
|
||||
GoogleAppsMenu(expanded = showGoogleAppsMenu, onDismiss = { showGoogleAppsMenu = false })
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
@ -185,14 +176,14 @@ fun StudioScreen(viewModel: NotebookViewModel) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.background(Color.White)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
"Notebook terbaru",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
color = Color.Black
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
@ -224,9 +215,7 @@ fun NewNotebookCard(onClick: () -> Unit) {
|
||||
.height(120.dp)
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFF0F4F7))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@ -237,20 +226,13 @@ fun NewNotebookCard(onClick: () -> Unit) {
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||
.background(Color(0xFFE1E3E6)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Buat notebook baru",
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Icon(Icons.Default.Add, contentDescription = "Buat notebook baru", tint = Color.Black)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
"Buat notebook baru",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text("Buat notebook baru", color = Color.Black)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -268,9 +250,7 @@ fun NotebookCard(
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F9FA))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@ -283,13 +263,13 @@ fun NotebookCard(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
.background(Color(0xFFE8EAF6)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Description,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
tint = Color(0xFF5C6BC0)
|
||||
)
|
||||
}
|
||||
|
||||
@ -302,15 +282,14 @@ fun NotebookCard(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = if (notebook.description.isNotBlank()) notebook.description
|
||||
else "Belum ada deskripsi",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
@ -318,7 +297,7 @@ fun NotebookCard(
|
||||
Text(
|
||||
text = dateFormat.format(Date(notebook.updatedAt)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
@ -327,7 +306,7 @@ fun NotebookCard(
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Hapus",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
tint = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -405,7 +384,7 @@ fun SourcesScreen(viewModel: NotebookViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
// === MENU COMPONENTS ===
|
||||
// === MENU COMPONENTS (Tetap sama) ===
|
||||
@Composable
|
||||
fun AccountScreen(onDismiss: () -> Unit) {
|
||||
Dialog(
|
||||
@ -456,40 +435,8 @@ fun AccountScreen(onDismiss: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsMenu(
|
||||
expanded: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
isDarkMode: Boolean,
|
||||
onThemeChange: (Boolean) -> Unit
|
||||
) {
|
||||
fun SettingsMenu(expanded: Boolean, onDismiss: () -> Unit) {
|
||||
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(
|
||||
text = { Text("NotebookLM Help") },
|
||||
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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
30
app/src/main/java/com/example/notebook/api/ApiConstants.kt
Normal file
30
app/src/main/java/com/example/notebook/api/ApiConstants.kt
Normal 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
|
||||
"""
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
94
app/src/main/java/com/example/notebook/api/GeminiModels.kt
Normal file
94
app/src/main/java/com/example/notebook/api/GeminiModels.kt
Normal 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
|
||||
}
|
||||
112
app/src/main/java/com/example/notebook/api/GeminiRepository.kt
Normal file
112
app/src/main/java/com/example/notebook/api/GeminiRepository.kt
Normal 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()))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -60,9 +60,19 @@ fun NotebookDetailScreen(
|
||||
|
||||
// Load notebook data
|
||||
LaunchedEffect(notebookId) {
|
||||
println("🚀 NotebookDetailScreen: LaunchedEffect triggered untuk ID: $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(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@ -104,6 +114,18 @@ fun NotebookDetailScreen(
|
||||
},
|
||||
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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,11 +76,27 @@ object FileHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* Baca text dari file
|
||||
* Baca text dari file (support Text, Markdown, dan PDF)
|
||||
*/
|
||||
fun readTextFromFile(filePath: String): String? {
|
||||
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) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
|
||||
93
app/src/main/java/com/example/notebook/utils/PdfHelper.kt
Normal file
93
app/src/main/java/com/example/notebook/utils/PdfHelper.kt
Normal 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
|
||||
)
|
||||
@ -3,6 +3,7 @@ package com.example.notebook.viewmodel
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.notebook.api.GeminiRepository
|
||||
import com.example.notebook.data.AppDatabase
|
||||
import com.example.notebook.data.NotebookEntity
|
||||
import com.example.notebook.data.NotebookRepository
|
||||
@ -20,6 +21,7 @@ import kotlinx.coroutines.launch
|
||||
class NotebookViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val repository: NotebookRepository
|
||||
private val geminiRepository = GeminiRepository()
|
||||
|
||||
// State untuk list notebooks
|
||||
private val _notebooks = MutableStateFlow<List<NotebookEntity>>(emptyList())
|
||||
@ -41,6 +43,10 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
// State error
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||
|
||||
init {
|
||||
val database = AppDatabase.getDatabase(application)
|
||||
repository = NotebookRepository(database.notebookDao())
|
||||
@ -49,9 +55,6 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
|
||||
|
||||
// === NOTEBOOK FUNCTIONS ===
|
||||
|
||||
/**
|
||||
* Load semua notebooks dari database
|
||||
*/
|
||||
private fun loadNotebooks() {
|
||||
viewModelScope.launch {
|
||||
repository.getAllNotebooks().collect { notebooks ->
|
||||
@ -60,15 +63,11 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Buat notebook baru
|
||||
*/
|
||||
fun createNotebook(title: String, description: String = "") {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
val notebookId = repository.createNotebook(title, description)
|
||||
// Otomatis ter-update karena Flow
|
||||
println("✅ Notebook berhasil dibuat dengan ID: $notebookId")
|
||||
} catch (e: Exception) {
|
||||
println("❌ Error membuat notebook: ${e.message}")
|
||||
@ -78,33 +77,28 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pilih notebook untuk dibuka
|
||||
*/
|
||||
fun selectNotebook(notebookId: Int) {
|
||||
println("📂 selectNotebook dipanggil dengan ID: $notebookId")
|
||||
viewModelScope.launch {
|
||||
repository.getNotebookById(notebookId).collect { notebook ->
|
||||
println("📖 Notebook data: ${notebook?.title ?: "NULL"}")
|
||||
_currentNotebook.value = notebook
|
||||
notebook?.let {
|
||||
println("📦 Loading sources untuk notebook ID: $notebookId")
|
||||
loadSources(notebookId)
|
||||
println("💬 Loading chat history untuk notebook ID: $notebookId")
|
||||
loadChatHistory(notebookId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update notebook
|
||||
*/
|
||||
fun updateNotebook(notebook: NotebookEntity) {
|
||||
viewModelScope.launch {
|
||||
repository.updateNotebook(notebook)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hapus notebook
|
||||
*/
|
||||
fun deleteNotebook(notebook: NotebookEntity) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteNotebook(notebook)
|
||||
@ -113,9 +107,6 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
|
||||
|
||||
// === SOURCE FUNCTIONS ===
|
||||
|
||||
/**
|
||||
* Load sources untuk notebook tertentu
|
||||
*/
|
||||
private fun loadSources(notebookId: Int) {
|
||||
viewModelScope.launch {
|
||||
repository.getSourcesByNotebook(notebookId).collect { sources ->
|
||||
@ -124,9 +115,6 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tambah source baru
|
||||
*/
|
||||
fun addSource(
|
||||
notebookId: Int,
|
||||
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) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
@ -171,15 +156,11 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hapus source
|
||||
*/
|
||||
fun deleteSource(source: com.example.notebook.data.SourceEntity) {
|
||||
fun deleteSource(source: 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) {
|
||||
@ -192,9 +173,6 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
|
||||
|
||||
// === CHAT FUNCTIONS ===
|
||||
|
||||
/**
|
||||
* Load chat history
|
||||
*/
|
||||
private fun loadChatHistory(notebookId: Int) {
|
||||
viewModelScope.launch {
|
||||
repository.getChatHistory(notebookId).collect { messages ->
|
||||
@ -203,36 +181,126 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kirim pesan user
|
||||
*/
|
||||
fun sendUserMessage(notebookId: Int, message: String) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
repository.sendMessage(notebookId, message, isUserMessage = true)
|
||||
// TODO: Panggil Gemini API di sini
|
||||
// Sementara kirim dummy AI response
|
||||
simulateAIResponse(notebookId, message)
|
||||
|
||||
val documentContext = buildDocumentContext()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulasi AI response (sementara sebelum Gemini API)
|
||||
*/
|
||||
private fun simulateAIResponse(notebookId: Int, userMessage: String) {
|
||||
viewModelScope.launch {
|
||||
// Delay simulasi "AI thinking"
|
||||
kotlinx.coroutines.delay(1000)
|
||||
val aiResponse = "Ini adalah response sementara untuk: \"$userMessage\""
|
||||
val result = geminiRepository.chatWithDocument(
|
||||
userMessage = message,
|
||||
documentContext = documentContext,
|
||||
chatHistory = chatHistory
|
||||
)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { aiResponse ->
|
||||
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) {
|
||||
viewModelScope.launch {
|
||||
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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user