Compare commits
No commits in common. "3692a291c7e906987e7865810c471e28be53d925" and "f4847ced63199721a50268f22659370b3f62fa1b" have entirely different histories.
3692a291c7
...
f4847ced63
@ -199,7 +199,7 @@
|
|||||||
* Sistem kategori dengan gradient
|
* Sistem kategori dengan gradient
|
||||||
* Buat/edit/hapus kategori dengan confirmation dialog
|
* Buat/edit/hapus kategori dengan confirmation dialog
|
||||||
* Buat/edit/hapus catatan
|
* Buat/edit/hapus catatan
|
||||||
* Pin catatan penting (Catatan Berbintang)
|
* Pin catatan penting
|
||||||
* Full-screen editor
|
* Full-screen editor
|
||||||
* Search kategori di beranda
|
* Search kategori di beranda
|
||||||
* Search catatan dalam kategori
|
* Search catatan dalam kategori
|
||||||
@ -208,7 +208,7 @@
|
|||||||
* AI membaca & menganalisis catatan pengguna
|
* AI membaca & menganalisis catatan pengguna
|
||||||
* Suggestion chips & copy response
|
* Suggestion chips & copy response
|
||||||
* Filter AI berdasarkan kategori
|
* Filter AI berdasarkan kategori
|
||||||
* Dark theme & Light theme
|
* Dark theme modern + gradient
|
||||||
* Animasi smooth
|
* Animasi smooth
|
||||||
* Empty states & error handling
|
* Empty states & error handling
|
||||||
|
|
||||||
@ -226,6 +226,7 @@
|
|||||||
## **Features for Sprint 4 v1.1.0**
|
## **Features for Sprint 4 v1.1.0**
|
||||||
|
|
||||||
* Penyesuaian UI/UX History Chat AI (ok)
|
* Penyesuaian UI/UX History Chat AI (ok)
|
||||||
* Rich text editor (ok - Harus Pengembangan Lanjutan)
|
* Rich text editor (ok - Pengembangan Lanjutan)
|
||||||
* Fungsi AI (Upload File) (ok)
|
* AI Agent Catatan
|
||||||
|
* Fungsi AI (Upload File)
|
||||||
* Fitur Sematkan Category, otomatis paling atas
|
* Fitur Sematkan Category, otomatis paling atas
|
||||||
|
|||||||
@ -80,30 +80,4 @@ dependencies {
|
|||||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
|
|
||||||
// File picker
|
|
||||||
implementation("androidx.activity:activity-compose:1.8.2")
|
|
||||||
|
|
||||||
// PDF Parser (ONLY THIS ONE!)
|
|
||||||
implementation("com.tom-roush:pdfbox-android:2.0.27.0")
|
|
||||||
|
|
||||||
// File operations
|
|
||||||
implementation("androidx.documentfile:documentfile:1.0.1")
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
packaging {
|
|
||||||
resources {
|
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
|
||||||
excludes += "/META-INF/DEPENDENCIES"
|
|
||||||
excludes += "/META-INF/LICENSE"
|
|
||||||
excludes += "/META-INF/LICENSE.txt"
|
|
||||||
excludes += "/META-INF/license.txt"
|
|
||||||
excludes += "/META-INF/NOTICE"
|
|
||||||
excludes += "/META-INF/NOTICE.txt"
|
|
||||||
excludes += "/META-INF/notice.txt"
|
|
||||||
excludes += "/META-INF/ASL2.0"
|
|
||||||
excludes += "/META-INF/*.kotlin_module"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -3,17 +3,8 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<!-- Read files -->
|
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
|
||||||
android:maxSdkVersion="32" />
|
|
||||||
|
|
||||||
<!-- For Android 13+ -->
|
|
||||||
<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" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".NotesAIApplication"
|
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
|||||||
@ -90,15 +90,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sortCategories(categories: List<Category>): List<Category> {
|
|
||||||
return categories
|
|
||||||
.filter { !it.isDeleted }
|
|
||||||
.sortedWith(
|
|
||||||
compareByDescending<Category> { it.isPinned } // Pinned dulu
|
|
||||||
.thenByDescending { it.timestamp } // Lalu timestamp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun NotesApp() {
|
fun NotesApp() {
|
||||||
@ -106,6 +97,7 @@ fun NotesApp() {
|
|||||||
val dataStoreManager = remember { DataStoreManager(context) }
|
val dataStoreManager = remember { DataStoreManager(context) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
||||||
|
|
||||||
var categories by remember { mutableStateOf(listOf<Category>()) }
|
var categories by remember { mutableStateOf(listOf<Category>()) }
|
||||||
var notes by remember { mutableStateOf(listOf<Note>()) }
|
var notes by remember { mutableStateOf(listOf<Note>()) }
|
||||||
var selectedCategory by remember { mutableStateOf<Category?>(null) }
|
var selectedCategory by remember { mutableStateOf<Category?>(null) }
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
package com.example.notesai
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import com.example.notesai.util.FileParser
|
|
||||||
|
|
||||||
class NotesAIApplication : Application() {
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
|
|
||||||
// Initialize PDFBox
|
|
||||||
FileParser.initPDFBox(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -30,8 +30,7 @@ data class SerializableCategory(
|
|||||||
val gradientStart: Long,
|
val gradientStart: Long,
|
||||||
val gradientEnd: Long,
|
val gradientEnd: Long,
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
val isDeleted: Boolean = false,
|
val isDeleted: Boolean = false
|
||||||
val isPinned: Boolean = false // NEW
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@ -78,8 +77,7 @@ class DataStoreManager(private val context: Context) {
|
|||||||
gradientStart = it.gradientStart,
|
gradientStart = it.gradientStart,
|
||||||
gradientEnd = it.gradientEnd,
|
gradientEnd = it.gradientEnd,
|
||||||
timestamp = it.timestamp,
|
timestamp = it.timestamp,
|
||||||
isDeleted = it.isDeleted,
|
isDeleted = it.isDeleted
|
||||||
isPinned = it.isPinned // NEW
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@ -149,8 +147,7 @@ class DataStoreManager(private val context: Context) {
|
|||||||
gradientStart = it.gradientStart,
|
gradientStart = it.gradientStart,
|
||||||
gradientEnd = it.gradientEnd,
|
gradientEnd = it.gradientEnd,
|
||||||
timestamp = it.timestamp,
|
timestamp = it.timestamp,
|
||||||
isDeleted = it.isDeleted,
|
isDeleted = it.isDeleted
|
||||||
isPinned = it.isPinned // NEW
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
preferences[CATEGORIES_KEY] = json.encodeToString(serializable)
|
preferences[CATEGORIES_KEY] = json.encodeToString(serializable)
|
||||||
|
|||||||
@ -10,6 +10,5 @@ data class Category(
|
|||||||
val gradientStart: Long,
|
val gradientStart: Long,
|
||||||
val gradientEnd: Long,
|
val gradientEnd: Long,
|
||||||
val timestamp: Long = System.currentTimeMillis(),
|
val timestamp: Long = System.currentTimeMillis(),
|
||||||
val isDeleted: Boolean = false,
|
val isDeleted: Boolean = false // Support soft delete
|
||||||
val isPinned: Boolean = false // NEW: Tambahkan ini
|
|
||||||
)
|
)
|
||||||
@ -3,6 +3,7 @@ package com.example.notesai.data.model
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SerializableCategory(
|
data class SerializableCategory(
|
||||||
val id: String,
|
val id: String,
|
||||||
@ -10,8 +11,7 @@ data class SerializableCategory(
|
|||||||
val gradientStart: Long,
|
val gradientStart: Long,
|
||||||
val gradientEnd: Long,
|
val gradientEnd: Long,
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
val isDeleted: Boolean = false,
|
val isDeleted: Boolean = false // TAMBAHKAN INI
|
||||||
val isPinned: Boolean = false // NEW: Tambahkan ini
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@SuppressLint("UnsafeOptInUsageError")
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
@ -34,8 +34,7 @@ fun Category.toSerializable() = SerializableCategory(
|
|||||||
gradientStart = gradientStart,
|
gradientStart = gradientStart,
|
||||||
gradientEnd = gradientEnd,
|
gradientEnd = gradientEnd,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
isDeleted = isDeleted,
|
isDeleted = isDeleted // TAMBAHKAN INI
|
||||||
isPinned = isPinned // NEW: Tambahkan ini
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun SerializableCategory.toCategory() = Category(
|
fun SerializableCategory.toCategory() = Category(
|
||||||
@ -44,8 +43,7 @@ fun SerializableCategory.toCategory() = Category(
|
|||||||
gradientStart = gradientStart,
|
gradientStart = gradientStart,
|
||||||
gradientEnd = gradientEnd,
|
gradientEnd = gradientEnd,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
isDeleted = isDeleted,
|
isDeleted = isDeleted // TAMBAHKAN INI
|
||||||
isPinned = isPinned // NEW: Tambahkan ini
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun Note.toSerializable() = SerializableNote(
|
fun Note.toSerializable() = SerializableNote(
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@ -31,9 +32,11 @@ import com.google.ai.client.generativeai.type.generationConfig
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import com.example.notesai.presentation.screens.ai.components.*
|
import com.example.notesai.presentation.screens.ai.components.ChatBubble
|
||||||
|
import com.example.notesai.presentation.screens.ai.components.ChatHistoryDrawer
|
||||||
|
import com.example.notesai.presentation.screens.ai.components.CompactStatItem
|
||||||
|
import com.example.notesai.presentation.screens.ai.components.SuggestionChip
|
||||||
import com.example.notesai.util.AppColors
|
import com.example.notesai.util.AppColors
|
||||||
import com.example.notesai.util.FileParseResult
|
|
||||||
|
|
||||||
private const val MAX_CHAT_TITLE_LENGTH = 30
|
private const val MAX_CHAT_TITLE_LENGTH = 30
|
||||||
|
|
||||||
@ -64,10 +67,6 @@ fun AIHelperScreen(
|
|||||||
var showHistoryDrawer by remember { mutableStateOf(false) }
|
var showHistoryDrawer by remember { mutableStateOf(false) }
|
||||||
var currentChatId by remember { mutableStateOf<String?>(null) }
|
var currentChatId by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
// NEW: File Upload States
|
|
||||||
var uploadedFile by remember { mutableStateOf<FileParseResult.Success?>(null) }
|
|
||||||
var isGeneratingSummary by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
@ -135,8 +134,6 @@ fun AIHelperScreen(
|
|||||||
currentChatId = null
|
currentChatId = null
|
||||||
errorMessage = ""
|
errorMessage = ""
|
||||||
showHistoryDrawer = false
|
showHistoryDrawer = false
|
||||||
uploadedFile = null
|
|
||||||
isGeneratingSummary = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
@ -280,8 +277,7 @@ fun AIHelperScreen(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(32.dp)
|
.padding(32.dp),
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
@ -337,70 +333,6 @@ fun AIHelperScreen(
|
|||||||
SuggestionChip("Buat ringkasan") { prompt = it }
|
SuggestionChip("Buat ringkasan") { prompt = it }
|
||||||
SuggestionChip("Berikan saran organisasi") { prompt = it }
|
SuggestionChip("Berikan saran organisasi") { prompt = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
// NEW: File Upload Button
|
|
||||||
FileUploadButton(
|
|
||||||
onFileSelected = { fileResult ->
|
|
||||||
uploadedFile = fileResult
|
|
||||||
|
|
||||||
// Auto-generate summary
|
|
||||||
scope.launch {
|
|
||||||
isGeneratingSummary = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
val summaryPrompt = """
|
|
||||||
Buatkan ringkasan dari dokumen berikut dalam bahasa Indonesia:
|
|
||||||
|
|
||||||
Judul File: ${fileResult.fileName}
|
|
||||||
Tipe: ${fileResult.fileType}
|
|
||||||
Jumlah Kata: ${fileResult.wordCount}
|
|
||||||
|
|
||||||
Konten:
|
|
||||||
${fileResult.content}
|
|
||||||
|
|
||||||
Buat ringkasan yang:
|
|
||||||
1. Mencakup poin-poin utama
|
|
||||||
2. Terstruktur dengan baik (gunakan markdown)
|
|
||||||
3. Mudah dipahami
|
|
||||||
4. Maksimal 300 kata
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
// Add user message
|
|
||||||
chatMessages = chatMessages + ChatMessage(
|
|
||||||
message = "📄 Upload file: ${fileResult.fileName}\n\nMohon buatkan ringkasan dari file ini.",
|
|
||||||
isUser = true
|
|
||||||
)
|
|
||||||
|
|
||||||
// Scroll to bottom to show loading
|
|
||||||
delay(100)
|
|
||||||
scrollState.animateScrollTo(scrollState.maxValue)
|
|
||||||
|
|
||||||
// Generate summary dengan Gemini
|
|
||||||
val response = generativeModel.generateContent(summaryPrompt)
|
|
||||||
val summary = response.text ?: "Gagal membuat ringkasan"
|
|
||||||
|
|
||||||
// Add AI response
|
|
||||||
chatMessages = chatMessages + ChatMessage(
|
|
||||||
message = summary,
|
|
||||||
isUser = false
|
|
||||||
)
|
|
||||||
|
|
||||||
saveChatHistory()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
errorMessage = "⚠️ Gagal membuat ringkasan: ${e.message}"
|
|
||||||
} finally {
|
|
||||||
isGeneratingSummary = false
|
|
||||||
uploadedFile = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError = { error ->
|
|
||||||
errorMessage = error
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Chat Messages
|
// Chat Messages
|
||||||
@ -413,8 +345,8 @@ fun AIHelperScreen(
|
|||||||
chatMessages.forEach { message ->
|
chatMessages.forEach { message ->
|
||||||
ChatBubble(
|
ChatBubble(
|
||||||
message = message,
|
message = message,
|
||||||
onCopy = { textToCopy ->
|
onCopy = {
|
||||||
clipboardManager.setText(AnnotatedString(textToCopy))
|
clipboardManager.setText(AnnotatedString(message.message))
|
||||||
copiedMessageId = message.id
|
copiedMessageId = message.id
|
||||||
showCopiedMessage = true
|
showCopiedMessage = true
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@ -427,8 +359,8 @@ fun AIHelperScreen(
|
|||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading Indicator (untuk chat biasa DAN file summary)
|
// Loading Indicator
|
||||||
if (isLoading || isGeneratingSummary) {
|
if (isLoading) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -446,11 +378,11 @@ fun AIHelperScreen(
|
|||||||
) {
|
) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
color = if (isGeneratingSummary) AppColors.Secondary else AppColors.Primary,
|
color = AppColors.Primary,
|
||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
if (isGeneratingSummary) "Membuat ringkasan..." else "AI sedang berpikir...",
|
"AI sedang berpikir...",
|
||||||
color = AppColors.OnSurfaceVariant,
|
color = AppColors.OnSurfaceVariant,
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium
|
||||||
)
|
)
|
||||||
@ -495,181 +427,117 @@ fun AIHelperScreen(
|
|||||||
shadowElevation = 8.dp,
|
shadowElevation = 8.dp,
|
||||||
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
|
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
// NEW: Upload File Button (di atas input text)
|
OutlinedTextField(
|
||||||
if (chatMessages.isNotEmpty() && !isGeneratingSummary && !isLoading) {
|
value = prompt,
|
||||||
FileUploadButton(
|
onValueChange = { prompt = it },
|
||||||
onFileSelected = { fileResult ->
|
placeholder = {
|
||||||
uploadedFile = fileResult
|
Text(
|
||||||
|
"Ketik pesan...",
|
||||||
|
color = AppColors.OnSurfaceTertiary
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.heightIn(min = 48.dp, max = 120.dp),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = AppColors.OnBackground,
|
||||||
|
unfocusedTextColor = AppColors.OnSurface,
|
||||||
|
focusedContainerColor = AppColors.SurfaceVariant,
|
||||||
|
unfocusedContainerColor = AppColors.SurfaceVariant,
|
||||||
|
cursorColor = AppColors.Primary,
|
||||||
|
focusedBorderColor = AppColors.Primary,
|
||||||
|
unfocusedBorderColor = Color.Transparent
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
maxLines = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send Button
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = {
|
||||||
|
if (prompt.isNotBlank() && !isLoading) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
isGeneratingSummary = true
|
chatMessages = chatMessages + ChatMessage(
|
||||||
|
message = prompt,
|
||||||
|
isUser = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val userPrompt = prompt
|
||||||
|
prompt = ""
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = ""
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val summaryPrompt = """
|
val filteredNotes = if (selectedCategory != null) {
|
||||||
Buatkan ringkasan dari dokumen berikut dalam bahasa Indonesia:
|
notes.filter { it.categoryId == selectedCategory!!.id && !it.isArchived }
|
||||||
|
} else {
|
||||||
Judul File: ${fileResult.fileName}
|
notes.filter { !it.isArchived }
|
||||||
Tipe: ${fileResult.fileType}
|
}
|
||||||
Jumlah Kata: ${fileResult.wordCount}
|
|
||||||
|
val notesContext = buildString {
|
||||||
Konten:
|
appendLine("Data catatan pengguna:")
|
||||||
${fileResult.content}
|
appendLine("Total catatan: ${filteredNotes.size}")
|
||||||
|
appendLine("Kategori: ${selectedCategory?.name ?: "Semua"}")
|
||||||
Buat ringkasan yang:
|
appendLine()
|
||||||
1. Mencakup poin-poin utama
|
appendLine("Daftar catatan:")
|
||||||
2. Terstruktur dengan baik (gunakan markdown)
|
filteredNotes.take(10).forEach { note ->
|
||||||
3. Mudah dipahami
|
appendLine("- Judul: ${note.title}")
|
||||||
4. Maksimal 300 kata
|
appendLine(" Isi: ${note.content.take(100)}")
|
||||||
""".trimIndent()
|
appendLine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val fullPrompt = "$notesContext\n\nPertanyaan: $userPrompt\n\nBerikan jawaban dalam bahasa Indonesia yang natural dan membantu."
|
||||||
|
val result = generativeModel.generateContent(fullPrompt)
|
||||||
|
val response = result.text ?: "Tidak ada respons dari AI"
|
||||||
|
|
||||||
chatMessages = chatMessages + ChatMessage(
|
chatMessages = chatMessages + ChatMessage(
|
||||||
message = "📄 Upload file: ${fileResult.fileName}\n\nMohon buatkan ringkasan dari file ini.",
|
message = response,
|
||||||
isUser = true
|
|
||||||
)
|
|
||||||
|
|
||||||
// Scroll to show loading
|
|
||||||
delay(100)
|
|
||||||
scrollState.animateScrollTo(scrollState.maxValue)
|
|
||||||
|
|
||||||
val response = generativeModel.generateContent(summaryPrompt)
|
|
||||||
val summary = response.text ?: "Gagal membuat ringkasan"
|
|
||||||
|
|
||||||
chatMessages = chatMessages + ChatMessage(
|
|
||||||
message = summary,
|
|
||||||
isUser = false
|
isUser = false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Auto-save chat history
|
||||||
saveChatHistory()
|
saveChatHistory()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
errorMessage = "⚠️ Gagal membuat ringkasan: ${e.message}"
|
// Better error handling
|
||||||
} finally {
|
errorMessage = when {
|
||||||
isGeneratingSummary = false
|
e.message?.contains("quota", ignoreCase = true) == true ->
|
||||||
uploadedFile = null
|
"⚠️ Kuota API habis. Silakan coba lagi nanti atau hubungi developer."
|
||||||
}
|
e.message?.contains("404", ignoreCase = true) == true ||
|
||||||
}
|
e.message?.contains("not found", ignoreCase = true) == true ->
|
||||||
},
|
"⚠️ Model AI tidak ditemukan. Silakan hubungi developer."
|
||||||
onError = { error ->
|
e.message?.contains("401", ignoreCase = true) == true ||
|
||||||
errorMessage = error
|
e.message?.contains("API key", ignoreCase = true) == true ->
|
||||||
},
|
"⚠️ API key tidak valid. Silakan hubungi developer."
|
||||||
modifier = Modifier.fillMaxWidth()
|
e.message?.contains("timeout", ignoreCase = true) == true ->
|
||||||
)
|
"⚠️ Koneksi timeout. Periksa koneksi internet Anda."
|
||||||
}
|
e.message?.contains("network", ignoreCase = true) == true ->
|
||||||
|
"⚠️ Tidak ada koneksi internet. Silakan periksa koneksi Anda."
|
||||||
// Text Input & Send Button
|
else ->
|
||||||
Row(
|
"⚠️ Terjadi kesalahan: ${e.message?.take(100) ?: "Unknown error"}"
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.Bottom,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = prompt,
|
|
||||||
onValueChange = { prompt = it },
|
|
||||||
placeholder = {
|
|
||||||
Text(
|
|
||||||
"Ketik pesan...",
|
|
||||||
color = AppColors.OnSurfaceTertiary
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.heightIn(min = 48.dp, max = 120.dp),
|
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
|
||||||
focusedTextColor = AppColors.OnBackground,
|
|
||||||
unfocusedTextColor = AppColors.OnSurface,
|
|
||||||
focusedContainerColor = AppColors.SurfaceVariant,
|
|
||||||
unfocusedContainerColor = AppColors.SurfaceVariant,
|
|
||||||
cursorColor = AppColors.Primary,
|
|
||||||
focusedBorderColor = AppColors.Primary,
|
|
||||||
unfocusedBorderColor = Color.Transparent
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(24.dp),
|
|
||||||
maxLines = 4
|
|
||||||
)
|
|
||||||
|
|
||||||
// Send Button
|
|
||||||
FloatingActionButton(
|
|
||||||
onClick = {
|
|
||||||
if (prompt.isNotBlank() && !isLoading) {
|
|
||||||
scope.launch {
|
|
||||||
chatMessages = chatMessages + ChatMessage(
|
|
||||||
message = prompt,
|
|
||||||
isUser = true
|
|
||||||
)
|
|
||||||
|
|
||||||
val userPrompt = prompt
|
|
||||||
prompt = ""
|
|
||||||
isLoading = true
|
|
||||||
errorMessage = ""
|
|
||||||
|
|
||||||
try {
|
|
||||||
val filteredNotes = if (selectedCategory != null) {
|
|
||||||
notes.filter { it.categoryId == selectedCategory!!.id && !it.isArchived }
|
|
||||||
} else {
|
|
||||||
notes.filter { !it.isArchived }
|
|
||||||
}
|
|
||||||
|
|
||||||
val notesContext = buildString {
|
|
||||||
appendLine("Data catatan pengguna:")
|
|
||||||
appendLine("Total catatan: ${filteredNotes.size}")
|
|
||||||
appendLine("Kategori: ${selectedCategory?.name ?: "Semua"}")
|
|
||||||
appendLine()
|
|
||||||
appendLine("Daftar catatan:")
|
|
||||||
filteredNotes.take(10).forEach { note ->
|
|
||||||
appendLine("- Judul: ${note.title}")
|
|
||||||
appendLine(" Isi: ${note.content.take(100)}")
|
|
||||||
appendLine()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val fullPrompt = "$notesContext\n\nPertanyaan: $userPrompt\n\nBerikan jawaban dalam bahasa Indonesia yang natural dan membantu."
|
|
||||||
val result = generativeModel.generateContent(fullPrompt)
|
|
||||||
val response = result.text ?: "Tidak ada respons dari AI"
|
|
||||||
|
|
||||||
chatMessages = chatMessages + ChatMessage(
|
|
||||||
message = response,
|
|
||||||
isUser = false
|
|
||||||
)
|
|
||||||
|
|
||||||
saveChatHistory()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
errorMessage = when {
|
|
||||||
e.message?.contains("quota", ignoreCase = true) == true ->
|
|
||||||
"⚠️ Kuota API habis. Silakan coba lagi nanti atau hubungi developer."
|
|
||||||
e.message?.contains("404", ignoreCase = true) == true ||
|
|
||||||
e.message?.contains("not found", ignoreCase = true) == true ->
|
|
||||||
"⚠️ Model AI tidak ditemukan. Silakan hubungi developer."
|
|
||||||
e.message?.contains("401", ignoreCase = true) == true ||
|
|
||||||
e.message?.contains("API key", ignoreCase = true) == true ->
|
|
||||||
"⚠️ API key tidak valid. Silakan hubungi developer."
|
|
||||||
e.message?.contains("timeout", ignoreCase = true) == true ->
|
|
||||||
"⚠️ Koneksi timeout. Periksa koneksi internet Anda."
|
|
||||||
e.message?.contains("network", ignoreCase = true) == true ->
|
|
||||||
"⚠️ Tidak ada koneksi internet. Silakan periksa koneksi Anda."
|
|
||||||
else ->
|
|
||||||
"⚠️ Terjadi kesalahan: ${e.message?.take(100) ?: "Unknown error"}"
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isLoading = false
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
containerColor = AppColors.Primary,
|
},
|
||||||
modifier = Modifier.size(48.dp)
|
containerColor = AppColors.Primary,
|
||||||
) {
|
modifier = Modifier.size(48.dp)
|
||||||
Icon(
|
) {
|
||||||
Icons.Default.Send,
|
Icon(
|
||||||
contentDescription = "Send",
|
Icons.Default.Send,
|
||||||
tint = Color.White,
|
contentDescription = "Send",
|
||||||
modifier = Modifier.size(20.dp)
|
tint = Color.White,
|
||||||
)
|
modifier = Modifier.size(20.dp)
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,10 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.SmartToy
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@ -16,7 +19,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.example.notesai.data.model.ChatMessage
|
import com.example.notesai.data.model.ChatMessage
|
||||||
import com.example.notesai.util.MarkdownText
|
import com.example.notesai.util.MarkdownText
|
||||||
import com.example.notesai.util.MarkdownStripper
|
|
||||||
import com.example.notesai.util.AppColors
|
import com.example.notesai.util.AppColors
|
||||||
import com.example.notesai.util.Constants
|
import com.example.notesai.util.Constants
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@ -25,18 +27,17 @@ import java.util.*
|
|||||||
@Composable
|
@Composable
|
||||||
fun ChatBubble(
|
fun ChatBubble(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
onCopy: (String) -> Unit, // CHANGED: Sekarang terima text parameter
|
onCopy: () -> Unit,
|
||||||
showCopied: Boolean
|
showCopied: Boolean
|
||||||
) {
|
) {
|
||||||
val dateFormat = remember { SimpleDateFormat("HH:mm", Locale("id", "ID")) }
|
val dateFormat = remember { SimpleDateFormat("HH:mm", Locale("id", "ID")) }
|
||||||
var showCopyMenu by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalAlignment = if (message.isUser) Alignment.End else Alignment.Start
|
horizontalAlignment = if (message.isUser) Alignment.End else Alignment.Start
|
||||||
) {
|
) {
|
||||||
if (message.isUser) {
|
if (message.isUser) {
|
||||||
// User Message (tidak berubah)
|
// User Message
|
||||||
Surface(
|
Surface(
|
||||||
color = AppColors.Primary,
|
color = AppColors.Primary,
|
||||||
shape = RoundedCornerShape(
|
shape = RoundedCornerShape(
|
||||||
@ -88,7 +89,7 @@ fun ChatBubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// AI Message with IMPROVED Copy Options
|
// AI Message with Markdown
|
||||||
Surface(
|
Surface(
|
||||||
color = AppColors.SurfaceVariant,
|
color = AppColors.SurfaceVariant,
|
||||||
shape = RoundedCornerShape(
|
shape = RoundedCornerShape(
|
||||||
@ -126,103 +127,23 @@ fun ChatBubble(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IMPROVED: Copy Button with Dropdown Menu
|
// Copy Button
|
||||||
Box {
|
IconButton(
|
||||||
IconButton(
|
onClick = onCopy,
|
||||||
onClick = { showCopyMenu = !showCopyMenu },
|
modifier = Modifier.size(28.dp)
|
||||||
modifier = Modifier.size(28.dp)
|
) {
|
||||||
) {
|
AnimatedContent(
|
||||||
AnimatedContent(
|
targetState = showCopied,
|
||||||
targetState = showCopied,
|
transitionSpec = {
|
||||||
transitionSpec = {
|
fadeIn() + scaleIn() togetherWith fadeOut() + scaleOut()
|
||||||
fadeIn() + scaleIn() togetherWith fadeOut() + scaleOut()
|
},
|
||||||
},
|
label = "copy_icon"
|
||||||
label = "copy_icon"
|
) { copied ->
|
||||||
) { copied ->
|
Icon(
|
||||||
Icon(
|
if (copied) Icons.Default.Check else Icons.Default.ContentCopy,
|
||||||
if (copied) Icons.Default.Check else Icons.Default.ContentCopy,
|
contentDescription = if (copied) "Copied" else "Copy",
|
||||||
contentDescription = if (copied) "Copied" else "Copy",
|
tint = if (copied) AppColors.Success else AppColors.OnSurfaceVariant,
|
||||||
tint = if (copied) AppColors.Success else AppColors.OnSurfaceVariant,
|
modifier = Modifier.size(16.dp)
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dropdown Menu untuk pilihan copy
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = showCopyMenu,
|
|
||||||
onDismissRequest = { showCopyMenu = false },
|
|
||||||
modifier = Modifier.background(AppColors.SurfaceElevated)
|
|
||||||
) {
|
|
||||||
// Option 1: Copy dengan Format (Markdown)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Code,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = AppColors.Primary,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
"Copy dengan Format",
|
|
||||||
fontSize = 13.sp,
|
|
||||||
color = AppColors.OnSurface,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Termasuk markdown",
|
|
||||||
fontSize = 10.sp,
|
|
||||||
color = AppColors.OnSurfaceTertiary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
onCopy(message.message) // Copy original dengan markdown
|
|
||||||
showCopyMenu = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider(color = AppColors.Divider)
|
|
||||||
|
|
||||||
// Option 2: Copy Teks Asli (Plain Text)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.TextFields,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = AppColors.Secondary,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
"Copy Teks Asli",
|
|
||||||
fontSize = 13.sp,
|
|
||||||
color = AppColors.OnSurface,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Tanpa format",
|
|
||||||
fontSize = 10.sp,
|
|
||||||
color = AppColors.OnSurfaceTertiary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
val plainText = MarkdownStripper.stripMarkdown(message.message)
|
|
||||||
onCopy(plainText) // Copy plain text tanpa markdown
|
|
||||||
showCopyMenu = false
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,206 +0,0 @@
|
|||||||
package com.example.notesai.presentation.screens.ai.components
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.animation.*
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
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.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 com.example.notesai.util.AppColors
|
|
||||||
import com.example.notesai.util.Constants
|
|
||||||
import com.example.notesai.util.FileParser
|
|
||||||
import com.example.notesai.util.FileParseResult
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun FileUploadButton(
|
|
||||||
onFileSelected: (FileParseResult.Success) -> Unit,
|
|
||||||
onError: (String) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var isProcessing by remember { mutableStateOf(false) }
|
|
||||||
var selectedFileName by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
// File picker launcher
|
|
||||||
val filePicker = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.GetContent()
|
|
||||||
) { uri: Uri? ->
|
|
||||||
uri?.let {
|
|
||||||
scope.launch {
|
|
||||||
isProcessing = true
|
|
||||||
try {
|
|
||||||
// Get file info
|
|
||||||
val fileSize = FileParser.getFileSize(context, uri)
|
|
||||||
|
|
||||||
// Check file size (max 10MB)
|
|
||||||
if (fileSize > 10 * 1024 * 1024) {
|
|
||||||
onError("File terlalu besar. Maksimal 10MB")
|
|
||||||
isProcessing = false
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse file
|
|
||||||
val result = withContext(Dispatchers.IO) {
|
|
||||||
FileParser.parseFile(context, uri)
|
|
||||||
}
|
|
||||||
|
|
||||||
when (result) {
|
|
||||||
is FileParseResult.Success -> {
|
|
||||||
selectedFileName = result.fileName
|
|
||||||
onFileSelected(result)
|
|
||||||
}
|
|
||||||
is FileParseResult.Error -> {
|
|
||||||
onError(result.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
onError("Gagal memproses file: ${e.message}")
|
|
||||||
} finally {
|
|
||||||
isProcessing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
// Launch file picker untuk PDF, TXT, DOCX
|
|
||||||
filePicker.launch("*/*")
|
|
||||||
},
|
|
||||||
enabled = !isProcessing,
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = AppColors.Secondary.copy(alpha = 0.15f),
|
|
||||||
contentColor = AppColors.Secondary,
|
|
||||||
disabledContainerColor = AppColors.SurfaceVariant,
|
|
||||||
disabledContentColor = AppColors.OnSurfaceVariant
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(Constants.Radius.Medium.dp),
|
|
||||||
modifier = modifier,
|
|
||||||
contentPadding = PaddingValues(
|
|
||||||
horizontal = Constants.Spacing.Medium.dp,
|
|
||||||
vertical = Constants.Spacing.Small.dp
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
if (isProcessing) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
color = AppColors.Secondary,
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text("Memproses...")
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.AttachFile,
|
|
||||||
contentDescription = "Upload File",
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
"Upload File",
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun FilePreviewCard(
|
|
||||||
fileResult: FileParseResult.Success,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
modifier = modifier,
|
|
||||||
color = AppColors.SurfaceVariant,
|
|
||||||
shape = RoundedCornerShape(Constants.Radius.Large.dp),
|
|
||||||
shadowElevation = 4.dp
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
) {
|
|
||||||
// Header
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
when (fileResult.fileType) {
|
|
||||||
"PDF" -> Icons.Default.PictureAsPdf
|
|
||||||
"Word" -> Icons.Default.Description
|
|
||||||
else -> Icons.Default.TextSnippet
|
|
||||||
},
|
|
||||||
contentDescription = null,
|
|
||||||
tint = AppColors.Secondary,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
fileResult.fileName,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = AppColors.OnSurface
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"${fileResult.fileType} • ${fileResult.wordCount} kata",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = AppColors.OnSurfaceTertiary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
IconButton(
|
|
||||||
onClick = onDismiss,
|
|
||||||
modifier = Modifier.size(32.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Close,
|
|
||||||
contentDescription = "Close",
|
|
||||||
tint = AppColors.OnSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
// Content Preview
|
|
||||||
Surface(
|
|
||||||
color = AppColors.Surface,
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
fileResult.content.take(200) + if (fileResult.content.length > 200) "..." else "",
|
|
||||||
modifier = Modifier.padding(12.dp),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = AppColors.OnSurfaceVariant,
|
|
||||||
lineHeight = 18.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -29,6 +29,8 @@ import androidx.compose.ui.unit.*
|
|||||||
import com.example.notesai.data.model.Note
|
import com.example.notesai.data.model.Note
|
||||||
import com.example.notesai.presentation.screens.note.components.DraggableMiniMarkdownToolbar
|
import com.example.notesai.presentation.screens.note.components.DraggableMiniMarkdownToolbar
|
||||||
import com.example.notesai.presentation.screens.note.editor.RichEditorState
|
import com.example.notesai.presentation.screens.note.editor.RichEditorState
|
||||||
|
import com.example.notesai.util.MarkdownParser
|
||||||
|
import com.example.notesai.util.MarkdownSerializer
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|||||||
@ -1,221 +0,0 @@
|
|||||||
package com.example.notesai.util
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
|
||||||
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
|
|
||||||
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
|
||||||
import com.tom_roush.pdfbox.text.PDFTextStripper
|
|
||||||
import java.io.BufferedReader
|
|
||||||
import java.io.InputStreamReader
|
|
||||||
|
|
||||||
object FileParser {
|
|
||||||
|
|
||||||
private const val TAG = "FileParser"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize PDFBox (call this in Application.onCreate or before first use)
|
|
||||||
*/
|
|
||||||
fun initPDFBox(context: Context) {
|
|
||||||
try {
|
|
||||||
PDFBoxResourceLoader.init(context)
|
|
||||||
Log.d(TAG, "PDFBox initialized successfully")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to initialize PDFBox", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse file berdasarkan tipe
|
|
||||||
*/
|
|
||||||
suspend fun parseFile(context: Context, uri: Uri): FileParseResult {
|
|
||||||
return try {
|
|
||||||
val mimeType = context.contentResolver.getType(uri)
|
|
||||||
val fileName = getFileName(context, uri)
|
|
||||||
|
|
||||||
Log.d(TAG, "Parsing file: $fileName, type: $mimeType")
|
|
||||||
|
|
||||||
val content = when {
|
|
||||||
mimeType == "application/pdf" || fileName.endsWith(".pdf", ignoreCase = true) -> {
|
|
||||||
parsePDF(context, uri)
|
|
||||||
}
|
|
||||||
mimeType == "text/plain" || fileName.endsWith(".txt", ignoreCase = true) -> {
|
|
||||||
parseTXT(context, uri)
|
|
||||||
}
|
|
||||||
mimeType == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
||||||
|| fileName.endsWith(".docx", ignoreCase = true) -> {
|
|
||||||
parseDOCX(context, uri)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
return FileParseResult.Error("Format file tidak didukung: $mimeType")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.isBlank()) {
|
|
||||||
FileParseResult.Error("File kosong atau tidak dapat dibaca")
|
|
||||||
} else {
|
|
||||||
FileParseResult.Success(
|
|
||||||
content = content,
|
|
||||||
fileName = fileName,
|
|
||||||
fileType = getFileType(fileName),
|
|
||||||
wordCount = content.split(Regex("\\s+")).size
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error parsing file", e)
|
|
||||||
FileParseResult.Error("Gagal membaca file: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse PDF file
|
|
||||||
*/
|
|
||||||
private fun parsePDF(context: Context, uri: Uri): String {
|
|
||||||
val inputStream = context.contentResolver.openInputStream(uri)
|
|
||||||
?: throw Exception("Cannot open file")
|
|
||||||
|
|
||||||
return inputStream.use { stream ->
|
|
||||||
val document = PDDocument.load(stream)
|
|
||||||
val stripper = PDFTextStripper()
|
|
||||||
val text = stripper.getText(document)
|
|
||||||
document.close()
|
|
||||||
text.trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse TXT file
|
|
||||||
*/
|
|
||||||
private fun parseTXT(context: Context, uri: Uri): String {
|
|
||||||
val inputStream = context.contentResolver.openInputStream(uri)
|
|
||||||
?: throw Exception("Cannot open file")
|
|
||||||
|
|
||||||
return inputStream.use { stream ->
|
|
||||||
BufferedReader(InputStreamReader(stream, Charsets.UTF_8))
|
|
||||||
.readText()
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse DOCX file - SIMPLIFIED VERSION
|
|
||||||
* Hanya extract text mentah dari XML
|
|
||||||
*/
|
|
||||||
private fun parseDOCX(context: Context, uri: Uri): String {
|
|
||||||
val inputStream = context.contentResolver.openInputStream(uri)
|
|
||||||
?: throw Exception("Cannot open file")
|
|
||||||
|
|
||||||
return inputStream.use { stream ->
|
|
||||||
try {
|
|
||||||
// DOCX adalah ZIP file, kita extract document.xml
|
|
||||||
val zipInputStream = java.util.zip.ZipInputStream(stream)
|
|
||||||
val text = StringBuilder()
|
|
||||||
|
|
||||||
var entry = zipInputStream.nextEntry
|
|
||||||
while (entry != null) {
|
|
||||||
if (entry.name == "word/document.xml") {
|
|
||||||
val xmlContent = zipInputStream.bufferedReader().readText()
|
|
||||||
|
|
||||||
// Extract text dari XML tags
|
|
||||||
// Format: <w:t>text here</w:t>
|
|
||||||
val textPattern = Regex("<w:t[^>]*>([^<]+)</w:t>")
|
|
||||||
textPattern.findAll(xmlContent).forEach { match ->
|
|
||||||
text.append(match.groupValues[1])
|
|
||||||
text.append(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract text dari paragraph tags
|
|
||||||
val paraPattern = Regex("<w:p[^>]*>(.*?)</w:p>", RegexOption.DOT_MATCHES_ALL)
|
|
||||||
paraPattern.findAll(xmlContent).forEach { match ->
|
|
||||||
val paraContent = match.groupValues[1]
|
|
||||||
val textInPara = Regex("<w:t[^>]*>([^<]+)</w:t>")
|
|
||||||
textInPara.findAll(paraContent).forEach { textMatch ->
|
|
||||||
text.append(textMatch.groupValues[1])
|
|
||||||
text.append(" ")
|
|
||||||
}
|
|
||||||
text.append("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
entry = zipInputStream.nextEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
zipInputStream.close()
|
|
||||||
text.toString().trim()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error parsing DOCX", e)
|
|
||||||
throw Exception("Gagal membaca file DOCX: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get file name from URI
|
|
||||||
*/
|
|
||||||
private fun getFileName(context: Context, uri: Uri): String {
|
|
||||||
var fileName = "unknown"
|
|
||||||
|
|
||||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
|
||||||
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
|
||||||
if (cursor.moveToFirst() && nameIndex != -1) {
|
|
||||||
fileName = cursor.getString(nameIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fileName
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get file type display name
|
|
||||||
*/
|
|
||||||
private fun getFileType(fileName: String): String {
|
|
||||||
return when {
|
|
||||||
fileName.endsWith(".pdf", ignoreCase = true) -> "PDF"
|
|
||||||
fileName.endsWith(".txt", ignoreCase = true) -> "Text"
|
|
||||||
fileName.endsWith(".docx", ignoreCase = true) -> "Word"
|
|
||||||
else -> "Unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get file size
|
|
||||||
*/
|
|
||||||
fun getFileSize(context: Context, uri: Uri): Long {
|
|
||||||
var size = 0L
|
|
||||||
|
|
||||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
|
||||||
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
|
|
||||||
if (cursor.moveToFirst() && sizeIndex != -1) {
|
|
||||||
size = cursor.getLong(sizeIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return size
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format file size untuk display
|
|
||||||
*/
|
|
||||||
fun formatFileSize(bytes: Long): String {
|
|
||||||
return when {
|
|
||||||
bytes < 1024 -> "$bytes B"
|
|
||||||
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
|
||||||
else -> "${bytes / (1024 * 1024)} MB"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result dari parsing file
|
|
||||||
*/
|
|
||||||
sealed class FileParseResult {
|
|
||||||
data class Success(
|
|
||||||
val content: String,
|
|
||||||
val fileName: String,
|
|
||||||
val fileType: String,
|
|
||||||
val wordCount: Int
|
|
||||||
) : FileParseResult()
|
|
||||||
|
|
||||||
data class Error(val message: String) : FileParseResult()
|
|
||||||
}
|
|
||||||
58
app/src/main/java/com/example/notesai/util/MarkdownParser.kt
Normal file
58
app/src/main/java/com/example/notesai/util/MarkdownParser.kt
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package com.example.notesai.util
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
||||||
|
object MarkdownParser {
|
||||||
|
|
||||||
|
fun parse(markdown: String): AnnotatedString {
|
||||||
|
val builder = AnnotatedString.Builder()
|
||||||
|
var i = 0
|
||||||
|
|
||||||
|
while (i < markdown.length) {
|
||||||
|
when {
|
||||||
|
markdown.startsWith("**", i) -> {
|
||||||
|
val end = markdown.indexOf("**", i + 2)
|
||||||
|
if (end != -1) {
|
||||||
|
val content = markdown.substring(i + 2, end)
|
||||||
|
val start = builder.length
|
||||||
|
builder.append(content)
|
||||||
|
builder.addStyle(
|
||||||
|
SpanStyle(fontWeight = FontWeight.Bold),
|
||||||
|
start,
|
||||||
|
start + content.length
|
||||||
|
)
|
||||||
|
i = end + 2
|
||||||
|
} else {
|
||||||
|
builder.append(markdown[i++])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markdown.startsWith("*", i) -> {
|
||||||
|
val end = markdown.indexOf("*", i + 1)
|
||||||
|
if (end != -1) {
|
||||||
|
val content = markdown.substring(i + 1, end)
|
||||||
|
val start = builder.length
|
||||||
|
builder.append(content)
|
||||||
|
builder.addStyle(
|
||||||
|
SpanStyle(fontStyle = FontStyle.Italic),
|
||||||
|
start,
|
||||||
|
start + content.length
|
||||||
|
)
|
||||||
|
i = end + 1
|
||||||
|
} else {
|
||||||
|
builder.append(markdown[i++])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
builder.append(markdown[i++])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.toAnnotatedString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package com.example.notesai.util
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
||||||
|
object MarkdownSerializer {
|
||||||
|
|
||||||
|
fun toMarkdown(text: AnnotatedString): String {
|
||||||
|
val raw = text.text
|
||||||
|
if (text.spanStyles.isEmpty()) return raw
|
||||||
|
|
||||||
|
val markers = Array(raw.length + 1) { mutableListOf<String>() }
|
||||||
|
|
||||||
|
text.spanStyles.forEach { span ->
|
||||||
|
if (span.item.fontWeight == FontWeight.Bold) {
|
||||||
|
markers[span.start].add("**")
|
||||||
|
markers[span.end].add("**")
|
||||||
|
}
|
||||||
|
if (span.item.fontStyle == FontStyle.Italic) {
|
||||||
|
markers[span.start].add("*")
|
||||||
|
markers[span.end].add("*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (i in raw.indices) {
|
||||||
|
markers[i].forEach { sb.append(it) }
|
||||||
|
sb.append(raw[i])
|
||||||
|
}
|
||||||
|
markers[raw.length].forEach { sb.append(it) }
|
||||||
|
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,68 +0,0 @@
|
|||||||
package com.example.notesai.util
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility untuk convert markdown text ke plain text
|
|
||||||
*/
|
|
||||||
object MarkdownStripper {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strip semua markdown formatting dan return plain text
|
|
||||||
*/
|
|
||||||
fun stripMarkdown(text: String): String {
|
|
||||||
var result = text
|
|
||||||
|
|
||||||
// 1. Remove code blocks (```...```)
|
|
||||||
result = result.replace(Regex("""```[\s\S]*?```"""), "")
|
|
||||||
|
|
||||||
// 2. Remove inline code (`...`)
|
|
||||||
result = result.replace(Regex("""`([^`]+)`"""), "$1")
|
|
||||||
|
|
||||||
// 3. Remove bold (**...**)
|
|
||||||
result = result.replace(Regex("""\*\*([^*]+)\*\*"""), "$1")
|
|
||||||
|
|
||||||
// 4. Remove italic (*...*)
|
|
||||||
result = result.replace(Regex("""\*([^*]+)\*"""), "$1")
|
|
||||||
|
|
||||||
// 5. Remove strikethrough (~~...~~)
|
|
||||||
result = result.replace(Regex("""~~([^~]+)~~"""), "$1")
|
|
||||||
|
|
||||||
// 6. Remove headers (# ## ### etc)
|
|
||||||
result = result.replace(Regex("""^#{1,6}\s+""", RegexOption.MULTILINE), "")
|
|
||||||
|
|
||||||
// 7. Remove links [text](url) → text
|
|
||||||
result = result.replace(Regex("""\[([^\]]+)\]\([^)]+\)"""), "$1")
|
|
||||||
|
|
||||||
// 8. Remove images  → alt
|
|
||||||
result = result.replace(Regex("""!\[([^\]]*)\]\([^)]+\)"""), "$1")
|
|
||||||
|
|
||||||
// 9. Remove horizontal rules (---, ***, ___)
|
|
||||||
result = result.replace(Regex("""^[-*_]{3,}$""", RegexOption.MULTILINE), "")
|
|
||||||
|
|
||||||
// 10. Remove blockquotes (> ...)
|
|
||||||
result = result.replace(Regex("""^>\s+""", RegexOption.MULTILINE), "")
|
|
||||||
|
|
||||||
// 11. Remove unordered list markers (-, *, +)
|
|
||||||
result = result.replace(Regex("""^[\s]*[-*+]\s+""", RegexOption.MULTILINE), "")
|
|
||||||
|
|
||||||
// 12. Remove ordered list markers (1. 2. 3.)
|
|
||||||
result = result.replace(Regex("""^[\s]*\d+\.\s+""", RegexOption.MULTILINE), "")
|
|
||||||
|
|
||||||
// 13. Clean up extra whitespace
|
|
||||||
result = result.replace(Regex("""\n{3,}"""), "\n\n") // Max 2 consecutive newlines
|
|
||||||
result = result.trim()
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get preview text (first N characters, stripped)
|
|
||||||
*/
|
|
||||||
fun getPlainPreview(text: String, maxLength: Int = 100): String {
|
|
||||||
val plain = stripMarkdown(text)
|
|
||||||
return if (plain.length > maxLength) {
|
|
||||||
plain.take(maxLength).trim() + "..."
|
|
||||||
} else {
|
|
||||||
plain
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
19
app/src/main/res/layout/activity_main.xml
Normal file
19
app/src/main/res/layout/activity_main.xml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/main"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Hello World!"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
@ -1,3 +1,7 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<!-- Base application theme. -->
|
<!-- Base application theme. -->
|
||||||
|
<style name="Base.Theme.Notesai" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
|
<!-- Customize your dark theme here. -->
|
||||||
|
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@ -1,2 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources></resources>
|
<resources>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
@ -1,5 +1,10 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<style name="Theme.NotesAI" parent="android:Theme.Material.Light.NoActionBar" />
|
<style name="Theme.NotesAI" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
<!-- Base application theme. -->
|
<!-- Base application theme. -->
|
||||||
|
<style name="Base.Theme.Notesai" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
|
<!-- Customize your light theme here. -->
|
||||||
|
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="Theme.Notesai" parent="Base.Theme.Notesai" />
|
||||||
</resources>
|
</resources>
|
||||||
Loading…
x
Reference in New Issue
Block a user