Compare commits
20 Commits
master
...
cobaDataba
| Author | SHA1 | Date | |
|---|---|---|---|
| 2587e1bd4d | |||
| b1d99a37bb | |||
| a51c24030f | |||
| 53472b2768 | |||
| ad5aaefdc4 | |||
| 385608225e | |||
| 912c489123 | |||
| f64bff7933 | |||
| 0c04473b43 | |||
| 524f1c1885 | |||
|
|
2ac0393847 | ||
|
|
a3337622c5 | ||
| 30298ac46e | |||
| a7f770adf4 | |||
| 30b9e45aa3 | |||
| 3dec997d41 | |||
| 4b6a44f3b1 | |||
| 5867c80588 | |||
| 793600dd5a | |||
| 7190b1574d |
8
.idea/deploymentTargetSelector.xml
generated
@ -4,6 +4,14 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2025-11-17T09:59:25.576794100Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\Fazri Abdurrahman\.android\avd\Medium_Phone.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
|
||||
2
.idea/deviceManager.xml
generated
@ -5,7 +5,7 @@
|
||||
<list>
|
||||
<ColumnSorterState>
|
||||
<option name="column" value="Name" />
|
||||
<option name="order" value="ASCENDING" />
|
||||
<option name="order" value="DESCENDING" />
|
||||
</ColumnSorterState>
|
||||
</list>
|
||||
</option>
|
||||
|
||||
@ -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")
|
||||
|
||||
// PDFBox untuk extract text dari PDF
|
||||
implementation("com.tom-roush:pdfbox-android:2.0.27.0")
|
||||
}
|
||||
@ -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"
|
||||
@ -11,6 +18,17 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Notebook">
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
88
app/src/main/java/com/example/notebook/api/ApiConstants.kt
Normal file
@ -0,0 +1,88 @@
|
||||
package com.example.notebook.api
|
||||
|
||||
object ApiConstants {
|
||||
|
||||
// API KEY GEMINI (GOOGLE AI STUDIO)
|
||||
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 nya
|
||||
const val SYSTEM_INSTRUCTION = """
|
||||
Anda adalah AI assistant yang membantu pengguna memahami dokumen mereka dengan lebih baik.
|
||||
|
||||
KARAKTERISTIK ANDA:
|
||||
- Ramah, sabar, dan edukatif
|
||||
- Memberikan penjelasan yang mudah dipahami
|
||||
- Menggunakan contoh konkret dari dokumen pengguna
|
||||
- Proaktif dalam memberikan insight tambahan yang relevan
|
||||
|
||||
CARA KERJA ANDA:
|
||||
|
||||
1. **Untuk pertanyaan langsung tentang isi dokumen:**
|
||||
- Jawab berdasarkan informasi dari dokumen
|
||||
- Kutip atau parafrase bagian relevan
|
||||
- Berikan konteks tambahan jika membantu pemahaman
|
||||
|
||||
2. **Untuk pertanyaan konseptual yang terkait dengan topik dokumen:**
|
||||
- Jelaskan konsep dengan bahasa sederhana
|
||||
- Hubungkan dengan contoh spesifik dari dokumen
|
||||
- Format: "Secara umum, [konsep] adalah [penjelasan]. Dalam dokumen Anda, hal ini terlihat pada [contoh spesifik dari dokumen]."
|
||||
|
||||
3. **Untuk pertanyaan yang memerlukan analisis atau perbandingan:**
|
||||
- Berikan analisis yang informatif
|
||||
- Gunakan pengetahuan umum untuk konteks
|
||||
- Selalu kaitkan kembali dengan isi dokumen
|
||||
- Tunjukkan bagaimana konsep umum diterapkan dalam dokumen
|
||||
|
||||
4. **Untuk pertanyaan definisi atau istilah:**
|
||||
- Berikan definisi yang jelas dan akurat
|
||||
- Jelaskan dengan bahasa yang mudah dipahami
|
||||
- Tambahkan: "Dalam konteks dokumen Anda tentang [topik], istilah ini merujuk pada [penjelasan spesifik]..."
|
||||
|
||||
5. **Untuk pertanyaan yang sama sekali tidak terkait:**
|
||||
- Sampaikan dengan sopan bahwa pertanyaan di luar topik dokumen
|
||||
- Berikan ringkasan singkat topik dokumen
|
||||
- Tawarkan untuk membahas topik dalam dokumen
|
||||
|
||||
PRINSIP MENJAWAB:
|
||||
✓ Prioritaskan informasi dari dokumen pengguna
|
||||
✓ Gunakan pengetahuan umum untuk memperkaya pemahaman
|
||||
✓ Selalu hubungkan konsep umum dengan contoh dari dokumen
|
||||
✓ Jelas membedakan mana dari dokumen vs pengetahuan umum
|
||||
✓ Gunakan format markdown untuk struktur yang jelas
|
||||
✓ Berikan jawaban yang komprehensif tapi tidak bertele-tele
|
||||
|
||||
GAYA KOMUNIKASI:
|
||||
- Bahasa Indonesia yang natural dan profesional
|
||||
- Hindari jargon kecuali diperlukan (dengan penjelasan)
|
||||
- Gunakan bullet points, numbering, bold untuk readability
|
||||
- Friendly tapi tetap informatif
|
||||
|
||||
FORMAT TABEL:
|
||||
Untuk perbandingan atau data tabular, gunakan markdown table:
|
||||
| Fitur | Algoritma A | Algoritma B |
|
||||
|------------|-------------|-------------|
|
||||
| Akurasi | 95% | 92% |
|
||||
| Kecepatan | Cepat | Sedang |
|
||||
|
||||
CONTOH INTERAKSI:
|
||||
|
||||
User: "Apa itu algoritma supervised learning?"
|
||||
Good: "Supervised learning adalah pendekatan machine learning dimana model belajar dari data yang sudah diberi label. **Berdasarkan dokumen Anda**, beberapa contoh algoritma supervised learning yang dibahas adalah:
|
||||
- **Naive Bayes**: Algoritma klasifikasi berbasis probabilitas
|
||||
- **Decision Tree**: Menggunakan struktur pohon untuk keputusan
|
||||
- **K-NN**: Klasifikasi berdasarkan kedekatan dengan data tetangga
|
||||
|
||||
Ketiga algoritma ini termasuk supervised learning karena menggunakan data training yang sudah memiliki label kelas."
|
||||
|
||||
User: "Berapa akurasi terbaik yang bisa dicapai?"
|
||||
Good: "Berdasarkan dokumen Anda, akurasi terbaik dicapai oleh algoritma Random Forest dengan hasil [X]%. Namun, perlu diingat bahwa akurasi optimal bergantung pada beberapa faktor seperti kualitas data, feature engineering, dan parameter tuning yang digunakan."
|
||||
|
||||
Selamat membantu pengguna! 🚀
|
||||
"""
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
105
app/src/main/java/com/example/notebook/api/GeminiModels.kt
Normal file
@ -0,0 +1,105 @@
|
||||
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? = null,
|
||||
|
||||
@SerializedName("inlineData")
|
||||
val inlineData: InlineData? = null
|
||||
)
|
||||
|
||||
data class InlineData(
|
||||
@SerializedName("mimeType")
|
||||
val mimeType: String,
|
||||
|
||||
@SerializedName("data")
|
||||
val data: String // Base64 encoded
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
379
app/src/main/java/com/example/notebook/api/GeminiRepository.kt
Normal file
@ -0,0 +1,379 @@
|
||||
package com.example.notebook.api
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Repository untuk handle Gemini API calls
|
||||
*/
|
||||
class GeminiRepository {
|
||||
|
||||
private val apiService = GeminiApiService.create()
|
||||
|
||||
// Retry configuration
|
||||
private val maxRetries = 3
|
||||
private val initialDelayMs = 2000L // 2 seconds
|
||||
|
||||
// Helper function untuk retry dengan exponential backoff
|
||||
private suspend fun <T> retryWithBackoff(
|
||||
maxAttempts: Int = maxRetries,
|
||||
initialDelay: Long = initialDelayMs,
|
||||
factor: Double = 2.0,
|
||||
block: suspend () -> T
|
||||
): T {
|
||||
var currentDelay = initialDelay
|
||||
repeat(maxAttempts - 1) { attempt ->
|
||||
try {
|
||||
return block()
|
||||
} catch (e: Exception) {
|
||||
println("⚠️ Attempt ${attempt + 1} failed: ${e.message}")
|
||||
|
||||
// Jika error bukan 429, langsung throw
|
||||
if (e.message?.contains("429") != true) {
|
||||
throw e
|
||||
}
|
||||
|
||||
println("⏳ Waiting ${currentDelay}ms before retry...")
|
||||
delay(currentDelay)
|
||||
currentDelay = (currentDelay * factor).toLong()
|
||||
}
|
||||
}
|
||||
return block() // Last attempt
|
||||
}
|
||||
|
||||
//Generate summary dengan retry logic
|
||||
suspend fun generateSummary(text: String): Result<String> {
|
||||
return try {
|
||||
retryWithBackoff {
|
||||
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) {
|
||||
return@retryWithBackoff Result.success(textResponse)
|
||||
} else {
|
||||
throw Exception("Empty response from API")
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string()
|
||||
when (response.code()) {
|
||||
429 -> throw Exception("API Error 429: Rate limit exceeded. Retrying...")
|
||||
else -> throw Exception("API Error: ${response.code()} - $errorBody")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// User-friendly error messages
|
||||
val friendlyMessage = when {
|
||||
e.message?.contains("429") == true ->
|
||||
"Quota API Gemini habis. Silakan tunggu beberapa menit atau upgrade ke paid plan."
|
||||
e.message?.contains("401") == true ->
|
||||
"API Key tidak valid. Periksa konfigurasi API Key Anda."
|
||||
e.message?.contains("Network") == true ->
|
||||
"Tidak ada koneksi internet. Periksa koneksi Anda."
|
||||
else -> "Error: ${e.message}"
|
||||
}
|
||||
Result.failure(Exception(friendlyMessage))
|
||||
}
|
||||
}
|
||||
|
||||
// Generate summary dari PDF dengan retry logic
|
||||
suspend fun generateSummaryFromPdfFile(
|
||||
filePath: String,
|
||||
fileName: String
|
||||
): Result<String> = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
try {
|
||||
retryWithBackoff {
|
||||
println("📄 Reading PDF file: $filePath")
|
||||
val file = java.io.File(filePath)
|
||||
|
||||
val fileSizeMB = file.length() / (1024.0 * 1024.0)
|
||||
if (fileSizeMB > 20) {
|
||||
throw Exception("File terlalu besar (${String.format("%.2f", fileSizeMB)} MB). Maksimal 20MB")
|
||||
}
|
||||
|
||||
val bytes = file.readBytes()
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
|
||||
val prompt = """
|
||||
Buatlah ringkasan komprehensif dari dokumen PDF ini dalam bahasa Indonesia.
|
||||
Ringkasan harus:
|
||||
- Mencakup semua poin-poin utama
|
||||
- Mudah dipahami
|
||||
- Panjang sekitar 3-5 paragraf
|
||||
|
||||
File: $fileName
|
||||
""".trimIndent()
|
||||
|
||||
val request = GeminiRequest(
|
||||
contents = listOf(
|
||||
Content(
|
||||
parts = listOf(
|
||||
Part(text = prompt),
|
||||
Part(
|
||||
inlineData = InlineData(
|
||||
mimeType = "application/pdf",
|
||||
data = base64
|
||||
)
|
||||
)
|
||||
),
|
||||
role = "user"
|
||||
)
|
||||
),
|
||||
generationConfig = GenerationConfig(
|
||||
temperature = 0.7,
|
||||
maxOutputTokens = 2048,
|
||||
topP = 0.95
|
||||
),
|
||||
systemInstruction = SystemInstruction(
|
||||
parts = listOf(Part(text = ApiConstants.SYSTEM_INSTRUCTION))
|
||||
)
|
||||
)
|
||||
|
||||
val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
val textResponse = body?.getTextResponse()
|
||||
if (textResponse != null) {
|
||||
return@retryWithBackoff Result.success(textResponse)
|
||||
} else {
|
||||
throw Exception("Empty response from API")
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string()
|
||||
when (response.code()) {
|
||||
429 -> throw Exception("API Error 429: Rate limit exceeded. Retrying...")
|
||||
else -> throw Exception("API Error: ${response.code()} - $errorBody")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val friendlyMessage = when {
|
||||
e.message?.contains("429") == true ->
|
||||
"Quota API Gemini habis. Silakan tunggu beberapa menit atau upgrade ke paid plan."
|
||||
e.message?.contains("401") == true ->
|
||||
"API Key tidak valid. Periksa konfigurasi API Key Anda."
|
||||
else -> "Error: ${e.message}"
|
||||
}
|
||||
Result.failure(Exception(friendlyMessage))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Chat dengan document - dengan retry logic
|
||||
suspend fun chatWithDocument(
|
||||
userMessage: String,
|
||||
documentContext: String,
|
||||
chatHistory: List<Pair<String, String>> = emptyList()
|
||||
): Result<String> {
|
||||
return try {
|
||||
retryWithBackoff {
|
||||
val promptBuilder = StringBuilder()
|
||||
|
||||
promptBuilder.append("""
|
||||
KONTEKS DOKUMEN PENGGUNA:
|
||||
$documentContext
|
||||
|
||||
---
|
||||
|
||||
""".trimIndent())
|
||||
|
||||
if (chatHistory.isNotEmpty()) {
|
||||
promptBuilder.append("RIWAYAT PERCAKAPAN:\n")
|
||||
chatHistory.takeLast(5).forEach { (user, ai) ->
|
||||
promptBuilder.append("User: $user\n")
|
||||
promptBuilder.append("Assistant: $ai\n\n")
|
||||
}
|
||||
promptBuilder.append("---\n\n")
|
||||
}
|
||||
|
||||
promptBuilder.append("PERTANYAAN SAAT INI:\n$userMessage\n\n")
|
||||
|
||||
promptBuilder.append("""
|
||||
PANDUAN MENJAWAB:
|
||||
|
||||
1. **PRIORITASKAN informasi dari dokumen di atas** untuk menjawab pertanyaan.
|
||||
|
||||
2. **JIKA pertanyaan terkait dengan topik dokumen** TETAPI membutuhkan penjelasan konsep umum:
|
||||
- Jelaskan konsep tersebut dengan jelas
|
||||
- Hubungkan dengan contoh konkret dari dokumen
|
||||
- Gunakan format: "Berdasarkan dokumen Anda tentang [topik], [penjelasan konsep], dan dalam dokumen ini dijelaskan bahwa [contoh dari dokumen]..."
|
||||
|
||||
3. **JIKA pertanyaan meminta perbandingan atau analisis** yang memerlukan pengetahuan luar dokumen:
|
||||
- Berikan analisis yang informatif
|
||||
- Selalu kaitkan kembali dengan isi dokumen
|
||||
- Contoh: "Secara umum [konsep], dan ini terlihat dalam dokumen Anda dimana [contoh spesifik]..."
|
||||
|
||||
4. **JIKA pertanyaan tentang definisi atau konsep dasar** yang disebutkan di dokumen:
|
||||
- Berikan definisi yang jelas dan mudah dipahami
|
||||
- Tambahkan konteks dari dokumen: "Dalam dokumen Anda, [topik] dijelaskan sebagai [kutipan/parafrase]..."
|
||||
|
||||
5. **JIKA pertanyaan meminta saran atau rekomendasi** berdasarkan dokumen:
|
||||
- Berikan saran yang konstruktif
|
||||
- Dasarkan pada informasi dari dokumen
|
||||
- Tambahkan insight tambahan jika relevan
|
||||
|
||||
6. **JIKA pertanyaan SAMA SEKALI tidak terkait** dengan dokumen:
|
||||
- Jawab dengan sopan: "Pertanyaan Anda tentang [topik] tidak terkait dengan dokumen yang Anda upload. Dokumen Anda membahas tentang [ringkasan singkat topik dokumen]. Apakah Anda ingin bertanya tentang topik dalam dokumen ini?"
|
||||
|
||||
GAYA BAHASA:
|
||||
- Gunakan bahasa Indonesia yang natural dan ramah
|
||||
- Jelaskan dengan cara yang mudah dipahami
|
||||
- Gunakan contoh konkret dari dokumen ketika relevan
|
||||
- Jika menggunakan istilah teknis, berikan penjelasan singkat
|
||||
- Gunakan format markdown untuk readability (bold, list, dll)
|
||||
|
||||
FORMAT TABEL (jika diperlukan):
|
||||
Untuk data perbandingan atau tabel, gunakan format markdown table:
|
||||
| Header 1 | Header 2 | Header 3 |
|
||||
|----------|----------|----------|
|
||||
| Data 1 | Data 2 | Data 3 |
|
||||
| Data 4 | Data 5 | Data 6 |
|
||||
|
||||
Jawab sekarang:
|
||||
""".trimIndent())
|
||||
|
||||
val request = createRequest(promptBuilder.toString())
|
||||
val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
val textResponse = body?.getTextResponse()
|
||||
if (textResponse != null) {
|
||||
return@retryWithBackoff Result.success(textResponse)
|
||||
} else {
|
||||
throw Exception("Empty response from API")
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string()
|
||||
when (response.code()) {
|
||||
429 -> throw Exception("API Error 429: Rate limit exceeded. Retrying...")
|
||||
else -> throw Exception("API Error: ${response.code()} - $errorBody")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val friendlyMessage = when {
|
||||
e.message?.contains("429") == true ->
|
||||
"⏳ Quota API habis. Coba lagi dalam beberapa menit atau upgrade ke paid plan."
|
||||
e.message?.contains("401") == true ->
|
||||
"❌ API Key tidak valid. Periksa konfigurasi."
|
||||
else -> "Error: ${e.message}"
|
||||
}
|
||||
Result.failure(Exception(friendlyMessage))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Chat dengan PDF file - dengan retry logic
|
||||
|
||||
suspend fun chatWithPdfFile(
|
||||
userMessage: String,
|
||||
pdfFilePath: String,
|
||||
pdfFileName: String,
|
||||
chatHistory: List<Pair<String, String>> = emptyList()
|
||||
): Result<String> = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
try {
|
||||
retryWithBackoff {
|
||||
val file = java.io.File(pdfFilePath)
|
||||
val fileSizeMB = file.length() / (1024.0 * 1024.0)
|
||||
|
||||
if (fileSizeMB > 20) {
|
||||
throw Exception("File terlalu besar (${String.format("%.2f", fileSizeMB)} MB). Maksimal 20MB")
|
||||
}
|
||||
|
||||
val bytes = file.readBytes()
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
|
||||
val promptBuilder = StringBuilder()
|
||||
promptBuilder.append("DOKUMEN PDF: $pdfFileName\n\n")
|
||||
|
||||
if (chatHistory.isNotEmpty()) {
|
||||
promptBuilder.append("RIWAYAT PERCAKAPAN:\n")
|
||||
chatHistory.takeLast(5).forEach { (user, ai) ->
|
||||
promptBuilder.append("User: $user\n")
|
||||
promptBuilder.append("Assistant: $ai\n\n")
|
||||
}
|
||||
promptBuilder.append("---\n\n")
|
||||
}
|
||||
|
||||
promptBuilder.append("PERTANYAAN SAAT INI:\n$userMessage\n\n")
|
||||
promptBuilder.append("""
|
||||
PANDUAN MENJAWAB:
|
||||
|
||||
1. **PRIORITASKAN informasi dari PDF** untuk menjawab pertanyaan.
|
||||
2. **JIKA pertanyaan terkait topik PDF** tetapi butuh penjelasan konsep:
|
||||
- Jelaskan konsep dengan jelas
|
||||
- Hubungkan dengan konten spesifik dari PDF
|
||||
3. Gunakan markdown untuk struktur (bold, list, tabel)
|
||||
|
||||
FORMAT TABEL (jika diperlukan):
|
||||
| Header 1 | Header 2 | Header 3 |
|
||||
|----------|----------|----------|
|
||||
| Data 1 | Data 2 | Data 3 |
|
||||
|
||||
Jawab sekarang:
|
||||
""".trimIndent())
|
||||
|
||||
val request = GeminiRequest(
|
||||
contents = listOf(
|
||||
Content(
|
||||
parts = listOf(
|
||||
Part(inlineData = InlineData(mimeType = "application/pdf", data = base64)),
|
||||
Part(text = promptBuilder.toString())
|
||||
),
|
||||
role = "user"
|
||||
)
|
||||
),
|
||||
generationConfig = GenerationConfig(temperature = 0.7, maxOutputTokens = 2048, topP = 0.95),
|
||||
systemInstruction = SystemInstruction(parts = listOf(Part(text = ApiConstants.SYSTEM_INSTRUCTION)))
|
||||
)
|
||||
|
||||
val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val textResponse = response.body()?.getTextResponse()
|
||||
if (textResponse != null) {
|
||||
return@retryWithBackoff Result.success(textResponse)
|
||||
} else {
|
||||
throw Exception("Empty response")
|
||||
}
|
||||
} else {
|
||||
when (response.code()) {
|
||||
429 -> throw Exception("API Error 429: Rate limit exceeded. Retrying...")
|
||||
else -> throw Exception("API Error: ${response.code()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val friendlyMessage = when {
|
||||
e.message?.contains("429") == true ->
|
||||
"⏳ Quota API habis. Tunggu beberapa menit atau upgrade plan."
|
||||
else -> "Error: ${e.message}"
|
||||
}
|
||||
Result.failure(Exception(friendlyMessage))
|
||||
}
|
||||
}
|
||||
|
||||
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)))
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,7 @@ import androidx.room.RoomDatabase
|
||||
|
||||
/**
|
||||
* Database utama aplikasi
|
||||
* Version 1 = versi pertama database kamu
|
||||
* Version 1 = versi pertama database
|
||||
*/
|
||||
@Database(
|
||||
entities = [
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
package com.example.notebook.data
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Helper untuk test database
|
||||
* Panggil fungsi ini dari MainActivity untuk test
|
||||
*/
|
||||
class DatabaseTest(private val context: Context) {
|
||||
|
||||
private val database = AppDatabase.getDatabase(context)
|
||||
private val dao = database.notebookDao()
|
||||
|
||||
fun testInsertNotebook() {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val testNotebook = NotebookEntity(
|
||||
title = "Notebook Test",
|
||||
description = "Ini adalah test database",
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
sourceCount = 0
|
||||
)
|
||||
|
||||
val id = dao.insertNotebook(testNotebook)
|
||||
println("✅ Database Test: Notebook berhasil disimpan dengan ID: $id")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10,8 +10,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
@Dao
|
||||
interface NotebookDao {
|
||||
|
||||
// === NOTEBOOK OPERATIONS ===
|
||||
|
||||
// NOTEBOOK OPERATIONS
|
||||
@Insert
|
||||
suspend fun insertNotebook(notebook: NotebookEntity): Long
|
||||
|
||||
@ -27,7 +26,7 @@ interface NotebookDao {
|
||||
@Query("SELECT * FROM notebooks WHERE id = :notebookId")
|
||||
fun getNotebookById(notebookId: Int): Flow<NotebookEntity?>
|
||||
|
||||
// === SOURCE OPERATIONS ===
|
||||
// SOURCE OPERATIONS
|
||||
|
||||
@Insert
|
||||
suspend fun insertSource(source: SourceEntity)
|
||||
@ -38,7 +37,7 @@ interface NotebookDao {
|
||||
@Delete
|
||||
suspend fun deleteSource(source: SourceEntity)
|
||||
|
||||
// === CHAT OPERATIONS ===
|
||||
// CHAT OPERATIONS
|
||||
|
||||
@Insert
|
||||
suspend fun insertChatMessage(message: ChatMessageEntity)
|
||||
|
||||
@ -10,17 +10,14 @@ import androidx.room.PrimaryKey
|
||||
data class NotebookEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Int = 0,
|
||||
|
||||
val title: String, // Judul notebook
|
||||
val description: String, // Deskripsi singkat
|
||||
val createdAt: Long, // Timestamp pembuatan
|
||||
val updatedAt: Long, // Timestamp update terakhir
|
||||
val sourceCount: Int = 0 // Jumlah sumber yang diupload
|
||||
val sourceCount: Int = 0, // Jumlah sumber yang diupload
|
||||
)
|
||||
|
||||
/**
|
||||
* Entity untuk menyimpan sumber/dokumen yang diupload
|
||||
*/
|
||||
// Entity untuk menyimpan sumber/dokumen yang diupload
|
||||
@Entity(tableName = "sources")
|
||||
data class SourceEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ -33,9 +30,8 @@ data class SourceEntity(
|
||||
val uploadedAt: Long // Timestamp upload
|
||||
)
|
||||
|
||||
/**
|
||||
* Entity untuk menyimpan chat history dengan AI
|
||||
*/
|
||||
|
||||
// Entity untuk menyimpan chat history dengan AI
|
||||
@Entity(tableName = "chat_messages")
|
||||
data class ChatMessageEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
|
||||
@ -0,0 +1,118 @@
|
||||
package com.example.notebook.data
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Repository adalah "perantara" antara ViewModel dan Database
|
||||
* Semua akses database harus lewat sini
|
||||
*/
|
||||
class NotebookRepository(private val dao: NotebookDao) {
|
||||
|
||||
// NOTEBOOK OPERATIONS
|
||||
|
||||
// Ambil semua notebooks (otomatis update kalau ada perubahan)
|
||||
fun getAllNotebooks(): Flow<List<NotebookEntity>> {
|
||||
return dao.getAllNotebooks()
|
||||
}
|
||||
|
||||
// Ambil notebook berdasarkan ID
|
||||
fun getNotebookById(id: Int): Flow<NotebookEntity?> {
|
||||
return dao.getNotebookById(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Buat notebook baru
|
||||
* Return: ID notebook yang baru dibuat
|
||||
*/
|
||||
suspend fun createNotebook(
|
||||
title: String,
|
||||
description: String = ""
|
||||
): Long {
|
||||
val notebook = NotebookEntity(
|
||||
title = title,
|
||||
description = description,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
sourceCount = 0
|
||||
)
|
||||
return dao.insertNotebook(notebook)
|
||||
}
|
||||
|
||||
// Update notebook yang sudah ada
|
||||
suspend fun updateNotebook(notebook: NotebookEntity) {
|
||||
val updated = notebook.copy(updatedAt = System.currentTimeMillis())
|
||||
dao.updateNotebook(updated)
|
||||
}
|
||||
|
||||
// Hapus notebook
|
||||
suspend fun deleteNotebook(notebook: NotebookEntity) {
|
||||
dao.deleteNotebook(notebook)
|
||||
}
|
||||
|
||||
// SOURCE OPERATIONS
|
||||
|
||||
// Tambah source ke notebook
|
||||
suspend fun addSource(
|
||||
notebookId: Int,
|
||||
fileName: String,
|
||||
fileType: String,
|
||||
filePath: String
|
||||
) {
|
||||
val source = SourceEntity(
|
||||
notebookId = notebookId,
|
||||
fileName = fileName,
|
||||
fileType = fileType,
|
||||
filePath = filePath,
|
||||
uploadedAt = System.currentTimeMillis()
|
||||
)
|
||||
dao.insertSource(source)
|
||||
|
||||
// Update sourceCount di notebook
|
||||
val notebook = dao.getNotebookById(notebookId)
|
||||
// Note: Ini simplified, di production pakai query COUNT
|
||||
}
|
||||
|
||||
// Ambil semua sources dalam notebook
|
||||
fun getSourcesByNotebook(notebookId: Int): Flow<List<SourceEntity>> {
|
||||
return dao.getSourcesByNotebook(notebookId)
|
||||
}
|
||||
|
||||
// Hapus source
|
||||
suspend fun deleteSource(source: SourceEntity) {
|
||||
dao.deleteSource(source)
|
||||
}
|
||||
|
||||
// CHAT OPERATIONS
|
||||
|
||||
// Kirim pesan chat (user atau AI)
|
||||
suspend fun sendMessage(
|
||||
notebookId: Int,
|
||||
message: String,
|
||||
isUserMessage: Boolean
|
||||
) {
|
||||
val chatMessage = ChatMessageEntity(
|
||||
notebookId = notebookId,
|
||||
message = message,
|
||||
isUserMessage = isUserMessage,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
dao.insertChatMessage(chatMessage)
|
||||
}
|
||||
|
||||
// Ambil history chat
|
||||
fun getChatHistory(notebookId: Int): Flow<List<ChatMessageEntity>> {
|
||||
return dao.getChatHistory(notebookId)
|
||||
}
|
||||
|
||||
// Hapus semua chat dalam notebook
|
||||
suspend fun clearChatHistory(notebookId: Int) {
|
||||
dao.clearChatHistory(notebookId)
|
||||
}
|
||||
|
||||
// Hapus pesan AI terakhir (untuk replace dengan hasil sebenarnya)
|
||||
suspend fun clearLastAIMessage(notebookId: Int) {
|
||||
// Get last AI message kaya di GPT bisa di Edit prompt nya
|
||||
// Note: Ini simplified, di production sebaiknya pakai query langsung
|
||||
// Untuk sekarang kita skip dulu, cukup pakai pesan "thinking" yang nanti di-override
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,325 @@
|
||||
package com.example.notebook.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Composable untuk render markdown text (Parser)
|
||||
* Support: **bold**, *italic*, `code`, # heading, numbered list, bullet list, TABLES
|
||||
*/
|
||||
@Composable
|
||||
fun MarkdownText(
|
||||
markdown: String,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = Color.Black
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
parseMarkdown(markdown).forEach { element ->
|
||||
when (element) {
|
||||
is MarkdownElement.Paragraph -> {
|
||||
Text(
|
||||
text = buildStyledText(element.text),
|
||||
color = color,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
is MarkdownElement.Heading -> {
|
||||
Text(
|
||||
text = element.text,
|
||||
fontSize = when (element.level) {
|
||||
1 -> 24.sp
|
||||
2 -> 20.sp
|
||||
3 -> 18.sp
|
||||
else -> 16.sp
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = color
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
is MarkdownElement.ListItem -> {
|
||||
Row(modifier = Modifier.padding(start = 16.dp)) {
|
||||
Text(
|
||||
text = if (element.isNumbered) "${element.number}. " else "• ",
|
||||
color = color,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = buildStyledText(element.text),
|
||||
color = color,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
is MarkdownElement.CodeBlock -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = Color(0xFFF5F5F5),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = element.code,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
color = Color(0xFF37474F)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
is MarkdownElement.Table -> {
|
||||
MarkdownTable(
|
||||
headers = element.headers,
|
||||
rows = element.rows,
|
||||
textColor = color
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render tabel dengan styling yang bagus
|
||||
@Composable
|
||||
fun MarkdownTable(
|
||||
headers: List<String>,
|
||||
rows: List<List<String>>,
|
||||
textColor: Color
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(1.dp, Color(0xFFE0E0E0), RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFFFAFAFA), RoundedCornerShape(8.dp))
|
||||
) {
|
||||
// Header Row
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xFFF0F0F0))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
headers.forEach { header ->
|
||||
Text(
|
||||
text = header.trim(),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Data Rows
|
||||
rows.forEachIndexed { index, row ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
if (index % 2 == 0) Color.White else Color(0xFFF9F9F9)
|
||||
)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
row.forEach { cell ->
|
||||
Text(
|
||||
text = cell.trim(),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp),
|
||||
fontSize = 13.sp,
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build styled text dengan support inline markdown
|
||||
@Composable
|
||||
private fun buildStyledText(text: String) = buildAnnotatedString {
|
||||
var currentIndex = 0
|
||||
val processed = mutableSetOf<IntRange>()
|
||||
|
||||
// Regex patterns
|
||||
val boldPattern = """\*\*(.+?)\*\*""".toRegex()
|
||||
val italicPattern = """\*(.+?)\*""".toRegex()
|
||||
val codePattern = """`(.+?)`""".toRegex()
|
||||
|
||||
// Process bold **text**
|
||||
boldPattern.findAll(text).forEach { match ->
|
||||
if (processed.none { it.contains(match.range.first) }) {
|
||||
append(text.substring(currentIndex, match.range.first))
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(match.groupValues[1])
|
||||
}
|
||||
currentIndex = match.range.last + 1
|
||||
processed.add(match.range)
|
||||
}
|
||||
}
|
||||
|
||||
// Process italic *text*
|
||||
italicPattern.findAll(text).forEach { match ->
|
||||
if (processed.none { it.contains(match.range.first) }) {
|
||||
append(text.substring(currentIndex, match.range.first))
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
append(match.groupValues[1])
|
||||
}
|
||||
currentIndex = match.range.last + 1
|
||||
processed.add(match.range)
|
||||
}
|
||||
}
|
||||
|
||||
// Process inline code `text`
|
||||
codePattern.findAll(text).forEach { match ->
|
||||
if (processed.none { it.contains(match.range.first) }) {
|
||||
append(text.substring(currentIndex, match.range.first))
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
background = Color(0xFFF5F5F5),
|
||||
color = Color(0xFFE91E63)
|
||||
)
|
||||
) {
|
||||
append(" ${match.groupValues[1]} ")
|
||||
}
|
||||
currentIndex = match.range.last + 1
|
||||
processed.add(match.range)
|
||||
}
|
||||
}
|
||||
|
||||
// Append remaining text
|
||||
if (currentIndex < text.length) {
|
||||
append(text.substring(currentIndex))
|
||||
}
|
||||
}
|
||||
|
||||
// Parse markdown string menjadi list of elements
|
||||
private fun parseMarkdown(markdown: String): List<MarkdownElement> {
|
||||
val lines = markdown.lines()
|
||||
val elements = mutableListOf<MarkdownElement>()
|
||||
var i = 0
|
||||
|
||||
while (i < lines.size) {
|
||||
val line = lines[i].trim()
|
||||
|
||||
when {
|
||||
// Tabel (detect by separator line with |---|---|)
|
||||
line.contains("|") && i + 1 < lines.size && lines[i + 1].trim().matches(Regex("^\\|?\\s*[-:]+\\s*\\|.*$")) -> {
|
||||
val tableLines = mutableListOf<String>()
|
||||
|
||||
// Collect all table lines
|
||||
var tableIndex = i
|
||||
while (tableIndex < lines.size && lines[tableIndex].contains("|")) {
|
||||
tableLines.add(lines[tableIndex])
|
||||
tableIndex++
|
||||
}
|
||||
|
||||
if (tableLines.size >= 2) {
|
||||
// Parse header
|
||||
val headerCells = tableLines[0]
|
||||
.split("|")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
// Parse rows (skip separator line)
|
||||
val dataRows = tableLines.drop(2).map { rowLine ->
|
||||
rowLine.split("|")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
}.filter { it.isNotEmpty() }
|
||||
|
||||
elements.add(MarkdownElement.Table(headerCells, dataRows))
|
||||
i = tableIndex
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Heading
|
||||
line.startsWith("#") -> {
|
||||
val level = line.takeWhile { it == '#' }.length
|
||||
val text = line.dropWhile { it == '#' }.trim()
|
||||
elements.add(MarkdownElement.Heading(level, text))
|
||||
}
|
||||
|
||||
// Numbered list
|
||||
line.matches("""^\d+\.\s+.+""".toRegex()) -> {
|
||||
val parts = line.split(".", limit = 2)
|
||||
val number = parts[0].toIntOrNull() ?: 1
|
||||
val text = parts.getOrNull(1)?.trim() ?: ""
|
||||
elements.add(MarkdownElement.ListItem(text, true, number))
|
||||
}
|
||||
|
||||
// Bullet list
|
||||
line.startsWith("•") || line.startsWith("-") || line.startsWith("*") -> {
|
||||
val text = line.drop(1).trim()
|
||||
elements.add(MarkdownElement.ListItem(text, false))
|
||||
}
|
||||
|
||||
// Code block (multi-line)
|
||||
line.startsWith("```") -> {
|
||||
i++
|
||||
val codeLines = mutableListOf<String>()
|
||||
while (i < lines.size && !lines[i].trim().startsWith("```")) {
|
||||
codeLines.add(lines[i])
|
||||
i++
|
||||
}
|
||||
elements.add(MarkdownElement.CodeBlock(codeLines.joinToString("\n")))
|
||||
}
|
||||
|
||||
// Empty line
|
||||
line.isEmpty() -> {
|
||||
// Skip empty lines
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
else -> {
|
||||
elements.add(MarkdownElement.Paragraph(line))
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return elements
|
||||
}
|
||||
|
||||
// Markdown Elements
|
||||
private sealed class MarkdownElement {
|
||||
data class Paragraph(val text: String) : MarkdownElement()
|
||||
data class Heading(val level: Int, val text: String) : MarkdownElement()
|
||||
data class ListItem(
|
||||
val text: String,
|
||||
val isNumbered: Boolean = false,
|
||||
val number: Int = 0
|
||||
) : MarkdownElement()
|
||||
data class CodeBlock(val code: String) : MarkdownElement()
|
||||
data class Table(
|
||||
val headers: List<String>,
|
||||
val rows: List<List<String>>
|
||||
) : MarkdownElement()
|
||||
}
|
||||
@ -0,0 +1,882 @@
|
||||
package com.example.notebook.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
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.lazy.rememberLazyListState
|
||||
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 androidx.core.content.FileProvider
|
||||
import com.example.notebook.data.ChatMessageEntity
|
||||
import com.example.notebook.data.SourceEntity
|
||||
import com.example.notebook.ui.components.MarkdownText
|
||||
import com.example.notebook.viewmodel.NotebookViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
// Color theme untuk konsistensi dengan menu utama
|
||||
private val PrimaryPurple = Color(0xFF7C3AED)
|
||||
private val LightPurple = Color(0xFFF3F0FF)
|
||||
private val BackgroundWhite = Color(0xFFFAFAFA)
|
||||
private val CardBackground = Color.White
|
||||
private val TextPrimary = Color(0xFF1F2937)
|
||||
private val TextSecondary = Color(0xFF6B7280)
|
||||
|
||||
/**
|
||||
* Fungsi untuk buka file dengan aplikasi default
|
||||
*/
|
||||
fun openFile(context: Context, source: SourceEntity) {
|
||||
try {
|
||||
val file = File(source.filePath)
|
||||
|
||||
if (!file.exists()) {
|
||||
android.widget.Toast.makeText(context, "File tidak ditemukan", android.widget.Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
val mimeType = when (source.fileType) {
|
||||
"PDF" -> "application/pdf"
|
||||
"Image" -> "image/*" // Belum Support
|
||||
"Text" -> "text/plain"
|
||||
"Markdown" -> "text/markdown"
|
||||
"Word" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document" // Belum Support
|
||||
"PowerPoint" -> "application/vnd.openxmlformats-officedocument.presentationml.presentation" // Belum Support
|
||||
"Audio" -> "audio/*" // Belum Support
|
||||
"Video" -> "video/*" // Belum Support
|
||||
else -> "*/*"
|
||||
}
|
||||
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, mimeType)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
context.startActivity(intent)
|
||||
} else {
|
||||
val chooser = Intent.createChooser(intent, "Buka dengan")
|
||||
context.startActivity(chooser)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
android.widget.Toast.makeText(
|
||||
context,
|
||||
"Tidak bisa membuka file: ${e.message}",
|
||||
android.widget.Toast.LENGTH_LONG
|
||||
).show()
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
@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()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
val tabs = listOf("Chat", "Sources")
|
||||
var chatInput by remember { mutableStateOf("") }
|
||||
var showUploadMenu by remember { mutableStateOf(false) }
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var sourceToDelete by remember { mutableStateOf<SourceEntity?>(null) }
|
||||
|
||||
// Loading overlay dengan desain modern
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.clickable(enabled = false) { },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.padding(32.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = CardBackground),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator(color = PrimaryPurple)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Text(
|
||||
"Processing...",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
"AI sedang membaca dokumen",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation dialog
|
||||
if (showDeleteDialog && sourceToDelete != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = {
|
||||
Text(
|
||||
"Hapus File?",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
"File \"${sourceToDelete?.fileName}\" akan dihapus permanen. Lanjutkan?",
|
||||
color = TextSecondary
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
sourceToDelete?.let { viewModel.deleteSource(it) }
|
||||
showDeleteDialog = false
|
||||
sourceToDelete = null
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFEF4444)),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Text("Hapus", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteDialog = false
|
||||
sourceToDelete = null
|
||||
}
|
||||
) {
|
||||
Text("Batal", color = TextSecondary, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
val filePickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
uri?.let {
|
||||
viewModel.uploadFile(context, it, notebookId)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(notebookId) {
|
||||
println("🚀 NotebookDetailScreen: LaunchedEffect triggered untuk ID: $notebookId")
|
||||
viewModel.selectNotebook(notebookId)
|
||||
}
|
||||
|
||||
LaunchedEffect(notebook) {
|
||||
println("📝 Notebook state updated: ${notebook?.title ?: "NULL"}")
|
||||
}
|
||||
|
||||
LaunchedEffect(sources) {
|
||||
println("📚 Sources updated: ${sources.size} items")
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = BackgroundWhite,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = notebook?.title ?: "Loading...",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary
|
||||
)
|
||||
if (notebook != null) {
|
||||
Text(
|
||||
text = "${sources.size} sources tersimpan",
|
||||
fontSize = 13.sp,
|
||||
color = TextSecondary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = TextPrimary
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
Box {
|
||||
IconButton(onClick = { showUploadMenu = true }) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Upload",
|
||||
tint = PrimaryPurple,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showUploadMenu,
|
||||
onDismissRequest = { showUploadMenu = false },
|
||||
modifier = Modifier.background(CardBackground, RoundedCornerShape(16.dp))
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
"Upload File",
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextPrimary
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
filePickerLauncher.launch("*/*")
|
||||
showUploadMenu = false
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.CloudUpload, null, tint = PrimaryPurple)
|
||||
}
|
||||
)
|
||||
if (sources.isNotEmpty()) {
|
||||
Divider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
"Generate Summary",
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextPrimary
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.generateSummary(notebookId)
|
||||
selectedTab = 0
|
||||
showUploadMenu = false
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Summarize, null, tint = PrimaryPurple)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = CardBackground
|
||||
)
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
if (selectedTab == 0) {
|
||||
Surface(
|
||||
color = CardBackground,
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = chatInput,
|
||||
onValueChange = { chatInput = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
"Tanyakan apapun tentang dokumen Anda...",
|
||||
color = TextSecondary
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = PrimaryPurple,
|
||||
unfocusedBorderColor = Color(0xFFE5E7EB)
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (chatInput.isNotBlank()) {
|
||||
viewModel.sendUserMessage(notebookId, chatInput)
|
||||
chatInput = ""
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.background(
|
||||
if (chatInput.isNotBlank()) PrimaryPurple else Color(0xFFE5E7EB),
|
||||
CircleShape
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send",
|
||||
tint = if (chatInput.isNotBlank()) Color.White else TextSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.padding(padding)) {
|
||||
// Custom Tab dengan desain modern
|
||||
Surface(
|
||||
color = CardBackground,
|
||||
shadowElevation = 2.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable { selectedTab = index },
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = if (selectedTab == index) PrimaryPurple else Color.Transparent
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.padding(vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Medium,
|
||||
color = if (selectedTab == index) Color.White else TextSecondary,
|
||||
fontSize = 15.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (selectedTab) {
|
||||
0 -> ChatTab(
|
||||
messages = chatMessages,
|
||||
sources = sources,
|
||||
onUploadClick = { filePickerLauncher.launch("*/*") },
|
||||
isLoading = isLoading
|
||||
)
|
||||
1 -> SourcesTab(
|
||||
sources = sources,
|
||||
onUploadClick = { filePickerLauncher.launch("*/*") },
|
||||
onDeleteSource = { source ->
|
||||
sourceToDelete = source
|
||||
showDeleteDialog = true
|
||||
},
|
||||
onOpenSource = { source ->
|
||||
openFile(context, source)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatTab(
|
||||
messages: List<ChatMessageEntity>,
|
||||
sources: List<SourceEntity>,
|
||||
onUploadClick: () -> Unit,
|
||||
isLoading: Boolean = false
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
// Auto scroll ke chat terbaru
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
coroutineScope.launch {
|
||||
listState.animateScrollToItem(messages.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.isEmpty() && !isLoading) {
|
||||
// Empty state dengan desain modern
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(BackgroundWhite)
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.background(LightPurple, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Chat,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = PrimaryPurple
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = if (sources.isEmpty()) "Upload dokumen untuk mulai chat"
|
||||
else "Tanyakan apapun tentang dokumen Anda!",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = TextPrimary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = if (sources.isEmpty())
|
||||
"AI akan membantu Anda memahami dokumen dengan lebih baik"
|
||||
else "AI siap menjawab pertanyaan Anda",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = TextSecondary
|
||||
)
|
||||
if (sources.isEmpty()) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = onUploadClick,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PrimaryPurple),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp)
|
||||
) {
|
||||
Icon(Icons.Default.CloudUpload, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Upload Dokumen", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(BackgroundWhite),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(messages) { message ->
|
||||
ChatBubble(message = message)
|
||||
}
|
||||
|
||||
// Typing indicator saat AI sedang memproses
|
||||
if (isLoading) {
|
||||
item {
|
||||
TypingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TypingIndicator() {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
// AI Avatar
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(LightPurple),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AutoAwesome,
|
||||
contentDescription = null,
|
||||
tint = PrimaryPurple,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Typing animation card
|
||||
Surface(
|
||||
color = CardBackground,
|
||||
shape = RoundedCornerShape(
|
||||
topStart = 20.dp,
|
||||
topEnd = 20.dp,
|
||||
bottomStart = 4.dp,
|
||||
bottomEnd = 20.dp
|
||||
),
|
||||
shadowElevation = 1.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Animated dots
|
||||
repeat(3) { index ->
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "dot$index")
|
||||
val scale by infiniteTransition.animateFloat(
|
||||
initialValue = 0.5f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(600, delayMillis = index * 150),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "scale$index"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp * scale)
|
||||
.clip(CircleShape)
|
||||
.background(TextSecondary.copy(alpha = 0.6f))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatBubble(message: ChatMessageEntity) {
|
||||
val dateFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||
val context = LocalContext.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (message.isUserMessage) Arrangement.End else Arrangement.Start
|
||||
) {
|
||||
if (!message.isUserMessage) {
|
||||
// AI Avatar dengan desain modern
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(LightPurple),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AutoAwesome,
|
||||
contentDescription = null,
|
||||
tint = PrimaryPurple,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 280.dp),
|
||||
horizontalAlignment = if (message.isUserMessage) Alignment.End else Alignment.Start
|
||||
) {
|
||||
Surface(
|
||||
color = if (message.isUserMessage) PrimaryPurple else CardBackground,
|
||||
shape = RoundedCornerShape(
|
||||
topStart = 20.dp,
|
||||
topEnd = 20.dp,
|
||||
bottomStart = if (message.isUserMessage) 20.dp else 4.dp,
|
||||
bottomEnd = if (message.isUserMessage) 4.dp else 20.dp
|
||||
),
|
||||
shadowElevation = if (!message.isUserMessage) 1.dp else 0.dp,
|
||||
modifier = if (!message.isUserMessage) {
|
||||
Modifier.clickable {
|
||||
// Copy text ke clipboard
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
val clip = android.content.ClipData.newPlainText("AI Response", message.message)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
|
||||
// Show toast feedback
|
||||
android.widget.Toast.makeText(
|
||||
context,
|
||||
"✅ Teks disalin!",
|
||||
android.widget.Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
} else Modifier
|
||||
) {
|
||||
if (message.isUserMessage) {
|
||||
Text(
|
||||
text = message.message,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
color = Color.White,
|
||||
fontSize = 15.sp
|
||||
)
|
||||
} else {
|
||||
MarkdownText(
|
||||
markdown = message.message,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
color = TextPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = dateFormat.format(Date(message.timestamp)),
|
||||
fontSize = 11.sp,
|
||||
color = TextSecondary
|
||||
)
|
||||
|
||||
// Copy indicator untuk AI message
|
||||
if (!message.isUserMessage) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
Icons.Default.ContentCopy,
|
||||
contentDescription = "Tap to copy",
|
||||
tint = TextSecondary.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (message.isUserMessage) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
// User Avatar dengan desain modern
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(PrimaryPurple),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SourcesTab(
|
||||
sources: List<SourceEntity>,
|
||||
onUploadClick: () -> Unit,
|
||||
onDeleteSource: (SourceEntity) -> Unit,
|
||||
onOpenSource: (SourceEntity) -> Unit
|
||||
) {
|
||||
if (sources.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(BackgroundWhite)
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.background(LightPurple, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Description,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = PrimaryPurple
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Belum ada dokumen",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = TextPrimary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Upload dokumen untuk memulai",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextSecondary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = onUploadClick,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PrimaryPurple),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp)
|
||||
) {
|
||||
Icon(Icons.Default.CloudUpload, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Upload Dokumen", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(BackgroundWhite),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(sources) { source ->
|
||||
SourceCard(
|
||||
source = source,
|
||||
onDelete = { onDeleteSource(source) },
|
||||
onOpen = { onOpenSource(source) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SourceCard(
|
||||
source: SourceEntity,
|
||||
onDelete: () -> Unit,
|
||||
onOpen: () -> Unit
|
||||
) {
|
||||
val dateFormat = SimpleDateFormat("dd MMM yyyy", Locale.getDefault())
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpen() },
|
||||
colors = CardDefaults.cardColors(containerColor = CardBackground),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
when (source.fileType) {
|
||||
"PDF" -> Color(0xFFE53935).copy(alpha = 0.15f)
|
||||
"Image" -> Color(0xFF43A047).copy(alpha = 0.15f)
|
||||
"Text", "Markdown" -> Color(0xFF1E88E5).copy(alpha = 0.15f)
|
||||
"Audio" -> Color(0xFFFF6F00).copy(alpha = 0.15f)
|
||||
"Word" -> Color(0xFF2196F3).copy(alpha = 0.15f)
|
||||
"PowerPoint" -> Color(0xFFFF6D00).copy(alpha = 0.15f)
|
||||
else -> Color.Gray.copy(alpha = 0.15f)
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
when (source.fileType) {
|
||||
"PDF" -> Icons.Default.PictureAsPdf
|
||||
"Image" -> Icons.Default.Image
|
||||
"Text", "Markdown" -> Icons.Default.Description
|
||||
"Audio" -> Icons.Default.AudioFile
|
||||
"Word" -> Icons.Default.Article
|
||||
"PowerPoint" -> Icons.Default.Slideshow
|
||||
else -> Icons.Default.InsertDriveFile
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = when (source.fileType) {
|
||||
"PDF" -> Color(0xFFE53935)
|
||||
"Image" -> Color(0xFF43A047)
|
||||
"Text", "Markdown" -> Color(0xFF1E88E5)
|
||||
"Audio" -> Color(0xFFFF6F00)
|
||||
"Word" -> Color(0xFF2196F3)
|
||||
"PowerPoint" -> Color(0xFFFF6D00)
|
||||
else -> Color.Gray
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = source.fileName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
color = TextPrimary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = source.fileType,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextSecondary
|
||||
)
|
||||
Text(
|
||||
text = " • ${dateFormat.format(Date(source.uploadedAt))}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TextSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = Color(0xFF9CA3AF)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
124
app/src/main/java/com/example/notebook/utils/FileHelper.kt
Normal file
@ -0,0 +1,124 @@
|
||||
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
|
||||
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 {
|
||||
val type = when (fileName.substringAfterLast('.').lowercase()) {
|
||||
"pdf" -> "PDF"
|
||||
"txt" -> "Text"
|
||||
"md", "markdown" -> "Markdown"
|
||||
"jpg", "jpeg", "png", "gif" -> "Image"
|
||||
"mp3", "wav", "m4a" -> "Audio"
|
||||
"mp4", "avi", "mkv" -> "Video"
|
||||
"doc", "docx" -> "Word"
|
||||
"ppt", "pptx" -> "PowerPoint"
|
||||
else -> "Unknown"
|
||||
}
|
||||
println("🔍 File: $fileName → Type: $type")
|
||||
return type
|
||||
}
|
||||
|
||||
// Baca text dari file (support Text, Markdown, dan PDF)
|
||||
fun readTextFromFile(filePath: String): String? {
|
||||
return try {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
87
app/src/main/java/com/example/notebook/utils/PdfHelper.kt
Normal file
@ -0,0 +1,87 @@
|
||||
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 (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
|
||||
)
|
||||
@ -0,0 +1,398 @@
|
||||
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
|
||||
import com.example.notebook.data.SourceEntity
|
||||
import com.example.notebook.data.ChatMessageEntity
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* ViewModel untuk manage state aplikasi
|
||||
* Semua logic business ada di sini
|
||||
*/
|
||||
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())
|
||||
val notebooks: StateFlow<List<NotebookEntity>> = _notebooks.asStateFlow()
|
||||
|
||||
// State untuk notebook yang sedang aktif
|
||||
private val _currentNotebook = MutableStateFlow<NotebookEntity?>(null)
|
||||
val currentNotebook: StateFlow<NotebookEntity?> = _currentNotebook.asStateFlow()
|
||||
|
||||
// State untuk sources dalam notebook aktif
|
||||
private val _sources = MutableStateFlow<List<SourceEntity>>(emptyList())
|
||||
val sources: StateFlow<List<SourceEntity>> = _sources.asStateFlow()
|
||||
|
||||
// State untuk chat history
|
||||
private val _chatMessages = MutableStateFlow<List<ChatMessageEntity>>(emptyList())
|
||||
val chatMessages: StateFlow<List<ChatMessageEntity>> = _chatMessages.asStateFlow()
|
||||
|
||||
// State loading
|
||||
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())
|
||||
loadNotebooks()
|
||||
}
|
||||
|
||||
// === NOTEBOOK FUNCTIONS ===
|
||||
|
||||
private fun loadNotebooks() {
|
||||
viewModelScope.launch {
|
||||
repository.getAllNotebooks().collect { notebooks ->
|
||||
_notebooks.value = notebooks
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createNotebook(title: String, description: String = "") {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
val notebookId = repository.createNotebook(title, description)
|
||||
println("✅ Notebook berhasil dibuat dengan ID: $notebookId")
|
||||
} catch (e: Exception) {
|
||||
println("❌ Error membuat notebook: ${e.message}")
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNotebook(notebook: NotebookEntity) {
|
||||
viewModelScope.launch {
|
||||
repository.updateNotebook(notebook)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteNotebook(notebook: NotebookEntity) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteNotebook(notebook)
|
||||
}
|
||||
}
|
||||
|
||||
// === SOURCE FUNCTIONS ===
|
||||
|
||||
private fun loadSources(notebookId: Int) {
|
||||
viewModelScope.launch {
|
||||
repository.getSourcesByNotebook(notebookId).collect { sources ->
|
||||
_sources.value = sources
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addSource(
|
||||
notebookId: Int,
|
||||
fileName: String,
|
||||
fileType: String,
|
||||
filePath: String
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
repository.addSource(notebookId, fileName, fileType, filePath)
|
||||
println("✅ Source berhasil ditambahkan: $fileName")
|
||||
} catch (e: Exception) {
|
||||
println("❌ Error menambahkan source: ${e.message}")
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSource(source: SourceEntity) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
repository.deleteSource(source)
|
||||
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 ===
|
||||
|
||||
private fun loadChatHistory(notebookId: Int) {
|
||||
viewModelScope.launch {
|
||||
repository.getChatHistory(notebookId).collect { messages ->
|
||||
_chatMessages.value = messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendUserMessage(notebookId: Int, message: String) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
// Simpan pesan user
|
||||
repository.sendMessage(notebookId, message, isUserMessage = true)
|
||||
|
||||
// Cek apakah ada PDF
|
||||
val pdfSources = _sources.value.filter { it.fileType == "PDF" }
|
||||
val textSources = _sources.value.filter { it.fileType in listOf("Text", "Markdown") }
|
||||
|
||||
if (pdfSources.isEmpty() && textSources.isEmpty()) {
|
||||
val reply = "Maaf, belum ada dokumen yang diupload. Silakan upload dokumen terlebih dahulu untuk bisa bertanya."
|
||||
repository.sendMessage(notebookId, reply, isUserMessage = false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
println("📊 Q&A - PDF: ${pdfSources.size}, Text: ${textSources.size}")
|
||||
|
||||
// Build chat history
|
||||
val chatHistory = _chatMessages.value
|
||||
.takeLast(10)
|
||||
.windowed(2, 2, partialWindows = false)
|
||||
.mapNotNull { messages ->
|
||||
if (messages.size == 2 && messages[0].isUserMessage && !messages[1].isUserMessage) {
|
||||
Pair(messages[0].message, messages[1].message)
|
||||
} else null
|
||||
}
|
||||
|
||||
// PATTERN SAMA dengan generateSummary
|
||||
if (pdfSources.isNotEmpty()) {
|
||||
val pdfSource = pdfSources.first()
|
||||
println("📄 Q&A Processing PDF: ${pdfSource.fileName}")
|
||||
|
||||
// Extract dengan Dispatchers.IO
|
||||
val extractedText = withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
com.example.notebook.utils.FileHelper.readTextFromFile(pdfSource.filePath)
|
||||
}
|
||||
|
||||
if (extractedText != null && extractedText.length > 100) {
|
||||
println("✅ Q&A: Using extracted text (${extractedText.length} chars)")
|
||||
|
||||
// Build context DENGAN Dispatchers.IO
|
||||
val documentContext = withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
buildDocumentContext()
|
||||
}
|
||||
|
||||
val result = geminiRepository.chatWithDocument(
|
||||
userMessage = message,
|
||||
documentContext = documentContext,
|
||||
chatHistory = chatHistory
|
||||
)
|
||||
handleChatResult(notebookId, result)
|
||||
} else {
|
||||
println("⚠️ Using Gemini Vision (${extractedText?.length ?: 0} chars)")
|
||||
val result = geminiRepository.chatWithPdfFile(
|
||||
userMessage = message,
|
||||
pdfFilePath = pdfSource.filePath,
|
||||
pdfFileName = pdfSource.fileName,
|
||||
chatHistory = chatHistory
|
||||
)
|
||||
handleChatResult(notebookId, result)
|
||||
}
|
||||
} else {
|
||||
println("📝 Q&A using text files only")
|
||||
val documentContext = buildDocumentContext()
|
||||
val result = geminiRepository.chatWithDocument(
|
||||
userMessage = message,
|
||||
documentContext = documentContext,
|
||||
chatHistory = chatHistory
|
||||
)
|
||||
handleChatResult(notebookId, result)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
println("❌ Error Q&A: ${e.message}")
|
||||
e.printStackTrace()
|
||||
val errorMsg = "Maaf, terjadi error: ${e.message}"
|
||||
repository.sendMessage(notebookId, errorMsg, isUserMessage = false)
|
||||
_errorMessage.value = e.message
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleChatResult(notebookId: Int, result: Result<String>) {
|
||||
result.fold(
|
||||
onSuccess = { aiResponse ->
|
||||
repository.sendMessage(notebookId, aiResponse, isUserMessage = false)
|
||||
println("✅ AI response: ${aiResponse.take(50)}...")
|
||||
},
|
||||
onFailure = { error ->
|
||||
val errorMsg = "Maaf, terjadi error: ${error.message}"
|
||||
repository.sendMessage(notebookId, errorMsg, isUserMessage = false)
|
||||
println("❌ Error: ${error.message}")
|
||||
_errorMessage.value = error.message
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun clearChatHistory(notebookId: Int) {
|
||||
viewModelScope.launch {
|
||||
repository.clearChatHistory(notebookId)
|
||||
}
|
||||
}
|
||||
|
||||
// GEMINI FUNCTIONS
|
||||
|
||||
private fun buildDocumentContext(): String {
|
||||
val context = StringBuilder()
|
||||
|
||||
println("🔍 Building document context dari ${_sources.value.size} sources")
|
||||
|
||||
_sources.value.forEach { source ->
|
||||
println("📋 Source: ${source.fileName} | Type: ${source.fileType}")
|
||||
|
||||
when (source.fileType) {
|
||||
"Text", "Markdown", "PDF" -> { // ← TAMBAH "PDF" DI SINI!
|
||||
val content = com.example.notebook.utils.FileHelper.readTextFromFile(source.filePath)
|
||||
if (content != null && content.isNotBlank()) {
|
||||
context.append("=== ${source.fileName} ===\n")
|
||||
context.append(content)
|
||||
context.append("\n\n")
|
||||
println("✅ Extracted ${content.length} chars from ${source.fileName}")
|
||||
} else {
|
||||
println("⚠️ Failed to extract from ${source.fileName}")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
println("⚠️ File type ${source.fileType} not supported")
|
||||
context.append("=== ${source.fileName} ===\n")
|
||||
context.append("[File type ${source.fileType} - content extraction not yet supported]\n\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println("📦 Total context length: ${context.length} chars")
|
||||
return context.toString()
|
||||
}
|
||||
|
||||
fun generateSummary(notebookId: Int) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
// Cek apakah ada PDF
|
||||
val pdfSources = _sources.value.filter { it.fileType == "PDF" }
|
||||
val textSources = _sources.value.filter { it.fileType in listOf("Text", "Markdown") }
|
||||
|
||||
if (pdfSources.isEmpty() && textSources.isEmpty()) {
|
||||
_errorMessage.value = "Tidak ada dokumen untuk diringkas"
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Untuk PDF, coba extract dulu
|
||||
if (pdfSources.isNotEmpty()) {
|
||||
val pdfSource = pdfSources.first()
|
||||
println("📄 Processing PDF: ${pdfSource.fileName}")
|
||||
|
||||
// val extractedText = com.example.notebook.utils.FileHelper.readTextFromFile(pdfSource.filePath)
|
||||
val extractedText = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
com.example.notebook.utils.FileHelper.readTextFromFile(pdfSource.filePath)
|
||||
}
|
||||
if (extractedText != null && extractedText.length > 100) {
|
||||
// Text extraction berhasil
|
||||
println("✅ Using extracted text (${extractedText.length} chars)")
|
||||
val result = geminiRepository.generateSummary(extractedText)
|
||||
handleSummaryResult(notebookId, result)
|
||||
} else {
|
||||
// Text extraction gagal/terlalu pendek, pakai Gemini Vision
|
||||
println("⚠️ Extracted text too short (${extractedText?.length ?: 0} chars), using Gemini Vision")
|
||||
val result = geminiRepository.generateSummaryFromPdfFile(
|
||||
pdfSource.filePath,
|
||||
pdfSource.fileName
|
||||
)
|
||||
handleSummaryResult(notebookId, result)
|
||||
}
|
||||
} else {
|
||||
// Hanya text files
|
||||
val documentContext = buildDocumentContext()
|
||||
val result = geminiRepository.generateSummary(documentContext)
|
||||
handleSummaryResult(notebookId, result)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
_errorMessage.value = e.message
|
||||
println("❌ Error: ${e.message}")
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleSummaryResult(notebookId: Int, result: Result<String>) {
|
||||
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}")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_errorMessage.value = null
|
||||
}
|
||||
}
|
||||
@ -2,5 +2,4 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@ -2,5 +2,4 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 11 KiB |
@ -1,3 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Notebook</string>
|
||||
<string name="app_name">NotesAI</string>
|
||||
</resources>
|
||||
11
app/src/main/res/xml/file_paths.xml
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Internal storage files -->
|
||||
<files-path name="internal_files" path="." />
|
||||
|
||||
<!-- Notebook files -->
|
||||
<files-path name="notebooks" path="notebooks/" />
|
||||
|
||||
<!-- Cache -->
|
||||
<cache-path name="cache" path="." />
|
||||
</paths>
|
||||