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>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<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>
|
</SelectionState>
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
2
.idea/deviceManager.xml
generated
@ -5,7 +5,7 @@
|
|||||||
<list>
|
<list>
|
||||||
<ColumnSorterState>
|
<ColumnSorterState>
|
||||||
<option name="column" value="Name" />
|
<option name="column" value="Name" />
|
||||||
<option name="order" value="ASCENDING" />
|
<option name="order" value="DESCENDING" />
|
||||||
</ColumnSorterState>
|
</ColumnSorterState>
|
||||||
</list>
|
</list>
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@ -80,4 +80,18 @@ dependencies {
|
|||||||
// ViewModel & Lifecycle
|
// ViewModel & Lifecycle
|
||||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
||||||
|
|
||||||
|
// Retrofit untuk HTTP requests
|
||||||
|
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||||
|
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||||
|
|
||||||
|
// OkHttp untuk logging
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||||
|
|
||||||
|
// Gson
|
||||||
|
implementation("com.google.code.gson:gson:2.10.1")
|
||||||
|
|
||||||
|
// 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"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
@ -11,6 +18,17 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Notebook">
|
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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
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
|
* Database utama aplikasi
|
||||||
* Version 1 = versi pertama database kamu
|
* Version 1 = versi pertama database
|
||||||
*/
|
*/
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
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
|
@Dao
|
||||||
interface NotebookDao {
|
interface NotebookDao {
|
||||||
|
|
||||||
// === NOTEBOOK OPERATIONS ===
|
// NOTEBOOK OPERATIONS
|
||||||
|
|
||||||
@Insert
|
@Insert
|
||||||
suspend fun insertNotebook(notebook: NotebookEntity): Long
|
suspend fun insertNotebook(notebook: NotebookEntity): Long
|
||||||
|
|
||||||
@ -27,7 +26,7 @@ interface NotebookDao {
|
|||||||
@Query("SELECT * FROM notebooks WHERE id = :notebookId")
|
@Query("SELECT * FROM notebooks WHERE id = :notebookId")
|
||||||
fun getNotebookById(notebookId: Int): Flow<NotebookEntity?>
|
fun getNotebookById(notebookId: Int): Flow<NotebookEntity?>
|
||||||
|
|
||||||
// === SOURCE OPERATIONS ===
|
// SOURCE OPERATIONS
|
||||||
|
|
||||||
@Insert
|
@Insert
|
||||||
suspend fun insertSource(source: SourceEntity)
|
suspend fun insertSource(source: SourceEntity)
|
||||||
@ -38,7 +37,7 @@ interface NotebookDao {
|
|||||||
@Delete
|
@Delete
|
||||||
suspend fun deleteSource(source: SourceEntity)
|
suspend fun deleteSource(source: SourceEntity)
|
||||||
|
|
||||||
// === CHAT OPERATIONS ===
|
// CHAT OPERATIONS
|
||||||
|
|
||||||
@Insert
|
@Insert
|
||||||
suspend fun insertChatMessage(message: ChatMessageEntity)
|
suspend fun insertChatMessage(message: ChatMessageEntity)
|
||||||
|
|||||||
@ -10,17 +10,14 @@ import androidx.room.PrimaryKey
|
|||||||
data class NotebookEntity(
|
data class NotebookEntity(
|
||||||
@PrimaryKey(autoGenerate = true)
|
@PrimaryKey(autoGenerate = true)
|
||||||
val id: Int = 0,
|
val id: Int = 0,
|
||||||
|
|
||||||
val title: String, // Judul notebook
|
val title: String, // Judul notebook
|
||||||
val description: String, // Deskripsi singkat
|
val description: String, // Deskripsi singkat
|
||||||
val createdAt: Long, // Timestamp pembuatan
|
val createdAt: Long, // Timestamp pembuatan
|
||||||
val updatedAt: Long, // Timestamp update terakhir
|
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")
|
@Entity(tableName = "sources")
|
||||||
data class SourceEntity(
|
data class SourceEntity(
|
||||||
@PrimaryKey(autoGenerate = true)
|
@PrimaryKey(autoGenerate = true)
|
||||||
@ -33,9 +30,8 @@ data class SourceEntity(
|
|||||||
val uploadedAt: Long // Timestamp upload
|
val uploadedAt: Long // Timestamp upload
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Entity untuk menyimpan chat history dengan AI
|
// Entity untuk menyimpan chat history dengan AI
|
||||||
*/
|
|
||||||
@Entity(tableName = "chat_messages")
|
@Entity(tableName = "chat_messages")
|
||||||
data class ChatMessageEntity(
|
data class ChatMessageEntity(
|
||||||
@PrimaryKey(autoGenerate = true)
|
@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">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@ -2,5 +2,4 @@
|
|||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
</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>
|
<resources>
|
||||||
<string name="app_name">Notebook</string>
|
<string name="app_name">NotesAI</string>
|
||||||
</resources>
|
</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>
|
||||||