Compare commits

..

20 Commits

Author SHA1 Message Date
2587e1bd4d Ganti Nama APP 2025-11-19 10:05:34 +07:00
b1d99a37bb Refinement 2025-11-18 16:06:42 +07:00
a51c24030f Contextual AI 2025-11-18 15:39:16 +07:00
53472b2768 Typing Indicator dan Auto Scroll ke Chat Terbaru 2025-11-18 14:54:51 +07:00
ad5aaefdc4 Ariq Desain 2025-11-17 18:13:15 +07:00
385608225e Apis benerin r7 2025-11-17 15:18:39 +07:00
912c489123 Apis benerin dong 2025-11-17 14:48:17 +07:00
f64bff7933 PDF Testing & Q&A 2025-11-16 19:16:16 +07:00
0c04473b43 PDF Testing 2025-11-14 22:02:22 +07:00
524f1c1885 Revert "PDF Testing"
This reverts commit 30298ac46eb71830a59cba5ae494fb12e3c2121d.
2025-11-14 22:01:51 +07:00
Awang
2ac0393847 Merge remote-tracking branch 'origin/cobaDatabase' into cobaDatabase
# Conflicts:
#	app/src/main/java/com/example/notebook/MainActivity.kt
2025-11-14 21:56:08 +07:00
Awang
a3337622c5 Goldenboy update 2025-11-14 21:51:25 +07:00
30298ac46e PDF Testing 2025-11-14 20:59:46 +07:00
a7f770adf4 API Gemini AI Testing 2025-11-14 14:10:13 +07:00
30b9e45aa3 Debug Navigation 2025-11-13 11:33:40 +07:00
3dec997d41 MainActivity Test 2025-11-13 11:19:08 +07:00
4b6a44f3b1 MainActivity Test 2025-11-13 11:11:53 +07:00
5867c80588 Tes Fungsi Database 2025-11-13 11:07:11 +07:00
793600dd5a Room Database Setup 2025-11-13 10:29:10 +07:00
7190b1574d Room Database Setup 2025-11-13 10:22:03 +07:00
34 changed files with 3721 additions and 554 deletions

View File

@ -4,6 +4,14 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-11-17T09:59:25.576794100Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\Fazri Abdurrahman\.android\avd\Medium_Phone.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>

View File

@ -5,7 +5,7 @@
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
<option name="order" value="DESCENDING" />
</ColumnSorterState>
</list>
</option>

View File

@ -80,4 +80,18 @@ dependencies {
// ViewModel & Lifecycle
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
// Retrofit untuk HTTP requests
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// OkHttp untuk logging
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Gson
implementation("com.google.code.gson:gson:2.10.1")
// PDFBox untuk extract text dari PDF
implementation("com.tom-roush:pdfbox-android:2.0.27.0")
}

View File

@ -2,6 +2,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@ -11,6 +18,17 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Notebook">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"

File diff suppressed because it is too large Load Diff

View 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! 🚀
"""
}

View File

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

View File

@ -0,0 +1,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
}

View 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)))
)
}
}

View File

@ -7,7 +7,7 @@ import androidx.room.RoomDatabase
/**
* Database utama aplikasi
* Version 1 = versi pertama database kamu
* Version 1 = versi pertama database
*/
@Database(
entities = [

View File

@ -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")
}
}
}

View File

@ -10,8 +10,7 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface NotebookDao {
// === NOTEBOOK OPERATIONS ===
// NOTEBOOK OPERATIONS
@Insert
suspend fun insertNotebook(notebook: NotebookEntity): Long
@ -27,7 +26,7 @@ interface NotebookDao {
@Query("SELECT * FROM notebooks WHERE id = :notebookId")
fun getNotebookById(notebookId: Int): Flow<NotebookEntity?>
// === SOURCE OPERATIONS ===
// SOURCE OPERATIONS
@Insert
suspend fun insertSource(source: SourceEntity)
@ -38,7 +37,7 @@ interface NotebookDao {
@Delete
suspend fun deleteSource(source: SourceEntity)
// === CHAT OPERATIONS ===
// CHAT OPERATIONS
@Insert
suspend fun insertChatMessage(message: ChatMessageEntity)

View File

@ -10,17 +10,14 @@ import androidx.room.PrimaryKey
data class NotebookEntity(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val title: String, // Judul notebook
val description: String, // Deskripsi singkat
val createdAt: Long, // Timestamp pembuatan
val updatedAt: Long, // Timestamp update terakhir
val sourceCount: Int = 0 // Jumlah sumber yang diupload
val sourceCount: Int = 0, // Jumlah sumber yang diupload
)
/**
* Entity untuk menyimpan sumber/dokumen yang diupload
*/
// Entity untuk menyimpan sumber/dokumen yang diupload
@Entity(tableName = "sources")
data class SourceEntity(
@PrimaryKey(autoGenerate = true)
@ -33,9 +30,8 @@ data class SourceEntity(
val uploadedAt: Long // Timestamp upload
)
/**
* Entity untuk menyimpan chat history dengan AI
*/
// Entity untuk menyimpan chat history dengan AI
@Entity(tableName = "chat_messages")
data class ChatMessageEntity(
@PrimaryKey(autoGenerate = true)

View File

@ -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
}
}

View File

@ -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()
}

View File

@ -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)
)
}
}
}
}

View 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
}
}
}

View 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
)

View File

@ -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
}
}

File diff suppressed because one or more lines are too long

View File

@ -2,5 +2,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -2,5 +2,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,3 +1,3 @@
<resources>
<string name="app_name">Notebook</string>
<string name="app_name">NotesAI</string>
</resources>

View 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>